[], 'children' => [], 'itemMeta' => []]; $qLI = queryTimed( "SELECT ID, ParentOrderLineItemID, ItemID, IsDeleted FROM OrderLineItems WHERE OrderID = ? ORDER BY ID", [$OrderID] ); if (empty($qLI)) return $out; $itemIds = []; foreach ($qLI as $row) { $id = (int) $row['ID']; $parentId = (int) $row['ParentOrderLineItemID']; $out['items'][$id] = [ 'id' => $id, 'parentId' => $parentId, 'itemId' => (int) $row['ItemID'], 'isDeleted' => (bool) $row['IsDeleted'], ]; $out['children'][$parentId][] = $id; $itemIds[] = (int) $row['ItemID']; } $uniq = array_unique($itemIds); if (!empty($uniq)) { $placeholders = implode(',', array_fill(0, count($uniq), '?')); $qMeta = queryTimed( "SELECT ID, RequiresChildSelection, MaxNumSelectionReq FROM Items WHERE ID IN ({$placeholders})", array_values($uniq) ); foreach ($qMeta as $row) { $out['itemMeta'][(int) $row['ID']] = [ 'requires' => (int) $row['RequiresChildSelection'], 'maxSel' => (int) $row['MaxNumSelectionReq'], ]; } } return $out; } function hasSelectedDescendant(array $graph, int $lineItemId): bool { $stack = $graph['children'][$lineItemId] ?? []; while (!empty($stack)) { $id = array_pop($stack); if (isset($graph['items'][$id])) { $node = $graph['items'][$id]; if (!$node['isDeleted']) return true; foreach ($graph['children'][$id] ?? [] as $kidId) { $stack[] = $kidId; } } } return false; } // --- Main logic --- $data = readJsonBody(); $OrderID = (int) ($data['OrderID'] ?? 0); if ($OrderID <= 0) { apiAbort(['OK' => false, 'ERROR' => 'missing_orderid', 'MESSAGE' => 'OrderID is required.']); } try { $qOrder = queryOne( "SELECT ID, UserID, StatusID, OrderTypeID, BusinessID, ServicePointID, GrantID, GrantOwnerBusinessID FROM Orders WHERE ID = ? LIMIT 1", [$OrderID] ); if (!$qOrder) { apiAbort(['OK' => false, 'ERROR' => 'not_found', 'MESSAGE' => 'Order not found.']); } if ((int) $qOrder['StatusID'] !== 0) { apiAbort(['OK' => false, 'ERROR' => 'bad_state', 'MESSAGE' => 'Order is not in cart state.']); } $orderTypeID = (int) $qOrder['OrderTypeID']; if ($orderTypeID < 1 || $orderTypeID > 3) { apiAbort(['OK' => false, 'ERROR' => 'bad_type', 'MESSAGE' => 'Order type must be set before submitting (1=dine-in, 2=takeaway, 3=delivery).']); } // Delivery requires address if ($orderTypeID === 3) { $qAddr = queryOne("SELECT AddressID FROM Orders WHERE ID = ? LIMIT 1", [$OrderID]); if (!$qAddr || (int) ($qAddr['AddressID'] ?? 0) <= 0) { apiAbort(['OK' => false, 'ERROR' => 'missing_address', 'MESSAGE' => 'Delivery orders require a delivery address.']); } } // Re-validate grant $grantID = (int) ($qOrder['GrantID'] ?? 0); if ($grantID > 0) { $qGrantCheck = queryOne( "SELECT StatusID, TimePolicyType, TimePolicyData FROM ServicePointGrants WHERE ID = ? LIMIT 1", [$grantID] ); if (!$qGrantCheck || (int) $qGrantCheck['StatusID'] !== 1) { apiAbort(['OK' => false, 'ERROR' => 'grant_revoked', 'MESSAGE' => 'Access to this service point has been revoked.']); } if (!isGrantTimeActive($qGrantCheck['TimePolicyType'], $qGrantCheck['TimePolicyData'])) { apiAbort(['OK' => false, 'ERROR' => 'grant_time_expired', 'MESSAGE' => 'Service point access is no longer available at this time.']); } } // Must have at least one root item $qRoots = queryOne( "SELECT COUNT(*) AS Cnt FROM OrderLineItems WHERE OrderID = ? AND ParentOrderLineItemID = 0 AND IsDeleted = 0", [$OrderID] ); if ((int) ($qRoots['Cnt'] ?? 0) <= 0) { apiAbort(['OK' => false, 'ERROR' => 'empty_order', 'MESSAGE' => 'Order has no items.']); } // Validate modifier selections $graph = buildLineItemsGraph($OrderID); foreach ($graph['items'] as $node) { if ($node['isDeleted']) continue; $meta = $graph['itemMeta'][$node['itemId']] ?? ['requires' => 0, 'maxSel' => 0]; // Max immediate selections if ($meta['maxSel'] > 0) { $selCount = 0; foreach ($graph['children'][$node['id']] ?? [] as $kidId) { if (isset($graph['items'][$kidId]) && !$graph['items'][$kidId]['isDeleted']) { $selCount++; } } if ($selCount > $meta['maxSel']) { apiAbort([ 'OK' => false, 'ERROR' => 'max_selection_exceeded', 'MESSAGE' => 'Too many selections under a modifier group.', 'DETAIL' => "LineItemID {$node['id']} has {$selCount} immediate children selected; max is {$meta['maxSel']}.", ]); } } // Requires descendant if ($meta['requires'] === 1 && !hasSelectedDescendant($graph, $node['id'])) { apiAbort([ 'OK' => false, 'ERROR' => 'required_selection_missing', 'MESSAGE' => 'A required modifier selection is missing.', 'DETAIL' => "LineItemID {$node['id']} requires at least one descendant selection.", ]); } } // Tab-aware submit $tabID = 0; $qOrderTab = queryOne("SELECT TabID FROM Orders WHERE ID = ? LIMIT 1", [$OrderID]); if ((int) ($qOrderTab['TabID'] ?? 0) > 0) { $tabID = (int) $qOrderTab['TabID']; $qTabCheck = queryOne("SELECT StatusID, OwnerUserID FROM Tabs WHERE ID = ? AND StatusID = 1 LIMIT 1", [$tabID]); if (!$qTabCheck) { apiAbort(['OK' => false, 'ERROR' => 'tab_not_open', 'MESSAGE' => 'The tab associated with this order is no longer open.']); } if ((int) $qOrder['UserID'] !== (int) $qTabCheck['OwnerUserID']) { $qApproval = queryOne( "SELECT ApprovalStatus FROM TabOrders WHERE TabID = ? AND OrderID = ? LIMIT 1", [$tabID, $OrderID] ); if (!$qApproval || $qApproval['ApprovalStatus'] !== 'approved') { apiAbort(['OK' => false, 'ERROR' => 'not_approved', 'MESSAGE' => 'This order needs tab owner approval before submitting.']); } } } // Submit queryTimed( "UPDATE Orders SET StatusID = 1, SubmittedOn = NOW(), LastEditedOn = NOW() WHERE ID = ?", [$OrderID] ); // Create kitchen task for KDS display $spID = (int) ($qOrder['ServicePointID'] ?? 0); $spName = 'Table'; if ($spID > 0) { $qSP = queryOne("SELECT Name FROM ServicePoints WHERE ID = ?", [$spID]); $spName = !empty(trim($qSP['Name'] ?? '')) ? $qSP['Name'] : 'Table'; } $taskTitle = "Prepare Order #{$OrderID} for {$spName}"; // Prevent duplicates (webhook.php also creates a task for card payments) $qExistingTask = queryOne( "SELECT ID FROM Tasks WHERE OrderID = ? AND Title LIKE 'Prepare Order%' LIMIT 1", [$OrderID] ); if (!$qExistingTask) { queryTimed(" INSERT INTO Tasks (BusinessID, OrderID, ServicePointID, TaskTypeID, Title, CreatedOn, ClaimedByUserID) VALUES (?, ?, ?, 0, ?, NOW(), 0) ", [$qOrder['BusinessID'], $OrderID, $spID > 0 ? $spID : null, $taskTitle]); } // Tab running total update if ($tabID > 0) { queryTimed("UPDATE Tabs SET LastActivityOn = NOW() WHERE ID = ?", [$tabID]); } jsonResponse(['OK' => true, 'ERROR' => '', 'OrderID' => $OrderID, 'MESSAGE' => 'submitted', 'TAB_ID' => $tabID]); } catch (Exception $e) { jsonResponse(['OK' => false, 'ERROR' => 'server_error', 'MESSAGE' => 'DB error submitting order', 'DETAIL' => $e->getMessage()]); }