2709 lines
140 KiB
OpenEdge ABL
2709 lines
140 KiB
OpenEdge ABL
/**
|
|
* Backing controller for the shopifySyncDashboard LWC.
|
|
*
|
|
* getStores() → SELECT Id, Name FROM HIC_ShopifySync__HIC_Configuration__c WHERE Name != 'default'
|
|
*
|
|
* getObjectCounts() → COUNT() per object via SOQL with HIC_ShopifySync__Shopify_Id__c != null
|
|
* + per-object filter + HIC_ShopifySync__Store__c = :storeName.
|
|
* (cacheable — feeds the Salesforce column on the sidebar.)
|
|
*
|
|
* getShopifyCounts() → Live Shopify REST callouts per selected store. Reads the
|
|
* access token from the matching HIC_ShopifySync__HIC_Configuration__c
|
|
* record (HIC_ShopifySync__Private_Key_Password__c) and hits
|
|
* /admin/<resource>/count.json for products, variants, customers,
|
|
* orders, draft_orders, locations. NOT cacheable (Apex rules
|
|
* forbid HTTP callouts on cacheable @AuraEnabled methods).
|
|
*
|
|
* Heads-up: each <storename>.myshopify.com must be allowed in
|
|
* Setup → Security → Remote Site Settings, OR via a Named Credential.
|
|
* The HIC_ShopifySync managed package very likely already sets these up
|
|
* during onboarding — if a callout returns "Unauthorized endpoint",
|
|
* that's the cause.
|
|
*/
|
|
public with sharing class ShopifySyncDashboardController {
|
|
|
|
/* =================== Stores =================== */
|
|
private static Boolean cachedPaEnabled;
|
|
private static Boolean paCacheSet = false;
|
|
|
|
@AuraEnabled(cacheable=true)
|
|
public static List<Store> getStores() {
|
|
List<Store> stores = new List<Store>();
|
|
for (HIC_ShopifySync__HIC_Configuration__c c : [
|
|
SELECT Id, Name, CreatedDate,
|
|
HIC_ShopifySync__Product_Sync__c,
|
|
HIC_ShopifySync__Person_Account__c,
|
|
HIC_ShopifySync__Order__c, // Parent: Order Sync
|
|
HIC_ShopifySync__Order_Sync__c, // Child: Order Toggle
|
|
HIC_ShopifySync__Opportunity__c, // Child: Opportunity Toggle
|
|
HIC_ShopifySync__Draft_Order_Sync__c, // Parent: Draft Order Sync
|
|
HIC_ShopifySync__Enable_Draft_Order__c, // Child: Draft Order Toggle
|
|
HIC_ShopifySync__Enable_Opportunity_for_SDO__c, // Child: Draft Order Opportunity Toggle
|
|
HIC_ShopifySync__Collection_Sync__c,
|
|
HIC_ShopifySync__Inventory_Location__c,
|
|
HIC_ShopifySync__Account_Contact__c,
|
|
HIC_ShopifySync__Enable_Lead__c
|
|
FROM HIC_ShopifySync__HIC_Configuration__c
|
|
WHERE Name != 'default'
|
|
ORDER BY CreatedDate ASC, Name ASC
|
|
LIMIT 200
|
|
]) {
|
|
Store s = new Store();
|
|
s.id = c.Id;
|
|
s.name = c.Name;
|
|
|
|
// General toggles
|
|
s.productSync = c.HIC_ShopifySync__Product_Sync__c == true;
|
|
s.accountContact = c.HIC_ShopifySync__Account_Contact__c == true;
|
|
s.enableLead = c.HIC_ShopifySync__Enable_Lead__c == true;
|
|
s.personAccount = c.HIC_ShopifySync__Person_Account__c == true;
|
|
s.collectionSync = c.HIC_ShopifySync__Collection_Sync__c == true;
|
|
s.inventoryLocation = c.HIC_ShopifySync__Inventory_Location__c == true;
|
|
|
|
// Paid Order toggles
|
|
s.orderSync = c.HIC_ShopifySync__Order_Sync__c == true; // Parent
|
|
s.orderToggle = c.HIC_ShopifySync__Order__c == true; // Child
|
|
s.opportunity = c.HIC_ShopifySync__Opportunity__c == true; // Child
|
|
|
|
// Draft Order toggles
|
|
s.draftOrderSync = c.HIC_ShopifySync__Draft_Order_Sync__c == true; // Parent
|
|
s.draftOrderToggle = c.HIC_ShopifySync__Enable_Draft_Order__c == true; // Child
|
|
s.draftOpportunity = c.HIC_ShopifySync__Enable_Opportunity_for_SDO__c == true; // Child
|
|
|
|
stores.add(s);
|
|
}
|
|
return stores;
|
|
}
|
|
|
|
/* =================== Salesforce side: SOQL counts =================== */
|
|
|
|
@AuraEnabled(cacheable=true)
|
|
public static Map<String, Integer> getObjectCounts(String storeName, String dateRange) {
|
|
Map<String, Integer> counts = initZeroCounts();
|
|
Map<String, ObjectDef> defs = objectDefs();
|
|
Boolean hasStoreFilter = String.isNotBlank(storeName);
|
|
String safeStore = hasStoreFilter ? String.escapeSingleQuotes(storeName) : null;
|
|
String drClause = dateRangeClause(dateRange);
|
|
|
|
for (String key : defs.keySet()) {
|
|
ObjectDef def = defs.get(key);
|
|
try {
|
|
// BOTH must be populated to count a record
|
|
String soql = 'SELECT COUNT() FROM ' + def.sobjectName
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null'
|
|
+ ' AND HIC_ShopifySync__Store__c != null';
|
|
if (hasStoreFilter) {
|
|
soql += ' AND HIC_ShopifySync__Store__c = \'' + safeStore + '\'';
|
|
}
|
|
if (String.isNotBlank(def.extraFilter)) {
|
|
soql += ' AND ' + def.extraFilter;
|
|
}
|
|
soql += drClause;
|
|
counts.put(key, Database.countQuery(soql));
|
|
} catch (Exception e) {
|
|
String msg = e.getMessage();
|
|
// DEFENSIVE: If IsPersonAccount error, feature not truly enabled
|
|
if (msg != null && msg.contains('IsPersonAccount')) {
|
|
System.debug('[ShopifySyncDashboardController] Person Accounts not queryable for '
|
|
+ key + ', returning 0. ' + msg);
|
|
counts.put(key, 0);
|
|
} else {
|
|
System.debug('[ShopifySyncDashboardController] SF count failed for '
|
|
+ key + ' (' + def.sobjectName + '): ' + msg);
|
|
counts.put(key, 0);
|
|
}
|
|
}
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
/* =================== Shopify side: REST callouts =================== */
|
|
|
|
@AuraEnabled
|
|
public static Map<String, Integer> getShopifyCounts(String storeName) {
|
|
Map<String, Integer> counts = initZeroCounts();
|
|
if (String.isBlank(storeName)) return counts;
|
|
|
|
String token = lookupAccessToken(storeName);
|
|
if (String.isBlank(token)) {
|
|
System.debug('[ShopifySyncDashboardController] no access token for store ' + storeName);
|
|
return counts;
|
|
}
|
|
|
|
String base = 'https://' + storeName + '.myshopify.com';
|
|
Map<String, String> endpoints = new Map<String, String>{
|
|
'products' => base + '/admin/products/count.json',
|
|
'variants' => base + '/admin/variants/count.json',
|
|
'customers' => base + '/admin/customers/count.json',
|
|
'orders' => base + '/admin/orders/count.json?status=any',
|
|
'draftOrders' => base + '/admin/draft_orders/count.json',
|
|
'locations' => base + '/admin/locations/count.json',
|
|
'customCollections' => base + '/admin/custom_collections/count.json',
|
|
'smartCollections' => base + '/admin/smart_collections/count.json'
|
|
};
|
|
|
|
Map<String, Integer> ep = new Map<String, Integer>();
|
|
for (String name : endpoints.keySet()) {
|
|
try {
|
|
ep.put(name, fetchShopifyCount(endpoints.get(name), token));
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] Shopify callout failed for '
|
|
+ name + ': ' + e.getMessage());
|
|
ep.put(name, 0);
|
|
}
|
|
}
|
|
|
|
// Map raw Shopify resources to the dashboard's sidebar keys.
|
|
counts.put('product', safeGet(ep, 'products'));
|
|
counts.put('variant', safeGet(ep, 'variants'));
|
|
counts.put('pacc', safeGet(ep, 'customers'));
|
|
counts.put('account', safeGet(ep, 'customers'));
|
|
counts.put('contact', safeGet(ep, 'customers'));
|
|
counts.put('opp', safeGet(ep, 'orders'));
|
|
counts.put('paidOrder', safeGet(ep, 'orders'));
|
|
counts.put('draftOrder', safeGet(ep, 'draftOrders'));
|
|
counts.put('location', safeGet(ep, 'locations'));
|
|
counts.put('collection', safeGet(ep, 'customCollections') + safeGet(ep, 'smartCollections'));
|
|
// lead, inventory → no Shopify endpoint specified yet, stay at 0.
|
|
|
|
return counts;
|
|
}
|
|
|
|
/* =================== Record list & describe (new in May 2026) =================== */
|
|
|
|
/**
|
|
* Returns the field metadata for the SObject mapped to objectKey.
|
|
* Used by the "Select fields" menu in the LWC.
|
|
* Excludes audit/system fields the user can't usefully select.
|
|
*/
|
|
@AuraEnabled(cacheable=true)
|
|
public static List<FieldInfo> getObjectFields(String objectKey) {
|
|
List<FieldInfo> out = new List<FieldInfo>();
|
|
Map<String, ObjectDef> defs = objectDefs();
|
|
ObjectDef def = defs.get(objectKey);
|
|
if (def == null) return out;
|
|
|
|
Schema.SObjectType sot = Schema.getGlobalDescribe().get(def.sobjectName);
|
|
if (sot == null) return out;
|
|
|
|
Schema.DescribeSObjectResult dsr = sot.getDescribe();
|
|
if (!dsr.isAccessible()) return out;
|
|
|
|
Map<String, Schema.SObjectField> fmap = dsr.fields.getMap();
|
|
|
|
// System fields to hide
|
|
Set<String> hidden = new Set<String>{
|
|
'systemmodstamp', 'isdeleted', 'lastvieweddate', 'lastreferenceddate',
|
|
'lastactivitydate', 'jigsaw', 'cleanstatus', 'masterrecordid',
|
|
'photourl', 'mediumphotourl', 'smallphotourl', 'isprivate'
|
|
};
|
|
|
|
// CRITICAL FIX: Also hide Person Account fields when feature disabled
|
|
Boolean paEnabled = personAccountsEnabled();
|
|
|
|
List<String> defaultFields = defaultFieldsFor(objectKey);
|
|
Map<String, Integer> defaultPos = new Map<String, Integer>();
|
|
for (Integer i = 0; i < defaultFields.size(); i++) {
|
|
String df = defaultFields[i];
|
|
if (df != null) defaultPos.put(df.toLowerCase(), i);
|
|
}
|
|
|
|
for (String apiLower : fmap.keySet()) {
|
|
Schema.DescribeFieldResult dfr = fmap.get(apiLower).getDescribe();
|
|
|
|
// Skip inaccessible fields
|
|
if (!dfr.isAccessible()) continue;
|
|
|
|
// Skip system fields
|
|
if (hidden.contains(apiLower)) continue;
|
|
|
|
// CRITICAL FIX: Skip Person Account-only fields when feature disabled
|
|
if (!paEnabled && def.sobjectName == 'Account') {
|
|
if (isPersonAccountOnlyField(apiLower)) continue;
|
|
}
|
|
|
|
FieldInfo fi = new FieldInfo();
|
|
fi.apiName = dfr.getName();
|
|
fi.type = String.valueOf(dfr.getType()).toLowerCase();
|
|
fi.sortable = dfr.isSortable();
|
|
|
|
// Friendly label for Reference fields
|
|
String rawLabel = dfr.getLabel();
|
|
if (dfr.getType() == Schema.DisplayType.REFERENCE && rawLabel != null) {
|
|
String stripped = rawLabel;
|
|
if (stripped.endsWithIgnoreCase(' ID')) {
|
|
stripped = stripped.substring(0, stripped.length() - 3);
|
|
}
|
|
fi.label = stripped + ' Name';
|
|
} else {
|
|
fi.label = rawLabel;
|
|
}
|
|
|
|
Integer pos = defaultPos.get(apiLower);
|
|
fi.isDefault = (pos != null);
|
|
fi.defaultOrder = pos;
|
|
out.add(fi);
|
|
}
|
|
out.sort();
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Returns REAL Salesforce records for the active object — driven by the
|
|
* caller's selected fields. Replaces the client-side mock data generator.
|
|
* Hard-capped at MAX_ROWS to keep heap usage predictable.
|
|
*/
|
|
private static final Integer MAX_ROWS = 200;
|
|
|
|
@AuraEnabled(cacheable=true)
|
|
public static List<Map<String, Object>> getObjectRecords(
|
|
String objectKey,
|
|
String storeName,
|
|
List<String> fields,
|
|
String dateRange
|
|
) {
|
|
List<Map<String, Object>> out = new List<Map<String, Object>>();
|
|
if (String.isBlank(objectKey)) return out;
|
|
|
|
Map<String, ObjectDef> defs = objectDefs();
|
|
ObjectDef def = defs.get(objectKey);
|
|
if (def == null) return out;
|
|
|
|
// If caller didn't pass fields, use the per-object defaults
|
|
if (fields == null || fields.isEmpty()) {
|
|
fields = defaultFieldsFor(objectKey);
|
|
}
|
|
|
|
// "All stores" mode — include Store column
|
|
if (String.isBlank(storeName)) {
|
|
fields = ensureStoreFieldAfterShopifyId(fields);
|
|
}
|
|
|
|
// Validate fields against the SObject schema
|
|
Schema.SObjectType sot = Schema.getGlobalDescribe().get(def.sobjectName);
|
|
if (sot == null) return out;
|
|
Map<String, Schema.SObjectField> fmap = sot.getDescribe().fields.getMap();
|
|
|
|
// Build safe field set — skip fields that don't exist or aren't accessible
|
|
List<String> safeFields = new List<String>();
|
|
Map<String, String> lookupRelMap = new Map<String, String>();
|
|
Map<String, String> lookupTargetMap = new Map<String, String>();
|
|
|
|
for (String f : fields) {
|
|
if (String.isBlank(f)) continue;
|
|
String lower = f.toLowerCase();
|
|
|
|
// CRITICAL FIX: Skip Person Account-only fields when feature is disabled
|
|
if (!personAccountsEnabled() && def.sobjectName == 'Account') {
|
|
if (isPersonAccountOnlyField(lower)) continue;
|
|
}
|
|
|
|
if (!fmap.containsKey(lower)) continue;
|
|
|
|
Schema.DescribeFieldResult dfr = fmap.get(lower).getDescribe();
|
|
if (!dfr.isAccessible()) continue; // Field exists but user can't see it
|
|
|
|
safeFields.add(f);
|
|
|
|
if (dfr.getType() == Schema.DisplayType.REFERENCE) {
|
|
String relName = dfr.getRelationshipName();
|
|
List<Schema.SObjectType> refs = dfr.getReferenceTo();
|
|
if (String.isNotBlank(relName) && refs != null && !refs.isEmpty()) {
|
|
lookupRelMap.put(f, relName);
|
|
lookupTargetMap.put(f, refs[0].getDescribe().getName());
|
|
}
|
|
}
|
|
}
|
|
|
|
if (safeFields.isEmpty()) safeFields.add('Id');
|
|
if (!safeFields.contains('Id')) safeFields.add('Id');
|
|
|
|
// Build SELECT with relationship traversals
|
|
List<String> selectFields = new List<String>(safeFields);
|
|
for (String f : lookupRelMap.keySet()) {
|
|
selectFields.add(lookupRelMap.get(f) + '.Name');
|
|
}
|
|
|
|
// Build SOQL
|
|
String soql = 'SELECT ' + String.join(selectFields, ', ')
|
|
+ ' FROM ' + def.sobjectName
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null'
|
|
+ ' AND HIC_ShopifySync__Store__c != null';
|
|
|
|
if (String.isNotBlank(storeName)) {
|
|
String safeStore = String.escapeSingleQuotes(storeName);
|
|
soql += ' AND HIC_ShopifySync__Store__c = \'' + safeStore + '\'';
|
|
}
|
|
if (String.isNotBlank(def.extraFilter)) {
|
|
soql += ' AND ' + def.extraFilter;
|
|
}
|
|
soql += dateRangeClause(dateRange);
|
|
soql += ' ORDER BY CreatedDate DESC LIMIT ' + MAX_ROWS;
|
|
|
|
try {
|
|
for (SObject r : Database.query(soql)) {
|
|
Map<String, Object> row = new Map<String, Object>();
|
|
row.put('__recordId__', r.Id);
|
|
row.put('__sobjectApi__', def.sobjectName);
|
|
|
|
for (String f : safeFields) {
|
|
row.put(f, r.get(f));
|
|
if (lookupRelMap.containsKey(f)) {
|
|
String relName = lookupRelMap.get(f);
|
|
try {
|
|
SObject relRec = r.getSObject(relName);
|
|
if (relRec != null) {
|
|
row.put(f + '__name', relRec.get('Name'));
|
|
}
|
|
} catch (Exception relEx) { /* skip silently */ }
|
|
row.put(f + '__sobject', lookupTargetMap.get(f));
|
|
}
|
|
}
|
|
out.add(row);
|
|
}
|
|
} catch (Exception e) {
|
|
String msg = e.getMessage();
|
|
// DEFENSIVE: Gracefully handle IsPersonAccount errors
|
|
if (msg != null && msg.contains('IsPersonAccount')) {
|
|
System.debug('[ShopifySyncDashboardController] Person Accounts not queryable for '
|
|
+ objectKey + ', returning empty. ' + msg);
|
|
return out; // empty list
|
|
}
|
|
System.debug('[ShopifySyncDashboardController] getObjectRecords failed for '
|
|
+ objectKey + ': ' + msg + ' :: SOQL: ' + soql);
|
|
throw new AuraHandledException('Record query failed: ' + msg);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/**
|
|
* Returns true for fields that ONLY exist when Person Accounts are enabled.
|
|
* These fields are on Account but don't exist in regular business-account orgs.
|
|
*/
|
|
private static Boolean isPersonAccountOnlyField(String fieldApiLower) {
|
|
Set<String> personOnlyFields = new Set<String>{
|
|
'ispersonaccount',
|
|
'personemail',
|
|
'personbirthdate',
|
|
'personcontactid',
|
|
'persondepartment',
|
|
'persondonotcall',
|
|
'personhasoptedoutofemail',
|
|
'personhasoptedoutoffax',
|
|
'personhomephone',
|
|
'personindividualid',
|
|
'personlastcurequestdate',
|
|
'personlastcuupdatedate',
|
|
'personleadsource',
|
|
'personmailingcity',
|
|
'personmailingcountry',
|
|
'personmailinggeocodeaccuracy',
|
|
'personmailinglatitude',
|
|
'personmailinglongitude',
|
|
'personmailingpostalcode',
|
|
'personmailingstate',
|
|
'personmailingstreet',
|
|
'personmobilephone',
|
|
'personothercity',
|
|
'personothercountry',
|
|
'personothergeocodeaccuracy',
|
|
'personotherlatitude',
|
|
'personotherlongitude',
|
|
'personotherphone',
|
|
'personotherpostalcode',
|
|
'personotherstate',
|
|
'personotherstreet',
|
|
'persontitle'
|
|
};
|
|
return personOnlyFields.contains(fieldApiLower);
|
|
}
|
|
|
|
/**
|
|
* Inserts HIC_ShopifySync__Store__c immediately AFTER HIC_ShopifySync__Shopify_Id__c
|
|
* in the given field list, if it isn't already present.
|
|
* Used when "All stores" is selected so the Store column is always
|
|
* visible — otherwise users can't tell which store a row came from.
|
|
*/
|
|
@TestVisible
|
|
private static List<String> ensureStoreFieldAfterShopifyId(List<String> fields) {
|
|
if (fields == null) return new List<String>();
|
|
// Already there? Nothing to do — preserves the user's chosen position.
|
|
for (String f : fields) {
|
|
if (f != null && f.equalsIgnoreCase('HIC_ShopifySync__Store__c')) {
|
|
return fields;
|
|
}
|
|
}
|
|
List<String> result = new List<String>();
|
|
Boolean inserted = false;
|
|
for (String f : fields) {
|
|
result.add(f);
|
|
if (!inserted && f != null && f.equalsIgnoreCase('HIC_ShopifySync__Shopify_Id__c')) {
|
|
result.add('HIC_ShopifySync__Store__c');
|
|
inserted = true;
|
|
}
|
|
}
|
|
// Fallback: if Shopify Id wasn't in the list, append Store at the end.
|
|
if (!inserted) result.add('HIC_ShopifySync__Store__c');
|
|
return result;
|
|
}
|
|
|
|
/**
|
|
* Fetch a single record by Id for the target object — used when the user
|
|
* clicks a LOOKUP field in the report (e.g., clicking the Account name on a
|
|
* Contact row should show the Account's full details in the popup, using
|
|
* the Account object's own default fields, not Contact's).
|
|
*
|
|
* Returns the same Map<String,Object> shape as getObjectRecords rows, including:
|
|
* __recordId__, __sobjectApi__, each field value, plus <field>__name +
|
|
* <field>__sobject for any lookup fields the target object has.
|
|
*/
|
|
@AuraEnabled(cacheable=true)
|
|
public static Map<String, Object> getObjectRecordById(String objectKey, String recordId) {
|
|
Map<String, Object> empty = new Map<String, Object>();
|
|
if (String.isBlank(objectKey) || String.isBlank(recordId)) return empty;
|
|
|
|
Map<String, ObjectDef> defs = objectDefs();
|
|
ObjectDef def = defs.get(objectKey);
|
|
if (def == null) return empty;
|
|
|
|
Schema.SObjectType sot = Schema.getGlobalDescribe().get(def.sobjectName);
|
|
if (sot == null) return empty;
|
|
Map<String, Schema.SObjectField> fmap = sot.getDescribe().fields.getMap();
|
|
|
|
// Use target object's default field list (same logic as getObjectRecords)
|
|
List<String> fields = defaultFieldsFor(objectKey);
|
|
List<String> safeFields = new List<String>();
|
|
Map<String, String> lookupRelMap = new Map<String, String>();
|
|
Map<String, String> lookupTargetMap = new Map<String, String>();
|
|
|
|
for (String f : fields) {
|
|
if (String.isBlank(f)) continue;
|
|
String lower = f.toLowerCase();
|
|
if (!fmap.containsKey(lower)) continue;
|
|
safeFields.add(f);
|
|
Schema.DescribeFieldResult dfr = fmap.get(lower).getDescribe();
|
|
if (dfr.getType() == Schema.DisplayType.REFERENCE) {
|
|
String relName = dfr.getRelationshipName();
|
|
List<Schema.SObjectType> refs = dfr.getReferenceTo();
|
|
if (String.isNotBlank(relName) && refs != null && !refs.isEmpty()) {
|
|
lookupRelMap.put(f, relName);
|
|
lookupTargetMap.put(f, refs[0].getDescribe().getName());
|
|
}
|
|
}
|
|
}
|
|
if (safeFields.isEmpty()) safeFields.add('Id');
|
|
if (!safeFields.contains('Id')) safeFields.add('Id');
|
|
|
|
List<String> selectFields = new List<String>(safeFields);
|
|
for (String f : lookupRelMap.keySet()) {
|
|
selectFields.add(lookupRelMap.get(f) + '.Name');
|
|
}
|
|
|
|
String safeId = String.escapeSingleQuotes(recordId);
|
|
String soql = 'SELECT ' + String.join(selectFields, ', ')
|
|
+ ' FROM ' + def.sobjectName
|
|
+ ' WHERE Id = \'' + safeId + '\''
|
|
+ ' LIMIT 1';
|
|
|
|
try {
|
|
List<SObject> rs = Database.query(soql);
|
|
if (rs.isEmpty()) return empty;
|
|
SObject r = rs[0];
|
|
Map<String, Object> row = new Map<String, Object>();
|
|
row.put('__recordId__', r.Id);
|
|
row.put('__sobjectApi__', def.sobjectName);
|
|
for (String f : safeFields) {
|
|
row.put(f, r.get(f));
|
|
if (lookupRelMap.containsKey(f)) {
|
|
String relName = lookupRelMap.get(f);
|
|
try {
|
|
SObject relRec = r.getSObject(relName);
|
|
if (relRec != null) row.put(f + '__name', relRec.get('Name'));
|
|
} catch (Exception relEx) { /* skip silently */ }
|
|
row.put(f + '__sobject', lookupTargetMap.get(f));
|
|
}
|
|
}
|
|
return row;
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] getObjectRecordById failed: ' + e.getMessage());
|
|
throw new AuraHandledException('Record fetch failed: ' + e.getMessage());
|
|
}
|
|
}
|
|
|
|
private static List<String> defaultFieldsFor(String objectKey) {
|
|
// Per-object default visible fields, in the user-requested order.
|
|
// Field names that don't exist on the target SObject are silently
|
|
// skipped by getObjectRecords()'s Schema.getGlobalDescribe() validation,
|
|
// so listing speculative custom fields (e.g. HIC_ShopifySync__Tags__c)
|
|
// is safe — they just won't render if absent.
|
|
Map<String, List<String>> defaults = new Map<String, List<String>>{
|
|
'product' => new List<String>{
|
|
'Name', // Product Name
|
|
'IsActive', // Active
|
|
'HIC_ShopifySync__Tags__c', // Tags
|
|
'Description', // Description
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'variant' => new List<String>{
|
|
'Name', // Variant Name
|
|
'HIC_ShopifySync__Variant_Title__c', // Variant Title
|
|
'HIC_ShopifySync__Product__c', // Product Name (lookup)
|
|
'HIC_ShopifySync__Inventory_Quantity__c', // Inventory Quantity
|
|
'HIC_ShopifySync__SKU__c', // SKU
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'lead' => new List<String>{
|
|
'Name', // Name
|
|
'Email', // Email
|
|
'Phone', // Phone
|
|
'LeadSource', // Lead Source
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'pacc' => new List<String>{
|
|
'Name', // Account Name
|
|
'PersonEmail', // personEmail
|
|
'Phone', // personPhone
|
|
'HIC_ShopifySync__Tags__c', // Tag
|
|
'HIC_ShopifySync__Notes__c', // Notes
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'account' => new List<String>{
|
|
'Name', // Name
|
|
'HIC_ShopifySync__Tags__c', // Tag
|
|
'HIC_ShopifySync__Notes__c', // Notes
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'contact' => new List<String>{
|
|
'Name', // Name
|
|
'AccountId', // Account Name (lookup)
|
|
'Phone', // Phone
|
|
'Email', // Email
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'opp' => new List<String>{
|
|
'Name', // Opportunity Name
|
|
'AccountId', // Account (lookup)
|
|
'CloseDate', // Close Date
|
|
'StageName', // Stage
|
|
'Amount', // Amount
|
|
'HIC_ShopifySync__Payment_Status__c', // Payment Status (NEW)
|
|
'HIC_ShopifySync__Paid_Order_Id__c', // Paid Order ID (NEW)
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'draftOrder' => new List<String>{
|
|
'OrderNumber', // Order Number (clickable)
|
|
'Name', // Order Name
|
|
'AccountId', // Account Name (lookup)
|
|
'TotalAmount', // Order Amount
|
|
'Status', // Status
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'paidOrder' => new List<String>{
|
|
'OrderNumber', // Order Number (clickable)
|
|
'Name', // Order Name
|
|
'AccountId', // Account Name (lookup)
|
|
'TotalAmount', // Order Amount
|
|
'Status', // Status
|
|
'HIC_ShopifySync__Payment_Status__c', // Payment Status
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'collection' => new List<String>{
|
|
'Name', // Collection Name
|
|
'HIC_ShopifySync__Rule__c', // Rule
|
|
'HIC_ShopifySync__Description__c', // Description
|
|
'HIC_ShopifySync__Currency__c', // Currency
|
|
'HIC_ShopifySync__Published_At__c', // Published At
|
|
'HIC_ShopifySync__Updated_At__c', // Updated At
|
|
'HIC_ShopifySync__Published_Scope__c', // Published scope
|
|
'HIC_ShopifySync__Handle__c', // Handle
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'inventory' => new List<String>{
|
|
'Name', // Inventory Name
|
|
'HIC_ShopifySync__Available__c', // Quantity
|
|
'HIC_ShopifySync__Currency__c', // Currency
|
|
'HIC_ShopifySync__Cost__c', // Cost
|
|
'HIC_ShopifySync__Tracked__c', // Tracked
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'CreatedDate' // Created At
|
|
},
|
|
'location' => new List<String>{
|
|
'Name', // Location Name
|
|
'HIC_ShopifySync__Is_Processed__c', // is processed
|
|
'HIC_ShopifySync__Phone__c', // Phone
|
|
'HIC_ShopifySync__Active__c', // Active
|
|
'HIC_ShopifySync__Currency__c', // Currency
|
|
'HIC_ShopifySync__Address__c', // Address 1
|
|
'HIC_ShopifySync__Address_2__c', // Address 2
|
|
'HIC_ShopifySync__Shopify_Id__c', // Shopify Id
|
|
'HIC_ShopifySync__Shopify_Updated_At__c', // Shopify Updated At
|
|
'HIC_ShopifySync__Shopify_Created_At__c', // Shopify Created At
|
|
'CreatedDate' // Created At
|
|
}
|
|
};
|
|
return defaults.containsKey(objectKey) ? defaults.get(objectKey) : new List<String>{'Name','Id'};
|
|
}
|
|
|
|
/* =================== Sync Health: last order per store =================== */
|
|
|
|
/**
|
|
* Returns the CreatedDate of the most recent synced Order per store.
|
|
* Used by the Sync Health panel to show "Last order: X ago".
|
|
*/
|
|
@AuraEnabled
|
|
public static Map<String, String> getLastOrderPerStore() {
|
|
Map<String, String> result = new Map<String, String>();
|
|
for (AggregateResult ar : [
|
|
SELECT HIC_ShopifySync__Store__c store, MAX(CreatedDate) lastOrder
|
|
FROM Order
|
|
WHERE HIC_ShopifySync__Shopify_Id__c != null
|
|
AND HIC_ShopifySync__Store__c != null
|
|
GROUP BY HIC_ShopifySync__Store__c
|
|
]) {
|
|
String storeName = (String) ar.get('store');
|
|
Datetime lastOrder = (Datetime) ar.get('lastOrder');
|
|
if (storeName != null && lastOrder != null) {
|
|
result.put(storeName, lastOrder.formatGmt('yyyy-MM-dd\'T\'HH:mm:ss.SSS\'Z\''));
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/* =================== Dashboard chart data (real SOQL) =================== */
|
|
|
|
/**
|
|
* Returns real chart data for the active object's 3 dashboard panels.
|
|
* Keys = 'dash1', 'dash2', 'dash3'. Only objects with real dashboards
|
|
* return data; missing keys → LWC falls back to its client-side mock.
|
|
*/
|
|
@AuraEnabled
|
|
public static Map<String, DashboardData> getDashboardChartData(String objectKey, String storeName, String dateRange) {
|
|
Map<String, DashboardData> result = new Map<String, DashboardData>();
|
|
if (String.isBlank(objectKey)) return result;
|
|
|
|
// Store + date-range scoping — EVERY chart honors both filters so the
|
|
// dashboards always match the global "Filter by" selection in the LWC.
|
|
String storeClause = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String drClause = dateRangeClause(dateRange);
|
|
String scope = storeClause + drClause;
|
|
String baseWhere = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + scope;
|
|
|
|
// Builders return null on per-chart failure (e.g. field/object missing
|
|
// in this org) — putChart() skips nulls so one failed chart no longer
|
|
// kills the other charts in the same branch, and the LWC mock fallback
|
|
// applies only to charts that genuinely couldn't be computed.
|
|
try {
|
|
if (objectKey == 'product') {
|
|
putChart(result, 'dash1', chartTopProductsByUnitsSold(storeName, dateRange));
|
|
putChart(result, 'dash2', chartCreatedLast3Months('Product2', baseWhere, 'Products Created Last 3 Months'));
|
|
putChart(result, 'dash3', chartTopProductsByPrice(scope));
|
|
} else if (objectKey == 'variant') {
|
|
putChart(result, 'dash1', chartTopVariantsByInventory(scope));
|
|
putChart(result, 'dash2', chartCreatedLast3Months('HIC_ShopifySync__Variant__c', baseWhere, 'Variants Created Last 3 Months'));
|
|
putChart(result, 'dash3', chartTopVariantsByPrice(scope));
|
|
} else if (objectKey == 'lead') {
|
|
putChart(result, 'dash1', chartLeadsByStatus(scope));
|
|
putChart(result, 'dash2', chartLeadsBySource(scope));
|
|
putChart(result, 'dash3', chartCreatedLast3Months('Lead', baseWhere, 'Leads Created Last 3 Months'));
|
|
} else if (objectKey == 'contact') {
|
|
putChart(result, 'dash1', chartContactsByCity(scope));
|
|
putChart(result, 'dash2', chartCreatedLast3Months('Contact', baseWhere, 'Contacts Created Last 3 Months'));
|
|
putChart(result, 'dash3', chartContactsPerAccount(scope));
|
|
} else if (objectKey == 'account') {
|
|
// IsPersonAccount only exists when Person Accounts are enabled.
|
|
// Without it, ALL accounts are business accounts — no filter needed.
|
|
String paAccFilter = personAccountsEnabled() ? ' AND Account.IsPersonAccount = false' : '';
|
|
String paWhereFilter = personAccountsEnabled() ? ' AND IsPersonAccount = false' : '';
|
|
putChart(result, 'dash1', chartTopAccountsByRevenue(scope, paAccFilter));
|
|
putChart(result, 'dash2', chartTopAccountsByOrderCount(scope, paAccFilter));
|
|
putChart(result, 'dash3', chartCreatedLast3Months('Account', baseWhere + paWhereFilter, 'Accounts Created Last 3 Months'));
|
|
} else if (objectKey == 'pacc') {
|
|
// Person Accounts disabled → no person accounts can exist; force
|
|
// empty result sets with a valid always-false filter instead of
|
|
// referencing the non-existent IsPersonAccount column.
|
|
String paAccFilter = personAccountsEnabled() ? ' AND Account.IsPersonAccount = true' : ' AND Account.Id = null';
|
|
String paWhereFilter = personAccountsEnabled() ? ' AND IsPersonAccount = true' : ' AND Id = null';
|
|
putChart(result, 'dash1', chartTopAccountsByRevenue(scope, paAccFilter));
|
|
putChart(result, 'dash2', chartTopAccountsByOrderCount(scope, paAccFilter));
|
|
putChart(result, 'dash3', chartCreatedLast3Months('Account', baseWhere + paWhereFilter, 'Person Accounts Created Last 3 Months'));
|
|
} else if (objectKey == 'opp') {
|
|
putChart(result, 'dash1', chartOpportunitiesByAccount(scope, null));
|
|
putChart(result, 'dash2', chartPaidVsDraftOpportunities(scope));
|
|
putChart(result, 'dash3', chartTopOpportunitiesByAmount(scope, null));
|
|
} else if (objectKey == 'paidOpp') {
|
|
// BUG FIX: the LWC sidebar sends 'paidOpp'/'draftOpp' (never
|
|
// 'opp'), so this branch and the next never ran before — both
|
|
// tabs silently fell back to 100% mock data. Mirrors the
|
|
// existing paidOrder/draftOrder pattern, scoped by the same
|
|
// Payment_Status field Opportunity shares with Order.
|
|
String oppFilter = 'HIC_ShopifySync__Payment_Status__c != null';
|
|
putChart(result, 'dash1', chartOpportunitiesByAccount(scope, oppFilter));
|
|
putChart(result, 'dash2', chartTopOpportunitiesByAmount(scope, oppFilter));
|
|
putChart(result, 'dash3', chartCreatedLast3Months('Opportunity', baseWhere + ' AND ' + oppFilter, 'Paid Opportunities Created Last 3 Months'));
|
|
} else if (objectKey == 'draftOpp') {
|
|
String oppFilter = 'HIC_ShopifySync__Payment_Status__c = null';
|
|
putChart(result, 'dash1', chartOpportunitiesByAccount(scope, oppFilter));
|
|
putChart(result, 'dash2', chartTopOpportunitiesByAmount(scope, oppFilter));
|
|
putChart(result, 'dash3', chartCreatedLast3Months('Opportunity', baseWhere + ' AND ' + oppFilter, 'Draft Opportunities Created Last 3 Months'));
|
|
} else if (objectKey == 'paidOrder') {
|
|
putChart(result, 'dash1', chartOrdersByAccount(scope, 'HIC_ShopifySync__Payment_Status__c != null'));
|
|
putChart(result, 'dash2', chartTopOrdersByAmount(scope, 'HIC_ShopifySync__Payment_Status__c != null'));
|
|
putChart(result, 'dash3', chartConvertedOrders(scope, 'HIC_ShopifySync__Shopify_Draft_Order_ID__c', 'HIC_ShopifySync__Payment_Status__c != null'));
|
|
} else if (objectKey == 'draftOrder') {
|
|
putChart(result, 'dash1', chartOrdersByAccount(scope, 'HIC_ShopifySync__Payment_Status__c = null'));
|
|
putChart(result, 'dash2', chartTopOrdersByAmount(scope, 'HIC_ShopifySync__Payment_Status__c = null'));
|
|
putChart(result, 'dash3', chartConvertedOrders(scope, 'HIC_ShopifySync__Shopify_Order_ID__c', 'HIC_ShopifySync__Payment_Status__c = null'));
|
|
} else if (objectKey == 'collection') {
|
|
// Junction object — store filter traverses to the parent Collection;
|
|
// the date filter applies to the junction's own CreatedDate
|
|
// ("products added to collections within the range").
|
|
String collScope = storeClauseFor(storeName, 'HIC_ShopifySync__Collection__r.HIC_ShopifySync__Store__c') + drClause;
|
|
putChart(result, 'dash1', chartProductsPerCollection(collScope));
|
|
} else if (objectKey == 'inventory') {
|
|
putChart(result, 'dash1', chartInventoryByVariant(scope));
|
|
} else if (objectKey == 'location') {
|
|
String locScope = storeClauseFor(storeName, 'HIC_ShopifySync__Location__r.HIC_ShopifySync__Store__c') + drClause;
|
|
putChart(result, 'dash1', chartProductsPerLocation(locScope, scope));
|
|
}
|
|
} catch (Exception e) {
|
|
String msg = e.getMessage();
|
|
// DEFENSIVE: Handle Person Accounts not queryable
|
|
if (msg != null && msg.contains('IsPersonAccount')) {
|
|
System.debug('[ShopifySyncDashboardController] Person Accounts not queryable, returning empty chart. ' + msg);
|
|
return null;
|
|
}
|
|
System.debug('[ShopifySyncDashboardController] chart failed: ' + msg);
|
|
return null;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/* =================== Dashboard KPI data (real SOQL — no hardcoded values) =================== */
|
|
|
|
/**
|
|
* Returns 8 real KpiData objects for the active sidebar object.
|
|
* Every value comes from SOQL — no ratio-based fakes, no mock constants.
|
|
*/
|
|
@AuraEnabled
|
|
public static List<KpiData> getDashboardKpis(String objectKey, String storeName, String dateRange) {
|
|
try {
|
|
if (objectKey == 'product') return kpisProduct(storeName, dateRange);
|
|
if (objectKey == 'variant') return kpisVariant(storeName, dateRange);
|
|
if (objectKey == 'lead') return kpisLead(storeName, dateRange);
|
|
if (objectKey == 'pacc') return kpisAccount(storeName, dateRange, true);
|
|
if (objectKey == 'account') return kpisAccount(storeName, dateRange, false);
|
|
if (objectKey == 'contact') return kpisContact(storeName, dateRange);
|
|
if (objectKey == 'paidOpp') return kpisOpportunity(storeName, dateRange, 'HIC_ShopifySync__Payment_Status__c != null');
|
|
if (objectKey == 'draftOpp') return kpisOpportunity(storeName, dateRange, 'HIC_ShopifySync__Payment_Status__c = null');
|
|
if (objectKey == 'opp') return kpisOpportunity(storeName, dateRange, null);
|
|
if (objectKey == 'paidOrder') return kpisOrder(storeName, dateRange, 'HIC_ShopifySync__Payment_Status__c != null');
|
|
if (objectKey == 'draftOrder') return kpisOrder(storeName, dateRange, 'HIC_ShopifySync__Payment_Status__c = null');
|
|
if (objectKey == 'collection') return kpisCollection(storeName, dateRange);
|
|
if (objectKey == 'inventory') return kpisInventory(storeName, dateRange);
|
|
if (objectKey == 'location') return kpisLocation(storeName, dateRange);
|
|
return new List<KpiData>();
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] getDashboardKpis failed for ' + objectKey + ': ' + e.getMessage());
|
|
return new List<KpiData>();
|
|
}
|
|
}
|
|
|
|
// ---------- KPI formatting helpers ----------
|
|
|
|
private static String fmtNum(Decimal n) {
|
|
if (n == null) return '0';
|
|
Long v = n.setScale(0).longValue();
|
|
Boolean neg = v < 0;
|
|
String s = String.valueOf(neg ? -v : v);
|
|
String r = '';
|
|
for (Integer i = 0; i < s.length(); i++) {
|
|
if (i > 0 && Math.mod(s.length() - i, 3) == 0) r += ',';
|
|
r += s.substring(i, i + 1);
|
|
}
|
|
return neg ? '-' + r : r;
|
|
}
|
|
private static String fmtNum(Integer n) { return fmtNum(n == null ? (Decimal)0 : Decimal.valueOf(n)); }
|
|
|
|
private static String fmtCurrency(Decimal n) {
|
|
if (n == null) return '$0';
|
|
Decimal abs = n.abs();
|
|
if (abs >= 1000000) return '$' + (n / 1000000).setScale(1) + 'M';
|
|
if (abs >= 1000) return '$' + (n / 1000).setScale(1) + 'K';
|
|
return '$' + n.setScale(0);
|
|
}
|
|
|
|
private static String fmtPct(Decimal numerator, Decimal denominator) {
|
|
if (denominator == null || denominator == 0) return '0%';
|
|
return (numerator / denominator * 100).setScale(1) + '%';
|
|
}
|
|
|
|
private static KpiData kd(String label, String value, String sub, String accent) {
|
|
return kd(label, value, sub, accent, null, 'neutral');
|
|
}
|
|
private static KpiData kd(String label, String value, String sub, String accent, String delta, String deltaDir) {
|
|
KpiData k = new KpiData();
|
|
k.label = label;
|
|
k.value = value;
|
|
k.sub = sub;
|
|
k.accent = accent;
|
|
k.delta = delta;
|
|
k.deltaDir = deltaDir;
|
|
return k;
|
|
}
|
|
|
|
/** Returns the first field value from a GROUP BY aggregate result, or '—' if none. */
|
|
private static String topGroupVal(List<AggregateResult> ars, String alias) {
|
|
if (ars != null && !ars.isEmpty()) {
|
|
Object v = ars[0].get(alias);
|
|
if (v != null && String.valueOf(v).trim() != '') return String.valueOf(v);
|
|
}
|
|
return '—';
|
|
}
|
|
|
|
/** Returns [deltaString, deltaDir] comparing current vs prior period for a simple COUNT. */
|
|
private static String[] periodDelta(String sobjectName, String baseWhere, String dateRange) {
|
|
try {
|
|
String cur = dateRangeClause(dateRange);
|
|
String prior = priorPeriodClause(dateRange);
|
|
if (String.isBlank(prior)) return new String[]{'', 'neutral'};
|
|
Integer c = Database.countQuery('SELECT COUNT() FROM ' + sobjectName + baseWhere + cur);
|
|
Integer p = Database.countQuery('SELECT COUNT() FROM ' + sobjectName + baseWhere + prior);
|
|
if (p == 0) return new String[]{'', 'neutral'};
|
|
Decimal pct = ((c - p) * 100.0) / p;
|
|
String sign = pct >= 0 ? '▲' : '▼';
|
|
String dir = pct >= 0 ? 'up' : 'down';
|
|
return new String[]{sign + ' ' + pct.abs().setScale(1) + '%', dir};
|
|
} catch (Exception e) { return new String[]{'', 'neutral'}; }
|
|
}
|
|
|
|
private static String priorPeriodClause(String dateRange) {
|
|
if (String.isBlank(dateRange) || dateRange == 'All Time') return ' AND CreatedDate = LAST_YEAR';
|
|
if (dateRange == 'Today') return ' AND CreatedDate = YESTERDAY';
|
|
if (dateRange == 'Last 7 days') return ' AND CreatedDate >= ' + Date.today().addDays(-14) + ' AND CreatedDate < ' + Date.today().addDays(-7);
|
|
if (dateRange == 'Last 30 days') return ' AND CreatedDate >= ' + Date.today().addDays(-60) + ' AND CreatedDate < ' + Date.today().addDays(-30);
|
|
if (dateRange == 'This Month') return ' AND CreatedDate = LAST_MONTH';
|
|
if (dateRange == 'This Quarter') return ' AND CreatedDate = LAST_QUARTER';
|
|
if (dateRange == 'This Year') return ' AND CreatedDate = LAST_YEAR';
|
|
return '';
|
|
}
|
|
|
|
// ---------- Per-object KPI builders ----------
|
|
|
|
private static List<KpiData> kpisProduct(String storeName, String dateRange) {
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
|
|
String vw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
|
|
|
|
Integer total = Database.countQuery('SELECT COUNT() FROM Product2' + bw);
|
|
Integer active = Database.countQuery('SELECT COUNT() FROM Product2' + bw + ' AND IsActive = true');
|
|
|
|
// OUT OF STOCK: group by Product so we count distinct products, not variants
|
|
Integer oos = 0;
|
|
try {
|
|
List<AggregateResult> oosAr = Database.query(
|
|
'SELECT HIC_ShopifySync__Product__c'
|
|
+ ' FROM HIC_ShopifySync__Variant__c'
|
|
+ vw
|
|
+ ' AND HIC_ShopifySync__Inventory_Quantity__c <= 0'
|
|
+ ' AND HIC_ShopifySync__Product__c != null'
|
|
+ ' GROUP BY HIC_ShopifySync__Product__c'
|
|
);
|
|
oos = oosAr.size();
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] oos count failed: ' + e.getMessage());
|
|
}
|
|
|
|
// LOW STOCK: group by Product so we count distinct products, not variants
|
|
Integer low = 0;
|
|
try {
|
|
List<AggregateResult> lowAr = Database.query(
|
|
'SELECT HIC_ShopifySync__Product__c'
|
|
+ ' FROM HIC_ShopifySync__Variant__c'
|
|
+ vw
|
|
+ ' AND HIC_ShopifySync__Inventory_Quantity__c > 0'
|
|
+ ' AND HIC_ShopifySync__Inventory_Quantity__c < 10'
|
|
+ ' AND HIC_ShopifySync__Product__c != null'
|
|
+ ' GROUP BY HIC_ShopifySync__Product__c'
|
|
);
|
|
low = lowAr.size();
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] low stock count failed: ' + e.getMessage());
|
|
}
|
|
|
|
Integer newM = Database.countQuery('SELECT COUNT() FROM Product2 WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + ' AND CreatedDate = THIS_MONTH');
|
|
|
|
Decimal revenue = 0, avgPrice = 0;
|
|
try {
|
|
List<AggregateResult> revAr = Database.query('SELECT SUM(TotalPrice) rev FROM OrderItem WHERE Product2Id != null AND Order.HIC_ShopifySync__Shopify_Id__c != null AND Order.HIC_ShopifySync__Store__c != null' + storeClauseFor(storeName, 'Order.HIC_ShopifySync__Store__c') + dateRangeClauseFor(dateRange, 'Order.CreatedDate'));
|
|
if (!revAr.isEmpty() && revAr[0].get('rev') != null) revenue = (Decimal) revAr[0].get('rev');
|
|
} catch (Exception e) {}
|
|
try {
|
|
List<AggregateResult> priceAr = Database.query('SELECT AVG(HIC_ShopifySync__Price__c) avg FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Price__c != null');
|
|
if (!priceAr.isEmpty() && priceAr[0].get('avg') != null) avgPrice = (Decimal) priceAr[0].get('avg');
|
|
} catch (Exception e) {}
|
|
|
|
Integer cats = 0;
|
|
try { cats = Database.query('SELECT Family FROM Product2' + bw + ' AND Family != null GROUP BY Family').size(); } catch (Exception e) {}
|
|
|
|
String[] delta = periodDelta('Product2', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd('TOTAL PRODUCTS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd('ACTIVE', fmtNum(active), 'live', 'k-green'));
|
|
r.add(kd('OUT OF STOCK', fmtNum(oos), 'products', 'k-red'));
|
|
r.add(kd('LOW STOCK', fmtNum(low), '< 10 qty', 'k-yellow'));
|
|
r.add(kd('REVENUE', fmtCurrency(revenue), 'total', 'k-purple'));
|
|
r.add(kd('AVG UNIT PRICE', fmtCurrency(avgPrice),'per SKU', 'k-cyan'));
|
|
r.add(kd('NEW THIS MONTH', fmtNum(newM), 'added', 'k-blue'));
|
|
r.add(kd('CATEGORIES', fmtNum(cats), 'product family', 'k-green'));
|
|
return r;
|
|
}
|
|
|
|
private static List<KpiData> kpisVariant(String storeName, String dateRange) {
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String vw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
|
|
|
|
Integer total = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Variant__c' + vw);
|
|
Integer active = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Inventory_Quantity__c > 0');
|
|
Integer oos = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Inventory_Quantity__c <= 0');
|
|
Integer low = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Inventory_Quantity__c > 0 AND HIC_ShopifySync__Inventory_Quantity__c < 10');
|
|
|
|
Decimal avgPrice = 0, avgQty = 0;
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT AVG(HIC_ShopifySync__Price__c) ap, AVG(HIC_ShopifySync__Inventory_Quantity__c) aq FROM HIC_ShopifySync__Variant__c' + vw);
|
|
if (!ar.isEmpty()) {
|
|
if (ar[0].get('ap') != null) avgPrice = (Decimal) ar[0].get('ap');
|
|
if (ar[0].get('aq') != null) avgQty = (Decimal) ar[0].get('aq');
|
|
}
|
|
} catch (Exception e) {}
|
|
|
|
Integer prods = 0;
|
|
try { prods = Database.query('SELECT HIC_ShopifySync__Product__c FROM HIC_ShopifySync__Variant__c' + vw + ' AND HIC_ShopifySync__Product__c != null GROUP BY HIC_ShopifySync__Product__c').size(); } catch (Exception e) {}
|
|
String varPerProd = prods > 0 ? (Decimal.valueOf(total) / prods).setScale(1) + '' : '—';
|
|
|
|
String[] delta = periodDelta('HIC_ShopifySync__Variant__c', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd('TOTAL VARIANTS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd('ACTIVE SKUS', fmtNum(active), 'qty > 0', 'k-green'));
|
|
r.add(kd('OUT OF STOCK', fmtNum(oos), 'qty = 0', 'k-red'));
|
|
r.add(kd('LOW STOCK', fmtNum(low), '< 10 qty', 'k-yellow'));
|
|
r.add(kd('AVG PRICE', fmtCurrency(avgPrice), 'per variant', 'k-purple'));
|
|
r.add(kd('AVG QTY', fmtNum(avgQty), 'per variant', 'k-cyan'));
|
|
r.add(kd('VARIANTS/PROD', varPerProd, 'avg', 'k-blue'));
|
|
r.add(kd('PRODUCTS', fmtNum(prods), 'with variants','k-green'));
|
|
return r;
|
|
}
|
|
|
|
private static List<KpiData> kpisLead(String storeName, String dateRange) {
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
|
|
|
|
Integer total = Database.countQuery('SELECT COUNT() FROM Lead' + bw);
|
|
Integer newL = Database.countQuery('SELECT COUNT() FROM Lead' + bw + ' AND Status = \'New\'');
|
|
Integer working = Database.countQuery('SELECT COUNT() FROM Lead' + bw + ' AND Status = \'Working\'');
|
|
Integer qualified = Database.countQuery('SELECT COUNT() FROM Lead' + bw + ' AND Status = \'Qualified\'');
|
|
Integer converted = Database.countQuery('SELECT COUNT() FROM Lead' + bw + ' AND IsConverted = true');
|
|
|
|
String topSrc = '—';
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT LeadSource src, COUNT(Id) cnt FROM Lead' + bw + ' AND LeadSource != null GROUP BY LeadSource ORDER BY COUNT(Id) DESC LIMIT 1');
|
|
topSrc = topGroupVal(ar, 'src');
|
|
} catch (Exception e) {}
|
|
|
|
String[] delta = periodDelta('Lead', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd('TOTAL LEADS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd('NEW', fmtNum(newL), 'open', 'k-green'));
|
|
r.add(kd('WORKING', fmtNum(working), 'in progress', 'k-yellow'));
|
|
r.add(kd('QUALIFIED', fmtNum(qualified), 'ready', 'k-purple'));
|
|
r.add(kd('CONVERTED', fmtNum(converted), 'total', 'k-cyan'));
|
|
r.add(kd('CONV. RATE', fmtPct(converted, total), '%', 'k-green'));
|
|
r.add(kd('TOP SOURCE', topSrc, 'channel', 'k-blue'));
|
|
r.add(kd('UNCONVERTED', fmtNum(total - converted), 'open pipeline', 'k-red'));
|
|
return r;
|
|
}
|
|
|
|
private static List<KpiData> kpisAccount(String storeName, String dateRange, Boolean personAccounts) {
|
|
Boolean paOn = personAccountsEnabled();
|
|
String paFilter = paOn ? (personAccounts ? ' AND IsPersonAccount = true' : ' AND IsPersonAccount = false') : '';
|
|
String paOrdFilter = paOn ? (personAccounts ? ' AND Account.IsPersonAccount = true' : ' AND Account.IsPersonAccount = false') : '';
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr + paFilter;
|
|
|
|
Integer total = (paOn && personAccounts && !paOn) ? 0 : Database.countQuery('SELECT COUNT() FROM Account' + bw);
|
|
Integer newQ = Database.countQuery('SELECT COUNT() FROM Account' + ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + paFilter + ' AND CreatedDate = THIS_QUARTER');
|
|
Integer withOpps = 0;
|
|
try { withOpps = Database.countQuery('SELECT COUNT() FROM Account WHERE Id IN (SELECT AccountId FROM Opportunity WHERE IsClosed = false AND HIC_ShopifySync__Shopify_Id__c != null) AND HIC_ShopifySync__Shopify_Id__c != null' + paFilter); } catch (Exception e) {}
|
|
|
|
Decimal avgRev = 0;
|
|
Integer ordAccts = 0;
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT AVG(TotalAmount) avg, COUNT_DISTINCT(AccountId) ac FROM Order WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null AND AccountId != null AND TotalAmount != null' + sf + paOrdFilter);
|
|
if (!ar.isEmpty()) {
|
|
if (ar[0].get('avg') != null) avgRev = (Decimal) ar[0].get('avg');
|
|
if (ar[0].get('ac') != null) ordAccts = Integer.valueOf(String.valueOf(ar[0].get('ac')));
|
|
}
|
|
} catch (Exception e) {}
|
|
|
|
Integer oppCount = 0;
|
|
try { oppCount = Database.countQuery('SELECT COUNT() FROM Opportunity WHERE HIC_ShopifySync__Shopify_Id__c != null AND AccountId != null' + sf); } catch (Exception e) {}
|
|
String oppPerAcct = total > 0 ? (Decimal.valueOf(oppCount) / total).setScale(1) + '' : '0';
|
|
|
|
String topRegion = '—';
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT BillingCountry r, COUNT(Id) cnt FROM Account' + bw + ' AND BillingCountry != null GROUP BY BillingCountry ORDER BY COUNT(Id) DESC LIMIT 1');
|
|
topRegion = topGroupVal(ar, 'r');
|
|
} catch (Exception e) {}
|
|
|
|
String[] delta = periodDelta('Account', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + paFilter, dateRange);
|
|
String label1 = personAccounts ? 'PERSON ACCOUNTS' : 'TOTAL ACCOUNTS';
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd(label1, fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd('WITH ORDERS', fmtNum(ordAccts), 'active buyers', 'k-green'));
|
|
r.add(kd('NEW THIS QTR', fmtNum(newQ), 'added', 'k-cyan'));
|
|
r.add(kd('AVG ORDER VAL', fmtCurrency(avgRev), 'per order', 'k-purple'));
|
|
r.add(kd('WITH OPEN OPPS',fmtNum(withOpps), 'in pipeline', 'k-blue'));
|
|
r.add(kd('OPPS/ACCT', oppPerAcct, 'avg', 'k-green'));
|
|
r.add(kd('TOP REGION', topRegion, '', 'k-yellow'));
|
|
r.add(kd('OPEN OPPS', fmtNum(oppCount), 'total pipeline','k-red'));
|
|
return r;
|
|
}
|
|
|
|
private static List<KpiData> kpisContact(String storeName, String dateRange) {
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
|
|
|
|
Integer total = Database.countQuery('SELECT COUNT() FROM Contact' + bw);
|
|
Integer newM = Database.countQuery('SELECT COUNT() FROM Contact WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + ' AND CreatedDate = THIS_MONTH');
|
|
Integer withAcct = Database.countQuery('SELECT COUNT() FROM Contact' + bw + ' AND AccountId != null');
|
|
Integer withEmail = Database.countQuery('SELECT COUNT() FROM Contact' + bw + ' AND Email != null');
|
|
|
|
String topCity = '—';
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT MailingCity c, COUNT(Id) cnt FROM Contact' + bw + ' AND MailingCity != null GROUP BY MailingCity ORDER BY COUNT(Id) DESC LIMIT 1');
|
|
topCity = topGroupVal(ar, 'c');
|
|
} catch (Exception e) {}
|
|
|
|
Integer accts = 0;
|
|
try { accts = Database.query('SELECT AccountId FROM Contact' + bw + ' AND AccountId != null GROUP BY AccountId').size(); } catch (Exception e) {}
|
|
String ctPerAcct = accts > 0 ? (Decimal.valueOf(withAcct) / accts).setScale(1) + '' : '0';
|
|
|
|
String[] delta = periodDelta('Contact', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd('TOTAL CONTACTS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd('NEW THIS MONTH', fmtNum(newM), 'added', 'k-green'));
|
|
r.add(kd('WITH EMAIL', fmtNum(withEmail), '% with email', 'k-cyan'));
|
|
r.add(kd('EMAIL %', fmtPct(withEmail, total), 'opt-in', 'k-purple'));
|
|
r.add(kd('WITH ACCOUNT', fmtNum(withAcct), 'linked', 'k-yellow'));
|
|
r.add(kd('CONTACTS/ACCT', ctPerAcct, 'avg', 'k-blue'));
|
|
r.add(kd('TOP CITY', topCity, 'location', 'k-green'));
|
|
r.add(kd('NO ACCOUNT', fmtNum(total - withAcct), 'unlinked','k-red'));
|
|
return r;
|
|
}
|
|
|
|
private static List<KpiData> kpisOpportunity(String storeName, String dateRange, String payFilter) {
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String pf = String.isNotBlank(payFilter) ? ' AND ' + payFilter : '';
|
|
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr + pf;
|
|
|
|
Integer total = Database.countQuery('SELECT COUNT() FROM Opportunity' + bw);
|
|
Integer closedWon = Database.countQuery('SELECT COUNT() FROM Opportunity' + bw + ' AND StageName = \'Closed Won\'');
|
|
Integer closingSoon = 0;
|
|
try { closingSoon = Database.countQuery('SELECT COUNT() FROM Opportunity' + bw + ' AND IsClosed = false AND CloseDate <= NEXT_N_DAYS:30'); } catch (Exception e) {}
|
|
Integer wonQtr = 0;
|
|
try { wonQtr = Database.countQuery('SELECT COUNT() FROM Opportunity' + bw + ' AND StageName = \'Closed Won\' AND CloseDate = THIS_QUARTER'); } catch (Exception e) {}
|
|
|
|
Decimal pipeline = 0, avgDeal = 0, maxOpen = 0;
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT SUM(Amount) total, AVG(Amount) avg FROM Opportunity' + bw + ' AND Amount != null');
|
|
if (!ar.isEmpty()) {
|
|
if (ar[0].get('total') != null) pipeline = (Decimal) ar[0].get('total');
|
|
if (ar[0].get('avg') != null) avgDeal = (Decimal) ar[0].get('avg');
|
|
}
|
|
} catch (Exception e) {}
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT MAX(Amount) mx FROM Opportunity' + bw + ' AND IsClosed = false AND Amount != null');
|
|
if (!ar.isEmpty() && ar[0].get('mx') != null) maxOpen = (Decimal) ar[0].get('mx');
|
|
} catch (Exception e) {}
|
|
|
|
String label1 = String.isNotBlank(payFilter) && payFilter.contains('!= null') ? 'PAID OPPS'
|
|
: String.isNotBlank(payFilter) ? 'DRAFT OPPS' : 'OPEN OPPS';
|
|
String[] delta = periodDelta('Opportunity', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + pf, dateRange);
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd(label1, fmtNum(total), 'total', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd('PIPELINE VALUE',fmtCurrency(pipeline), 'total $', 'k-green'));
|
|
r.add(kd('WIN RATE', fmtPct(closedWon, total), '%', 'k-cyan'));
|
|
r.add(kd('AVG DEAL SIZE', fmtCurrency(avgDeal), 'per opp', 'k-purple'));
|
|
r.add(kd('CLOSING SOON', fmtNum(closingSoon), 'next 30 days', 'k-yellow'));
|
|
r.add(kd('WON THIS QTR', fmtNum(wonQtr), 'closed won', 'k-green'));
|
|
r.add(kd('CLOSED WON', fmtNum(closedWon), 'total', 'k-blue'));
|
|
r.add(kd('LARGEST OPEN', fmtCurrency(maxOpen), '$', 'k-red'));
|
|
return r;
|
|
}
|
|
|
|
private static List<KpiData> kpisOrder(String storeName, String dateRange, String payFilter) {
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String pf = String.isNotBlank(payFilter) ? ' AND ' + payFilter : '';
|
|
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr + pf;
|
|
|
|
Integer total = Database.countQuery('SELECT COUNT() FROM Order' + bw);
|
|
Integer newWeek = Database.countQuery('SELECT COUNT() FROM Order' + ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + pf + ' AND CreatedDate = LAST_N_DAYS:7');
|
|
|
|
Decimal revenue = 0, avgOrder = 0;
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT SUM(TotalAmount) s, AVG(TotalAmount) a FROM Order' + bw + ' AND TotalAmount != null');
|
|
if (!ar.isEmpty()) {
|
|
if (ar[0].get('s') != null) revenue = (Decimal) ar[0].get('s');
|
|
if (ar[0].get('a') != null) avgOrder = (Decimal) ar[0].get('a');
|
|
}
|
|
} catch (Exception e) {}
|
|
|
|
String topStore = '—';
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT HIC_ShopifySync__Store__c s, SUM(TotalAmount) rev FROM Order' + bw + ' AND TotalAmount != null GROUP BY HIC_ShopifySync__Store__c ORDER BY SUM(TotalAmount) DESC LIMIT 1');
|
|
topStore = topGroupVal(ar, 's');
|
|
} catch (Exception e) {}
|
|
|
|
Integer acctCount = 0;
|
|
Integer repeatAccts = 0;
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT AccountId ac, COUNT(Id) cnt FROM Order' + bw + ' AND AccountId != null GROUP BY AccountId');
|
|
acctCount = ar.size();
|
|
for (AggregateResult r : ar) {
|
|
if ((Decimal) r.get('cnt') > 1) repeatAccts++;
|
|
}
|
|
} catch (Exception e) {}
|
|
String repeatRate = acctCount > 0 ? fmtPct(repeatAccts, acctCount) : '—';
|
|
|
|
String label1 = payFilter != null && payFilter.contains('!= null') ? 'PAID ORDERS' : 'DRAFT ORDERS';
|
|
String label2 = payFilter != null && payFilter.contains('!= null') ? 'REVENUE' : 'CART VALUE';
|
|
String[] delta = periodDelta('Order', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + pf, dateRange);
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd(label1, fmtNum(total), 'total', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd(label2, fmtCurrency(revenue), 'total $', 'k-green'));
|
|
r.add(kd('AVG ORDER', fmtCurrency(avgOrder), 'per order', 'k-yellow'));
|
|
r.add(kd('REPEAT RATE',repeatRate, 'returning', 'k-purple'));
|
|
r.add(kd('CUSTOMERS', fmtNum(acctCount), 'distinct', 'k-cyan'));
|
|
r.add(kd('TOP STORE', topStore, 'by revenue', 'k-blue'));
|
|
r.add(kd('NEW THIS WEEK', fmtNum(newWeek), 'last 7 days', 'k-green'));
|
|
r.add(kd('REPEAT BUYERS', fmtNum(repeatAccts), '> 1 order', 'k-red'));
|
|
return r;
|
|
}
|
|
|
|
private static List<KpiData> kpisCollection(String storeName, String dateRange) {
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
|
|
|
|
Integer total = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Collection__c' + bw);
|
|
Integer active = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Collection__c' + bw + ' AND HIC_ShopifySync__Published_At__c != null');
|
|
Integer newM = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Collection__c WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + ' AND CreatedDate = THIS_MONTH');
|
|
|
|
Integer junctionTotal = 0;
|
|
String topColl = '—';
|
|
try {
|
|
List<AggregateResult> jAr = Database.query('SELECT COUNT(Id) cnt FROM HIC_ShopifySync__Collect__c WHERE HIC_ShopifySync__Collection__c != null AND HIC_ShopifySync__Collection__r.HIC_ShopifySync__Shopify_Id__c != null' + storeClauseFor(storeName, 'HIC_ShopifySync__Collection__r.HIC_ShopifySync__Store__c'));
|
|
if (!jAr.isEmpty() && jAr[0].get('cnt') != null) junctionTotal = Integer.valueOf(String.valueOf(jAr[0].get('cnt')));
|
|
} catch (Exception e) {}
|
|
try {
|
|
List<AggregateResult> tAr = Database.query('SELECT HIC_ShopifySync__Collection__r.Name n, COUNT(Id) cnt FROM HIC_ShopifySync__Collect__c WHERE HIC_ShopifySync__Collection__c != null AND HIC_ShopifySync__Collection__r.HIC_ShopifySync__Shopify_Id__c != null' + storeClauseFor(storeName, 'HIC_ShopifySync__Collection__r.HIC_ShopifySync__Store__c') + ' GROUP BY HIC_ShopifySync__Collection__r.Name ORDER BY COUNT(Id) DESC LIMIT 1');
|
|
topColl = topGroupVal(tAr, 'n');
|
|
} catch (Exception e) {}
|
|
|
|
String avgProds = total > 0 ? (Decimal.valueOf(junctionTotal) / total).setScale(1) + '' : '0';
|
|
String[] delta = periodDelta('HIC_ShopifySync__Collection__c', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd('COLLECTIONS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd('ACTIVE', fmtNum(active), 'published', 'k-green'));
|
|
r.add(kd('AVG PRODUCTS', avgProds, 'per collection','k-cyan'));
|
|
r.add(kd('TOP COLLECTION',topColl, 'most products', 'k-purple'));
|
|
r.add(kd('TOTAL ITEMS', fmtNum(junctionTotal), 'in collections', 'k-yellow'));
|
|
r.add(kd('NEW THIS MONTH',fmtNum(newM), 'added', 'k-blue'));
|
|
r.add(kd('INACTIVE', fmtNum(total - active), 'unpublished', 'k-red'));
|
|
r.add(kd('ACTIVE %', fmtPct(active, total), 'published %', 'k-green'));
|
|
return r;
|
|
}
|
|
|
|
private static List<KpiData> kpisInventory(String storeName, String dateRange) {
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
|
|
|
|
Integer total = 0, oos = 0, low = 0, alerts = 0, tracked = 0;
|
|
try { total = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw); } catch (Exception e) {}
|
|
try { oos = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Available__c <= 0'); } catch (Exception e) {}
|
|
try { low = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Available__c > 0 AND HIC_ShopifySync__Available__c < 10'); } catch (Exception e) {}
|
|
try { alerts = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Available__c < 5'); } catch (Exception e) {}
|
|
try { tracked = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Tracked__c = true'); } catch (Exception e) {}
|
|
|
|
Decimal avgQty = 0;
|
|
try {
|
|
List<AggregateResult> ar = Database.query('SELECT AVG(HIC_ShopifySync__Available__c) avg FROM HIC_ShopifySync__Inventory__c' + bw + ' AND HIC_ShopifySync__Available__c != null');
|
|
if (!ar.isEmpty() && ar[0].get('avg') != null) avgQty = (Decimal) ar[0].get('avg');
|
|
} catch (Exception e) {}
|
|
|
|
String topLoc = '—';
|
|
try {
|
|
String locStoreClause = storeClauseFor(storeName, 'HIC_ShopifySync__Location__r.HIC_ShopifySync__Store__c');
|
|
List<AggregateResult> ar = Database.query(
|
|
'SELECT HIC_ShopifySync__Location__r.Name l, COUNT(Id) cnt'
|
|
+ ' FROM HIC_ShopifySync__Inventory_Level__c'
|
|
+ ' WHERE HIC_ShopifySync__Location__c != null'
|
|
+ ' AND HIC_ShopifySync__Location__r.HIC_ShopifySync__Shopify_Id__c != null'
|
|
+ ' AND HIC_ShopifySync__Location__r.HIC_ShopifySync__Store__c != null'
|
|
+ locStoreClause
|
|
+ ' GROUP BY HIC_ShopifySync__Location__r.Name'
|
|
+ ' ORDER BY COUNT(Id) DESC LIMIT 1');
|
|
topLoc = topGroupVal(ar, 'l');
|
|
} catch (Exception e) {}
|
|
|
|
String[] delta = periodDelta('HIC_ShopifySync__Inventory__c', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd('TOTAL ITEMS', fmtNum(total), 'tracked', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd('TRACKED', fmtNum(tracked), 'monitored', 'k-green'));
|
|
r.add(kd('OUT OF STOCK', fmtNum(oos), '0 qty', 'k-red'));
|
|
r.add(kd('LOW STOCK', fmtNum(low), '< 10 qty', 'k-yellow'));
|
|
r.add(kd('AVG QUANTITY', fmtNum(avgQty), 'per item', 'k-cyan'));
|
|
r.add(kd('TOP LOCATION', topLoc, 'by item count', 'k-purple'));
|
|
r.add(kd('REORDER ALERTS', fmtNum(alerts), 'qty < 5', 'k-red'));
|
|
r.add(kd('IN STOCK %', total > 0 ? fmtPct(total - oos, total) : '—', 'available', 'k-green'));
|
|
return r;
|
|
}
|
|
|
|
private static List<KpiData> kpisLocation(String storeName, String dateRange) {
|
|
String sf = storeClauseFor(storeName, 'HIC_ShopifySync__Store__c');
|
|
String dr = dateRangeClause(dateRange);
|
|
String bw = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + dr;
|
|
|
|
Integer total = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Location__c' + bw);
|
|
Integer active = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Location__c' + bw + ' AND HIC_ShopifySync__Active__c = true');
|
|
Integer newYear = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Location__c WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf + ' AND CreatedDate = THIS_YEAR');
|
|
|
|
Integer countries = 0, cities = 0;
|
|
try { countries = Database.query('SELECT HIC_ShopifySync__Country__c FROM HIC_ShopifySync__Location__c' + bw + ' AND HIC_ShopifySync__Country__c != null GROUP BY HIC_ShopifySync__Country__c').size(); } catch (Exception e) {}
|
|
try { cities = Database.query('SELECT HIC_ShopifySync__City__c FROM HIC_ShopifySync__Location__c' + bw + ' AND HIC_ShopifySync__City__c != null GROUP BY HIC_ShopifySync__City__c').size(); } catch (Exception e) {}
|
|
|
|
Integer withInv = 0;
|
|
try { withInv = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Location__c WHERE Id IN (SELECT HIC_ShopifySync__Location__c FROM HIC_ShopifySync__Inventory_Level__c) AND HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf); } catch (Exception e) {}
|
|
|
|
Integer storeCount = 0;
|
|
try { storeCount = Database.query('SELECT HIC_ShopifySync__Store__c FROM HIC_ShopifySync__Location__c' + bw + ' AND HIC_ShopifySync__Store__c != null GROUP BY HIC_ShopifySync__Store__c').size(); } catch (Exception e) {}
|
|
String avgPerStore = storeCount > 0 ? (Decimal.valueOf(total) / storeCount).setScale(1) + '' : '—';
|
|
|
|
String[] delta = periodDelta('HIC_ShopifySync__Location__c', ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + sf, dateRange);
|
|
List<KpiData> r = new List<KpiData>();
|
|
r.add(kd('LOCATIONS', fmtNum(total), 'synced', 'k-blue', delta[0], delta[1]));
|
|
r.add(kd('ACTIVE', fmtNum(active), 'open', 'k-green'));
|
|
r.add(kd('COUNTRIES', fmtNum(countries), 'distinct', 'k-cyan'));
|
|
r.add(kd('CITIES', fmtNum(cities), 'distinct', 'k-purple'));
|
|
r.add(kd('WITH INVENTORY',fmtNum(withInv), 'stocked', 'k-yellow'));
|
|
r.add(kd('AVG PER STORE', avgPerStore, 'locations', 'k-blue'));
|
|
r.add(kd('INACTIVE', fmtNum(total - active), 'inactive', 'k-red'));
|
|
r.add(kd('NEW THIS YEAR', fmtNum(newYear), 'added', 'k-green'));
|
|
return r;
|
|
}
|
|
|
|
private static void putChart(Map<String, DashboardData> m, String key, DashboardData d) {
|
|
if (d != null) m.put(key, d);
|
|
}
|
|
|
|
// ---------- Private chart builders ----------
|
|
|
|
private static DashboardData chartCreatedLast3Months(String sobjectName, String baseWhere, String title) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = title;
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b'};
|
|
|
|
Date today = Date.today();
|
|
List<String> monthKeys = new List<String>();
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>{0, 0, 0};
|
|
|
|
for (Integer i = 3; i >= 1; i--) {
|
|
Date m = today.addMonths(-i);
|
|
monthKeys.add(m.year() + '-' + m.month());
|
|
d.labels.add(monthAbbrev(m.month()) + ' ' + m.year());
|
|
}
|
|
|
|
String soql = 'SELECT CALENDAR_MONTH(CreatedDate) mo, CALENDAR_YEAR(CreatedDate) yr, COUNT(Id) cnt'
|
|
+ ' FROM ' + sobjectName + baseWhere
|
|
+ ' AND CreatedDate >= LAST_N_MONTHS:3'
|
|
+ ' GROUP BY CALENDAR_MONTH(CreatedDate), CALENDAR_YEAR(CreatedDate)';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
Integer mo = (Integer) ar.get('mo');
|
|
Integer yr = (Integer) ar.get('yr');
|
|
String key = yr + '-' + mo;
|
|
Integer idx = monthKeys.indexOf(key);
|
|
if (idx >= 0) {
|
|
d.values[idx] = (Decimal) ar.get('cnt');
|
|
}
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartCreatedLast3Months(' + sobjectName + ') failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/**
|
|
* Product dash1 — REAL "Top 6 Products by Units Sold" (replaces the LWC mock).
|
|
* Sums OrderItem.Quantity per product across synced Orders; store + date
|
|
* filters apply to the parent Order (i.e. units sold WITHIN the range).
|
|
*/
|
|
private static DashboardData chartTopProductsByUnitsSold(String storeName, String dateRange) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 6 Products by Units Sold';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2','#16a34a'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT Product2.Name prodName, SUM(Quantity) units'
|
|
+ ' FROM OrderItem'
|
|
+ ' WHERE Product2Id != null'
|
|
+ ' AND Order.HIC_ShopifySync__Shopify_Id__c != null'
|
|
+ ' AND Order.HIC_ShopifySync__Store__c != null'
|
|
+ storeClauseFor(storeName, 'Order.HIC_ShopifySync__Store__c')
|
|
+ dateRangeClauseFor(dateRange, 'Order.CreatedDate')
|
|
+ ' GROUP BY Product2.Name'
|
|
+ ' ORDER BY SUM(Quantity) DESC'
|
|
+ ' LIMIT 6';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
String name = (String) ar.get('prodName');
|
|
Decimal units = (Decimal) ar.get('units');
|
|
d.labels.add(name != null ? name.left(20) : 'Unknown');
|
|
d.values.add(units != null ? units : 0);
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartTopProductsByUnitsSold failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/** Variant dash1 — REAL "Top 6 Variants by Inventory" (replaces the LWC mock). */
|
|
private static DashboardData chartTopVariantsByInventory(String scopeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 6 Products by Variant Count';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2','#16a34a'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
// Fallback path: try qty-based ordering first (matches original title if data is populated).
|
|
// If ALL variants have null qty in this store, we return a "variants per product" bar instead
|
|
// so the tile always renders something meaningful.
|
|
String qtySoql = 'SELECT Name, HIC_ShopifySync__Inventory_Quantity__c qty'
|
|
+ ' FROM HIC_ShopifySync__Variant__c'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ scopeClause
|
|
+ ' AND HIC_ShopifySync__Inventory_Quantity__c != null'
|
|
+ ' AND HIC_ShopifySync__Inventory_Quantity__c > 0'
|
|
+ ' ORDER BY HIC_ShopifySync__Inventory_Quantity__c DESC'
|
|
+ ' LIMIT 6';
|
|
|
|
try {
|
|
List<SObject> qtyRows = Database.query(qtySoql);
|
|
if (!qtyRows.isEmpty()) {
|
|
d.title = 'Top 6 Variants by Inventory';
|
|
for (SObject r : qtyRows) {
|
|
String name = (String) r.get('Name');
|
|
d.labels.add(name != null ? name.left(20) : 'Unknown');
|
|
d.values.add((Decimal) r.get('HIC_ShopifySync__Inventory_Quantity__c'));
|
|
}
|
|
return d;
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartTopVariantsByInventory qty path failed: ' + e.getMessage());
|
|
}
|
|
|
|
// Fallback: variants grouped per parent product (always populated when variants exist)
|
|
try {
|
|
String grpSoql = 'SELECT HIC_ShopifySync__Product__r.Name prodName, COUNT(Id) cnt'
|
|
+ ' FROM HIC_ShopifySync__Variant__c'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ scopeClause
|
|
+ ' AND HIC_ShopifySync__Product__c != null'
|
|
+ ' GROUP BY HIC_ShopifySync__Product__r.Name'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 6';
|
|
for (AggregateResult ar : Database.query(grpSoql)) {
|
|
String name = (String) ar.get('prodName');
|
|
d.labels.add(name != null ? name.left(20) : 'Unknown');
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartTopVariantsByInventory group path failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/** Lead dash1 — REAL "Leads by Status" donut (replaces the LWC mock ratios). */
|
|
private static DashboardData chartLeadsByStatus(String scopeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Leads by Status';
|
|
d.defaultChart = 'donut';
|
|
d.colors = new List<String>{'#1f6feb','#f59e0b','#2f9e44','#94a3b8','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT Status st, COUNT(Id) cnt'
|
|
+ ' FROM Lead'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ scopeClause
|
|
+ ' GROUP BY Status'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 6';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
String st = (String) ar.get('st');
|
|
d.labels.add(st != null ? st : 'Unknown');
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartLeadsByStatus failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/** Lead dash2 — REAL "Top Lead Sources" bar (replaces the LWC mock ratios). */
|
|
private static DashboardData chartLeadsBySource(String scopeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top Lead Sources';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2','#16a34a'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT LeadSource src, COUNT(Id) cnt'
|
|
+ ' FROM Lead'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ scopeClause
|
|
+ ' GROUP BY LeadSource'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 6';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
String src = (String) ar.get('src');
|
|
d.labels.add(src != null ? src : 'Unknown');
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartLeadsBySource failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/** Contact dash1 — REAL "Contacts by City" donut (replaces the LWC mock). */
|
|
private static DashboardData chartContactsByCity(String scopeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Contacts by City';
|
|
d.defaultChart = 'donut';
|
|
d.colors = new List<String>{'#1f6feb','#2f9e44','#f59e0b','#7c3aed','#0891b2','#16a34a'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT MailingCity city, COUNT(Id) cnt'
|
|
+ ' FROM Contact'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ scopeClause
|
|
+ ' GROUP BY MailingCity'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 6';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
String city = (String) ar.get('city');
|
|
d.labels.add(city != null ? city : 'Unknown');
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartContactsByCity failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/** Contact dash3 — REAL "Top 5 Accounts by Contact Count" (replaces the LWC mock). */
|
|
private static DashboardData chartContactsPerAccount(String scopeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 5 Accounts by Contact Count';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT Account.Name accName, COUNT(Id) cnt'
|
|
+ ' FROM Contact'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ scopeClause
|
|
+ ' AND AccountId != null'
|
|
+ ' GROUP BY Account.Name'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
d.labels.add((String) ar.get('accName'));
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartContactsPerAccount failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static DashboardData chartTopProductsByPrice(String storeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 5 Products by Price';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT HIC_ShopifySync__Product__r.Name prodName, MAX(HIC_ShopifySync__Price__c) maxPrice'
|
|
+ ' FROM HIC_ShopifySync__Variant__c'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ ' AND HIC_ShopifySync__Product__c != null AND HIC_ShopifySync__Price__c != null'
|
|
+ ' GROUP BY HIC_ShopifySync__Product__r.Name'
|
|
+ ' ORDER BY MAX(HIC_ShopifySync__Price__c) DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
String name = (String) ar.get('prodName');
|
|
Decimal price = (Decimal) ar.get('maxPrice');
|
|
d.labels.add(name != null ? name : 'Unknown');
|
|
d.values.add(price != null ? price : 0);
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartTopProductsByPrice failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static DashboardData chartTopVariantsByPrice(String storeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 5 Variants by Price';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT Name, HIC_ShopifySync__Price__c'
|
|
+ ' FROM HIC_ShopifySync__Variant__c'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ ' AND HIC_ShopifySync__Price__c != null'
|
|
+ ' ORDER BY HIC_ShopifySync__Price__c DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (SObject r : Database.query(soql)) {
|
|
d.labels.add((String) r.get('Name'));
|
|
d.values.add((Decimal) r.get('HIC_ShopifySync__Price__c'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartTopVariantsByPrice failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
// accountTypeFilter: ' AND Account.IsPersonAccount = true/false' — splits the
|
|
// chart correctly between the Account and Person Account sidebar tabs.
|
|
private static DashboardData chartTopAccountsByOrderCount(String storeClause, String accountTypeFilter) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 5 Accounts by Order Count';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT Account.Name accName, COUNT(Id) cnt'
|
|
+ ' FROM Order'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ ' AND AccountId != null'
|
|
+ (String.isNotBlank(accountTypeFilter) ? accountTypeFilter : '')
|
|
+ ' GROUP BY Account.Name'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
d.labels.add((String) ar.get('accName'));
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
// e.g. IsPersonAccount not available (Person Accounts disabled)
|
|
System.debug('[ShopifySyncDashboardController] chartTopAccountsByOrderCount failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/**
|
|
* oppTypeFilter: optional field filter (e.g. payment-status) appended with
|
|
* an explicit AND, mirroring the parameterized chartOrdersByAccount() used
|
|
* by paidOrder/draftOrder. Pass null/blank for the unfiltered 'opp' tab.
|
|
*/
|
|
private static DashboardData chartOpportunitiesByAccount(String storeClause, String oppTypeFilter) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Opportunities per Account';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT Account.Name accName, COUNT(Id) cnt'
|
|
+ ' FROM Opportunity'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ ' AND AccountId != null'
|
|
+ (String.isNotBlank(oppTypeFilter) ? ' AND ' + oppTypeFilter : '')
|
|
+ ' GROUP BY Account.Name'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
d.labels.add((String) ar.get('accName'));
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartOpportunitiesByAccount failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static DashboardData chartPaidVsDraftOpportunities(String storeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Paid vs Draft Opportunities';
|
|
d.defaultChart = 'donut';
|
|
d.colors = new List<String>{'#2f9e44','#f59e0b'};
|
|
d.labels = new List<String>{'Paid', 'Draft'};
|
|
|
|
String baseWhere = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + storeClause;
|
|
|
|
try {
|
|
Integer paid = Database.countQuery(
|
|
'SELECT COUNT() FROM Opportunity' + baseWhere + ' AND HIC_ShopifySync__Payment_Status__c != null'
|
|
);
|
|
Integer draft = Database.countQuery(
|
|
'SELECT COUNT() FROM Opportunity' + baseWhere + ' AND HIC_ShopifySync__Payment_Status__c = null'
|
|
);
|
|
d.values = new List<Decimal>{paid, draft};
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartPaidVsDraftOpportunities failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/**
|
|
* oppTypeFilter: optional field filter (e.g. payment-status) appended with
|
|
* an explicit AND, mirroring the parameterized chartTopOrdersByAmount()
|
|
* used by paidOrder/draftOrder. Pass null/blank for the unfiltered 'opp' tab.
|
|
*/
|
|
private static DashboardData chartTopOpportunitiesByAmount(String storeClause, String oppTypeFilter) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 5 Opportunities by Amount';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT Name, Amount'
|
|
+ ' FROM Opportunity'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ (String.isNotBlank(oppTypeFilter) ? ' AND ' + oppTypeFilter : '')
|
|
+ ' AND Amount != null'
|
|
+ ' ORDER BY Amount DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (SObject r : Database.query(soql)) {
|
|
String name = (String) r.get('Name');
|
|
d.labels.add(name != null ? name.left(20) : 'Unknown');
|
|
d.values.add((Decimal) r.get('Amount'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartTopOpportunitiesByAmount failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static DashboardData chartOrdersByAccount(String storeClause, String orderTypeFilter) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Orders per Account';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT Account.Name accName, COUNT(Id) cnt'
|
|
+ ' FROM Order'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ ' AND AccountId != null'
|
|
+ (String.isNotBlank(orderTypeFilter) ? ' AND ' + orderTypeFilter : '')
|
|
+ ' GROUP BY Account.Name'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
d.labels.add((String) ar.get('accName'));
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartOrdersByAccount failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static DashboardData chartTopOrdersByAmount(String storeClause, String orderTypeFilter) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 5 Orders by Amount';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT OrderNumber, TotalAmount'
|
|
+ ' FROM Order'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ (String.isNotBlank(orderTypeFilter) ? ' AND ' + orderTypeFilter : '')
|
|
+ ' AND TotalAmount != null'
|
|
+ ' ORDER BY TotalAmount DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (SObject r : Database.query(soql)) {
|
|
d.labels.add((String) r.get('OrderNumber'));
|
|
d.values.add((Decimal) r.get('TotalAmount'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartTopOrdersByAmount failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static DashboardData chartConvertedOrders(String storeClause, String convFieldName, String orderTypeFilter) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Converted vs Non-Converted';
|
|
d.defaultChart = 'donut';
|
|
d.colors = new List<String>{'#2f9e44','#94a3b8'};
|
|
d.labels = new List<String>{'Converted', 'Non-Converted'};
|
|
|
|
String baseWhere = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ (String.isNotBlank(orderTypeFilter) ? ' AND ' + orderTypeFilter : '');
|
|
|
|
try {
|
|
Integer converted = Database.countQuery(
|
|
'SELECT COUNT() FROM Order' + baseWhere + ' AND ' + convFieldName + ' != null'
|
|
);
|
|
Integer nonConverted = Database.countQuery(
|
|
'SELECT COUNT() FROM Order' + baseWhere + ' AND ' + convFieldName + ' = null'
|
|
);
|
|
d.values = new List<Decimal>{converted, nonConverted};
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartConvertedOrders failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static DashboardData chartTopAccountsByRevenue(String storeClause, String accountTypeFilter) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 5 Accounts by Revenue';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
String soql = 'SELECT Account.Name accName, SUM(TotalAmount) total'
|
|
+ ' FROM Order'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ ' AND AccountId != null AND TotalAmount != null'
|
|
+ (String.isNotBlank(accountTypeFilter) ? accountTypeFilter : '')
|
|
+ ' GROUP BY Account.Name'
|
|
+ ' ORDER BY SUM(TotalAmount) DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
d.labels.add((String) ar.get('accName'));
|
|
d.values.add((Decimal) ar.get('total'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartTopAccountsByRevenue failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
// scopeClause arrives pre-prefixed for the junction (Collection__r.Store +
|
|
// junction CreatedDate) — built by the caller, no string surgery here.
|
|
private static DashboardData chartProductsPerCollection(String scopeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 5 Collections by Product Count';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
// Collect__c is the junction between Collection and Product
|
|
String soql = 'SELECT HIC_ShopifySync__Collection__r.Name collName, COUNT(Id) cnt'
|
|
+ ' FROM HIC_ShopifySync__Collect__c'
|
|
+ ' WHERE HIC_ShopifySync__Collection__c != null'
|
|
+ ' AND HIC_ShopifySync__Collection__r.HIC_ShopifySync__Shopify_Id__c != null'
|
|
+ ' AND HIC_ShopifySync__Collection__r.HIC_ShopifySync__Store__c != null'
|
|
+ scopeClause
|
|
+ ' GROUP BY HIC_ShopifySync__Collection__r.Name'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
String name = (String) ar.get('collName');
|
|
d.labels.add(name != null ? name.left(20) : 'Unknown');
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartProductsPerCollection failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
private static DashboardData chartInventoryByVariant(String storeClause) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Top 5 Inventory Items by Quantity';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
// Primary path: top inventory items by Available quantity (only when populated).
|
|
String soql = 'SELECT Name, HIC_ShopifySync__Available__c'
|
|
+ ' FROM HIC_ShopifySync__Inventory__c'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ storeClause
|
|
+ ' AND HIC_ShopifySync__Available__c != null'
|
|
+ ' AND HIC_ShopifySync__Available__c > 0'
|
|
+ ' ORDER BY HIC_ShopifySync__Available__c DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
try {
|
|
List<SObject> rows = Database.query(soql);
|
|
if (!rows.isEmpty()) {
|
|
for (SObject r : rows) {
|
|
String name = (String) r.get('Name');
|
|
d.labels.add(name != null ? name.left(20) : 'Unknown');
|
|
d.values.add((Decimal) r.get('HIC_ShopifySync__Available__c'));
|
|
}
|
|
return d;
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartInventoryByVariant qty path failed: ' + e.getMessage());
|
|
}
|
|
|
|
// Fallback: donut of Tracked vs Not Tracked (Tracked field is always populated)
|
|
try {
|
|
d.title = 'Inventory by Tracked Status';
|
|
d.defaultChart = 'donut';
|
|
d.colors = new List<String>{'#2f9e44','#94a3b8'};
|
|
String baseWhere = ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null' + storeClause;
|
|
Integer tracked = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + baseWhere + ' AND HIC_ShopifySync__Tracked__c = true');
|
|
Integer untracked = Database.countQuery('SELECT COUNT() FROM HIC_ShopifySync__Inventory__c' + baseWhere + ' AND (HIC_ShopifySync__Tracked__c = false OR HIC_ShopifySync__Tracked__c = null)');
|
|
d.labels = new List<String>{'Tracked','Not Tracked'};
|
|
d.values = new List<Decimal>{tracked, untracked};
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] chartInventoryByVariant tracked fallback failed: ' + e.getMessage());
|
|
return null;
|
|
}
|
|
return d;
|
|
}
|
|
|
|
// prefixedScope = store/date clauses on Location__r path (for the junction
|
|
// query); plainScope = same clauses on the Location object itself (fallback).
|
|
private static DashboardData chartProductsPerLocation(String prefixedScope, String plainScope) {
|
|
DashboardData d = new DashboardData();
|
|
d.title = 'Inventory Items per Location';
|
|
d.defaultChart = 'bar';
|
|
d.colors = new List<String>{'#2f9e44','#1f6feb','#f59e0b','#7c3aed','#0891b2'};
|
|
d.labels = new List<String>();
|
|
d.values = new List<Decimal>();
|
|
|
|
// Try Inventory_Level__c which links inventory items to locations
|
|
try {
|
|
String soql = 'SELECT HIC_ShopifySync__Location__r.Name locName, COUNT(Id) cnt'
|
|
+ ' FROM HIC_ShopifySync__Inventory_Level__c'
|
|
+ ' WHERE HIC_ShopifySync__Location__c != null'
|
|
+ ' AND HIC_ShopifySync__Location__r.HIC_ShopifySync__Shopify_Id__c != null'
|
|
+ ' AND HIC_ShopifySync__Location__r.HIC_ShopifySync__Store__c != null'
|
|
+ prefixedScope
|
|
+ ' GROUP BY HIC_ShopifySync__Location__r.Name'
|
|
+ ' ORDER BY COUNT(Id) DESC'
|
|
+ ' LIMIT 5';
|
|
|
|
for (AggregateResult ar : Database.query(soql)) {
|
|
String name = (String) ar.get('locName');
|
|
d.labels.add(name != null ? name : 'Unknown');
|
|
d.values.add((Decimal) ar.get('cnt'));
|
|
}
|
|
} catch (Exception e) {
|
|
// Inventory_Level__c may not exist — fallback to showing Location names
|
|
System.debug('[ShopifySyncDashboardController] chartProductsPerLocation fallback: ' + e.getMessage());
|
|
try {
|
|
String soql2 = 'SELECT Name FROM HIC_ShopifySync__Location__c'
|
|
+ ' WHERE HIC_ShopifySync__Shopify_Id__c != null AND HIC_ShopifySync__Store__c != null'
|
|
+ plainScope
|
|
+ ' ORDER BY Name ASC LIMIT 5';
|
|
for (SObject r : Database.query(soql2)) {
|
|
d.labels.add((String) r.get('Name'));
|
|
d.values.add(1);
|
|
}
|
|
} catch (Exception e2) {
|
|
System.debug('[ShopifySyncDashboardController] chartProductsPerLocation failed: ' + e2.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
return d;
|
|
}
|
|
|
|
/**
|
|
* Converts a UI date-range label into a SOQL WHERE fragment on CreatedDate.
|
|
* Returns empty string for 'All Time' or unrecognised values (= no filter).
|
|
*/
|
|
private static String dateRangeClause(String dateRange) {
|
|
return dateRangeClauseFor(dateRange, 'CreatedDate');
|
|
}
|
|
|
|
/**
|
|
* Field-path-aware variant — lets charts on related objects scope by the
|
|
* correct path (e.g. 'Order.CreatedDate' on OrderItem queries).
|
|
*/
|
|
private static String dateRangeClauseFor(String dateRange, String fieldPath) {
|
|
if (String.isBlank(dateRange) || dateRange == 'All Time') return '';
|
|
if (dateRange == 'Today') return ' AND ' + fieldPath + ' = TODAY';
|
|
if (dateRange == 'Yesterday') return ' AND ' + fieldPath + ' = YESTERDAY';
|
|
if (dateRange == 'Last 7 days') return ' AND ' + fieldPath + ' = LAST_N_DAYS:7';
|
|
if (dateRange == 'Last 30 days') return ' AND ' + fieldPath + ' = LAST_N_DAYS:30';
|
|
if (dateRange == 'This Month') return ' AND ' + fieldPath + ' = THIS_MONTH';
|
|
if (dateRange == 'Last Month') return ' AND ' + fieldPath + ' = LAST_MONTH';
|
|
if (dateRange == 'This Quarter') return ' AND ' + fieldPath + ' = THIS_QUARTER';
|
|
if (dateRange == 'This Year') return ' AND ' + fieldPath + ' = THIS_YEAR';
|
|
return '';
|
|
}
|
|
|
|
/** Store filter on an arbitrary field path; empty when no store selected. */
|
|
private static String storeClauseFor(String storeName, String fieldPath) {
|
|
if (String.isBlank(storeName)) return '';
|
|
return ' AND ' + fieldPath + ' = \'' + String.escapeSingleQuotes(storeName) + '\'';
|
|
}
|
|
|
|
private static String monthAbbrev(Integer m) {
|
|
List<String> names = new List<String>{'Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'};
|
|
return (m >= 1 && m <= 12) ? names[m - 1] : String.valueOf(m);
|
|
}
|
|
|
|
/* =================== Internals =================== */
|
|
|
|
private static String lookupAccessToken(String storeName) {
|
|
try {
|
|
List<HIC_ShopifySync__HIC_Configuration__c> configs = [
|
|
SELECT Id, HIC_ShopifySync__Private_Key_Password__c
|
|
FROM HIC_ShopifySync__HIC_Configuration__c
|
|
WHERE HIC_ShopifySync__Store__c = :storeName
|
|
LIMIT 1
|
|
];
|
|
if (!configs.isEmpty()) {
|
|
return configs[0].HIC_ShopifySync__Private_Key_Password__c;
|
|
}
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] config lookup failed: ' + e.getMessage());
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static Integer fetchShopifyCount(String url, String token) {
|
|
HttpRequest req = new HttpRequest();
|
|
req.setEndpoint(url);
|
|
req.setMethod('GET');
|
|
req.setHeader('X-Shopify-Access-Token', token);
|
|
req.setHeader('Content-Type', 'application/json');
|
|
req.setTimeout(30000);
|
|
|
|
Http http = new Http();
|
|
HttpResponse res = http.send(req);
|
|
Integer status = res.getStatusCode();
|
|
if (status < 200 || status >= 300) {
|
|
throw new ShopifyCalloutException(
|
|
'HTTP ' + status + ' from ' + url + ' :: ' + res.getBody());
|
|
}
|
|
Map<String, Object> body = (Map<String, Object>) JSON.deserializeUntyped(res.getBody());
|
|
Object countVal = body.get('count');
|
|
if (countVal == null) return 0;
|
|
if (countVal instanceof Integer) return (Integer) countVal;
|
|
if (countVal instanceof Long) return ((Long) countVal).intValue();
|
|
if (countVal instanceof Decimal) return ((Decimal) countVal).intValue();
|
|
return Integer.valueOf(String.valueOf(countVal));
|
|
}
|
|
|
|
private static Integer safeGet(Map<String, Integer> m, String k) {
|
|
Integer v = m.get(k);
|
|
return v == null ? 0 : v;
|
|
}
|
|
|
|
private static Map<String, Integer> initZeroCounts() {
|
|
return new Map<String, Integer>{
|
|
'product' => 0, 'variant' => 0, 'lead' => 0,
|
|
'pacc' => 0, 'account' => 0, 'contact' => 0,
|
|
'opp' => 0, 'draftOrder' => 0, 'paidOrder' => 0,
|
|
'collection' => 0, 'inventory' => 0, 'location' => 0
|
|
};
|
|
}
|
|
|
|
/**
|
|
* True when Person Accounts are enabled in the org — i.e. the
|
|
* IsPersonAccount column exists on Account. Querying it in orgs where the
|
|
* feature is OFF throws "No such column 'IsPersonAccount'".
|
|
*/
|
|
@TestVisible
|
|
private static Boolean personAccountsEnabled() {
|
|
if (paCacheSet) return cachedPaEnabled;
|
|
|
|
paCacheSet = true;
|
|
cachedPaEnabled = false;
|
|
|
|
try {
|
|
// Check 1: Field exists in schema map
|
|
Map<String, Schema.SObjectField> fieldMap = Schema.SObjectType.Account.fields.getMap();
|
|
if (!fieldMap.containsKey('ispersonaccount')) {
|
|
return false;
|
|
}
|
|
|
|
// Check 2: Field is actually accessible and usable in WHERE clauses
|
|
Schema.DescribeFieldResult dfr = fieldMap.get('ispersonaccount').getDescribe();
|
|
if (!dfr.isAccessible() || !dfr.isFilterable()) {
|
|
return false;
|
|
}
|
|
|
|
// Check 3: Defensive — actually try to query the field
|
|
// LIMIT 0 = no rows returned, minimal cost, but query must compile + execute
|
|
Database.countQuery('SELECT COUNT() FROM Account WHERE IsPersonAccount = false LIMIT 0');
|
|
cachedPaEnabled = true;
|
|
|
|
} catch (Exception e) {
|
|
// Any exception = field not truly usable (No such column, Invalid field, etc.)
|
|
System.debug('[ShopifySyncDashboardController] Person Accounts verification failed: ' + e.getMessage());
|
|
cachedPaEnabled = false;
|
|
}
|
|
|
|
return cachedPaEnabled;
|
|
}
|
|
|
|
private static Map<String, ObjectDef> objectDefs() {
|
|
Boolean paOn = personAccountsEnabled();
|
|
return new Map<String, ObjectDef>{
|
|
'product' => new ObjectDef('Product2', null),
|
|
'variant' => new ObjectDef('HIC_ShopifySync__Variant__c', null),
|
|
'lead' => new ObjectDef('Lead', null),
|
|
'pacc' => new ObjectDef('Account', paOn ? 'IsPersonAccount = true' : 'Id = null'),
|
|
'account' => new ObjectDef('Account', paOn ? 'IsPersonAccount = false' : null),
|
|
'contact' => new ObjectDef('Contact', null),
|
|
'paidOpp' => new ObjectDef('Opportunity', 'HIC_ShopifySync__Payment_Status__c != null'), // Paid Order Opportunities
|
|
'draftOpp' => new ObjectDef('Opportunity', 'HIC_ShopifySync__Payment_Status__c = null'), // Draft Order Opportunities
|
|
'draftOrder' => new ObjectDef('Order', 'HIC_ShopifySync__Payment_Status__c = null'),
|
|
'paidOrder' => new ObjectDef('Order', 'HIC_ShopifySync__Payment_Status__c != null'),
|
|
'collection' => new ObjectDef('HIC_ShopifySync__Collection__c', null),
|
|
'inventory' => new ObjectDef('HIC_ShopifySync__Inventory__c', null),
|
|
'location' => new ObjectDef('HIC_ShopifySync__Location__c', null)
|
|
};
|
|
}
|
|
/* =================== DTO + helpers =================== */
|
|
|
|
public class Store {
|
|
@AuraEnabled public String id;
|
|
@AuraEnabled public String name;
|
|
|
|
// General toggles
|
|
@AuraEnabled public Boolean productSync;
|
|
@AuraEnabled public Boolean accountContact;
|
|
@AuraEnabled public Boolean enableLead;
|
|
@AuraEnabled public Boolean personAccount;
|
|
@AuraEnabled public Boolean collectionSync;
|
|
@AuraEnabled public Boolean inventoryLocation;
|
|
|
|
// Paid Order toggles
|
|
@AuraEnabled public Boolean orderSync; // Parent: HIC_ShopifySync__Order__c
|
|
@AuraEnabled public Boolean orderToggle; // Child: HIC_ShopifySync__Order_Sync__c
|
|
@AuraEnabled public Boolean opportunity; // Child: HIC_ShopifySync__Opportunity__c
|
|
|
|
// Draft Order toggles
|
|
@AuraEnabled public Boolean draftOrderSync; // Parent: HIC_ShopifySync__Draft_Order_Sync__c
|
|
@AuraEnabled public Boolean draftOrderToggle; // Child: HIC_ShopifySync__Enable_Draft_Order__c
|
|
@AuraEnabled public Boolean draftOpportunity; // Child: HIC_ShopifySync__Enable_Opportunity_for_SDO__c
|
|
}
|
|
|
|
private class ObjectDef {
|
|
public String sobjectName;
|
|
public String extraFilter;
|
|
public ObjectDef(String s, String f) { this.sobjectName = s; this.extraFilter = f; }
|
|
}
|
|
|
|
public class FieldInfo implements Comparable {
|
|
@AuraEnabled public String apiName;
|
|
@AuraEnabled public String label;
|
|
@AuraEnabled public String type;
|
|
@AuraEnabled public Boolean sortable;
|
|
@AuraEnabled public Boolean isDefault; // true if in defaultFieldsFor() list
|
|
@AuraEnabled public Integer defaultOrder; // position in defaultFieldsFor(), null otherwise
|
|
|
|
/**
|
|
* Sort order for the field selector menu AND default visible columns:
|
|
* 1) Default fields first, in the order they appear in defaultFieldsFor()
|
|
* 2) Non-default fields after, alphabetically by label
|
|
* Apex doesn't have Integer.MAX_VALUE — we handle null defaultOrder
|
|
* with explicit checks so the comparator stays primitive-safe.
|
|
*/
|
|
public Integer compareTo(Object o) {
|
|
FieldInfo other = (FieldInfo) o;
|
|
Boolean a = (this.isDefault == true);
|
|
Boolean b = (other.isDefault == true);
|
|
if (a != b) return a ? -1 : 1;
|
|
if (a && b) {
|
|
// Both flagged as default — order by defaultOrder.
|
|
// null defaultOrder sorts AFTER any concrete value (treated as last).
|
|
if (this.defaultOrder == null && other.defaultOrder == null) return 0;
|
|
if (this.defaultOrder == null) return 1;
|
|
if (other.defaultOrder == null) return -1;
|
|
return this.defaultOrder - other.defaultOrder;
|
|
}
|
|
// Both non-default: alphabetical by label
|
|
if (this.label == null) return -1;
|
|
if (other.label == null) return 1;
|
|
return this.label.compareTo(other.label);
|
|
}
|
|
}
|
|
|
|
public class DashboardData {
|
|
@AuraEnabled public String title;
|
|
@AuraEnabled public String defaultChart;
|
|
@AuraEnabled public List<String> labels;
|
|
@AuraEnabled public List<Decimal> values;
|
|
@AuraEnabled public List<String> colors;
|
|
}
|
|
|
|
public class KpiData {
|
|
@AuraEnabled public String label; // e.g. 'TOTAL PRODUCTS'
|
|
@AuraEnabled public String value; // pre-formatted e.g. '1,234' or '$12.3K'
|
|
@AuraEnabled public String sub; // sub-label e.g. 'synced'
|
|
@AuraEnabled public String accent; // CSS class e.g. 'k-blue'
|
|
@AuraEnabled public String delta; // e.g. '▲ 12.3%' or null
|
|
@AuraEnabled public String deltaDir; // 'up', 'down', or 'neutral'
|
|
}
|
|
|
|
/**
|
|
* Share → More → Copy Link backend.
|
|
* Saves the client-generated report file (CSV/XLS/HTML) as a ContentVersion,
|
|
* wraps it in a ContentDistribution, and returns the PUBLIC download URL.
|
|
* Anyone opening that URL downloads the file directly — no SF login needed.
|
|
*
|
|
* @param fileName e.g. "shopify-sync_product_20260610_1430.csv"
|
|
* @param base64Data base64-encoded file content
|
|
* @return ContentDownloadUrl (direct download)
|
|
*/
|
|
@AuraEnabled
|
|
public static String createPublicFileLink(String fileName, String base64Data) {
|
|
if (String.isBlank(fileName) || String.isBlank(base64Data)) {
|
|
AuraHandledException bad = new AuraHandledException('fileName and base64Data are required.');
|
|
bad.setMessage('fileName and base64Data are required.');
|
|
throw bad;
|
|
}
|
|
try {
|
|
ContentVersion cv = new ContentVersion(
|
|
Title = fileName,
|
|
PathOnClient = fileName,
|
|
VersionData = EncodingUtil.base64Decode(base64Data),
|
|
IsMajorVersion = true
|
|
);
|
|
insert cv;
|
|
|
|
ContentDistribution cd = new ContentDistribution(
|
|
Name = fileName,
|
|
ContentVersionId = cv.Id,
|
|
PreferencesAllowOriginalDownload = true,
|
|
PreferencesAllowPDFDownload = true,
|
|
PreferencesAllowViewInBrowser = false, // force download, not preview
|
|
PreferencesNotifyOnVisit = false,
|
|
PreferencesLinkLatestVersion = true
|
|
);
|
|
insert cd;
|
|
|
|
cd = [SELECT ContentDownloadUrl, DistributionPublicUrl
|
|
FROM ContentDistribution WHERE Id = :cd.Id LIMIT 1];
|
|
return String.isNotBlank(cd.ContentDownloadUrl)
|
|
? cd.ContentDownloadUrl
|
|
: cd.DistributionPublicUrl;
|
|
} catch (Exception e) {
|
|
AuraHandledException ex = new AuraHandledException(e.getMessage());
|
|
ex.setMessage('Could not create public download link: ' + e.getMessage());
|
|
throw ex;
|
|
}
|
|
}
|
|
|
|
public class ShopifyCalloutException extends Exception {}
|
|
|
|
/* =================== Pure Apex PDF Generation (no VF page) =================== */
|
|
@AuraEnabled
|
|
public static String generatePdfBase64(List<String> headers, List<List<String>> rows, String title) {
|
|
if (headers == null || headers.isEmpty()) {
|
|
throw new AuraHandledException('Headers are required.');
|
|
}
|
|
try {
|
|
// ---- Page geometry ----
|
|
Integer margin = 36;
|
|
Integer pageWidth = 842; // A4 landscape
|
|
Integer pageHeight = 612;
|
|
Integer contentWidth = pageWidth - (margin * 2);
|
|
Integer colCount = headers.size();
|
|
// Give every column an equal share of the content width.
|
|
// Integer division is fine — any leftover pixel lands in whitespace.
|
|
Integer colWidth = contentWidth / colCount;
|
|
|
|
String reportTitle = String.isNotBlank(title) ? title : 'Shopify Sync Report';
|
|
Integer totalRows = rows != null ? rows.size() : 0;
|
|
|
|
// ---- Brand colours ----
|
|
// #95BE45 → R=149 G=190 B=69 → PDF: 0.584 0.745 0.271
|
|
// Already used as accent; now ALSO the header band colour.
|
|
String GREEN_RG = '0.584 0.745 0.271'; // #95BE45
|
|
String NAVY_RG = '0.122 0.161 0.216'; // #1F2937 dark navy
|
|
String WHITE_RG = '1 1 1';
|
|
String LIGHT_RG = '0.961 0.969 0.976'; // alternating row tint
|
|
String RULE_RG = '0.88 0.89 0.91'; // thin rule / footer line
|
|
String GRAY_RG = '0.45 0.45 0.45'; // subtitle text
|
|
|
|
List<String> ops = new List<String>();
|
|
|
|
// ---- Top accent strip (6px green bar across full width) ----
|
|
ops.add(GREEN_RG + ' rg');
|
|
ops.add('0 ' + (pageHeight - 6) + ' ' + pageWidth + ' 6 re f');
|
|
|
|
// ---- Title line ----
|
|
Integer titleY = pageHeight - margin - 36;
|
|
ops.add('BT /F1 18 Tf ' + NAVY_RG + ' rg ' + margin + ' ' + titleY + ' Td (' + escapePdfText(reportTitle) + ') Tj ET');
|
|
|
|
// ---- Record-count badge (right-aligned on title line) ----
|
|
String badgeText = String.valueOf(totalRows) + (totalRows == 1 ? ' record' : ' records');
|
|
// ~6px per char + 20px padding each side → badge always fits its text
|
|
Integer badgeW = badgeText.length() * 6 + 40;
|
|
Integer badgeX = pageWidth - margin - badgeW;
|
|
Integer badgeY = titleY - 4;
|
|
// Light green fill
|
|
ops.add('0.906 0.953 0.835 rg');
|
|
ops.add(badgeX + ' ' + badgeY + ' ' + badgeW + ' 18 re f');
|
|
// Dark green text centred in badge
|
|
ops.add('BT /F2 8 Tf 0.18 0.42 0.1 rg '
|
|
+ (badgeX + 10) + ' ' + (badgeY + 4) + ' Td ('
|
|
+ escapePdfText(badgeText) + ') Tj ET');
|
|
|
|
// ---- Generated-at subtitle ----
|
|
Integer subY = titleY - 18;
|
|
ops.add('BT /F2 8 Tf ' + GRAY_RG + ' rg ' + margin + ' ' + subY + ' Td (Generated: '
|
|
+ escapePdfText(Datetime.now().formatGmt('yyyy-MM-dd HH:mm:ss')) + ' UTC) Tj ET');
|
|
|
|
// ---- Thin navy rule above header band ----
|
|
Integer ruleY = subY - 10;
|
|
ops.add(NAVY_RG + ' rg');
|
|
ops.add(margin + ' ' + ruleY + ' ' + contentWidth + ' 1 re f');
|
|
|
|
// ---- Header band: #95BE45 background, white bold text ----
|
|
Integer headerBandH = 20;
|
|
Integer headerBandY = ruleY - headerBandH - 2;
|
|
ops.add(GREEN_RG + ' rg');
|
|
ops.add(margin + ' ' + headerBandY + ' ' + contentWidth + ' ' + headerBandH + ' re f');
|
|
|
|
// Column header labels — centred vertically inside the band
|
|
Integer headerTextY = headerBandY + 6; // baseline sits 6px above band bottom
|
|
Integer cellX = margin;
|
|
for (Integer i = 0; i < colCount; i++) {
|
|
String h = headers[i] != null ? headers[i] : '';
|
|
// Truncate if too wide for the column (≈ 5px per char at font size 7)
|
|
Integer maxChars = Math.max(5, colWidth / 5);
|
|
if (h.length() > maxChars) h = h.substring(0, maxChars - 1) + '.';
|
|
ops.add('BT /F1 7 Tf ' + WHITE_RG + ' rg '
|
|
+ (cellX + 4) + ' ' + headerTextY + ' Td ('
|
|
+ escapePdfText(h) + ') Tj ET');
|
|
cellX += colWidth;
|
|
}
|
|
|
|
// ---- Thin navy rule immediately below header band ----
|
|
Integer postHeaderY = headerBandY - 1;
|
|
ops.add(NAVY_RG + ' rg');
|
|
ops.add(margin + ' ' + postHeaderY + ' ' + contentWidth + ' 1 re f');
|
|
|
|
// ---- Data rows ----
|
|
Integer rowH = 14;
|
|
Integer rowY = postHeaderY - rowH; // top of first data row
|
|
Integer rowNum = 0;
|
|
|
|
for (List<String> row : rows) {
|
|
// Stop before we'd overwrite the footer area (30px reserve)
|
|
if (rowY < margin + 30) break;
|
|
|
|
// Alternating row background
|
|
if (Math.mod(rowNum, 2) == 0) {
|
|
ops.add(LIGHT_RG + ' rg');
|
|
ops.add(margin + ' ' + rowY + ' ' + contentWidth + ' ' + rowH + ' re f');
|
|
}
|
|
|
|
// Thin bottom rule for every row
|
|
ops.add(RULE_RG + ' rg');
|
|
ops.add(margin + ' ' + rowY + ' ' + contentWidth + ' 0.5 re f');
|
|
|
|
// Cell text — baseline 3px above row bottom
|
|
Integer textY = rowY + 3;
|
|
Integer cellXR = margin;
|
|
for (Integer i = 0; i < colCount; i++) {
|
|
String cell = (i < row.size() && row[i] != null) ? row[i] : '';
|
|
Integer maxC = Math.max(5, colWidth / 5);
|
|
if (cell.length() > maxC) cell = cell.substring(0, maxC - 2) + '..';
|
|
ops.add('BT /F2 7 Tf ' + NAVY_RG + ' rg '
|
|
+ (cellXR + 4) + ' ' + textY + ' Td ('
|
|
+ escapePdfText(cell) + ') Tj ET');
|
|
cellXR += colWidth;
|
|
}
|
|
|
|
rowY -= rowH;
|
|
rowNum++;
|
|
}
|
|
|
|
// ---- Bottom border closing the table ----
|
|
ops.add(NAVY_RG + ' rg');
|
|
ops.add(margin + ' ' + (rowY + rowH) + ' ' + contentWidth + ' 1 re f');
|
|
|
|
// ---- Footer ----
|
|
ops.add(RULE_RG + ' rg');
|
|
ops.add(margin + ' 28 ' + contentWidth + ' 0.5 re f');
|
|
|
|
ops.add('BT /F2 7 Tf ' + GRAY_RG + ' rg ' + margin + ' 16 Td (Sync Made Easy) Tj ET');
|
|
|
|
String footerRight = 'Page 1';
|
|
// ~5px per char for Helvetica at size 7
|
|
Integer frX = pageWidth - margin - footerRight.length() * 5;
|
|
ops.add('BT /F2 7 Tf ' + GRAY_RG + ' rg ' + frX + ' 16 Td (' + escapePdfText(footerRight) + ') Tj ET');
|
|
|
|
// ---- Assemble PDF ----
|
|
String contentStream = String.join(ops, '\n') + '\n';
|
|
|
|
// Object 3 = page — font resource dictionary references F1 (Bold) and F2 (Regular)
|
|
List<String> objects = new List<String>{
|
|
'1 0 obj\n<</Type/Catalog/Pages 2 0 R>>\nendobj\n',
|
|
'2 0 obj\n<</Type/Pages/Kids[3 0 R]/Count 1>>\nendobj\n',
|
|
'3 0 obj\n<</Type/Page/Parent 2 0 R/MediaBox[0 0 842 612]/Contents 4 0 R'
|
|
+ '/Resources<</Font<</F1<</Type/Font/Subtype/Type1/BaseFont/Helvetica-Bold>>'
|
|
+ '/F2<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>>>>>/Rotate 0>>\nendobj\n',
|
|
'4 0 obj\n<</Length ' + contentStream.length() + '>>\nstream\n'
|
|
+ contentStream + 'endstream\nendobj\n'
|
|
};
|
|
|
|
String pdfString = buildPdfWithXref(objects, 1);
|
|
Blob pdfBlob = Blob.valueOf(pdfString);
|
|
return EncodingUtil.base64Encode(pdfBlob);
|
|
|
|
} catch (Exception e) {
|
|
System.debug('[ShopifySyncDashboardController] PDF generation failed: ' + e.getMessage());
|
|
throw new AuraHandledException('PDF generation failed: ' + e.getMessage());
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Assembles object strings (passed in ascending object-number order,
|
|
* numbered 1..N contiguously) into a spec-valid PDF: header + objects +
|
|
* xref table + trailer, with correct byte offsets. This xref table is what
|
|
* was missing before — without it, strict readers (Preview, Acrobat, most
|
|
* mobile viewers, many download managers) can't locate Root and refuse to
|
|
* open the file, even though lenient browser viewers may still render it.
|
|
*/
|
|
private static String buildPdfWithXref(List<String> objects, Integer rootObjNum) {
|
|
String header = '%PDF-1.4\n';
|
|
List<Integer> offsets = new List<Integer>();
|
|
Integer running = header.length();
|
|
for (String obj : objects) {
|
|
offsets.add(running);
|
|
running += obj.length();
|
|
}
|
|
Integer xrefOffset = running;
|
|
Integer objCount = objects.size() + 1; // +1 for the mandatory free object 0
|
|
|
|
String body = header + String.join(objects, '');
|
|
|
|
// Each entry must be EXACTLY 20 bytes: 10-digit offset + space + 5-digit
|
|
// generation + space + n/f + CRLF. Wrong width = rejected by strict readers.
|
|
List<String> xrefEntries = new List<String>();
|
|
xrefEntries.add(pad10(0) + ' 65535 f\r\n');
|
|
for (Integer off : offsets) {
|
|
xrefEntries.add(pad10(off) + ' 00000 n\r\n');
|
|
}
|
|
String xrefSection = 'xref\n0 ' + objCount + '\n' + String.join(xrefEntries, '');
|
|
|
|
String trailer = 'trailer\n<</Size ' + objCount + '/Root ' + rootObjNum + ' 0 R>>\nstartxref\n' + xrefOffset + '\n%%EOF';
|
|
|
|
return body + xrefSection + trailer;
|
|
}
|
|
|
|
private static String pad10(Integer n) {
|
|
String s = String.valueOf(n);
|
|
while (s.length() < 10) s = '0' + s;
|
|
return s;
|
|
}
|
|
|
|
// === TOP-LEVEL STATIC HELPER — accessible from both generatePdfBase64 and PdfBuilder ===
|
|
private static String escapePdfText(String s) {
|
|
if (s == null) return '';
|
|
String result = '';
|
|
for (Integer i = 0; i < s.length(); i++) {
|
|
String c = s.substring(i, i + 1);
|
|
Integer code = c.charAt(0);
|
|
if (c == '\\') result += '\\\\';
|
|
else if (c == '(') result += '\\(';
|
|
else if (c == ')') result += '\\)';
|
|
else if (code > 126 || code < 32) result += ' ';
|
|
else result += c;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
public class PdfBuilder {
|
|
private List<String> objects;
|
|
private Integer objCounter;
|
|
private Integer pageCount;
|
|
private List<String> pageRefs;
|
|
private String currentContent;
|
|
private Integer currentPageObj;
|
|
private Boolean hasContent;
|
|
|
|
private final String FONT_HELVETICA = '/Helvetica';
|
|
private final String FONT_HELVETICA_BOLD = '/Helvetica-Bold';
|
|
|
|
public PdfBuilder() {
|
|
this.objects = new List<String>();
|
|
this.objCounter = 0;
|
|
this.pageCount = 0;
|
|
this.pageRefs = new List<String>();
|
|
this.currentContent = '';
|
|
this.currentPageObj = 0;
|
|
this.hasContent = false;
|
|
this.newPage();
|
|
}
|
|
|
|
public void newPage() {
|
|
if (this.hasContent) {
|
|
this.finishPage();
|
|
}
|
|
this.pageCount++;
|
|
this.currentContent = '';
|
|
this.hasContent = false;
|
|
this.currentPageObj = ++this.objCounter;
|
|
}
|
|
|
|
public void drawText(String text, Integer x, Integer y, Integer fontSize, Boolean bold) {
|
|
drawText(text, x, y, fontSize, bold, '1F2937');
|
|
}
|
|
|
|
public void drawText(String text, Integer x, Integer y, Integer fontSize, Boolean bold, String color) {
|
|
String font = bold ? FONT_HELVETICA_BOLD : FONT_HELVETICA;
|
|
String rgb = hexToRgb(color);
|
|
this.currentContent += 'BT ' + font + ' ' + fontSize + ' Tf ' + rgb + ' ' + x + ' ' + y + ' Td (' + ShopifySyncDashboardController.escapePdfText(text) + ') Tj ET\n';
|
|
this.hasContent = true;
|
|
}
|
|
|
|
public void drawLine(Integer x1, Integer y1, Integer x2, Integer y2, String color, Decimal width) {
|
|
String rgb = hexToRgb(color);
|
|
this.currentContent += rgb + ' ' + width + ' w ' + x1 + ' ' + y1 + ' m ' + x2 + ' ' + y2 + ' l S\n';
|
|
this.hasContent = true;
|
|
}
|
|
|
|
public void fillRect(Integer x, Integer y, Integer w, Integer h, String color) {
|
|
String rgb = hexToRgb(color);
|
|
this.currentContent += rgb + ' ' + x + ' ' + y + ' ' + w + ' ' + h + ' re f\n';
|
|
this.hasContent = true;
|
|
}
|
|
|
|
public Blob toBlob() {
|
|
if (this.hasContent) {
|
|
this.finishPage();
|
|
}
|
|
|
|
String header = '%PDF-1.4\n';
|
|
|
|
Integer catalogObj = ++this.objCounter;
|
|
String catalogStr = catalogObj + ' 0 obj <</Type/Catalog/Pages 2 0 R>> endobj\n';
|
|
|
|
Integer pagesObj = ++this.objCounter;
|
|
String kids = String.join(this.pageRefs, ' ');
|
|
String pagesStr = pagesObj + ' 0 obj <</Type/Pages/Kids[' + kids + ']/Count ' + this.pageCount + '>> endobj\n';
|
|
|
|
// this.objects holds content/page objects interleaved out of numeric
|
|
// order (e.g. 2,1,4,3...), so offsets are tracked by NUMBER, not by
|
|
// array position, then re-sorted ascending when the xref table is built.
|
|
Map<Integer, Integer> offsetsByNum = new Map<Integer, Integer>();
|
|
Integer running = header.length();
|
|
|
|
offsetsByNum.put(catalogObj, running);
|
|
running += catalogStr.length();
|
|
offsetsByNum.put(pagesObj, running);
|
|
running += pagesStr.length();
|
|
|
|
String bodyRest = '';
|
|
for (String obj : this.objects) {
|
|
Integer num = extractObjNum(obj);
|
|
offsetsByNum.put(num, running);
|
|
running += obj.length();
|
|
bodyRest += obj;
|
|
}
|
|
|
|
Integer xrefOffset = running;
|
|
Integer objCount = this.objCounter + 1; // +1 for free object 0
|
|
|
|
List<String> xrefEntries = new List<String>();
|
|
// NOTE: calls the OUTER class's pad10 — inner classes in Apex cannot
|
|
// declare their own `static` members, so there is only ever one pad10.
|
|
xrefEntries.add(ShopifySyncDashboardController.pad10(0) + ' 65535 f\r\n');
|
|
for (Integer n = 1; n <= this.objCounter; n++) {
|
|
Integer off = offsetsByNum.containsKey(n) ? offsetsByNum.get(n) : 0;
|
|
xrefEntries.add(ShopifySyncDashboardController.pad10(off) + ' 00000 n\r\n');
|
|
}
|
|
String xrefSection = 'xref\n0 ' + objCount + '\n' + String.join(xrefEntries, '');
|
|
String trailer = 'trailer\n<</Size ' + objCount + '/Root ' + catalogObj + ' 0 R>>\nstartxref\n' + xrefOffset + '\n%%EOF';
|
|
|
|
String full = header + catalogStr + pagesStr + bodyRest + xrefSection + trailer;
|
|
return Blob.valueOf(full);
|
|
}
|
|
|
|
private Integer extractObjNum(String objStr) {
|
|
Integer spaceIdx = objStr.indexOf(' ');
|
|
if (spaceIdx <= 0) return 0;
|
|
return Integer.valueOf(objStr.substring(0, spaceIdx));
|
|
}
|
|
|
|
private void finishPage() {
|
|
Integer contentObj = ++this.objCounter;
|
|
String stream = this.currentContent;
|
|
String obj = contentObj + ' 0 obj <</Length ' + stream.length() + '>> stream\n' + stream + 'endstream endobj\n';
|
|
this.objects.add(obj);
|
|
|
|
Integer pageObj = this.currentPageObj;
|
|
String page = pageObj + ' 0 obj <</Type/Page/Parent 2 0 R/MediaBox[0 0 842 612]/Contents ' + contentObj + ' 0 R/Resources<</Font<</Helvetica<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>/Helvetica-Bold<</Type/Font/Subtype/Type1/BaseFont/Helvetica-Bold>>>>>>/Rotate 0>> endobj\n';
|
|
this.objects.add(page);
|
|
this.pageRefs.add(pageObj + ' 0 R');
|
|
}
|
|
|
|
private String hexToRgb(String hex) {
|
|
if (String.isBlank(hex)) return '0 0 0';
|
|
String h = hex;
|
|
if (h.startsWith('#')) h = h.substring(1);
|
|
if (h.length() == 3) {
|
|
h = h.substring(0,1) + h.substring(0,1) + h.substring(1,2) + h.substring(1,2) + h.substring(2,3) + h.substring(2,3);
|
|
}
|
|
if (h.length() != 6) return '0 0 0';
|
|
|
|
Integer r = hexToDec(h.substring(0,2));
|
|
Integer g = hexToDec(h.substring(2,4));
|
|
Integer b = hexToDec(h.substring(4,6));
|
|
|
|
Decimal rd = Decimal.valueOf(r) / 255.0;
|
|
Decimal gd = Decimal.valueOf(g) / 255.0;
|
|
Decimal bd = Decimal.valueOf(b) / 255.0;
|
|
|
|
return rd.setScale(3) + ' ' + gd.setScale(3) + ' ' + bd.setScale(3);
|
|
}
|
|
|
|
private Integer hexToDec(String hex) {
|
|
String chars = '0123456789ABCDEF';
|
|
Integer result = 0;
|
|
hex = hex.toUpperCase();
|
|
for (Integer i = 0; i < hex.length(); i++) {
|
|
String c = hex.substring(i, i + 1);
|
|
Integer val = chars.indexOf(c);
|
|
if (val < 0) return 0;
|
|
result = result * 16 + val;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
// pad10 intentionally NOT redeclared here — Apex forbids `static`
|
|
// members on inner classes. We call ShopifySyncDashboardController.pad10
|
|
// above instead.
|
|
}
|
|
|
|
public class StringBuilder {
|
|
private List<String> parts;
|
|
public StringBuilder() { this.parts = new List<String>(); }
|
|
public void add(String s) { this.parts.add(s); }
|
|
public override String toString() { return String.join(parts, ''); }
|
|
}
|
|
|
|
} |