payfrit-api/api/menu/items.php
John Mizerek 04d09cfc4e Fix item prices returning as strings instead of floats in JSON
MySQL PDO returns DECIMAL/FLOAT columns as strings, causing json_encode
to emit them as JSON strings ("9.99") instead of numbers (9.99).
Android's Gson Map parsing fails the as? Number cast on strings,
defaulting to 0.0. Cast Price to (float) before building the response.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 17:24:57 -07:00

489 lines
20 KiB
PHP

<?php
require_once __DIR__ . '/../helpers.php';
runAuth();
/**
* Get Menu Items for Customer Apps
*
* POST body:
* {
* "BusinessID": 37,
* "OrderTypeID": 0, // optional: 1=Dine-In, 2=Takeaway, 3=Delivery
* "MenuID": 0 // optional: filter to specific menu
* }
*/
$data = readJsonBody();
$businessID = (int) ($data['BusinessID'] ?? 0);
$orderTypeID = (int) ($data['OrderTypeID'] ?? 0);
$requestedMenuID = (int) ($data['MenuID'] ?? 0);
if ($businessID <= 0) {
apiAbort(['OK' => false, 'ERROR' => 'missing_businessid', 'MESSAGE' => 'BusinessID is required.', 'DETAIL' => '']);
}
// Get business timezone for schedule filtering
$businessTimezone = 'America/Los_Angeles';
try {
$qTz = queryOne("SELECT Timezone FROM Businesses WHERE ID = ?", [$businessID]);
if ($qTz && !empty($qTz['Timezone'])) {
$businessTimezone = $qTz['Timezone'];
}
} catch (Exception $e) {
// Column might not exist yet, use default
}
$currentTime = getTimeInZone($businessTimezone);
$currentDayID = getDayInZone($businessTimezone);
$menuList = [];
try {
// Check if new schema is active (BusinessID column exists and has data)
$newSchemaActive = false;
try {
$qCheck = queryOne("SELECT COUNT(*) as cnt FROM Items WHERE BusinessID = ? AND BusinessID > 0", [$businessID]);
$newSchemaActive = $qCheck && (int) $qCheck['cnt'] > 0;
} catch (Exception $e) {
$newSchemaActive = false;
}
$rows = [];
$qCategories = null;
if ($newSchemaActive) {
// Check if Categories table has data for this business
$hasCategoriesData = false;
try {
$qCatCheck = queryOne("SELECT COUNT(*) as cnt FROM Categories WHERE BusinessID = ?", [$businessID]);
$hasCategoriesData = $qCatCheck && (int) $qCatCheck['cnt'] > 0;
} catch (Exception $e) {
$hasCategoriesData = false;
}
if ($hasCategoriesData) {
// Get active menus
$activeMenuIds = '';
try {
$qAllMenus = queryTimed("
SELECT ID, Name FROM Menus
WHERE BusinessID = ? AND IsActive = 1
ORDER BY SortOrder, Name
", [$businessID]);
if ($requestedMenuID > 0) {
$activeMenuIds = (string) $requestedMenuID;
} else {
$ids = array_column($qAllMenus, 'ID');
$activeMenuIds = implode(',', $ids);
}
foreach ($qAllMenus as $m) {
$menuList[] = ['MenuID' => (int) $m['ID'], 'Name' => $m['Name']];
}
} catch (Exception $e) {
// Menus table might not exist yet
}
// Build category query with schedule/channel/menu filtering
$catParams = [$businessID, $orderTypeID, $orderTypeID, $currentTime, $currentTime, $currentDayID];
$menuClause = '';
if (strlen($activeMenuIds) > 0) {
// Build safe IN clause for menu IDs
$menuIdArr = array_map('intval', explode(',', $activeMenuIds));
$menuPlaceholders = implode(',', $menuIdArr);
$menuClause = "OR MenuID IN ($menuPlaceholders)";
}
$qCategories = queryTimed("
SELECT
ID, Name, SortOrder, OrderTypes, ParentCategoryID,
ScheduleStart, ScheduleEnd, ScheduleDays, MenuID
FROM Categories
WHERE BusinessID = ?
AND (? = 0 OR FIND_IN_SET(?, OrderTypes) > 0)
AND (
ScheduleStart IS NULL
OR ScheduleEnd IS NULL
OR (TIME(?) >= ScheduleStart AND TIME(?) <= ScheduleEnd)
)
AND (
ScheduleDays IS NULL
OR ScheduleDays = ''
OR FIND_IN_SET(?, ScheduleDays) > 0
)
AND (
MenuID IS NULL
OR MenuID = 0
$menuClause
)
ORDER BY SortOrder
", $catParams);
// Get visible category IDs
$visibleCategoryIds = array_column($qCategories, 'ID');
$visibleStr = count($visibleCategoryIds) > 0
? implode(',', array_map('intval', $visibleCategoryIds))
: '0';
// Get menu items for visible categories
$q = queryTimed("
SELECT
i.ID,
i.CategoryID,
c.Name AS CategoryName,
i.Name AS ItemName,
i.Description,
i.ParentItemID,
i.Price,
i.IsActive,
i.IsCheckedByDefault,
i.RequiresChildSelection,
i.MaxNumSelectionReq,
i.IsCollapsible,
i.SortOrder,
i.StationID,
s.Name AS StationName,
s.Color,
c.MenuID
FROM Items i
LEFT JOIN Categories c ON c.ID = i.CategoryID
LEFT JOIN Stations s ON s.ID = i.StationID
WHERE i.BusinessID = ?
AND i.IsActive = 1
AND (i.CategoryID IN ($visibleStr) OR (i.CategoryID = 0 AND i.ParentItemID > 0))
AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = i.ID)
AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl2 WHERE tl2.TemplateItemID = i.ParentItemID)
AND NOT (i.ParentItemID = 0 AND i.CategoryID = 0 AND i.Price = 0)
ORDER BY COALESCE(c.SortOrder, 999), i.SortOrder, i.ID
", [$businessID]);
} else {
// Fallback: Derive categories from parent Items
$q = queryTimed("
SELECT
i.ID,
CASE
WHEN i.ParentItemID = 0 AND i.IsCollapsible = 0 THEN i.ID
ELSE COALESCE(
(SELECT cat.ID FROM Items cat
WHERE cat.ID = i.ParentItemID
AND cat.ParentItemID = 0
AND cat.IsCollapsible = 0),
0
)
END as CategoryID,
CASE
WHEN i.ParentItemID = 0 AND i.IsCollapsible = 0 THEN i.Name
ELSE COALESCE(
(SELECT cat.Name FROM Items cat
WHERE cat.ID = i.ParentItemID
AND cat.ParentItemID = 0
AND cat.IsCollapsible = 0),
''
)
END as CategoryName,
i.Name AS ItemName,
i.Description,
i.ParentItemID,
i.Price,
i.IsActive,
i.IsCheckedByDefault,
i.RequiresChildSelection,
i.MaxNumSelectionReq,
i.IsCollapsible,
i.SortOrder,
i.StationID,
s.Name AS StationName,
s.Color
FROM Items i
LEFT JOIN Stations s ON s.ID = i.StationID
WHERE i.BusinessID = ?
AND i.IsActive = 1
AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl WHERE tl.TemplateItemID = i.ID)
AND NOT EXISTS (SELECT 1 FROM lt_ItemID_TemplateItemID tl2 WHERE tl2.TemplateItemID = i.ParentItemID)
AND (
i.ParentItemID > 0
OR (i.ParentItemID = 0 AND i.IsCollapsible = 0)
)
ORDER BY i.ParentItemID, i.SortOrder, i.ID
", [$businessID]);
}
} else {
// OLD SCHEMA: Use Categories table
$q = queryTimed("
SELECT
i.ID,
i.CategoryID,
c.Name AS CategoryName,
i.Name AS ItemName,
i.Description,
i.ParentItemID,
i.Price,
i.IsActive,
i.IsCheckedByDefault,
i.RequiresChildSelection,
i.MaxNumSelectionReq,
i.IsCollapsible,
i.SortOrder,
i.StationID,
s.Name AS StationName,
s.Color
FROM Items i
INNER JOIN Categories c ON c.ID = i.CategoryID
LEFT JOIN Stations s ON s.ID = i.StationID
WHERE c.BusinessID = ?
ORDER BY i.ParentItemID, i.SortOrder, i.ID
", [$businessID]);
}
// Build set of category IDs that have items
$categoriesWithItems = [];
foreach ($q as $row) {
if ((int) $row['CategoryID'] > 0) {
$categoriesWithItems[(int) $row['CategoryID']] = true;
}
}
// Build category ID set for parent remapping
$categoryIdSet = [];
if ($qCategories !== null) {
// Mark parent categories of subcategories that have items
foreach ($qCategories as $cat) {
if ((int) ($cat['ParentCategoryID'] ?? 0) > 0 && isset($categoriesWithItems[(int) $cat['ID']])) {
$categoriesWithItems[(int) $cat['ParentCategoryID']] = true;
}
}
foreach ($qCategories as $cat) {
$categoryIdSet[(int) $cat['ID']] = true;
}
// Add category headers as virtual parent items
foreach ($qCategories as $cat) {
if (isset($categoriesWithItems[(int) $cat['ID']])) {
$rows[] = [
'ItemID' => (int) $cat['ID'],
'CategoryID' => (int) $cat['ID'],
'Name' => $cat['Name'],
'Description' => '',
'ParentItemID' => 0,
'ParentCategoryID' => (int) ($cat['ParentCategoryID'] ?? 0),
'Price' => 0,
'IsActive' => 1,
'IsCheckedByDefault' => 0,
'RequiresChildSelection' => 0,
'MaxNumSelectionReq' => 0,
'IsCollapsible' => 0,
'SortOrder' => (int) $cat['SortOrder'],
'MenuID' => (int) ($cat['MenuID'] ?? 0),
'StationID' => '',
'ItemName' => '',
'ItemColor' => '',
];
}
}
}
// Process items
foreach ($q as $row) {
$effectiveParentID = (int) $row['ParentItemID'];
if ($newSchemaActive && $qCategories !== null && (int) $row['CategoryID'] > 0) {
if ($effectiveParentID === 0) {
$effectiveParentID = (int) $row['CategoryID'];
} elseif (isset($categoryIdSet[$effectiveParentID])) {
// Parent IS a category ID — correct
} elseif (!isset($categoryIdSet[$effectiveParentID])) {
$effectiveParentID = (int) $row['CategoryID'];
}
}
$itemMenuID = (int) ($row['MenuID'] ?? 0);
$itemName = $row['ItemName'] ?? $row['Name'] ?? '';
$catName = $row['CategoryName'] ?? $row['Name'] ?? '';
$rows[] = [
'ItemID' => (int) $row['ID'],
'CategoryID' => (int) $row['CategoryID'],
'Name' => strlen(trim($itemName)) > 0 ? $itemName : $catName,
'Description' => $row['Description'] ?? '',
'ParentItemID' => $effectiveParentID,
'Price' => (float) $row['Price'],
'IsActive' => (int) $row['IsActive'],
'IsCheckedByDefault' => (int) $row['IsCheckedByDefault'],
'RequiresChildSelection' => (int) $row['RequiresChildSelection'],
'MaxNumSelectionReq' => (int) $row['MaxNumSelectionReq'],
'IsCollapsible' => (int) $row['IsCollapsible'],
'SortOrder' => (int) $row['SortOrder'],
'MenuID' => $itemMenuID,
'StationID' => !empty($row['StationID']) ? $row['StationID'] : '',
'ItemName' => strlen(trim($catName)) > 0 ? $catName : '',
'ItemColor' => !empty($row['Color']) ? $row['Color'] : '',
];
}
// Add template-linked modifiers as virtual children (unified schema only)
if ($newSchemaActive) {
$qTemplateLinks = queryTimed("
SELECT
tl.ItemID as MenuItemID,
tmpl.ID as TemplateItemID,
tmpl.Name as TemplateName,
tmpl.Description as TemplateDescription,
tmpl.RequiresChildSelection as TemplateRequired,
tmpl.MaxNumSelectionReq as TemplateMaxSelections,
tmpl.IsCollapsible as TemplateIsCollapsible,
tl.SortOrder as TemplateSortOrder
FROM lt_ItemID_TemplateItemID tl
INNER JOIN Items tmpl ON tmpl.ID = tl.TemplateItemID AND tmpl.IsActive = 1
INNER JOIN Items menuItem ON menuItem.ID = tl.ItemID
WHERE menuItem.BusinessID = ?
AND menuItem.IsActive = 1
ORDER BY tl.ItemID, tl.SortOrder
", [$businessID]);
$qTemplateOptions = queryTimed("
SELECT DISTINCT
opt.ID as OptionItemID,
opt.ParentItemID as TemplateItemID,
opt.Name as OptionName,
opt.Description as OptionDescription,
opt.Price as OptionPrice,
opt.IsCheckedByDefault as OptionIsDefault,
opt.SortOrder as OptionSortOrder
FROM Items opt
INNER JOIN lt_ItemID_TemplateItemID tl ON tl.TemplateItemID = opt.ParentItemID
INNER JOIN Items menuItem ON menuItem.ID = tl.ItemID
WHERE menuItem.BusinessID = ?
AND menuItem.IsActive = 1
AND opt.IsActive = 1
ORDER BY opt.ParentItemID, opt.SortOrder
", [$businessID]);
// Build template options map: templateID -> [options]
$templateOptionsMap = [];
foreach ($qTemplateOptions as $opt) {
$tid = (int) $opt['TemplateItemID'];
$templateOptionsMap[$tid][] = [
'ItemID' => (int) $opt['OptionItemID'],
'Name' => $opt['OptionName'],
'Description' => $opt['OptionDescription'] ?? '',
'Price' => (float) $opt['OptionPrice'],
'IsCheckedByDefault' => (int) $opt['OptionIsDefault'],
'SortOrder' => (int) $opt['OptionSortOrder'],
];
}
// Add templates and options as virtual children
$addedTemplates = [];
foreach ($qTemplateLinks as $link) {
$menuItemID = (int) $link['MenuItemID'];
$templateID = (int) $link['TemplateItemID'];
$linkKey = "{$menuItemID}_{$templateID}";
if (isset($addedTemplates[$linkKey])) continue;
$addedTemplates[$linkKey] = true;
$virtualTemplateID = $menuItemID * 100000 + $templateID;
// Template as modifier group
$rows[] = [
'ItemID' => $virtualTemplateID,
'CategoryID' => 0,
'Name' => $link['TemplateName'],
'Description' => $link['TemplateDescription'] ?? '',
'ParentItemID' => $menuItemID,
'Price' => 0,
'IsActive' => 1,
'IsCheckedByDefault' => 0,
'RequiresChildSelection' => (int) $link['TemplateRequired'],
'MaxNumSelectionReq' => (int) $link['TemplateMaxSelections'],
'IsCollapsible' => (int) $link['TemplateIsCollapsible'],
'SortOrder' => (int) $link['TemplateSortOrder'],
'StationID' => '',
'ItemName' => '',
'ItemColor' => '',
];
// Template options
if (isset($templateOptionsMap[$templateID])) {
foreach ($templateOptionsMap[$templateID] as $opt) {
$virtualOptionID = $menuItemID * 100000 + $opt['ItemID'];
$rows[] = [
'ItemID' => $virtualOptionID,
'CategoryID' => 0,
'Name' => $opt['Name'],
'Description' => $opt['Description'],
'ParentItemID' => $virtualTemplateID,
'Price' => $opt['Price'],
'IsActive' => 1,
'IsCheckedByDefault' => $opt['IsCheckedByDefault'],
'RequiresChildSelection' => 0,
'MaxNumSelectionReq' => 0,
'IsCollapsible' => 0,
'SortOrder' => $opt['SortOrder'],
'StationID' => '',
'ItemName' => '',
'ItemColor' => '',
];
}
}
}
}
// Get brand color, tax rate, payfrit fee, header image
$qBrand = queryOne("
SELECT BrandColor, BrandColorLight, TaxRate, PayfritFee,
HeaderImageExtension, SessionEnabled, OrderTypes
FROM Businesses WHERE ID = ?
", [$businessID]);
if (!$qBrand) {
apiAbort(['OK' => false, 'ERROR' => 'business_not_found']);
}
if (!is_numeric($qBrand['TaxRate'])) {
apiAbort(['OK' => false, 'ERROR' => 'business_tax_rate_not_configured']);
}
if (!is_numeric($qBrand['PayfritFee']) || (float) $qBrand['PayfritFee'] <= 0) {
apiAbort(['OK' => false, 'ERROR' => 'business_payfrit_fee_not_configured']);
}
$brandColor = '';
if (!empty($qBrand['BrandColor'])) {
$brandColor = $qBrand['BrandColor'][0] === '#' ? $qBrand['BrandColor'] : '#' . $qBrand['BrandColor'];
}
$brandColorLight = '';
if (!empty($qBrand['BrandColorLight'])) {
$brandColorLight = $qBrand['BrandColorLight'][0] === '#' ? $qBrand['BrandColorLight'] : '#' . $qBrand['BrandColorLight'];
}
$headerImageUrl = '';
if (!empty($qBrand['HeaderImageExtension'])) {
$headerImageUrl = "/uploads/headers/{$businessID}.{$qBrand['HeaderImageExtension']}";
}
jsonResponse([
'OK' => true,
'ERROR' => '',
'Items' => $rows,
'COUNT' => count($rows),
'SCHEMA' => $newSchemaActive ? 'unified' : 'legacy',
'BRANDCOLOR' => $brandColor,
'BRANDCOLORLIGHT' => $brandColorLight,
'HEADERIMAGEURL' => $headerImageUrl,
'TAXRATE' => (float) $qBrand['TaxRate'],
'PAYFRITFEE' => (float) $qBrand['PayfritFee'],
'SESSIONENABLED' => (int) ($qBrand['SessionEnabled'] ?? 0),
'Menus' => $menuList,
'SelectedMenuID' => $requestedMenuID,
'ORDERTYPES' => !empty($qBrand['OrderTypes']) ? $qBrand['OrderTypes'] : '1',
]);
} catch (Exception $e) {
jsonResponse([
'OK' => false,
'ERROR' => 'server_error',
'MESSAGE' => 'DB error loading items',
'DETAIL' => $e->getMessage(),
]);
}