Add API performance profiling, caching, and query optimizations
- Add queryTimed() wrapper and logPerf() for per-endpoint timing metrics - Add api_perf_log table flush mechanism with background thread batching - Add application-scope cache (appCacheGet/Put/Invalidate) with TTL - Cache businesses/get (5m), addresses/states (24h), menu/items (2m) - Fix N+1 queries in orders/history, orders/listForKDS (batch fetch) - Fix correlated subquery in orders/getDetail (LEFT JOIN) - Combine 4 queries into 1 in portal/stats (subselects) - Optimize getForBuilder tree building with pre-indexed parent lookup - Add cache invalidation in update, saveBrandColor, updateHours, saveFromBuilder - New admin/perf.cfm dashboard (localhost-protected) - Instrument top 10 endpoints with queryTimed + logPerf Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
a5fa1c1041
commit
dc9db32b58
19 changed files with 554 additions and 156 deletions
|
|
@ -1,6 +1,11 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
|
||||
<!--- Performance timing: start clock as early as possible --->
|
||||
<cfset request._perf_start = getTickCount()>
|
||||
<cfset request._perf_queryCount = 0>
|
||||
<cfset request._perf_queryTimeMs = 0>
|
||||
|
||||
<!---
|
||||
Payfrit API Application.cfm (updated 2026-01-22)
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@
|
|||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8">
|
||||
|
||||
<!--- List US states for address forms --->
|
||||
<!--- List US states for address forms (cached 24h - static data) --->
|
||||
<cfscript>
|
||||
try {
|
||||
cached = appCacheGet("states", 86400);
|
||||
if (!isNull(cached)) {
|
||||
writeOutput(cached);
|
||||
abort;
|
||||
}
|
||||
|
||||
qStates = queryExecute("
|
||||
SELECT tt_StateID as StateID, tt_StateAbbreviation as StateAbbreviation, tt_StateName as StateName
|
||||
FROM tt_States
|
||||
|
|
@ -20,10 +26,12 @@ try {
|
|||
});
|
||||
}
|
||||
|
||||
writeOutput(serializeJSON({
|
||||
jsonResponse = serializeJSON({
|
||||
"OK": true,
|
||||
"STATES": states
|
||||
}));
|
||||
});
|
||||
appCachePut("states", jsonResponse);
|
||||
writeOutput(jsonResponse);
|
||||
|
||||
} catch (any e) {
|
||||
writeOutput(serializeJSON({
|
||||
|
|
|
|||
179
api/admin/perf.cfm
Normal file
179
api/admin/perf.cfm
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
<cfsetting showdebugoutput="false">
|
||||
<cfsetting enablecfoutputonly="true">
|
||||
<cfcontent type="application/json; charset=utf-8" reset="true">
|
||||
<cfheader name="Cache-Control" value="no-store">
|
||||
|
||||
<cfscript>
|
||||
function apiAbort(payload) {
|
||||
writeOutput(serializeJSON(payload));
|
||||
abort;
|
||||
}
|
||||
|
||||
// Localhost-only protection
|
||||
remoteAddr = cgi.REMOTE_ADDR;
|
||||
if (remoteAddr != "127.0.0.1" && remoteAddr != "::1" && remoteAddr != "0:0:0:0:0:0:0:1" && remoteAddr != "10.10.0.2") {
|
||||
apiAbort({ "OK": false, "ERROR": "forbidden" });
|
||||
}
|
||||
|
||||
try {
|
||||
// Parse parameters
|
||||
view = structKeyExists(url, "view") ? lcase(url.view) : "count";
|
||||
hours = structKeyExists(url, "hours") ? val(url.hours) : 24;
|
||||
if (hours <= 0 || hours > 720) hours = 24;
|
||||
limitRows = structKeyExists(url, "limit") ? val(url.limit) : 20;
|
||||
if (limitRows <= 0 || limitRows > 100) limitRows = 20;
|
||||
|
||||
// Flush any buffered metrics first
|
||||
flushPerfBuffer();
|
||||
|
||||
response = { "OK": true, "VIEW": view, "HOURS": hours };
|
||||
|
||||
if (view == "count") {
|
||||
// Top endpoints by call count
|
||||
q = queryExecute("
|
||||
SELECT
|
||||
Endpoint,
|
||||
COUNT(*) as Calls,
|
||||
ROUND(AVG(TotalMs)) as AvgMs,
|
||||
ROUND(AVG(DbMs)) as AvgDbMs,
|
||||
ROUND(AVG(AppMs)) as AvgAppMs,
|
||||
MAX(TotalMs) as MaxMs,
|
||||
ROUND(AVG(QueryCount), 1) as AvgQueries,
|
||||
ROUND(AVG(ResponseBytes)) as AvgBytes
|
||||
FROM api_perf_log
|
||||
WHERE LoggedAt > DATE_SUB(NOW(), INTERVAL :hours HOUR)
|
||||
GROUP BY Endpoint
|
||||
ORDER BY Calls DESC
|
||||
LIMIT :lim
|
||||
", {
|
||||
hours: { value: hours, cfsqltype: "cf_sql_integer" },
|
||||
lim: { value: limitRows, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
rows = [];
|
||||
for (row in q) {
|
||||
arrayAppend(rows, {
|
||||
"Endpoint": row.Endpoint,
|
||||
"Calls": row.Calls,
|
||||
"AvgMs": row.AvgMs,
|
||||
"AvgDbMs": row.AvgDbMs,
|
||||
"AvgAppMs": row.AvgAppMs,
|
||||
"MaxMs": row.MaxMs,
|
||||
"AvgQueries": row.AvgQueries,
|
||||
"AvgBytes": row.AvgBytes
|
||||
});
|
||||
}
|
||||
response["DATA"] = rows;
|
||||
|
||||
} else if (view == "latency") {
|
||||
// Top endpoints by average latency
|
||||
q = queryExecute("
|
||||
SELECT
|
||||
Endpoint,
|
||||
COUNT(*) as Calls,
|
||||
ROUND(AVG(TotalMs)) as AvgMs,
|
||||
ROUND(AVG(DbMs)) as AvgDbMs,
|
||||
ROUND(AVG(AppMs)) as AvgAppMs,
|
||||
MAX(TotalMs) as MaxMs,
|
||||
ROUND(AVG(QueryCount), 1) as AvgQueries
|
||||
FROM api_perf_log
|
||||
WHERE LoggedAt > DATE_SUB(NOW(), INTERVAL :hours HOUR)
|
||||
GROUP BY Endpoint
|
||||
HAVING Calls >= 3
|
||||
ORDER BY AvgMs DESC
|
||||
LIMIT :lim
|
||||
", {
|
||||
hours: { value: hours, cfsqltype: "cf_sql_integer" },
|
||||
lim: { value: limitRows, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
rows = [];
|
||||
for (row in q) {
|
||||
arrayAppend(rows, {
|
||||
"Endpoint": row.Endpoint,
|
||||
"Calls": row.Calls,
|
||||
"AvgMs": row.AvgMs,
|
||||
"AvgDbMs": row.AvgDbMs,
|
||||
"AvgAppMs": row.AvgAppMs,
|
||||
"MaxMs": row.MaxMs,
|
||||
"AvgQueries": row.AvgQueries
|
||||
});
|
||||
}
|
||||
response["DATA"] = rows;
|
||||
|
||||
} else if (view == "slow") {
|
||||
// Slowest individual requests
|
||||
q = queryExecute("
|
||||
SELECT Endpoint, TotalMs, DbMs, AppMs, QueryCount, ResponseBytes,
|
||||
BusinessID, UserID, LoggedAt
|
||||
FROM api_perf_log
|
||||
WHERE LoggedAt > DATE_SUB(NOW(), INTERVAL :hours HOUR)
|
||||
ORDER BY TotalMs DESC
|
||||
LIMIT :lim
|
||||
", {
|
||||
hours: { value: hours, cfsqltype: "cf_sql_integer" },
|
||||
lim: { value: limitRows, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
rows = [];
|
||||
for (row in q) {
|
||||
arrayAppend(rows, {
|
||||
"Endpoint": row.Endpoint,
|
||||
"TotalMs": row.TotalMs,
|
||||
"DbMs": row.DbMs,
|
||||
"AppMs": row.AppMs,
|
||||
"QueryCount": row.QueryCount,
|
||||
"ResponseBytes": row.ResponseBytes,
|
||||
"BusinessID": row.BusinessID,
|
||||
"UserID": row.UserID,
|
||||
"LoggedAt": dateTimeFormat(row.LoggedAt, "yyyy-mm-dd HH:nn:ss")
|
||||
});
|
||||
}
|
||||
response["DATA"] = rows;
|
||||
|
||||
} else if (view == "summary") {
|
||||
// Overall summary stats
|
||||
q = queryExecute("
|
||||
SELECT
|
||||
COUNT(*) as TotalRequests,
|
||||
COUNT(DISTINCT Endpoint) as UniqueEndpoints,
|
||||
ROUND(AVG(TotalMs)) as OverallAvgMs,
|
||||
MAX(TotalMs) as OverallMaxMs,
|
||||
ROUND(AVG(DbMs)) as OverallAvgDbMs,
|
||||
ROUND(AVG(AppMs)) as OverallAvgAppMs,
|
||||
ROUND(AVG(QueryCount), 1) as OverallAvgQueries,
|
||||
MIN(LoggedAt) as FirstLog,
|
||||
MAX(LoggedAt) as LastLog
|
||||
FROM api_perf_log
|
||||
WHERE LoggedAt > DATE_SUB(NOW(), INTERVAL :hours HOUR)
|
||||
", {
|
||||
hours: { value: hours, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
response["DATA"] = {
|
||||
"TotalRequests": q.TotalRequests,
|
||||
"UniqueEndpoints": q.UniqueEndpoints,
|
||||
"OverallAvgMs": q.OverallAvgMs,
|
||||
"OverallMaxMs": q.OverallMaxMs,
|
||||
"OverallAvgDbMs": q.OverallAvgDbMs,
|
||||
"OverallAvgAppMs": q.OverallAvgAppMs,
|
||||
"OverallAvgQueries": q.OverallAvgQueries,
|
||||
"FirstLog": isDate(q.FirstLog) ? dateTimeFormat(q.FirstLog, "yyyy-mm-dd HH:nn:ss") : "",
|
||||
"LastLog": isDate(q.LastLog) ? dateTimeFormat(q.LastLog, "yyyy-mm-dd HH:nn:ss") : ""
|
||||
};
|
||||
|
||||
} else {
|
||||
apiAbort({ "OK": false, "ERROR": "invalid_view", "MESSAGE": "Use ?view=count|latency|slow|summary" });
|
||||
}
|
||||
|
||||
apiAbort(response);
|
||||
|
||||
} catch (any e) {
|
||||
apiAbort({
|
||||
"OK": false,
|
||||
"ERROR": "server_error",
|
||||
"MESSAGE": e.message,
|
||||
"DETAIL": e.detail
|
||||
});
|
||||
}
|
||||
</cfscript>
|
||||
|
|
@ -32,7 +32,7 @@ try {
|
|||
}
|
||||
|
||||
// Look up the token
|
||||
qToken = queryExecute("
|
||||
qToken = queryTimed("
|
||||
SELECT ut.UserID, u.UserFirstName, u.UserLastName
|
||||
FROM UserTokens ut
|
||||
JOIN Users u ON u.UserID = ut.UserID
|
||||
|
|
@ -47,7 +47,7 @@ try {
|
|||
userID = qToken.UserID;
|
||||
|
||||
// Determine if user is a worker (has any active employment)
|
||||
qWorker = queryExecute("
|
||||
qWorker = queryTimed("
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM lt_Users_Businesses_Employees
|
||||
WHERE UserID = :userID AND EmployeeIsActive = 1
|
||||
|
|
@ -55,6 +55,7 @@ try {
|
|||
|
||||
userType = qWorker.cnt > 0 ? "worker" : "customer";
|
||||
|
||||
logPerf();
|
||||
apiAbort({
|
||||
"OK": true,
|
||||
"UserID": userID,
|
||||
|
|
|
|||
|
|
@ -81,4 +81,5 @@ if (structKeyExists(data, "onlyActive")) {
|
|||
})>
|
||||
</cfloop>
|
||||
|
||||
<cfset logPerf()>
|
||||
<cfoutput>#serializeJSON({ OK=true, ERROR="", BusinessID=bizId, COUNT=arrayLen(beacons), BEACONS=beacons })#</cfoutput>
|
||||
|
|
|
|||
|
|
@ -32,8 +32,16 @@ try {
|
|||
abort;
|
||||
}
|
||||
|
||||
// Check cache (5 minute TTL)
|
||||
cached = appCacheGet("biz_" & businessID, 300);
|
||||
if (!isNull(cached)) {
|
||||
logPerf();
|
||||
writeOutput(cached);
|
||||
abort;
|
||||
}
|
||||
|
||||
// Get business details
|
||||
q = queryExecute("
|
||||
q = queryTimed("
|
||||
SELECT
|
||||
BusinessID,
|
||||
BusinessName,
|
||||
|
|
@ -55,7 +63,7 @@ try {
|
|||
}
|
||||
|
||||
// Get address from Addresses table (either linked via AddressBusinessID or via Businesses.BusinessAddressID)
|
||||
qAddr = queryExecute("
|
||||
qAddr = queryTimed("
|
||||
SELECT a.AddressLine1, a.AddressLine2, a.AddressCity, a.AddressZIPCode, s.tt_StateAbbreviation
|
||||
FROM Addresses a
|
||||
LEFT JOIN tt_States s ON s.tt_StateID = a.AddressStateID
|
||||
|
|
@ -87,7 +95,7 @@ try {
|
|||
}
|
||||
|
||||
// Get hours from Hours table
|
||||
qHours = queryExecute("
|
||||
qHours = queryTimed("
|
||||
SELECT h.HoursDayID, h.HoursOpenTime, h.HoursClosingTime, d.tt_DayAbbrev
|
||||
FROM Hours h
|
||||
JOIN tt_Days d ON d.tt_DayID = h.HoursDayID
|
||||
|
|
@ -152,9 +160,17 @@ try {
|
|||
response["OK"] = true;
|
||||
response["BUSINESS"] = business;
|
||||
|
||||
// Cache successful response
|
||||
jsonResponse = serializeJSON(response);
|
||||
appCachePut("biz_" & businessID, jsonResponse);
|
||||
logPerf();
|
||||
writeOutput(jsonResponse);
|
||||
abort;
|
||||
|
||||
} catch (any e) {
|
||||
response["ERROR"] = e.message;
|
||||
}
|
||||
|
||||
logPerf();
|
||||
writeOutput(serializeJSON(response));
|
||||
</cfscript>
|
||||
|
|
|
|||
|
|
@ -61,6 +61,7 @@ queryExecute("
|
|||
bizId: { value: bizId, cfsqltype: "cf_sql_integer" }
|
||||
}, { datasource: "payfrit" });
|
||||
|
||||
appCacheInvalidate("biz_" & bizId);
|
||||
writeOutput(serializeJSON({
|
||||
"OK": true,
|
||||
"ERROR": "",
|
||||
|
|
|
|||
|
|
@ -126,6 +126,7 @@ try {
|
|||
}
|
||||
|
||||
response.OK = true;
|
||||
appCacheInvalidate("biz_" & businessId);
|
||||
|
||||
} catch (any e) {
|
||||
response.ERROR = e.message;
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ try {
|
|||
|
||||
response.OK = true;
|
||||
response.hoursUpdated = arrayLen(hours);
|
||||
appCacheInvalidate("biz_" & businessId);
|
||||
|
||||
} catch (any e) {
|
||||
response.ERROR = e.message;
|
||||
|
|
|
|||
|
|
@ -116,6 +116,142 @@ function apiError(message, detail = "", statusCode = 500) {
|
|||
return response;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// PERFORMANCE PROFILING
|
||||
// ============================================
|
||||
application.perfEnabled = true; // Toggle profiling on/off
|
||||
|
||||
// Initialize perf buffer (once per application start)
|
||||
if (!structKeyExists(application, "perfBuffer")) {
|
||||
application.perfBuffer = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop-in replacement for queryExecute that tracks query count and time.
|
||||
* Usage: queryTimed("SELECT ...", [...], { datasource: "payfrit" })
|
||||
*/
|
||||
function queryTimed(required string sql, any params = [], struct options = {}) {
|
||||
var t = getTickCount();
|
||||
var result = queryExecute(arguments.sql, arguments.params, arguments.options);
|
||||
var elapsed = getTickCount() - t;
|
||||
request._perf_queryCount++;
|
||||
request._perf_queryTimeMs += elapsed;
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Flush the in-memory perf buffer to the api_perf_log MySQL table.
|
||||
* Called automatically when buffer reaches 100 entries.
|
||||
*/
|
||||
function flushPerfBuffer() {
|
||||
var batch = [];
|
||||
lock name="payfrit_perfBuffer" timeout="2" type="exclusive" {
|
||||
batch = duplicate(application.perfBuffer);
|
||||
application.perfBuffer = [];
|
||||
}
|
||||
if (arrayLen(batch) == 0) return;
|
||||
|
||||
try {
|
||||
var sql = "INSERT INTO api_perf_log (Endpoint, TotalMs, DbMs, AppMs, QueryCount, ResponseBytes, BusinessID, UserID, LoggedAt) VALUES ";
|
||||
var rows = [];
|
||||
for (var m in batch) {
|
||||
arrayAppend(rows,
|
||||
"('" & replace(m.endpoint, "'", "''", "all") & "',"
|
||||
& m.totalMs & "," & m.dbMs & "," & m.appMs & ","
|
||||
& m.queryCount & "," & m.responseBytes & ","
|
||||
& m.businessId & "," & m.userId & ",'"
|
||||
& m.loggedAt & "')"
|
||||
);
|
||||
}
|
||||
sql &= arrayToList(rows, ",");
|
||||
queryExecute(sql, {}, { datasource: "payfrit" });
|
||||
|
||||
// Cleanup: delete rows older than 30 days (run occasionally)
|
||||
if (randRange(1, 100) == 1) {
|
||||
queryExecute(
|
||||
"DELETE FROM api_perf_log WHERE LoggedAt < DATE_SUB(NOW(), INTERVAL 30 DAY)",
|
||||
{}, { datasource: "payfrit" }
|
||||
);
|
||||
}
|
||||
} catch (any e) {
|
||||
// Silent fail - never break the app for profiling
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log performance metrics for the current request.
|
||||
* Call this at the end of any instrumented endpoint, before the final writeOutput/abort.
|
||||
*/
|
||||
function logPerf(numeric responseBytes = 0) {
|
||||
if (!structKeyExists(application, "perfEnabled") || !application.perfEnabled) return;
|
||||
|
||||
var totalMs = getTickCount() - request._perf_start;
|
||||
var dbMs = request._perf_queryTimeMs;
|
||||
|
||||
var metric = {
|
||||
endpoint: structKeyExists(request, "_api_path") ? request._api_path : "unknown",
|
||||
totalMs: totalMs,
|
||||
dbMs: dbMs,
|
||||
appMs: totalMs - dbMs,
|
||||
queryCount: request._perf_queryCount,
|
||||
responseBytes: arguments.responseBytes,
|
||||
businessId: structKeyExists(request, "BusinessID") ? val(request.BusinessID) : 0,
|
||||
userId: structKeyExists(request, "UserID") ? val(request.UserID) : 0,
|
||||
loggedAt: dateTimeFormat(now(), "yyyy-mm-dd HH:nn:ss")
|
||||
};
|
||||
|
||||
lock name="payfrit_perfBuffer" timeout="1" type="exclusive" {
|
||||
arrayAppend(application.perfBuffer, metric);
|
||||
if (arrayLen(application.perfBuffer) >= 100) {
|
||||
thread name="perfFlush_#createUUID()#" {
|
||||
flushPerfBuffer();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// APPLICATION-SCOPE CACHE
|
||||
// ============================================
|
||||
if (!structKeyExists(application, "cache")) {
|
||||
application.cache = {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a cached value. Returns null if not found or expired.
|
||||
* Usage: cached = cacheGet("menu_123", 120);
|
||||
* if (!isNull(cached)) { use it } else { query and cachePut }
|
||||
*/
|
||||
function appCacheGet(required string key, numeric ttlSeconds = 300) {
|
||||
if (structKeyExists(application.cache, arguments.key)) {
|
||||
var entry = application.cache[arguments.key];
|
||||
if (dateDiff("s", entry.cachedAt, now()) < arguments.ttlSeconds) {
|
||||
return entry.data;
|
||||
}
|
||||
structDelete(application.cache, arguments.key);
|
||||
}
|
||||
return javaCast("null", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Store a value in cache.
|
||||
*/
|
||||
function appCachePut(required string key, required any data) {
|
||||
application.cache[arguments.key] = { data: arguments.data, cachedAt: now() };
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate all cache keys starting with the given prefix.
|
||||
*/
|
||||
function appCacheInvalidate(required string keyPrefix) {
|
||||
var keys = structKeyArray(application.cache);
|
||||
for (var k in keys) {
|
||||
if (findNoCase(arguments.keyPrefix, k) == 1) {
|
||||
structDelete(application.cache, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Store in application scope
|
||||
application.isDevEnvironment = isDevEnvironment;
|
||||
application.isDev = isDev;
|
||||
|
|
|
|||
|
|
@ -11,24 +11,34 @@
|
|||
|
||||
response = { "OK": false };
|
||||
|
||||
// Recursive function to build nested options
|
||||
function buildOptionsTree(allOptions, parentId) {
|
||||
var result = [];
|
||||
// Pre-index query rows by parentId for O(1) lookup instead of O(n) scan per level
|
||||
function buildParentIndex(allOptions) {
|
||||
var byParent = {};
|
||||
for (var i = 1; i <= allOptions.recordCount; i++) {
|
||||
if (allOptions.ParentItemID[i] == parentId) {
|
||||
var children = buildOptionsTree(allOptions, allOptions.ItemID[i]);
|
||||
arrayAppend(result, {
|
||||
"id": "opt_" & allOptions.ItemID[i],
|
||||
"dbId": allOptions.ItemID[i],
|
||||
"name": allOptions.ItemName[i],
|
||||
"price": allOptions.ItemPrice[i],
|
||||
"isDefault": allOptions.IsDefault[i] == 1 ? true : false,
|
||||
"sortOrder": allOptions.ItemSortOrder[i],
|
||||
"requiresSelection": isNull(allOptions.RequiresSelection[i]) ? false : (allOptions.RequiresSelection[i] == 1),
|
||||
"maxSelections": isNull(allOptions.MaxSelections[i]) ? 0 : allOptions.MaxSelections[i],
|
||||
"options": children
|
||||
});
|
||||
}
|
||||
var pid = allOptions.ParentItemID[i];
|
||||
if (!structKeyExists(byParent, pid)) byParent[pid] = [];
|
||||
arrayAppend(byParent[pid], i);
|
||||
}
|
||||
return byParent;
|
||||
}
|
||||
|
||||
// Recursive function to build nested options (uses pre-built index)
|
||||
function buildOptionsTree(allOptions, parentId, byParent) {
|
||||
if (!structKeyExists(byParent, parentId)) return [];
|
||||
var result = [];
|
||||
for (var idx in byParent[parentId]) {
|
||||
var children = buildOptionsTree(allOptions, allOptions.ItemID[idx], byParent);
|
||||
arrayAppend(result, {
|
||||
"id": "opt_" & allOptions.ItemID[idx],
|
||||
"dbId": allOptions.ItemID[idx],
|
||||
"name": allOptions.ItemName[idx],
|
||||
"price": allOptions.ItemPrice[idx],
|
||||
"isDefault": allOptions.IsDefault[idx] == 1 ? true : false,
|
||||
"sortOrder": allOptions.ItemSortOrder[idx],
|
||||
"requiresSelection": isNull(allOptions.RequiresSelection[idx]) ? false : (allOptions.RequiresSelection[idx] == 1),
|
||||
"maxSelections": isNull(allOptions.MaxSelections[idx]) ? 0 : allOptions.MaxSelections[idx],
|
||||
"options": children
|
||||
});
|
||||
}
|
||||
if (arrayLen(result) > 1) {
|
||||
arraySort(result, function(a, b) {
|
||||
|
|
@ -63,7 +73,7 @@ try {
|
|||
// Get all menus for this business
|
||||
allMenus = [];
|
||||
try {
|
||||
qMenus = queryExecute("
|
||||
qMenus = queryTimed("
|
||||
SELECT MenuID, MenuName, MenuDescription, MenuDaysActive,
|
||||
MenuStartTime, MenuEndTime, MenuSortOrder
|
||||
FROM Menus
|
||||
|
|
@ -123,7 +133,7 @@ try {
|
|||
// Check if Categories table has data for this business
|
||||
hasCategoriesData = false;
|
||||
try {
|
||||
qCatCheck = queryExecute("
|
||||
qCatCheck = queryTimed("
|
||||
SELECT 1 FROM Categories WHERE CategoryBusinessID = :businessID LIMIT 1
|
||||
", { businessID: businessID }, { datasource: "payfrit" });
|
||||
hasCategoriesData = (qCatCheck.recordCount > 0);
|
||||
|
|
@ -141,7 +151,7 @@ try {
|
|||
menuParams["menuID"] = menuID;
|
||||
}
|
||||
|
||||
qCategories = queryExecute("
|
||||
qCategories = queryTimed("
|
||||
SELECT
|
||||
CategoryID,
|
||||
CategoryName,
|
||||
|
|
@ -153,7 +163,7 @@ try {
|
|||
", menuParams, { datasource: "payfrit" });
|
||||
|
||||
// Get menu items - items that belong to categories (not modifiers)
|
||||
qItems = queryExecute("
|
||||
qItems = queryTimed("
|
||||
SELECT
|
||||
i.ItemID,
|
||||
i.ItemCategoryID as CategoryItemID,
|
||||
|
|
@ -170,7 +180,7 @@ try {
|
|||
", { businessID: businessID }, { datasource: "payfrit" });
|
||||
|
||||
// Get direct modifiers (items with ParentItemID pointing to menu items, not categories)
|
||||
qDirectModifiers = queryExecute("
|
||||
qDirectModifiers = queryTimed("
|
||||
SELECT
|
||||
m.ItemID,
|
||||
m.ItemParentItemID as ParentItemID,
|
||||
|
|
@ -190,7 +200,7 @@ try {
|
|||
|
||||
} else {
|
||||
// NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children
|
||||
qCategories = queryExecute("
|
||||
qCategories = queryTimed("
|
||||
SELECT DISTINCT
|
||||
p.ItemID as CategoryID,
|
||||
p.ItemName as CategoryName,
|
||||
|
|
@ -206,7 +216,7 @@ try {
|
|||
ORDER BY p.ItemSortOrder, p.ItemName
|
||||
", { businessID: businessID }, { datasource: "payfrit" });
|
||||
|
||||
qItems = queryExecute("
|
||||
qItems = queryTimed("
|
||||
SELECT
|
||||
i.ItemID,
|
||||
i.ItemParentItemID as CategoryItemID,
|
||||
|
|
@ -226,7 +236,7 @@ try {
|
|||
ORDER BY i.ItemSortOrder, i.ItemName
|
||||
", { businessID: businessID }, { datasource: "payfrit" });
|
||||
|
||||
qDirectModifiers = queryExecute("
|
||||
qDirectModifiers = queryTimed("
|
||||
SELECT
|
||||
m.ItemID,
|
||||
m.ItemParentItemID as ParentItemID,
|
||||
|
|
@ -253,7 +263,7 @@ try {
|
|||
// Get template links ONLY for this business's menu items
|
||||
qTemplateLinks = queryNew("ParentItemID,TemplateItemID,SortOrder");
|
||||
if (arrayLen(menuItemIds) > 0) {
|
||||
qTemplateLinks = queryExecute("
|
||||
qTemplateLinks = queryTimed("
|
||||
SELECT
|
||||
tl.ItemID as ParentItemID,
|
||||
tl.TemplateItemID,
|
||||
|
|
@ -265,7 +275,7 @@ try {
|
|||
}
|
||||
|
||||
// Get templates for this business only
|
||||
qTemplates = queryExecute("
|
||||
qTemplates = queryTimed("
|
||||
SELECT DISTINCT
|
||||
t.ItemID,
|
||||
t.ItemName,
|
||||
|
|
@ -290,7 +300,7 @@ try {
|
|||
|
||||
qTemplateChildren = queryNew("ItemID,ParentItemID,ItemName,ItemPrice,IsDefault,ItemSortOrder,RequiresSelection,MaxSelections");
|
||||
if (arrayLen(templateIds) > 0) {
|
||||
qTemplateChildren = queryExecute("
|
||||
qTemplateChildren = queryTimed("
|
||||
SELECT
|
||||
c.ItemID,
|
||||
c.ItemParentItemID as ParentItemID,
|
||||
|
|
@ -307,11 +317,12 @@ try {
|
|||
", { templateIds: { value: arrayToList(templateIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" });
|
||||
}
|
||||
|
||||
// Build templates lookup with their options
|
||||
// Build templates lookup with their options (pre-index for fast tree building)
|
||||
templateChildrenIndex = buildParentIndex(qTemplateChildren);
|
||||
templatesById = {};
|
||||
for (i = 1; i <= qTemplates.recordCount; i++) {
|
||||
templateID = qTemplates.ItemID[i];
|
||||
options = buildOptionsTree(qTemplateChildren, templateID);
|
||||
options = buildOptionsTree(qTemplateChildren, templateID, templateChildrenIndex);
|
||||
templatesById[templateID] = {
|
||||
"id": "mod_" & qTemplates.ItemID[i],
|
||||
"dbId": qTemplates.ItemID[i],
|
||||
|
|
@ -343,10 +354,11 @@ try {
|
|||
}
|
||||
}
|
||||
|
||||
// Build nested direct modifiers for each menu item
|
||||
// Build nested direct modifiers for each menu item (pre-index for fast tree building)
|
||||
directModifiersIndex = buildParentIndex(qDirectModifiers);
|
||||
directModsByItem = {};
|
||||
for (itemId in menuItemIds) {
|
||||
options = buildOptionsTree(qDirectModifiers, itemId);
|
||||
options = buildOptionsTree(qDirectModifiers, itemId, directModifiersIndex);
|
||||
if (arrayLen(options) > 0) {
|
||||
directModsByItem[itemId] = options;
|
||||
}
|
||||
|
|
@ -441,7 +453,7 @@ try {
|
|||
// Get business brand color
|
||||
brandColor = "";
|
||||
try {
|
||||
qBrand = queryExecute("
|
||||
qBrand = queryTimed("
|
||||
SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = :bizId
|
||||
", { bizId: businessID }, { datasource: "payfrit" });
|
||||
if (qBrand.recordCount > 0 && len(trim(qBrand.BusinessBrandColor))) {
|
||||
|
|
@ -474,5 +486,6 @@ try {
|
|||
response["DETAIL"] = e.detail ?: "";
|
||||
}
|
||||
|
||||
logPerf();
|
||||
writeOutput(serializeJSON(response));
|
||||
</cfscript>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,16 @@
|
|||
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required.", "DETAIL": "" })>
|
||||
</cfif>
|
||||
|
||||
<!--- Check menu cache (2 minute TTL) --->
|
||||
<cfset menuCacheKey = "menu_" & BusinessID & "_" & OrderTypeID & "_" & requestedMenuID>
|
||||
<cfset cachedMenu = appCacheGet(menuCacheKey, 120)>
|
||||
<cfif NOT isNull(cachedMenu)>
|
||||
<cfset logPerf()>
|
||||
<cfcontent type="application/json; charset=utf-8">
|
||||
<cfoutput>#cachedMenu#</cfoutput>
|
||||
<cfabort>
|
||||
</cfif>
|
||||
|
||||
<!--- Get current time and day for schedule filtering --->
|
||||
<cfset currentTime = timeFormat(now(), "HH:mm:ss")>
|
||||
<cfset currentDayID = dayOfWeek(now())>
|
||||
|
|
@ -57,7 +67,7 @@
|
|||
<!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
|
||||
<cfset newSchemaActive = false>
|
||||
<cftry>
|
||||
<cfset qCheck = queryExecute(
|
||||
<cfset qCheck = queryTimed(
|
||||
"SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = ? AND ItemBusinessID > 0",
|
||||
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||
{ datasource = "payfrit" }
|
||||
|
|
@ -74,7 +84,7 @@
|
|||
<!--- Check if Categories table has data for this business --->
|
||||
<cfset hasCategoriesData = false>
|
||||
<cftry>
|
||||
<cfset qCatCheck = queryExecute(
|
||||
<cfset qCatCheck = queryTimed(
|
||||
"SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = ?",
|
||||
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||
{ datasource = "payfrit" }
|
||||
|
|
@ -91,7 +101,7 @@
|
|||
<cfset activeMenuIds = "">
|
||||
<cfset menuList = []>
|
||||
<cftry>
|
||||
<cfset qAllMenus = queryExecute(
|
||||
<cfset qAllMenus = queryTimed(
|
||||
"
|
||||
SELECT MenuID, MenuName FROM Menus
|
||||
WHERE MenuBusinessID = :bizId
|
||||
|
|
@ -124,7 +134,7 @@
|
|||
|
||||
<!--- Get category headers as virtual items --->
|
||||
<!--- Apply schedule filtering, order type filtering, and menu filtering --->
|
||||
<cfset qCategories = queryExecute(
|
||||
<cfset qCategories = queryTimed(
|
||||
"
|
||||
SELECT
|
||||
CategoryID,
|
||||
|
|
@ -178,7 +188,7 @@
|
|||
<cfif len(trim(visibleCategoryIds)) EQ 0>
|
||||
<cfset visibleCategoryIds = "0">
|
||||
</cfif>
|
||||
<cfset q = queryExecute(
|
||||
<cfset q = queryTimed(
|
||||
"
|
||||
SELECT
|
||||
i.ItemID,
|
||||
|
|
@ -197,6 +207,7 @@
|
|||
i.ItemStationID,
|
||||
s.StationName,
|
||||
s.StationColor
|
||||
,COALESCE(c.CategoryMenuID, 0) as CategoryMenuID
|
||||
FROM Items i
|
||||
LEFT JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
||||
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
|
||||
|
|
@ -213,7 +224,7 @@
|
|||
)>
|
||||
<cfelse>
|
||||
<!--- Fallback: Derive categories from parent Items --->
|
||||
<cfset q = queryExecute(
|
||||
<cfset q = queryTimed(
|
||||
"
|
||||
SELECT
|
||||
i.ItemID,
|
||||
|
|
@ -268,7 +279,7 @@
|
|||
</cfif>
|
||||
<cfelse>
|
||||
<!--- OLD SCHEMA: Use Categories table --->
|
||||
<cfset q = queryExecute(
|
||||
<cfset q = queryTimed(
|
||||
"
|
||||
SELECT
|
||||
i.ItemID,
|
||||
|
|
@ -286,7 +297,8 @@
|
|||
i.ItemSortOrder,
|
||||
i.ItemStationID,
|
||||
s.StationName,
|
||||
s.StationColor
|
||||
s.StationColor,
|
||||
COALESCE(c.CategoryMenuID, 0) as CategoryMenuID
|
||||
FROM Items i
|
||||
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
||||
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
|
||||
|
|
@ -329,7 +341,8 @@
|
|||
"ItemSortOrder": qCategories.CategorySortOrder,
|
||||
"ItemStationID": "",
|
||||
"ItemStationName": "",
|
||||
"ItemStationColor": ""
|
||||
"ItemStationColor": "",
|
||||
"CategoryMenuID": isNull(qCategories.CategoryMenuID) ? 0 : val(qCategories.CategoryMenuID)
|
||||
})>
|
||||
</cfif>
|
||||
</cfloop>
|
||||
|
|
@ -360,6 +373,11 @@
|
|||
</cfif>
|
||||
</cfif>
|
||||
|
||||
<cfset itemMenuID = 0>
|
||||
<cftry>
|
||||
<cfset itemMenuID = isDefined("q.CategoryMenuID") ? val(q.CategoryMenuID) : 0>
|
||||
<cfcatch><cfset itemMenuID = 0></cfcatch>
|
||||
</cftry>
|
||||
<cfset arrayAppend(rows, {
|
||||
"ItemID": q.ItemID,
|
||||
"ItemCategoryID": q.ItemCategoryID,
|
||||
|
|
@ -376,14 +394,15 @@
|
|||
"ItemSortOrder": q.ItemSortOrder,
|
||||
"ItemStationID": len(trim(q.ItemStationID)) ? q.ItemStationID : "",
|
||||
"ItemStationName": len(trim(q.StationName)) ? q.StationName : "",
|
||||
"ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : ""
|
||||
"ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : "",
|
||||
"CategoryMenuID": itemMenuID
|
||||
})>
|
||||
</cfloop>
|
||||
|
||||
<!--- For unified schema: Add template-linked modifiers as virtual children of menu items --->
|
||||
<cfif newSchemaActive>
|
||||
<!--- Get template links: which menu items use which templates --->
|
||||
<cfset qTemplateLinks = queryExecute(
|
||||
<cfset qTemplateLinks = queryTimed(
|
||||
"
|
||||
SELECT
|
||||
tl.ItemID as MenuItemID,
|
||||
|
|
@ -406,7 +425,7 @@
|
|||
)>
|
||||
|
||||
<!--- Get template options --->
|
||||
<cfset qTemplateOptions = queryExecute(
|
||||
<cfset qTemplateOptions = queryTimed(
|
||||
"
|
||||
SELECT DISTINCT
|
||||
opt.ItemID as OptionItemID,
|
||||
|
|
@ -514,7 +533,7 @@
|
|||
<!--- Get brand color for this business --->
|
||||
<cfset brandColor = "">
|
||||
<cftry>
|
||||
<cfset qBrand = queryExecute(
|
||||
<cfset qBrand = queryTimed(
|
||||
"SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = ?",
|
||||
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||
{ datasource = "payfrit" }
|
||||
|
|
@ -527,7 +546,7 @@
|
|||
</cfcatch>
|
||||
</cftry>
|
||||
|
||||
<cfset apiAbort({
|
||||
<cfset menuResponse = serializeJSON({
|
||||
"OK": true,
|
||||
"ERROR": "",
|
||||
"Items": rows,
|
||||
|
|
@ -537,6 +556,11 @@
|
|||
"Menus": menuList,
|
||||
"SelectedMenuID": requestedMenuID
|
||||
})>
|
||||
<cfset appCachePut(menuCacheKey, menuResponse)>
|
||||
<cfset logPerf()>
|
||||
<cfcontent type="application/json; charset=utf-8">
|
||||
<cfoutput>#menuResponse#</cfoutput>
|
||||
<cfabort>
|
||||
|
||||
<cfcatch>
|
||||
<cfset apiAbort({
|
||||
|
|
|
|||
|
|
@ -379,6 +379,9 @@ try {
|
|||
}
|
||||
|
||||
response = { "OK": true, "SCHEMA": newSchemaActive ? "unified" : "legacy" };
|
||||
// Invalidate menu cache for this business
|
||||
appCacheInvalidate("menu_" & businessID);
|
||||
appCacheInvalidate("menuBuilder_" & businessID);
|
||||
|
||||
} catch (any e) {
|
||||
response = {
|
||||
|
|
|
|||
|
|
@ -103,5 +103,6 @@
|
|||
</cftry>
|
||||
|
||||
<!--- Return JSON response --->
|
||||
<cfset logPerf()>
|
||||
<cfcontent type="application/json" reset="true">
|
||||
<cfoutput>#serializeJSON(payload)#</cfoutput>
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ try {
|
|||
}
|
||||
|
||||
// Get order details
|
||||
qOrder = queryExecute("
|
||||
qOrder = queryTimed("
|
||||
SELECT
|
||||
o.OrderID,
|
||||
o.OrderBusinessID,
|
||||
|
|
@ -78,7 +78,7 @@ try {
|
|||
}
|
||||
|
||||
// Get line items (excluding deleted items)
|
||||
qItems = queryExecute("
|
||||
qItems = queryTimed("
|
||||
SELECT
|
||||
oli.OrderLineItemID,
|
||||
oli.OrderLineItemItemID,
|
||||
|
|
@ -150,20 +150,16 @@ try {
|
|||
// Calculate total
|
||||
total = subtotal + tax + tip;
|
||||
|
||||
// Get staff who worked on this order (from Tasks table) with pending rating tokens
|
||||
qStaff = queryExecute("
|
||||
SELECT DISTINCT u.UserID, u.UserFirstName,
|
||||
(SELECT r.TaskRatingAccessToken
|
||||
FROM TaskRatings r
|
||||
INNER JOIN Tasks t2 ON t2.TaskID = r.TaskRatingTaskID
|
||||
WHERE t2.TaskOrderID = :orderID
|
||||
AND r.TaskRatingForUserID = u.UserID
|
||||
AND r.TaskRatingDirection = 'customer_rates_worker'
|
||||
AND r.TaskRatingCompletedOn IS NULL
|
||||
AND r.TaskRatingExpiresOn > NOW()
|
||||
LIMIT 1) AS RatingToken
|
||||
// Get staff who worked on this order (LEFT JOIN instead of correlated subquery)
|
||||
qStaff = queryTimed("
|
||||
SELECT DISTINCT u.UserID, u.UserFirstName, r.TaskRatingAccessToken AS RatingToken
|
||||
FROM Tasks t
|
||||
INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID
|
||||
LEFT JOIN TaskRatings r ON r.TaskRatingTaskID = t.TaskID
|
||||
AND r.TaskRatingForUserID = u.UserID
|
||||
AND r.TaskRatingDirection = 'customer_rates_worker'
|
||||
AND r.TaskRatingCompletedOn IS NULL
|
||||
AND r.TaskRatingExpiresOn > NOW()
|
||||
WHERE t.TaskOrderID = :orderID
|
||||
AND t.TaskClaimedByUserID > 0
|
||||
", { orderID: orderID });
|
||||
|
|
@ -221,6 +217,7 @@ try {
|
|||
response["MESSAGE"] = e.message;
|
||||
}
|
||||
|
||||
logPerf();
|
||||
writeOutput(serializeJSON(response));
|
||||
|
||||
// Helper functions
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ if (structKeyExists(request, "UserID") && isNumeric(request.UserID) && request.U
|
|||
userToken = getHeader("X-User-Token");
|
||||
if (len(userToken)) {
|
||||
try {
|
||||
qTok = queryExecute(
|
||||
qTok = queryTimed(
|
||||
"SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1",
|
||||
[ { value = userToken, cfsqltype = "cf_sql_varchar" } ],
|
||||
{ datasource = "payfrit" }
|
||||
|
|
@ -63,7 +63,7 @@ if (offset < 0) offset = 0;
|
|||
|
||||
try {
|
||||
// Get orders for this user (exclude carts - status 0)
|
||||
qOrders = queryExecute("
|
||||
qOrders = queryTimed("
|
||||
SELECT
|
||||
o.OrderID,
|
||||
o.OrderUUID,
|
||||
|
|
@ -88,29 +88,40 @@ try {
|
|||
});
|
||||
|
||||
// Get total count
|
||||
qCount = queryExecute("
|
||||
qCount = queryTimed("
|
||||
SELECT COUNT(*) as TotalCount
|
||||
FROM Orders
|
||||
WHERE OrderUserID = :userId
|
||||
AND OrderStatusID > 0
|
||||
", { userId: { value = userId, cfsqltype = "cf_sql_integer" } });
|
||||
|
||||
// Build orders array with item counts and totals
|
||||
orders = [];
|
||||
for (row in qOrders) {
|
||||
// Get line item count and calculate total
|
||||
qItems = queryExecute("
|
||||
// Batch fetch line item counts and totals for ALL orders (eliminates N+1)
|
||||
itemSummary = {};
|
||||
orderIds = valueList(qOrders.OrderID);
|
||||
if (len(orderIds)) {
|
||||
qAllItems = queryTimed("
|
||||
SELECT
|
||||
OrderLineItemOrderID,
|
||||
COUNT(*) as ItemCount,
|
||||
SUM(OrderLineItemQuantity * OrderLineItemPrice) as Subtotal
|
||||
FROM OrderLineItems
|
||||
WHERE OrderLineItemOrderID = :orderId
|
||||
WHERE OrderLineItemOrderID IN (:orderIds)
|
||||
AND OrderLineItemParentOrderLineItemID = 0
|
||||
AND (OrderLineItemIsDeleted = 0 OR OrderLineItemIsDeleted IS NULL)
|
||||
", { orderId: { value = row.OrderID, cfsqltype = "cf_sql_integer" } });
|
||||
GROUP BY OrderLineItemOrderID
|
||||
", { orderIds: { value = orderIds, cfsqltype = "cf_sql_varchar", list = true } });
|
||||
|
||||
itemCount = val(qItems.ItemCount);
|
||||
subtotal = val(qItems.Subtotal);
|
||||
for (r in qAllItems) {
|
||||
itemSummary[r.OrderLineItemOrderID] = { count: r.ItemCount, subtotal: r.Subtotal };
|
||||
}
|
||||
}
|
||||
|
||||
// Build orders array
|
||||
orders = [];
|
||||
for (row in qOrders) {
|
||||
summary = structKeyExists(itemSummary, row.OrderID) ? itemSummary[row.OrderID] : { count: 0, subtotal: 0 };
|
||||
itemCount = val(summary.count);
|
||||
subtotal = val(summary.subtotal);
|
||||
tax = subtotal * 0.0875;
|
||||
total = subtotal + tax;
|
||||
|
||||
|
|
@ -156,6 +167,7 @@ try {
|
|||
});
|
||||
}
|
||||
|
||||
logPerf();
|
||||
writeOutput(serializeJSON({
|
||||
"OK": true,
|
||||
"ORDERS": orders,
|
||||
|
|
|
|||
|
|
@ -58,7 +58,7 @@
|
|||
<cfif StationID GT 0>
|
||||
<cfset stationParams = duplicate(params)>
|
||||
<cfset arrayAppend(stationParams, { value = StationID, cfsqltype = "cf_sql_integer" })>
|
||||
<cfset qOrders = queryExecute("
|
||||
<cfset qOrders = queryTimed("
|
||||
SELECT DISTINCT
|
||||
o.OrderID,
|
||||
o.OrderUUID,
|
||||
|
|
@ -84,7 +84,7 @@
|
|||
ORDER BY o.OrderSubmittedOn ASC, o.OrderID ASC
|
||||
", stationParams, { datasource = "payfrit" })>
|
||||
<cfelse>
|
||||
<cfset qOrders = queryExecute("
|
||||
<cfset qOrders = queryTimed("
|
||||
SELECT
|
||||
o.OrderID,
|
||||
o.OrderUUID,
|
||||
|
|
@ -109,12 +109,14 @@
|
|||
|
||||
<cfset orders = []>
|
||||
|
||||
<cfloop query="qOrders">
|
||||
<!--- Get line items for this order --->
|
||||
<!--- If filtering by station, only show items for that station (plus modifiers) --->
|
||||
<!--- Batch fetch ALL line items for all orders in one query (eliminates N+1) --->
|
||||
<cfset orderIds = valueList(qOrders.OrderID)>
|
||||
<cfset lineItemsByOrder = {}>
|
||||
<cfif len(orderIds)>
|
||||
<cfif StationID GT 0>
|
||||
<cfset qLineItems = queryExecute("
|
||||
<cfset qAllLineItems = queryTimed("
|
||||
SELECT
|
||||
oli.OrderLineItemOrderID,
|
||||
oli.OrderLineItemID,
|
||||
oli.OrderLineItemParentOrderLineItemID,
|
||||
oli.OrderLineItemItemID,
|
||||
|
|
@ -130,17 +132,15 @@
|
|||
FROM OrderLineItems oli
|
||||
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
|
||||
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
|
||||
WHERE oli.OrderLineItemOrderID = ?
|
||||
WHERE oli.OrderLineItemOrderID IN (#orderIds#)
|
||||
AND oli.OrderLineItemIsDeleted = b'0'
|
||||
AND (i.ItemStationID = ? OR i.ItemStationID = 0 OR i.ItemStationID IS NULL OR oli.OrderLineItemParentOrderLineItemID > 0)
|
||||
ORDER BY oli.OrderLineItemID
|
||||
", [
|
||||
{ value = qOrders.OrderID, cfsqltype = "cf_sql_integer" },
|
||||
{ value = StationID, cfsqltype = "cf_sql_integer" }
|
||||
], { datasource = "payfrit" })>
|
||||
ORDER BY oli.OrderLineItemOrderID, oli.OrderLineItemID
|
||||
", [ { value = StationID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
||||
<cfelse>
|
||||
<cfset qLineItems = queryExecute("
|
||||
<cfset qAllLineItems = queryTimed("
|
||||
SELECT
|
||||
oli.OrderLineItemOrderID,
|
||||
oli.OrderLineItemID,
|
||||
oli.OrderLineItemParentOrderLineItemID,
|
||||
oli.OrderLineItemItemID,
|
||||
|
|
@ -156,28 +156,37 @@
|
|||
FROM OrderLineItems oli
|
||||
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
|
||||
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
|
||||
WHERE oli.OrderLineItemOrderID = ?
|
||||
WHERE oli.OrderLineItemOrderID IN (#orderIds#)
|
||||
AND oli.OrderLineItemIsDeleted = b'0'
|
||||
ORDER BY oli.OrderLineItemID
|
||||
", [ { value = qOrders.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
||||
ORDER BY oli.OrderLineItemOrderID, oli.OrderLineItemID
|
||||
", [], { datasource = "payfrit" })>
|
||||
</cfif>
|
||||
|
||||
<cfset lineItems = []>
|
||||
<cfloop query="qLineItems">
|
||||
<cfset arrayAppend(lineItems, {
|
||||
"OrderLineItemID": qLineItems.OrderLineItemID,
|
||||
"OrderLineItemParentOrderLineItemID": qLineItems.OrderLineItemParentOrderLineItemID,
|
||||
"OrderLineItemItemID": qLineItems.OrderLineItemItemID,
|
||||
"OrderLineItemPrice": qLineItems.OrderLineItemPrice,
|
||||
"OrderLineItemQuantity": qLineItems.OrderLineItemQuantity,
|
||||
"OrderLineItemRemark": qLineItems.OrderLineItemRemark,
|
||||
"ItemName": qLineItems.ItemName,
|
||||
"ItemParentItemID": qLineItems.ItemParentItemID,
|
||||
"ItemParentName": qLineItems.ItemParentName,
|
||||
"ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault,
|
||||
"ItemStationID": qLineItems.ItemStationID
|
||||
<!--- Group line items by OrderID --->
|
||||
<cfloop query="qAllLineItems">
|
||||
<cfset oid = qAllLineItems.OrderLineItemOrderID>
|
||||
<cfif NOT structKeyExists(lineItemsByOrder, oid)>
|
||||
<cfset lineItemsByOrder[oid] = []>
|
||||
</cfif>
|
||||
<cfset arrayAppend(lineItemsByOrder[oid], {
|
||||
"OrderLineItemID": qAllLineItems.OrderLineItemID,
|
||||
"OrderLineItemParentOrderLineItemID": qAllLineItems.OrderLineItemParentOrderLineItemID,
|
||||
"OrderLineItemItemID": qAllLineItems.OrderLineItemItemID,
|
||||
"OrderLineItemPrice": qAllLineItems.OrderLineItemPrice,
|
||||
"OrderLineItemQuantity": qAllLineItems.OrderLineItemQuantity,
|
||||
"OrderLineItemRemark": qAllLineItems.OrderLineItemRemark,
|
||||
"ItemName": qAllLineItems.ItemName,
|
||||
"ItemParentItemID": qAllLineItems.ItemParentItemID,
|
||||
"ItemParentName": qAllLineItems.ItemParentName,
|
||||
"ItemIsCheckedByDefault": qAllLineItems.ItemIsCheckedByDefault,
|
||||
"ItemStationID": qAllLineItems.ItemStationID
|
||||
})>
|
||||
</cfloop>
|
||||
</cfif>
|
||||
|
||||
<!--- Build orders array using pre-fetched line items --->
|
||||
<cfloop query="qOrders">
|
||||
<cfset lineItems = structKeyExists(lineItemsByOrder, qOrders.OrderID) ? lineItemsByOrder[qOrders.OrderID] : []>
|
||||
|
||||
<!--- Determine order type name --->
|
||||
<cfset orderTypeName = "">
|
||||
|
|
@ -207,6 +216,7 @@
|
|||
})>
|
||||
</cfloop>
|
||||
|
||||
<cfset logPerf()>
|
||||
<cfset apiAbort({
|
||||
"OK": true,
|
||||
"ERROR": "",
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ try {
|
|||
// Get businesses for this user
|
||||
// Users are linked to businesses via BusinessUserID field (owner)
|
||||
|
||||
q = queryExecute("
|
||||
q = queryTimed("
|
||||
SELECT
|
||||
b.BusinessID,
|
||||
b.BusinessName
|
||||
|
|
@ -71,5 +71,6 @@ try {
|
|||
response["MESSAGE"] = e.message;
|
||||
}
|
||||
|
||||
logPerf();
|
||||
writeOutput(serializeJSON(response));
|
||||
</cfscript>
|
||||
|
|
|
|||
|
|
@ -36,63 +36,51 @@ try {
|
|||
todayStart = dateFormat(now(), "yyyy-mm-dd") & " 00:00:00";
|
||||
todayEnd = dateFormat(now(), "yyyy-mm-dd") & " 23:59:59";
|
||||
|
||||
// Orders today count
|
||||
qOrdersToday = queryExecute("
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM Orders
|
||||
WHERE OrderBusinessID = :businessID
|
||||
AND OrderSubmittedOn >= :todayStart
|
||||
AND OrderSubmittedOn <= :todayEnd
|
||||
// Combined stats query — 1 round trip instead of 4
|
||||
qStats = queryTimed("
|
||||
SELECT
|
||||
(SELECT COUNT(*)
|
||||
FROM Orders
|
||||
WHERE OrderBusinessID = :businessID
|
||||
AND OrderSubmittedOn >= :todayStart
|
||||
AND OrderSubmittedOn <= :todayEnd) as ordersToday,
|
||||
|
||||
(SELECT COALESCE(SUM(li.OrderLineItemQuantity * li.OrderLineItemPrice), 0)
|
||||
FROM Orders o
|
||||
JOIN OrderLineItems li ON li.OrderLineItemOrderID = o.OrderID
|
||||
WHERE o.OrderBusinessID = :businessID
|
||||
AND o.OrderSubmittedOn >= :todayStart
|
||||
AND o.OrderSubmittedOn <= :todayEnd
|
||||
AND o.OrderStatusID >= 1) as revenueToday,
|
||||
|
||||
(SELECT COUNT(*)
|
||||
FROM Orders
|
||||
WHERE OrderBusinessID = :businessID
|
||||
AND OrderStatusID IN (1, 2)) as pendingOrders,
|
||||
|
||||
(SELECT COUNT(*)
|
||||
FROM Items
|
||||
WHERE ItemBusinessID = :businessID
|
||||
AND ItemIsActive = 1
|
||||
AND ItemParentItemID > 0) as menuItems
|
||||
", {
|
||||
businessID: businessID,
|
||||
todayStart: { value: todayStart, cfsqltype: "cf_sql_varchar" },
|
||||
todayEnd: { value: todayEnd, cfsqltype: "cf_sql_varchar" }
|
||||
});
|
||||
|
||||
// Revenue today (sum of line items)
|
||||
qRevenueToday = queryExecute("
|
||||
SELECT COALESCE(SUM(li.OrderLineItemQuantity * li.OrderLineItemPrice), 0) as total
|
||||
FROM Orders o
|
||||
JOIN OrderLineItems li ON li.OrderLineItemOrderID = o.OrderID
|
||||
WHERE o.OrderBusinessID = :businessID
|
||||
AND o.OrderSubmittedOn >= :todayStart
|
||||
AND o.OrderSubmittedOn <= :todayEnd
|
||||
AND o.OrderStatusID >= 1
|
||||
", {
|
||||
businessID: businessID,
|
||||
todayStart: { value: todayStart, cfsqltype: "cf_sql_varchar" },
|
||||
todayEnd: { value: todayEnd, cfsqltype: "cf_sql_varchar" }
|
||||
});
|
||||
|
||||
// Pending orders (status 1 = submitted, 2 = preparing)
|
||||
qPendingOrders = queryExecute("
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM Orders
|
||||
WHERE OrderBusinessID = :businessID
|
||||
AND OrderStatusID IN (1, 2)
|
||||
", { businessID: businessID });
|
||||
|
||||
// Menu items count (active items that have a parent category, excluding categories themselves)
|
||||
// Categories are items with ItemParentItemID = 0 and ItemIsCollapsible = 0
|
||||
qMenuItems = queryExecute("
|
||||
SELECT COUNT(*) as cnt
|
||||
FROM Items
|
||||
WHERE ItemBusinessID = :businessID
|
||||
AND ItemIsActive = 1
|
||||
AND ItemParentItemID > 0
|
||||
", { businessID: businessID });
|
||||
|
||||
response["OK"] = true;
|
||||
response["STATS"] = {
|
||||
"ordersToday": qOrdersToday.cnt,
|
||||
"revenueToday": qRevenueToday.total,
|
||||
"pendingOrders": qPendingOrders.cnt,
|
||||
"menuItems": qMenuItems.cnt
|
||||
"ordersToday": qStats.ordersToday,
|
||||
"revenueToday": qStats.revenueToday,
|
||||
"pendingOrders": qStats.pendingOrders,
|
||||
"menuItems": qStats.menuItems
|
||||
};
|
||||
|
||||
} catch (any e) {
|
||||
response["ERROR"] = e.message;
|
||||
}
|
||||
|
||||
logPerf();
|
||||
writeOutput(serializeJSON(response));
|
||||
</cfscript>
|
||||
|
|
|
|||
Reference in a new issue