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 showdebugoutput="false">
|
||||||
<cfsetting enablecfoutputonly="true">
|
<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)
|
Payfrit API Application.cfm (updated 2026-01-22)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,9 +2,15 @@
|
||||||
<cfsetting enablecfoutputonly="true">
|
<cfsetting enablecfoutputonly="true">
|
||||||
<cfcontent type="application/json; charset=utf-8">
|
<cfcontent type="application/json; charset=utf-8">
|
||||||
|
|
||||||
<!--- List US states for address forms --->
|
<!--- List US states for address forms (cached 24h - static data) --->
|
||||||
<cfscript>
|
<cfscript>
|
||||||
try {
|
try {
|
||||||
|
cached = appCacheGet("states", 86400);
|
||||||
|
if (!isNull(cached)) {
|
||||||
|
writeOutput(cached);
|
||||||
|
abort;
|
||||||
|
}
|
||||||
|
|
||||||
qStates = queryExecute("
|
qStates = queryExecute("
|
||||||
SELECT tt_StateID as StateID, tt_StateAbbreviation as StateAbbreviation, tt_StateName as StateName
|
SELECT tt_StateID as StateID, tt_StateAbbreviation as StateAbbreviation, tt_StateName as StateName
|
||||||
FROM tt_States
|
FROM tt_States
|
||||||
|
|
@ -20,10 +26,12 @@ try {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
writeOutput(serializeJSON({
|
jsonResponse = serializeJSON({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"STATES": states
|
"STATES": states
|
||||||
}));
|
});
|
||||||
|
appCachePut("states", jsonResponse);
|
||||||
|
writeOutput(jsonResponse);
|
||||||
|
|
||||||
} catch (any e) {
|
} catch (any e) {
|
||||||
writeOutput(serializeJSON({
|
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
|
// Look up the token
|
||||||
qToken = queryExecute("
|
qToken = queryTimed("
|
||||||
SELECT ut.UserID, u.UserFirstName, u.UserLastName
|
SELECT ut.UserID, u.UserFirstName, u.UserLastName
|
||||||
FROM UserTokens ut
|
FROM UserTokens ut
|
||||||
JOIN Users u ON u.UserID = ut.UserID
|
JOIN Users u ON u.UserID = ut.UserID
|
||||||
|
|
@ -47,7 +47,7 @@ try {
|
||||||
userID = qToken.UserID;
|
userID = qToken.UserID;
|
||||||
|
|
||||||
// Determine if user is a worker (has any active employment)
|
// Determine if user is a worker (has any active employment)
|
||||||
qWorker = queryExecute("
|
qWorker = queryTimed("
|
||||||
SELECT COUNT(*) as cnt
|
SELECT COUNT(*) as cnt
|
||||||
FROM lt_Users_Businesses_Employees
|
FROM lt_Users_Businesses_Employees
|
||||||
WHERE UserID = :userID AND EmployeeIsActive = 1
|
WHERE UserID = :userID AND EmployeeIsActive = 1
|
||||||
|
|
@ -55,6 +55,7 @@ try {
|
||||||
|
|
||||||
userType = qWorker.cnt > 0 ? "worker" : "customer";
|
userType = qWorker.cnt > 0 ? "worker" : "customer";
|
||||||
|
|
||||||
|
logPerf();
|
||||||
apiAbort({
|
apiAbort({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"UserID": userID,
|
"UserID": userID,
|
||||||
|
|
|
||||||
|
|
@ -81,4 +81,5 @@ if (structKeyExists(data, "onlyActive")) {
|
||||||
})>
|
})>
|
||||||
</cfloop>
|
</cfloop>
|
||||||
|
|
||||||
|
<cfset logPerf()>
|
||||||
<cfoutput>#serializeJSON({ OK=true, ERROR="", BusinessID=bizId, COUNT=arrayLen(beacons), BEACONS=beacons })#</cfoutput>
|
<cfoutput>#serializeJSON({ OK=true, ERROR="", BusinessID=bizId, COUNT=arrayLen(beacons), BEACONS=beacons })#</cfoutput>
|
||||||
|
|
|
||||||
|
|
@ -32,8 +32,16 @@ try {
|
||||||
abort;
|
abort;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check cache (5 minute TTL)
|
||||||
|
cached = appCacheGet("biz_" & businessID, 300);
|
||||||
|
if (!isNull(cached)) {
|
||||||
|
logPerf();
|
||||||
|
writeOutput(cached);
|
||||||
|
abort;
|
||||||
|
}
|
||||||
|
|
||||||
// Get business details
|
// Get business details
|
||||||
q = queryExecute("
|
q = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
BusinessID,
|
BusinessID,
|
||||||
BusinessName,
|
BusinessName,
|
||||||
|
|
@ -55,7 +63,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get address from Addresses table (either linked via AddressBusinessID or via Businesses.BusinessAddressID)
|
// 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
|
SELECT a.AddressLine1, a.AddressLine2, a.AddressCity, a.AddressZIPCode, s.tt_StateAbbreviation
|
||||||
FROM Addresses a
|
FROM Addresses a
|
||||||
LEFT JOIN tt_States s ON s.tt_StateID = a.AddressStateID
|
LEFT JOIN tt_States s ON s.tt_StateID = a.AddressStateID
|
||||||
|
|
@ -87,7 +95,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get hours from Hours table
|
// Get hours from Hours table
|
||||||
qHours = queryExecute("
|
qHours = queryTimed("
|
||||||
SELECT h.HoursDayID, h.HoursOpenTime, h.HoursClosingTime, d.tt_DayAbbrev
|
SELECT h.HoursDayID, h.HoursOpenTime, h.HoursClosingTime, d.tt_DayAbbrev
|
||||||
FROM Hours h
|
FROM Hours h
|
||||||
JOIN tt_Days d ON d.tt_DayID = h.HoursDayID
|
JOIN tt_Days d ON d.tt_DayID = h.HoursDayID
|
||||||
|
|
@ -152,9 +160,17 @@ try {
|
||||||
response["OK"] = true;
|
response["OK"] = true;
|
||||||
response["BUSINESS"] = business;
|
response["BUSINESS"] = business;
|
||||||
|
|
||||||
|
// Cache successful response
|
||||||
|
jsonResponse = serializeJSON(response);
|
||||||
|
appCachePut("biz_" & businessID, jsonResponse);
|
||||||
|
logPerf();
|
||||||
|
writeOutput(jsonResponse);
|
||||||
|
abort;
|
||||||
|
|
||||||
} catch (any e) {
|
} catch (any e) {
|
||||||
response["ERROR"] = e.message;
|
response["ERROR"] = e.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logPerf();
|
||||||
writeOutput(serializeJSON(response));
|
writeOutput(serializeJSON(response));
|
||||||
</cfscript>
|
</cfscript>
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,7 @@ queryExecute("
|
||||||
bizId: { value: bizId, cfsqltype: "cf_sql_integer" }
|
bizId: { value: bizId, cfsqltype: "cf_sql_integer" }
|
||||||
}, { datasource: "payfrit" });
|
}, { datasource: "payfrit" });
|
||||||
|
|
||||||
|
appCacheInvalidate("biz_" & bizId);
|
||||||
writeOutput(serializeJSON({
|
writeOutput(serializeJSON({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"ERROR": "",
|
"ERROR": "",
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
response.OK = true;
|
response.OK = true;
|
||||||
|
appCacheInvalidate("biz_" & businessId);
|
||||||
|
|
||||||
} catch (any e) {
|
} catch (any e) {
|
||||||
response.ERROR = e.message;
|
response.ERROR = e.message;
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ try {
|
||||||
|
|
||||||
response.OK = true;
|
response.OK = true;
|
||||||
response.hoursUpdated = arrayLen(hours);
|
response.hoursUpdated = arrayLen(hours);
|
||||||
|
appCacheInvalidate("biz_" & businessId);
|
||||||
|
|
||||||
} catch (any e) {
|
} catch (any e) {
|
||||||
response.ERROR = e.message;
|
response.ERROR = e.message;
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,142 @@ function apiError(message, detail = "", statusCode = 500) {
|
||||||
return response;
|
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
|
// Store in application scope
|
||||||
application.isDevEnvironment = isDevEnvironment;
|
application.isDevEnvironment = isDevEnvironment;
|
||||||
application.isDev = isDev;
|
application.isDev = isDev;
|
||||||
|
|
|
||||||
|
|
@ -11,24 +11,34 @@
|
||||||
|
|
||||||
response = { "OK": false };
|
response = { "OK": false };
|
||||||
|
|
||||||
// Recursive function to build nested options
|
// Pre-index query rows by parentId for O(1) lookup instead of O(n) scan per level
|
||||||
function buildOptionsTree(allOptions, parentId) {
|
function buildParentIndex(allOptions) {
|
||||||
var result = [];
|
var byParent = {};
|
||||||
for (var i = 1; i <= allOptions.recordCount; i++) {
|
for (var i = 1; i <= allOptions.recordCount; i++) {
|
||||||
if (allOptions.ParentItemID[i] == parentId) {
|
var pid = allOptions.ParentItemID[i];
|
||||||
var children = buildOptionsTree(allOptions, allOptions.ItemID[i]);
|
if (!structKeyExists(byParent, pid)) byParent[pid] = [];
|
||||||
arrayAppend(result, {
|
arrayAppend(byParent[pid], i);
|
||||||
"id": "opt_" & allOptions.ItemID[i],
|
}
|
||||||
"dbId": allOptions.ItemID[i],
|
return byParent;
|
||||||
"name": allOptions.ItemName[i],
|
}
|
||||||
"price": allOptions.ItemPrice[i],
|
|
||||||
"isDefault": allOptions.IsDefault[i] == 1 ? true : false,
|
// Recursive function to build nested options (uses pre-built index)
|
||||||
"sortOrder": allOptions.ItemSortOrder[i],
|
function buildOptionsTree(allOptions, parentId, byParent) {
|
||||||
"requiresSelection": isNull(allOptions.RequiresSelection[i]) ? false : (allOptions.RequiresSelection[i] == 1),
|
if (!structKeyExists(byParent, parentId)) return [];
|
||||||
"maxSelections": isNull(allOptions.MaxSelections[i]) ? 0 : allOptions.MaxSelections[i],
|
var result = [];
|
||||||
"options": children
|
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) {
|
if (arrayLen(result) > 1) {
|
||||||
arraySort(result, function(a, b) {
|
arraySort(result, function(a, b) {
|
||||||
|
|
@ -63,7 +73,7 @@ try {
|
||||||
// Get all menus for this business
|
// Get all menus for this business
|
||||||
allMenus = [];
|
allMenus = [];
|
||||||
try {
|
try {
|
||||||
qMenus = queryExecute("
|
qMenus = queryTimed("
|
||||||
SELECT MenuID, MenuName, MenuDescription, MenuDaysActive,
|
SELECT MenuID, MenuName, MenuDescription, MenuDaysActive,
|
||||||
MenuStartTime, MenuEndTime, MenuSortOrder
|
MenuStartTime, MenuEndTime, MenuSortOrder
|
||||||
FROM Menus
|
FROM Menus
|
||||||
|
|
@ -123,7 +133,7 @@ try {
|
||||||
// Check if Categories table has data for this business
|
// Check if Categories table has data for this business
|
||||||
hasCategoriesData = false;
|
hasCategoriesData = false;
|
||||||
try {
|
try {
|
||||||
qCatCheck = queryExecute("
|
qCatCheck = queryTimed("
|
||||||
SELECT 1 FROM Categories WHERE CategoryBusinessID = :businessID LIMIT 1
|
SELECT 1 FROM Categories WHERE CategoryBusinessID = :businessID LIMIT 1
|
||||||
", { businessID: businessID }, { datasource: "payfrit" });
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
hasCategoriesData = (qCatCheck.recordCount > 0);
|
hasCategoriesData = (qCatCheck.recordCount > 0);
|
||||||
|
|
@ -141,7 +151,7 @@ try {
|
||||||
menuParams["menuID"] = menuID;
|
menuParams["menuID"] = menuID;
|
||||||
}
|
}
|
||||||
|
|
||||||
qCategories = queryExecute("
|
qCategories = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
CategoryID,
|
CategoryID,
|
||||||
CategoryName,
|
CategoryName,
|
||||||
|
|
@ -153,7 +163,7 @@ try {
|
||||||
", menuParams, { datasource: "payfrit" });
|
", menuParams, { datasource: "payfrit" });
|
||||||
|
|
||||||
// Get menu items - items that belong to categories (not modifiers)
|
// Get menu items - items that belong to categories (not modifiers)
|
||||||
qItems = queryExecute("
|
qItems = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
i.ItemID,
|
i.ItemID,
|
||||||
i.ItemCategoryID as CategoryItemID,
|
i.ItemCategoryID as CategoryItemID,
|
||||||
|
|
@ -170,7 +180,7 @@ try {
|
||||||
", { businessID: businessID }, { datasource: "payfrit" });
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
// Get direct modifiers (items with ParentItemID pointing to menu items, not categories)
|
// Get direct modifiers (items with ParentItemID pointing to menu items, not categories)
|
||||||
qDirectModifiers = queryExecute("
|
qDirectModifiers = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
m.ItemID,
|
m.ItemID,
|
||||||
m.ItemParentItemID as ParentItemID,
|
m.ItemParentItemID as ParentItemID,
|
||||||
|
|
@ -190,7 +200,7 @@ try {
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
// NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children
|
// NEW UNIFIED SCHEMA: Categories are Items at ParentID=0 with children
|
||||||
qCategories = queryExecute("
|
qCategories = queryTimed("
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
p.ItemID as CategoryID,
|
p.ItemID as CategoryID,
|
||||||
p.ItemName as CategoryName,
|
p.ItemName as CategoryName,
|
||||||
|
|
@ -206,7 +216,7 @@ try {
|
||||||
ORDER BY p.ItemSortOrder, p.ItemName
|
ORDER BY p.ItemSortOrder, p.ItemName
|
||||||
", { businessID: businessID }, { datasource: "payfrit" });
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
qItems = queryExecute("
|
qItems = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
i.ItemID,
|
i.ItemID,
|
||||||
i.ItemParentItemID as CategoryItemID,
|
i.ItemParentItemID as CategoryItemID,
|
||||||
|
|
@ -226,7 +236,7 @@ try {
|
||||||
ORDER BY i.ItemSortOrder, i.ItemName
|
ORDER BY i.ItemSortOrder, i.ItemName
|
||||||
", { businessID: businessID }, { datasource: "payfrit" });
|
", { businessID: businessID }, { datasource: "payfrit" });
|
||||||
|
|
||||||
qDirectModifiers = queryExecute("
|
qDirectModifiers = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
m.ItemID,
|
m.ItemID,
|
||||||
m.ItemParentItemID as ParentItemID,
|
m.ItemParentItemID as ParentItemID,
|
||||||
|
|
@ -253,7 +263,7 @@ try {
|
||||||
// Get template links ONLY for this business's menu items
|
// Get template links ONLY for this business's menu items
|
||||||
qTemplateLinks = queryNew("ParentItemID,TemplateItemID,SortOrder");
|
qTemplateLinks = queryNew("ParentItemID,TemplateItemID,SortOrder");
|
||||||
if (arrayLen(menuItemIds) > 0) {
|
if (arrayLen(menuItemIds) > 0) {
|
||||||
qTemplateLinks = queryExecute("
|
qTemplateLinks = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
tl.ItemID as ParentItemID,
|
tl.ItemID as ParentItemID,
|
||||||
tl.TemplateItemID,
|
tl.TemplateItemID,
|
||||||
|
|
@ -265,7 +275,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get templates for this business only
|
// Get templates for this business only
|
||||||
qTemplates = queryExecute("
|
qTemplates = queryTimed("
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
t.ItemID,
|
t.ItemID,
|
||||||
t.ItemName,
|
t.ItemName,
|
||||||
|
|
@ -290,7 +300,7 @@ try {
|
||||||
|
|
||||||
qTemplateChildren = queryNew("ItemID,ParentItemID,ItemName,ItemPrice,IsDefault,ItemSortOrder,RequiresSelection,MaxSelections");
|
qTemplateChildren = queryNew("ItemID,ParentItemID,ItemName,ItemPrice,IsDefault,ItemSortOrder,RequiresSelection,MaxSelections");
|
||||||
if (arrayLen(templateIds) > 0) {
|
if (arrayLen(templateIds) > 0) {
|
||||||
qTemplateChildren = queryExecute("
|
qTemplateChildren = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
c.ItemID,
|
c.ItemID,
|
||||||
c.ItemParentItemID as ParentItemID,
|
c.ItemParentItemID as ParentItemID,
|
||||||
|
|
@ -307,11 +317,12 @@ try {
|
||||||
", { templateIds: { value: arrayToList(templateIds), cfsqltype: "cf_sql_varchar", list: true } }, { datasource: "payfrit" });
|
", { 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 = {};
|
templatesById = {};
|
||||||
for (i = 1; i <= qTemplates.recordCount; i++) {
|
for (i = 1; i <= qTemplates.recordCount; i++) {
|
||||||
templateID = qTemplates.ItemID[i];
|
templateID = qTemplates.ItemID[i];
|
||||||
options = buildOptionsTree(qTemplateChildren, templateID);
|
options = buildOptionsTree(qTemplateChildren, templateID, templateChildrenIndex);
|
||||||
templatesById[templateID] = {
|
templatesById[templateID] = {
|
||||||
"id": "mod_" & qTemplates.ItemID[i],
|
"id": "mod_" & qTemplates.ItemID[i],
|
||||||
"dbId": 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 = {};
|
directModsByItem = {};
|
||||||
for (itemId in menuItemIds) {
|
for (itemId in menuItemIds) {
|
||||||
options = buildOptionsTree(qDirectModifiers, itemId);
|
options = buildOptionsTree(qDirectModifiers, itemId, directModifiersIndex);
|
||||||
if (arrayLen(options) > 0) {
|
if (arrayLen(options) > 0) {
|
||||||
directModsByItem[itemId] = options;
|
directModsByItem[itemId] = options;
|
||||||
}
|
}
|
||||||
|
|
@ -441,7 +453,7 @@ try {
|
||||||
// Get business brand color
|
// Get business brand color
|
||||||
brandColor = "";
|
brandColor = "";
|
||||||
try {
|
try {
|
||||||
qBrand = queryExecute("
|
qBrand = queryTimed("
|
||||||
SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = :bizId
|
SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = :bizId
|
||||||
", { bizId: businessID }, { datasource: "payfrit" });
|
", { bizId: businessID }, { datasource: "payfrit" });
|
||||||
if (qBrand.recordCount > 0 && len(trim(qBrand.BusinessBrandColor))) {
|
if (qBrand.recordCount > 0 && len(trim(qBrand.BusinessBrandColor))) {
|
||||||
|
|
@ -474,5 +486,6 @@ try {
|
||||||
response["DETAIL"] = e.detail ?: "";
|
response["DETAIL"] = e.detail ?: "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logPerf();
|
||||||
writeOutput(serializeJSON(response));
|
writeOutput(serializeJSON(response));
|
||||||
</cfscript>
|
</cfscript>
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,16 @@
|
||||||
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required.", "DETAIL": "" })>
|
<cfset apiAbort({ "OK": false, "ERROR": "missing_businessid", "MESSAGE": "BusinessID is required.", "DETAIL": "" })>
|
||||||
</cfif>
|
</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 --->
|
<!--- Get current time and day for schedule filtering --->
|
||||||
<cfset currentTime = timeFormat(now(), "HH:mm:ss")>
|
<cfset currentTime = timeFormat(now(), "HH:mm:ss")>
|
||||||
<cfset currentDayID = dayOfWeek(now())>
|
<cfset currentDayID = dayOfWeek(now())>
|
||||||
|
|
@ -57,7 +67,7 @@
|
||||||
<!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
|
<!--- Check if new schema is active (ItemBusinessID column exists and has data) --->
|
||||||
<cfset newSchemaActive = false>
|
<cfset newSchemaActive = false>
|
||||||
<cftry>
|
<cftry>
|
||||||
<cfset qCheck = queryExecute(
|
<cfset qCheck = queryTimed(
|
||||||
"SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = ? AND ItemBusinessID > 0",
|
"SELECT COUNT(*) as cnt FROM Items WHERE ItemBusinessID = ? AND ItemBusinessID > 0",
|
||||||
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||||
{ datasource = "payfrit" }
|
{ datasource = "payfrit" }
|
||||||
|
|
@ -74,7 +84,7 @@
|
||||||
<!--- Check if Categories table has data for this business --->
|
<!--- Check if Categories table has data for this business --->
|
||||||
<cfset hasCategoriesData = false>
|
<cfset hasCategoriesData = false>
|
||||||
<cftry>
|
<cftry>
|
||||||
<cfset qCatCheck = queryExecute(
|
<cfset qCatCheck = queryTimed(
|
||||||
"SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = ?",
|
"SELECT COUNT(*) as cnt FROM Categories WHERE CategoryBusinessID = ?",
|
||||||
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||||
{ datasource = "payfrit" }
|
{ datasource = "payfrit" }
|
||||||
|
|
@ -91,7 +101,7 @@
|
||||||
<cfset activeMenuIds = "">
|
<cfset activeMenuIds = "">
|
||||||
<cfset menuList = []>
|
<cfset menuList = []>
|
||||||
<cftry>
|
<cftry>
|
||||||
<cfset qAllMenus = queryExecute(
|
<cfset qAllMenus = queryTimed(
|
||||||
"
|
"
|
||||||
SELECT MenuID, MenuName FROM Menus
|
SELECT MenuID, MenuName FROM Menus
|
||||||
WHERE MenuBusinessID = :bizId
|
WHERE MenuBusinessID = :bizId
|
||||||
|
|
@ -124,7 +134,7 @@
|
||||||
|
|
||||||
<!--- Get category headers as virtual items --->
|
<!--- Get category headers as virtual items --->
|
||||||
<!--- Apply schedule filtering, order type filtering, and menu filtering --->
|
<!--- Apply schedule filtering, order type filtering, and menu filtering --->
|
||||||
<cfset qCategories = queryExecute(
|
<cfset qCategories = queryTimed(
|
||||||
"
|
"
|
||||||
SELECT
|
SELECT
|
||||||
CategoryID,
|
CategoryID,
|
||||||
|
|
@ -178,7 +188,7 @@
|
||||||
<cfif len(trim(visibleCategoryIds)) EQ 0>
|
<cfif len(trim(visibleCategoryIds)) EQ 0>
|
||||||
<cfset visibleCategoryIds = "0">
|
<cfset visibleCategoryIds = "0">
|
||||||
</cfif>
|
</cfif>
|
||||||
<cfset q = queryExecute(
|
<cfset q = queryTimed(
|
||||||
"
|
"
|
||||||
SELECT
|
SELECT
|
||||||
i.ItemID,
|
i.ItemID,
|
||||||
|
|
@ -197,6 +207,7 @@
|
||||||
i.ItemStationID,
|
i.ItemStationID,
|
||||||
s.StationName,
|
s.StationName,
|
||||||
s.StationColor
|
s.StationColor
|
||||||
|
,COALESCE(c.CategoryMenuID, 0) as CategoryMenuID
|
||||||
FROM Items i
|
FROM Items i
|
||||||
LEFT JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
LEFT JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
||||||
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
|
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
|
||||||
|
|
@ -213,7 +224,7 @@
|
||||||
)>
|
)>
|
||||||
<cfelse>
|
<cfelse>
|
||||||
<!--- Fallback: Derive categories from parent Items --->
|
<!--- Fallback: Derive categories from parent Items --->
|
||||||
<cfset q = queryExecute(
|
<cfset q = queryTimed(
|
||||||
"
|
"
|
||||||
SELECT
|
SELECT
|
||||||
i.ItemID,
|
i.ItemID,
|
||||||
|
|
@ -268,7 +279,7 @@
|
||||||
</cfif>
|
</cfif>
|
||||||
<cfelse>
|
<cfelse>
|
||||||
<!--- OLD SCHEMA: Use Categories table --->
|
<!--- OLD SCHEMA: Use Categories table --->
|
||||||
<cfset q = queryExecute(
|
<cfset q = queryTimed(
|
||||||
"
|
"
|
||||||
SELECT
|
SELECT
|
||||||
i.ItemID,
|
i.ItemID,
|
||||||
|
|
@ -286,7 +297,8 @@
|
||||||
i.ItemSortOrder,
|
i.ItemSortOrder,
|
||||||
i.ItemStationID,
|
i.ItemStationID,
|
||||||
s.StationName,
|
s.StationName,
|
||||||
s.StationColor
|
s.StationColor,
|
||||||
|
COALESCE(c.CategoryMenuID, 0) as CategoryMenuID
|
||||||
FROM Items i
|
FROM Items i
|
||||||
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
INNER JOIN Categories c ON c.CategoryID = i.ItemCategoryID
|
||||||
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
|
LEFT JOIN Stations s ON s.StationID = i.ItemStationID
|
||||||
|
|
@ -329,7 +341,8 @@
|
||||||
"ItemSortOrder": qCategories.CategorySortOrder,
|
"ItemSortOrder": qCategories.CategorySortOrder,
|
||||||
"ItemStationID": "",
|
"ItemStationID": "",
|
||||||
"ItemStationName": "",
|
"ItemStationName": "",
|
||||||
"ItemStationColor": ""
|
"ItemStationColor": "",
|
||||||
|
"CategoryMenuID": isNull(qCategories.CategoryMenuID) ? 0 : val(qCategories.CategoryMenuID)
|
||||||
})>
|
})>
|
||||||
</cfif>
|
</cfif>
|
||||||
</cfloop>
|
</cfloop>
|
||||||
|
|
@ -360,6 +373,11 @@
|
||||||
</cfif>
|
</cfif>
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
||||||
|
<cfset itemMenuID = 0>
|
||||||
|
<cftry>
|
||||||
|
<cfset itemMenuID = isDefined("q.CategoryMenuID") ? val(q.CategoryMenuID) : 0>
|
||||||
|
<cfcatch><cfset itemMenuID = 0></cfcatch>
|
||||||
|
</cftry>
|
||||||
<cfset arrayAppend(rows, {
|
<cfset arrayAppend(rows, {
|
||||||
"ItemID": q.ItemID,
|
"ItemID": q.ItemID,
|
||||||
"ItemCategoryID": q.ItemCategoryID,
|
"ItemCategoryID": q.ItemCategoryID,
|
||||||
|
|
@ -376,14 +394,15 @@
|
||||||
"ItemSortOrder": q.ItemSortOrder,
|
"ItemSortOrder": q.ItemSortOrder,
|
||||||
"ItemStationID": len(trim(q.ItemStationID)) ? q.ItemStationID : "",
|
"ItemStationID": len(trim(q.ItemStationID)) ? q.ItemStationID : "",
|
||||||
"ItemStationName": len(trim(q.StationName)) ? q.StationName : "",
|
"ItemStationName": len(trim(q.StationName)) ? q.StationName : "",
|
||||||
"ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : ""
|
"ItemStationColor": len(trim(q.StationColor)) ? q.StationColor : "",
|
||||||
|
"CategoryMenuID": itemMenuID
|
||||||
})>
|
})>
|
||||||
</cfloop>
|
</cfloop>
|
||||||
|
|
||||||
<!--- For unified schema: Add template-linked modifiers as virtual children of menu items --->
|
<!--- For unified schema: Add template-linked modifiers as virtual children of menu items --->
|
||||||
<cfif newSchemaActive>
|
<cfif newSchemaActive>
|
||||||
<!--- Get template links: which menu items use which templates --->
|
<!--- Get template links: which menu items use which templates --->
|
||||||
<cfset qTemplateLinks = queryExecute(
|
<cfset qTemplateLinks = queryTimed(
|
||||||
"
|
"
|
||||||
SELECT
|
SELECT
|
||||||
tl.ItemID as MenuItemID,
|
tl.ItemID as MenuItemID,
|
||||||
|
|
@ -406,7 +425,7 @@
|
||||||
)>
|
)>
|
||||||
|
|
||||||
<!--- Get template options --->
|
<!--- Get template options --->
|
||||||
<cfset qTemplateOptions = queryExecute(
|
<cfset qTemplateOptions = queryTimed(
|
||||||
"
|
"
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
opt.ItemID as OptionItemID,
|
opt.ItemID as OptionItemID,
|
||||||
|
|
@ -514,7 +533,7 @@
|
||||||
<!--- Get brand color for this business --->
|
<!--- Get brand color for this business --->
|
||||||
<cfset brandColor = "">
|
<cfset brandColor = "">
|
||||||
<cftry>
|
<cftry>
|
||||||
<cfset qBrand = queryExecute(
|
<cfset qBrand = queryTimed(
|
||||||
"SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = ?",
|
"SELECT BusinessBrandColor FROM Businesses WHERE BusinessID = ?",
|
||||||
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
[ { value = BusinessID, cfsqltype = "cf_sql_integer" } ],
|
||||||
{ datasource = "payfrit" }
|
{ datasource = "payfrit" }
|
||||||
|
|
@ -527,7 +546,7 @@
|
||||||
</cfcatch>
|
</cfcatch>
|
||||||
</cftry>
|
</cftry>
|
||||||
|
|
||||||
<cfset apiAbort({
|
<cfset menuResponse = serializeJSON({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"ERROR": "",
|
"ERROR": "",
|
||||||
"Items": rows,
|
"Items": rows,
|
||||||
|
|
@ -537,6 +556,11 @@
|
||||||
"Menus": menuList,
|
"Menus": menuList,
|
||||||
"SelectedMenuID": requestedMenuID
|
"SelectedMenuID": requestedMenuID
|
||||||
})>
|
})>
|
||||||
|
<cfset appCachePut(menuCacheKey, menuResponse)>
|
||||||
|
<cfset logPerf()>
|
||||||
|
<cfcontent type="application/json; charset=utf-8">
|
||||||
|
<cfoutput>#menuResponse#</cfoutput>
|
||||||
|
<cfabort>
|
||||||
|
|
||||||
<cfcatch>
|
<cfcatch>
|
||||||
<cfset apiAbort({
|
<cfset apiAbort({
|
||||||
|
|
|
||||||
|
|
@ -379,6 +379,9 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
response = { "OK": true, "SCHEMA": newSchemaActive ? "unified" : "legacy" };
|
response = { "OK": true, "SCHEMA": newSchemaActive ? "unified" : "legacy" };
|
||||||
|
// Invalidate menu cache for this business
|
||||||
|
appCacheInvalidate("menu_" & businessID);
|
||||||
|
appCacheInvalidate("menuBuilder_" & businessID);
|
||||||
|
|
||||||
} catch (any e) {
|
} catch (any e) {
|
||||||
response = {
|
response = {
|
||||||
|
|
|
||||||
|
|
@ -103,5 +103,6 @@
|
||||||
</cftry>
|
</cftry>
|
||||||
|
|
||||||
<!--- Return JSON response --->
|
<!--- Return JSON response --->
|
||||||
|
<cfset logPerf()>
|
||||||
<cfcontent type="application/json" reset="true">
|
<cfcontent type="application/json" reset="true">
|
||||||
<cfoutput>#serializeJSON(payload)#</cfoutput>
|
<cfoutput>#serializeJSON(payload)#</cfoutput>
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get order details
|
// Get order details
|
||||||
qOrder = queryExecute("
|
qOrder = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
o.OrderID,
|
o.OrderID,
|
||||||
o.OrderBusinessID,
|
o.OrderBusinessID,
|
||||||
|
|
@ -78,7 +78,7 @@ try {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get line items (excluding deleted items)
|
// Get line items (excluding deleted items)
|
||||||
qItems = queryExecute("
|
qItems = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
oli.OrderLineItemID,
|
oli.OrderLineItemID,
|
||||||
oli.OrderLineItemItemID,
|
oli.OrderLineItemItemID,
|
||||||
|
|
@ -150,20 +150,16 @@ try {
|
||||||
// Calculate total
|
// Calculate total
|
||||||
total = subtotal + tax + tip;
|
total = subtotal + tax + tip;
|
||||||
|
|
||||||
// Get staff who worked on this order (from Tasks table) with pending rating tokens
|
// Get staff who worked on this order (LEFT JOIN instead of correlated subquery)
|
||||||
qStaff = queryExecute("
|
qStaff = queryTimed("
|
||||||
SELECT DISTINCT u.UserID, u.UserFirstName,
|
SELECT DISTINCT u.UserID, u.UserFirstName, r.TaskRatingAccessToken AS RatingToken
|
||||||
(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
|
|
||||||
FROM Tasks t
|
FROM Tasks t
|
||||||
INNER JOIN Users u ON u.UserID = t.TaskClaimedByUserID
|
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
|
WHERE t.TaskOrderID = :orderID
|
||||||
AND t.TaskClaimedByUserID > 0
|
AND t.TaskClaimedByUserID > 0
|
||||||
", { orderID: orderID });
|
", { orderID: orderID });
|
||||||
|
|
@ -221,6 +217,7 @@ try {
|
||||||
response["MESSAGE"] = e.message;
|
response["MESSAGE"] = e.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logPerf();
|
||||||
writeOutput(serializeJSON(response));
|
writeOutput(serializeJSON(response));
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ if (structKeyExists(request, "UserID") && isNumeric(request.UserID) && request.U
|
||||||
userToken = getHeader("X-User-Token");
|
userToken = getHeader("X-User-Token");
|
||||||
if (len(userToken)) {
|
if (len(userToken)) {
|
||||||
try {
|
try {
|
||||||
qTok = queryExecute(
|
qTok = queryTimed(
|
||||||
"SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1",
|
"SELECT UserID FROM UserTokens WHERE Token = ? LIMIT 1",
|
||||||
[ { value = userToken, cfsqltype = "cf_sql_varchar" } ],
|
[ { value = userToken, cfsqltype = "cf_sql_varchar" } ],
|
||||||
{ datasource = "payfrit" }
|
{ datasource = "payfrit" }
|
||||||
|
|
@ -63,7 +63,7 @@ if (offset < 0) offset = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Get orders for this user (exclude carts - status 0)
|
// Get orders for this user (exclude carts - status 0)
|
||||||
qOrders = queryExecute("
|
qOrders = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
o.OrderID,
|
o.OrderID,
|
||||||
o.OrderUUID,
|
o.OrderUUID,
|
||||||
|
|
@ -88,29 +88,40 @@ try {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
qCount = queryExecute("
|
qCount = queryTimed("
|
||||||
SELECT COUNT(*) as TotalCount
|
SELECT COUNT(*) as TotalCount
|
||||||
FROM Orders
|
FROM Orders
|
||||||
WHERE OrderUserID = :userId
|
WHERE OrderUserID = :userId
|
||||||
AND OrderStatusID > 0
|
AND OrderStatusID > 0
|
||||||
", { userId: { value = userId, cfsqltype = "cf_sql_integer" } });
|
", { userId: { value = userId, cfsqltype = "cf_sql_integer" } });
|
||||||
|
|
||||||
// Build orders array with item counts and totals
|
// Batch fetch line item counts and totals for ALL orders (eliminates N+1)
|
||||||
orders = [];
|
itemSummary = {};
|
||||||
for (row in qOrders) {
|
orderIds = valueList(qOrders.OrderID);
|
||||||
// Get line item count and calculate total
|
if (len(orderIds)) {
|
||||||
qItems = queryExecute("
|
qAllItems = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
|
OrderLineItemOrderID,
|
||||||
COUNT(*) as ItemCount,
|
COUNT(*) as ItemCount,
|
||||||
SUM(OrderLineItemQuantity * OrderLineItemPrice) as Subtotal
|
SUM(OrderLineItemQuantity * OrderLineItemPrice) as Subtotal
|
||||||
FROM OrderLineItems
|
FROM OrderLineItems
|
||||||
WHERE OrderLineItemOrderID = :orderId
|
WHERE OrderLineItemOrderID IN (:orderIds)
|
||||||
AND OrderLineItemParentOrderLineItemID = 0
|
AND OrderLineItemParentOrderLineItemID = 0
|
||||||
AND (OrderLineItemIsDeleted = 0 OR OrderLineItemIsDeleted IS NULL)
|
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);
|
for (r in qAllItems) {
|
||||||
subtotal = val(qItems.Subtotal);
|
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;
|
tax = subtotal * 0.0875;
|
||||||
total = subtotal + tax;
|
total = subtotal + tax;
|
||||||
|
|
||||||
|
|
@ -156,6 +167,7 @@ try {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logPerf();
|
||||||
writeOutput(serializeJSON({
|
writeOutput(serializeJSON({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"ORDERS": orders,
|
"ORDERS": orders,
|
||||||
|
|
|
||||||
|
|
@ -58,7 +58,7 @@
|
||||||
<cfif StationID GT 0>
|
<cfif StationID GT 0>
|
||||||
<cfset stationParams = duplicate(params)>
|
<cfset stationParams = duplicate(params)>
|
||||||
<cfset arrayAppend(stationParams, { value = StationID, cfsqltype = "cf_sql_integer" })>
|
<cfset arrayAppend(stationParams, { value = StationID, cfsqltype = "cf_sql_integer" })>
|
||||||
<cfset qOrders = queryExecute("
|
<cfset qOrders = queryTimed("
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
o.OrderID,
|
o.OrderID,
|
||||||
o.OrderUUID,
|
o.OrderUUID,
|
||||||
|
|
@ -84,7 +84,7 @@
|
||||||
ORDER BY o.OrderSubmittedOn ASC, o.OrderID ASC
|
ORDER BY o.OrderSubmittedOn ASC, o.OrderID ASC
|
||||||
", stationParams, { datasource = "payfrit" })>
|
", stationParams, { datasource = "payfrit" })>
|
||||||
<cfelse>
|
<cfelse>
|
||||||
<cfset qOrders = queryExecute("
|
<cfset qOrders = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
o.OrderID,
|
o.OrderID,
|
||||||
o.OrderUUID,
|
o.OrderUUID,
|
||||||
|
|
@ -109,12 +109,14 @@
|
||||||
|
|
||||||
<cfset orders = []>
|
<cfset orders = []>
|
||||||
|
|
||||||
<cfloop query="qOrders">
|
<!--- Batch fetch ALL line items for all orders in one query (eliminates N+1) --->
|
||||||
<!--- Get line items for this order --->
|
<cfset orderIds = valueList(qOrders.OrderID)>
|
||||||
<!--- If filtering by station, only show items for that station (plus modifiers) --->
|
<cfset lineItemsByOrder = {}>
|
||||||
|
<cfif len(orderIds)>
|
||||||
<cfif StationID GT 0>
|
<cfif StationID GT 0>
|
||||||
<cfset qLineItems = queryExecute("
|
<cfset qAllLineItems = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
|
oli.OrderLineItemOrderID,
|
||||||
oli.OrderLineItemID,
|
oli.OrderLineItemID,
|
||||||
oli.OrderLineItemParentOrderLineItemID,
|
oli.OrderLineItemParentOrderLineItemID,
|
||||||
oli.OrderLineItemItemID,
|
oli.OrderLineItemItemID,
|
||||||
|
|
@ -130,17 +132,15 @@
|
||||||
FROM OrderLineItems oli
|
FROM OrderLineItems oli
|
||||||
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
|
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
|
||||||
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
|
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
|
||||||
WHERE oli.OrderLineItemOrderID = ?
|
WHERE oli.OrderLineItemOrderID IN (#orderIds#)
|
||||||
AND oli.OrderLineItemIsDeleted = b'0'
|
AND oli.OrderLineItemIsDeleted = b'0'
|
||||||
AND (i.ItemStationID = ? OR i.ItemStationID = 0 OR i.ItemStationID IS NULL OR oli.OrderLineItemParentOrderLineItemID > 0)
|
AND (i.ItemStationID = ? OR i.ItemStationID = 0 OR i.ItemStationID IS NULL OR oli.OrderLineItemParentOrderLineItemID > 0)
|
||||||
ORDER BY oli.OrderLineItemID
|
ORDER BY oli.OrderLineItemOrderID, oli.OrderLineItemID
|
||||||
", [
|
", [ { value = StationID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
||||||
{ value = qOrders.OrderID, cfsqltype = "cf_sql_integer" },
|
|
||||||
{ value = StationID, cfsqltype = "cf_sql_integer" }
|
|
||||||
], { datasource = "payfrit" })>
|
|
||||||
<cfelse>
|
<cfelse>
|
||||||
<cfset qLineItems = queryExecute("
|
<cfset qAllLineItems = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
|
oli.OrderLineItemOrderID,
|
||||||
oli.OrderLineItemID,
|
oli.OrderLineItemID,
|
||||||
oli.OrderLineItemParentOrderLineItemID,
|
oli.OrderLineItemParentOrderLineItemID,
|
||||||
oli.OrderLineItemItemID,
|
oli.OrderLineItemItemID,
|
||||||
|
|
@ -156,28 +156,37 @@
|
||||||
FROM OrderLineItems oli
|
FROM OrderLineItems oli
|
||||||
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
|
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
|
||||||
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
|
LEFT JOIN Items parent ON parent.ItemID = i.ItemParentItemID
|
||||||
WHERE oli.OrderLineItemOrderID = ?
|
WHERE oli.OrderLineItemOrderID IN (#orderIds#)
|
||||||
AND oli.OrderLineItemIsDeleted = b'0'
|
AND oli.OrderLineItemIsDeleted = b'0'
|
||||||
ORDER BY oli.OrderLineItemID
|
ORDER BY oli.OrderLineItemOrderID, oli.OrderLineItemID
|
||||||
", [ { value = qOrders.OrderID, cfsqltype = "cf_sql_integer" } ], { datasource = "payfrit" })>
|
", [], { datasource = "payfrit" })>
|
||||||
</cfif>
|
</cfif>
|
||||||
|
|
||||||
<cfset lineItems = []>
|
<!--- Group line items by OrderID --->
|
||||||
<cfloop query="qLineItems">
|
<cfloop query="qAllLineItems">
|
||||||
<cfset arrayAppend(lineItems, {
|
<cfset oid = qAllLineItems.OrderLineItemOrderID>
|
||||||
"OrderLineItemID": qLineItems.OrderLineItemID,
|
<cfif NOT structKeyExists(lineItemsByOrder, oid)>
|
||||||
"OrderLineItemParentOrderLineItemID": qLineItems.OrderLineItemParentOrderLineItemID,
|
<cfset lineItemsByOrder[oid] = []>
|
||||||
"OrderLineItemItemID": qLineItems.OrderLineItemItemID,
|
</cfif>
|
||||||
"OrderLineItemPrice": qLineItems.OrderLineItemPrice,
|
<cfset arrayAppend(lineItemsByOrder[oid], {
|
||||||
"OrderLineItemQuantity": qLineItems.OrderLineItemQuantity,
|
"OrderLineItemID": qAllLineItems.OrderLineItemID,
|
||||||
"OrderLineItemRemark": qLineItems.OrderLineItemRemark,
|
"OrderLineItemParentOrderLineItemID": qAllLineItems.OrderLineItemParentOrderLineItemID,
|
||||||
"ItemName": qLineItems.ItemName,
|
"OrderLineItemItemID": qAllLineItems.OrderLineItemItemID,
|
||||||
"ItemParentItemID": qLineItems.ItemParentItemID,
|
"OrderLineItemPrice": qAllLineItems.OrderLineItemPrice,
|
||||||
"ItemParentName": qLineItems.ItemParentName,
|
"OrderLineItemQuantity": qAllLineItems.OrderLineItemQuantity,
|
||||||
"ItemIsCheckedByDefault": qLineItems.ItemIsCheckedByDefault,
|
"OrderLineItemRemark": qAllLineItems.OrderLineItemRemark,
|
||||||
"ItemStationID": qLineItems.ItemStationID
|
"ItemName": qAllLineItems.ItemName,
|
||||||
|
"ItemParentItemID": qAllLineItems.ItemParentItemID,
|
||||||
|
"ItemParentName": qAllLineItems.ItemParentName,
|
||||||
|
"ItemIsCheckedByDefault": qAllLineItems.ItemIsCheckedByDefault,
|
||||||
|
"ItemStationID": qAllLineItems.ItemStationID
|
||||||
})>
|
})>
|
||||||
</cfloop>
|
</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 --->
|
<!--- Determine order type name --->
|
||||||
<cfset orderTypeName = "">
|
<cfset orderTypeName = "">
|
||||||
|
|
@ -207,6 +216,7 @@
|
||||||
})>
|
})>
|
||||||
</cfloop>
|
</cfloop>
|
||||||
|
|
||||||
|
<cfset logPerf()>
|
||||||
<cfset apiAbort({
|
<cfset apiAbort({
|
||||||
"OK": true,
|
"OK": true,
|
||||||
"ERROR": "",
|
"ERROR": "",
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ try {
|
||||||
// Get businesses for this user
|
// Get businesses for this user
|
||||||
// Users are linked to businesses via BusinessUserID field (owner)
|
// Users are linked to businesses via BusinessUserID field (owner)
|
||||||
|
|
||||||
q = queryExecute("
|
q = queryTimed("
|
||||||
SELECT
|
SELECT
|
||||||
b.BusinessID,
|
b.BusinessID,
|
||||||
b.BusinessName
|
b.BusinessName
|
||||||
|
|
@ -71,5 +71,6 @@ try {
|
||||||
response["MESSAGE"] = e.message;
|
response["MESSAGE"] = e.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logPerf();
|
||||||
writeOutput(serializeJSON(response));
|
writeOutput(serializeJSON(response));
|
||||||
</cfscript>
|
</cfscript>
|
||||||
|
|
|
||||||
|
|
@ -36,63 +36,51 @@ try {
|
||||||
todayStart = dateFormat(now(), "yyyy-mm-dd") & " 00:00:00";
|
todayStart = dateFormat(now(), "yyyy-mm-dd") & " 00:00:00";
|
||||||
todayEnd = dateFormat(now(), "yyyy-mm-dd") & " 23:59:59";
|
todayEnd = dateFormat(now(), "yyyy-mm-dd") & " 23:59:59";
|
||||||
|
|
||||||
// Orders today count
|
// Combined stats query — 1 round trip instead of 4
|
||||||
qOrdersToday = queryExecute("
|
qStats = queryTimed("
|
||||||
SELECT COUNT(*) as cnt
|
SELECT
|
||||||
FROM Orders
|
(SELECT COUNT(*)
|
||||||
WHERE OrderBusinessID = :businessID
|
FROM Orders
|
||||||
AND OrderSubmittedOn >= :todayStart
|
WHERE OrderBusinessID = :businessID
|
||||||
AND OrderSubmittedOn <= :todayEnd
|
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,
|
businessID: businessID,
|
||||||
todayStart: { value: todayStart, cfsqltype: "cf_sql_varchar" },
|
todayStart: { value: todayStart, cfsqltype: "cf_sql_varchar" },
|
||||||
todayEnd: { value: todayEnd, 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["OK"] = true;
|
||||||
response["STATS"] = {
|
response["STATS"] = {
|
||||||
"ordersToday": qOrdersToday.cnt,
|
"ordersToday": qStats.ordersToday,
|
||||||
"revenueToday": qRevenueToday.total,
|
"revenueToday": qStats.revenueToday,
|
||||||
"pendingOrders": qPendingOrders.cnt,
|
"pendingOrders": qStats.pendingOrders,
|
||||||
"menuItems": qMenuItems.cnt
|
"menuItems": qStats.menuItems
|
||||||
};
|
};
|
||||||
|
|
||||||
} catch (any e) {
|
} catch (any e) {
|
||||||
response["ERROR"] = e.message;
|
response["ERROR"] = e.message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logPerf();
|
||||||
writeOutput(serializeJSON(response));
|
writeOutput(serializeJSON(response));
|
||||||
</cfscript>
|
</cfscript>
|
||||||
|
|
|
||||||
Reference in a new issue