- 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>
246 lines
8.3 KiB
Text
246 lines
8.3 KiB
Text
<cfsetting showdebugoutput="false">
|
|
<cfsetting enablecfoutputonly="true">
|
|
<cfcontent type="application/json; charset=utf-8" reset="true">
|
|
<cfheader name="Cache-Control" value="no-store">
|
|
|
|
<cfscript>
|
|
/**
|
|
* Get Order Detail
|
|
* Returns full order info including line items, customer details, and staff who worked on the order
|
|
*
|
|
* GET: ?OrderID=123
|
|
* POST: { OrderID: 123 }
|
|
*/
|
|
|
|
response = { "OK": false };
|
|
|
|
try {
|
|
// Get OrderID from request
|
|
orderID = 0;
|
|
|
|
// Check URL params
|
|
if (structKeyExists(url, "OrderID")) {
|
|
orderID = val(url.OrderID);
|
|
}
|
|
|
|
// Check POST body
|
|
if (orderID == 0) {
|
|
requestBody = toString(getHttpRequestData().content);
|
|
if (len(requestBody)) {
|
|
requestData = deserializeJSON(requestBody);
|
|
if (structKeyExists(requestData, "OrderID")) {
|
|
orderID = val(requestData.OrderID);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (orderID == 0) {
|
|
response["ERROR"] = "missing_order_id";
|
|
response["MESSAGE"] = "OrderID is required";
|
|
writeOutput(serializeJSON(response));
|
|
abort;
|
|
}
|
|
|
|
// Get order details
|
|
qOrder = queryTimed("
|
|
SELECT
|
|
o.OrderID,
|
|
o.OrderBusinessID,
|
|
o.OrderUserID,
|
|
o.OrderServicePointID,
|
|
o.OrderStatusID,
|
|
o.OrderTypeID,
|
|
o.OrderRemarks,
|
|
o.OrderAddedOn,
|
|
o.OrderLastEditedOn,
|
|
o.OrderSubmittedOn,
|
|
o.OrderTipAmount,
|
|
u.UserFirstName,
|
|
u.UserLastName,
|
|
u.UserContactNumber,
|
|
u.UserEmailAddress,
|
|
sp.ServicePointName,
|
|
sp.ServicePointTypeID,
|
|
b.BusinessName,
|
|
b.BusinessTaxRate
|
|
FROM Orders o
|
|
LEFT JOIN Users u ON u.UserID = o.OrderUserID
|
|
LEFT JOIN ServicePoints sp ON sp.ServicePointID = o.OrderServicePointID
|
|
LEFT JOIN Businesses b ON b.BusinessID = o.OrderBusinessID
|
|
WHERE o.OrderID = :orderID
|
|
", { orderID: orderID });
|
|
|
|
if (qOrder.recordCount == 0) {
|
|
response["ERROR"] = "order_not_found";
|
|
response["MESSAGE"] = "Order not found";
|
|
writeOutput(serializeJSON(response));
|
|
abort;
|
|
}
|
|
|
|
// Get line items (excluding deleted items)
|
|
qItems = queryTimed("
|
|
SELECT
|
|
oli.OrderLineItemID,
|
|
oli.OrderLineItemItemID,
|
|
oli.OrderLineItemParentOrderLineItemID,
|
|
oli.OrderLineItemQuantity,
|
|
oli.OrderLineItemPrice,
|
|
oli.OrderLineItemRemark,
|
|
i.ItemName,
|
|
i.ItemPrice,
|
|
i.ItemIsCheckedByDefault
|
|
FROM OrderLineItems oli
|
|
INNER JOIN Items i ON i.ItemID = oli.OrderLineItemItemID
|
|
WHERE oli.OrderLineItemOrderID = :orderID
|
|
AND oli.OrderLineItemIsDeleted = 0
|
|
ORDER BY oli.OrderLineItemID
|
|
", { orderID: orderID });
|
|
|
|
// Build line items array with parent-child structure
|
|
lineItems = [];
|
|
itemsById = {};
|
|
|
|
// First pass: create all items (use bracket notation to preserve key casing)
|
|
for (row in qItems) {
|
|
item = structNew("ordered");
|
|
item["LineItemID"] = val(row.OrderLineItemID);
|
|
item["ItemID"] = val(row.OrderLineItemItemID);
|
|
item["ParentLineItemID"] = val(row.OrderLineItemParentOrderLineItemID);
|
|
item["ItemName"] = row.ItemName ?: "";
|
|
item["Quantity"] = val(row.OrderLineItemQuantity);
|
|
item["UnitPrice"] = val(row.OrderLineItemPrice);
|
|
item["Remarks"] = row.OrderLineItemRemark ?: "";
|
|
item["IsDefault"] = (val(row.ItemIsCheckedByDefault) == 1);
|
|
item["Modifiers"] = [];
|
|
itemsById[row.OrderLineItemID] = item;
|
|
}
|
|
|
|
// Second pass: build hierarchy
|
|
for (row in qItems) {
|
|
item = itemsById[row.OrderLineItemID];
|
|
parentID = row.OrderLineItemParentOrderLineItemID;
|
|
|
|
if (parentID > 0 && structKeyExists(itemsById, parentID)) {
|
|
// This is a modifier - add to parent (use bracket notation)
|
|
arrayAppend(itemsById[parentID]["Modifiers"], item);
|
|
} else {
|
|
// This is a top-level item
|
|
arrayAppend(lineItems, item);
|
|
}
|
|
}
|
|
|
|
// Calculate subtotal from root line items (use bracket notation)
|
|
subtotal = 0;
|
|
for (item in lineItems) {
|
|
itemTotal = item["UnitPrice"] * item["Quantity"];
|
|
// Add modifier prices
|
|
for (mod in item["Modifiers"]) {
|
|
itemTotal += mod["UnitPrice"] * mod["Quantity"];
|
|
}
|
|
subtotal += itemTotal;
|
|
}
|
|
|
|
// Calculate tax using business tax rate or default 8.25%
|
|
taxRate = isNumeric(qOrder.BusinessTaxRate) && qOrder.BusinessTaxRate > 0 ? qOrder.BusinessTaxRate : 0.0825;
|
|
tax = subtotal * taxRate;
|
|
|
|
// Get tip from order
|
|
tip = isNumeric(qOrder.OrderTipAmount) ? qOrder.OrderTipAmount : 0;
|
|
|
|
// Calculate total
|
|
total = subtotal + tax + tip;
|
|
|
|
// 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 });
|
|
|
|
// Build staff array with avatar URLs and rating tokens (use ordered structs)
|
|
staff = [];
|
|
for (row in qStaff) {
|
|
staffMember = structNew("ordered");
|
|
staffMember["UserID"] = row.UserID;
|
|
staffMember["FirstName"] = row.UserFirstName;
|
|
staffMember["AvatarUrl"] = "https://biz.payfrit.com/uploads/users/" & row.UserID & ".jpg";
|
|
staffMember["RatingToken"] = row.RatingToken ?: "";
|
|
arrayAppend(staff, staffMember);
|
|
}
|
|
|
|
// Build response (use ordered structs to preserve key casing)
|
|
customer = structNew("ordered");
|
|
customer["UserID"] = qOrder.OrderUserID;
|
|
customer["FirstName"] = qOrder.UserFirstName;
|
|
customer["LastName"] = qOrder.UserLastName;
|
|
customer["Phone"] = qOrder.UserContactNumber;
|
|
customer["Email"] = qOrder.UserEmailAddress;
|
|
|
|
servicePoint = structNew("ordered");
|
|
servicePoint["ServicePointID"] = qOrder.OrderServicePointID;
|
|
servicePoint["Name"] = qOrder.ServicePointName;
|
|
servicePoint["TypeID"] = qOrder.ServicePointTypeID;
|
|
|
|
order = structNew("ordered");
|
|
order["OrderID"] = qOrder.OrderID;
|
|
order["BusinessID"] = qOrder.OrderBusinessID;
|
|
order["BusinessName"] = qOrder.BusinessName ?: "";
|
|
order["Status"] = qOrder.OrderStatusID;
|
|
order["StatusText"] = getStatusText(qOrder.OrderStatusID);
|
|
order["OrderTypeID"] = qOrder.OrderTypeID ?: 0;
|
|
order["OrderTypeName"] = getOrderTypeName(qOrder.OrderTypeID ?: 0);
|
|
order["Subtotal"] = subtotal;
|
|
order["Tax"] = tax;
|
|
order["Tip"] = tip;
|
|
order["Total"] = total;
|
|
order["Notes"] = qOrder.OrderRemarks;
|
|
order["CreatedOn"] = dateTimeFormat(qOrder.OrderAddedOn, "yyyy-mm-dd HH:nn:ss");
|
|
order["SubmittedOn"] = len(qOrder.OrderSubmittedOn) ? dateTimeFormat(qOrder.OrderSubmittedOn, "yyyy-mm-dd HH:nn:ss") : "";
|
|
order["UpdatedOn"] = len(qOrder.OrderLastEditedOn) ? dateTimeFormat(qOrder.OrderLastEditedOn, "yyyy-mm-dd HH:nn:ss") : "";
|
|
order["Customer"] = customer;
|
|
order["ServicePoint"] = servicePoint;
|
|
order["LineItems"] = lineItems;
|
|
order["Staff"] = staff;
|
|
|
|
response["OK"] = true;
|
|
response["ORDER"] = order;
|
|
|
|
} catch (any e) {
|
|
response["ERROR"] = "server_error";
|
|
response["MESSAGE"] = e.message;
|
|
}
|
|
|
|
logPerf();
|
|
writeOutput(serializeJSON(response));
|
|
|
|
// Helper functions
|
|
function getStatusText(status) {
|
|
switch (status) {
|
|
case 0: return "Cart";
|
|
case 1: return "Submitted";
|
|
case 2: return "In Progress";
|
|
case 3: return "Ready";
|
|
case 4: return "On the Way";
|
|
case 5: return "Complete";
|
|
case 6: return "Cancelled";
|
|
case 7: return "Deleted";
|
|
default: return "Unknown";
|
|
}
|
|
}
|
|
|
|
function getOrderTypeName(orderType) {
|
|
switch (orderType) {
|
|
case 1: return "Dine-in";
|
|
case 2: return "Takeaway";
|
|
case 3: return "Delivery";
|
|
default: return "Unknown";
|
|
}
|
|
}
|
|
</cfscript>
|