payfrit-works/api/setup/analyzeMenu.cfm
John Mizerek 51a80b537d Add local dev support and fix menu builder API
Portal local development:
- Add BASE_PATH detection to all portal files (login, portal.js, menu-builder, station-assignment)
- Allows portal to work at /biz.payfrit.com/ path locally

Menu Builder fixes:
- Fix duplicate template options in getForBuilder.cfm query
- Filter template children by business ID with DISTINCT

New APIs:
- api/portal/myBusinesses.cfm - List businesses for logged-in user
- api/stations/list.cfm - List KDS stations
- api/menu/updateStations.cfm - Update item station assignments
- api/setup/reimportBigDeans.cfm - Full Big Dean's menu import script

Admin utilities:
- Various debug and migration scripts for menu/template management
- Beacon switching, category cleanup, modifier template setup

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 22:47:12 -08:00

415 lines
16 KiB
Text

<cfsetting showdebugoutput="false">
<cfsetting enablecfoutputonly="true">
<cfcontent type="application/json; charset=utf-8" reset="true">
<cfscript>
/**
* Analyze Menu for Modifier Patterns
*
* Parses item descriptions to detect modifier groups and suggests
* which templates should be created and applied.
*
* Detection patterns:
* - Size variants: "Small available", "1/2 order available", "Small/Large"
* - Protein choices: "With Chicken / With Steak or Shrimp"
* - Required choices: "Boneless or bone in"
* - Category-wide add-ons: "Add to any salad: ..."
* - Price modifiers: "Add cheese +$0.50"
*
* POST JSON:
* {
* "categories": [
* {
* "name": "Burgers",
* "categoryNote": "All burgers served with lettuce, tomato...",
* "items": [
* { "name": "Burger", "description": "Served with fries" }
* ]
* }
* ]
* }
*
* Returns analyzed menu with detected modifiers and suggestions
*/
response = { "OK": false };
// Pattern definitions for modifier detection
patterns = {
// Size patterns
"size_small_available": {
"regex": "(?i)\bsmall\s+(available|option)\b",
"templateName": "Size",
"options": [
{ "name": "Regular", "price": 0, "isDefault": true },
{ "name": "Small", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1
},
"size_half_available": {
"regex": "(?i)\b(1/2|half)\s+(order\s+)?(available|option)\b",
"templateName": "Size",
"options": [
{ "name": "Full", "price": 0, "isDefault": true },
{ "name": "Half", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1
},
// Choice patterns (X or Y)
"choice_or": {
"regex": "(?i)\b(\w+)\s+or\s+(\w+)\b(?!\s+shrimp)",
"templateName": "auto", // Will be generated from matched words
"required": true,
"maxSelections": 1
},
// Protein add-ons
"protein_chicken_steak_shrimp": {
"regex": "(?i)with\s+chicken\s*/?\s*(?:with\s+)?(?:steak\s+or\s+shrimp|steak\/shrimp)",
"templateName": "Add Protein",
"options": [
{ "name": "Plain", "price": 0, "isDefault": true },
{ "name": "With Chicken", "price": 0, "isDefault": false },
{ "name": "With Steak or Shrimp", "price": 0, "isDefault": false }
],
"required": false,
"maxSelections": 1
},
// Add-on with price
"addon_with_price": {
"regex": "(?i)add\s+(\w+(?:\s+\w+)?)\s*[\+\$]?\s*\.?(\d+(?:\.\d{2})?)",
"templateName": "Add-ons",
"required": false,
"maxSelections": 0
},
// Category note patterns
"salad_addons": {
"regex": "(?i)add\s+(?:the\s+following\s+)?to\s+any\s+salad[:\s]+(.+)",
"templateName": "Add Protein",
"appliesTo": "category",
"required": false,
"maxSelections": 1
}
};
try {
requestBody = toString(getHttpRequestData().content);
if (!len(requestBody)) {
throw(message="No request body provided");
}
data = deserializeJSON(requestBody);
categories = structKeyExists(data, "categories") ? data.categories : [];
// Track detected templates and their usage
detectedTemplates = {};
itemModifiers = {}; // Maps item name to array of template IDs
// Analyze each category
analyzedCategories = [];
for (cat in categories) {
catName = cat.name;
catNote = structKeyExists(cat, "categoryNote") ? cat.categoryNote : "";
items = structKeyExists(cat, "items") ? cat.items : [];
// Check for category-wide patterns in the note
categoryTemplates = [];
// Check for "Add to any salad" pattern
if (reFindNoCase("add\s+(?:the\s+following\s+)?to\s+any\s+(salad|item)", catNote)) {
// Extract the add-on items
addOnMatch = reMatchNoCase("add[^:]+:\s*(.+)", catNote);
if (arrayLen(addOnMatch)) {
addOnText = addOnMatch[1];
// Split by comma
addOnItems = listToArray(addOnText, ",");
templateID = "cat_" & lCase(reReplace(catName, "\W+", "_", "all")) & "_addons";
if (!structKeyExists(detectedTemplates, templateID)) {
options = [{ "name": "None", "price": 0, "isDefault": true }];
for (addon in addOnItems) {
addon = trim(addon);
if (len(addon)) {
arrayAppend(options, { "name": addon, "price": 0, "isDefault": false });
}
}
detectedTemplates[templateID] = {
"id": templateID,
"name": "Add Protein",
"options": options,
"required": false,
"maxSelections": 1,
"detectedFrom": "Category note: " & catName,
"appliesTo": []
};
}
arrayAppend(categoryTemplates, templateID);
}
}
// Analyze each item
analyzedItems = [];
for (item in items) {
itemName = item.name;
itemDesc = structKeyExists(item, "description") ? item.description : "";
itemMods = [];
// Apply category-wide templates
for (catTmpl in categoryTemplates) {
arrayAppend(itemMods, catTmpl);
arrayAppend(detectedTemplates[catTmpl].appliesTo, itemName);
}
// Check for size patterns
if (reFindNoCase("\bsmall\s*(available|option)?\b", itemDesc) ||
reFindNoCase("\bsmall\b", itemName)) {
templateID = "size_regular_small";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Size",
"options": [
{ "name": "Regular", "price": 0, "isDefault": true },
{ "name": "Small", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1,
"detectedFrom": "Pattern: 'Small available'",
"appliesTo": []
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Check for half order pattern
if (reFindNoCase("\b(1/2|half)\s*(order)?\s*(available)?\b", itemDesc)) {
templateID = "size_full_half";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Size",
"options": [
{ "name": "Full Order", "price": 0, "isDefault": true },
{ "name": "Half Order", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1,
"detectedFrom": "Pattern: '1/2 order available'",
"appliesTo": []
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Check for "boneless or bone in" pattern
if (reFindNoCase("\bboneless\s+(or|/)\s*bone\s*-?\s*in\b", itemDesc)) {
templateID = "wing_style";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Style",
"options": [
{ "name": "Bone-In", "price": 0, "isDefault": true },
{ "name": "Boneless", "price": 0, "isDefault": false }
],
"required": true,
"maxSelections": 1,
"detectedFrom": "Pattern: 'Boneless or bone in'",
"appliesTo": []
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Check for protein add-on pattern (With Chicken / With Steak or Shrimp)
if (reFindNoCase("with\s+chicken.*(?:steak|shrimp)", itemDesc)) {
templateID = "protein_addon";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Add Protein",
"options": [
{ "name": "Plain", "price": 0, "isDefault": true },
{ "name": "With Chicken", "price": 0, "isDefault": false },
{ "name": "With Steak or Shrimp", "price": 0, "isDefault": false }
],
"required": false,
"maxSelections": 1,
"detectedFrom": "Pattern: 'With Chicken / With Steak or Shrimp'",
"appliesTo": []
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Check for "add X +$Y" pattern
priceMatch = reMatchNoCase("add\s+(\w+(?:\s+(?:and\s+)?\w+)?)\s*[\.\+]?\s*(\d+(?:\.\d{2})?)", itemDesc);
if (arrayLen(priceMatch)) {
for (match in priceMatch) {
// Parse the match to get item name and price
parts = reMatchNoCase("add\s+(.+?)\s*[\.\+]?\s*(\d+(?:\.\d{2})?)\s*$", match);
if (arrayLen(parts)) {
templateID = "addon_" & lCase(reReplace(itemName, "\W+", "_", "all"));
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Add-ons",
"options": [],
"required": false,
"maxSelections": 0,
"detectedFrom": "Pattern: 'Add X +$Y' in " & itemName,
"appliesTo": [],
"needsPricing": true
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
}
}
// Check for "with guacamole" type optional add-ons
if (reFindNoCase("\bwith\s+guacamole\b", itemDesc)) {
templateID = "addon_guacamole";
if (!structKeyExists(detectedTemplates, templateID)) {
detectedTemplates[templateID] = {
"id": templateID,
"name": "Add Guacamole",
"options": [
{ "name": "No Guacamole", "price": 0, "isDefault": true },
{ "name": "Add Guacamole", "price": 0, "isDefault": false }
],
"required": false,
"maxSelections": 1,
"detectedFrom": "Pattern: 'With guacamole'",
"appliesTo": [],
"needsPricing": true
};
}
arrayAppend(itemMods, templateID);
arrayAppend(detectedTemplates[templateID].appliesTo, itemName);
}
// Store item with detected modifiers
arrayAppend(analyzedItems, {
"name": itemName,
"description": itemDesc,
"price": structKeyExists(item, "price") ? item.price : 0,
"detectedModifiers": itemMods,
"originalDescription": itemDesc
});
}
arrayAppend(analyzedCategories, {
"name": catName,
"categoryNote": catNote,
"items": analyzedItems,
"categoryWideTemplates": categoryTemplates
});
}
// Build template array sorted by usage count
templateArray = [];
for (key in detectedTemplates) {
tmpl = detectedTemplates[key];
tmpl["usageCount"] = arrayLen(tmpl.appliesTo);
arrayAppend(templateArray, tmpl);
}
// Sort by usage count descending
arraySort(templateArray, function(a, b) {
return b.usageCount - a.usageCount;
});
// Generate questions for owner confirmation
questions = [];
// Question about detected templates
if (arrayLen(templateArray)) {
arrayAppend(questions, {
"type": "confirm_templates",
"question": "I detected these modifier groups. Are they correct?",
"templates": templateArray
});
}
// Question about items needing pricing
itemsNeedingPrices = [];
for (cat in analyzedCategories) {
for (item in cat.items) {
if (item.price == 0) {
arrayAppend(itemsNeedingPrices, {
"category": cat.name,
"item": item.name
});
}
}
}
if (arrayLen(itemsNeedingPrices)) {
arrayAppend(questions, {
"type": "pricing_needed",
"question": "These items need prices:",
"items": itemsNeedingPrices
});
}
// Question about template options needing pricing
templatesNeedingPrices = [];
for (tmpl in templateArray) {
if (structKeyExists(tmpl, "needsPricing") && tmpl.needsPricing) {
arrayAppend(templatesNeedingPrices, {
"templateId": tmpl.id,
"templateName": tmpl.name,
"appliesTo": tmpl.appliesTo
});
}
}
if (arrayLen(templatesNeedingPrices)) {
arrayAppend(questions, {
"type": "modifier_pricing_needed",
"question": "These add-on options need prices:",
"templates": templatesNeedingPrices
});
}
response.OK = true;
response.analyzedMenu = {
"categories": analyzedCategories,
"detectedTemplates": templateArray,
"totalItems": 0,
"totalTemplates": arrayLen(templateArray),
"totalModifierLinks": 0
};
// Count totals
totalItems = 0;
totalLinks = 0;
for (cat in analyzedCategories) {
totalItems += arrayLen(cat.items);
for (item in cat.items) {
totalLinks += arrayLen(item.detectedModifiers);
}
}
response.analyzedMenu.totalItems = totalItems;
response.analyzedMenu.totalModifierLinks = totalLinks;
response.questions = questions;
response.summary = "Analyzed " & totalItems & " items, detected " & arrayLen(templateArray) & " modifier templates with " & totalLinks & " links";
} catch (any e) {
response.error = e.message;
if (len(e.detail)) {
response.errorDetail = e.detail;
}
}
writeOutput(serializeJSON(response));
</cfscript>