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>
415 lines
16 KiB
Text
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>
|