From 87eac171bf4b2a39979e6efe9983c06476120228 Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Tue, 19 May 2026 16:51:37 +0000 Subject: [PATCH] fix: recipe quantities for conf+weight; move modal remembers location MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PHP (all 3 recipe endpoints): conf products with weight/volume package_unit (e.g., 300g/conf) now keep qty_number in sub-units (grams/ml) instead of converting to fractional conf. Post-normalisation block converts any AI- returned fractional conf values to grams automatically. - JS submitRecipeUse: vacuum state now read from actual inventory item at the used location (_recipeUseContext.items), not from recipe ingredient data. - JS showRecipeMoveModal: now uses _prefMoveLocCache preference system — after 2 consistent choices the modal is skipped automatically. 'Stay' button records the choice. Added _recipeMoveCancelStay() helper. - JS confirmRecipeMove: records move choice via _recordMoveLocChoice(); accepts optional forcedVacuum param for preference-triggered auto-moves. Closes #108 (duplicate of #107 — data dir permissions) --- api/index.php | 75 ++++++++++++++++++++++++++++++++++++++++-------- assets/js/app.js | 40 ++++++++++++++++++++------ 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/api/index.php b/api/index.php index 4f23226..7a66a67 100644 --- a/api/index.php +++ b/api/index.php @@ -4519,6 +4519,7 @@ PROMPT; } // Convert qty_number to inventory unit if mismatch detected + $confAlreadyInSubUnit = false; if ($recipeUnit && $recipeUnit !== $invUnit) { // Weight conversions (both should be 'g' now, but handle legacy 'kg') if ($recipeUnit === 'g' && $invUnit === 'kg') { @@ -4530,22 +4531,31 @@ PROMPT; $qtyNum = $recipeVal / 1000; } elseif ($recipeUnit === 'ml' && $invUnit === 'ml') { $qtyNum = $recipeVal; - // g/ml → pz/conf (approximate to nearest piece) - } elseif ($invUnit === 'pz' || $invUnit === 'conf') { + // g/ml → conf with weight/volume pkg_unit: keep in sub-units so JS modal works + } elseif ($invUnit === 'conf') { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') + && ($recipeUnit === 'g' || $recipeUnit === 'ml')) { + // Keep qty_number in sub-units; JS handles g↔conf conversion + $qtyNum = $recipeVal; + $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; + $confAlreadyInSubUnit = true; + } else { + // conf without weight pkg_unit: fractional conf + $qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : 1; + } + // g/ml → pz (approximate to nearest piece) + } elseif ($invUnit === 'pz') { $defQty = (float)($bestMatch['default_quantity'] ?? 0); if ($defQty > 0) { - // Convert recipe grams/ml to pieces using default_quantity - $qtyNum = $recipeVal / $defQty; - $qtyNum = max(0.25, round($qtyNum * 4) / 4); // round to nearest quarter + $qtyNum = max(0.25, round(($recipeVal / $defQty) * 4) / 4); } else { - // No default_quantity: AI was told to use pieces but sent grams. - // If the original qty_number looks like a piece count (≤ invQty and ≤ 100) - // keep it; otherwise fall back to 1. $origQtyNum = (float)($ing['qty_number'] ?? 0); if ($origQtyNum >= 1 && $origQtyNum <= $invQty && $origQtyNum <= 100) { - $qtyNum = $origQtyNum; // already a plausible piece count + $qtyNum = $origQtyNum; } else { - $qtyNum = 1; // safe minimum: 1 piece + $qtyNum = 1; } } } @@ -4557,6 +4567,17 @@ PROMPT; } } + // Conf+weight post-normalisation: if qty_number wasn't already set to + // sub-units above, and it looks like a fractional conf value (≤ available + // conf count), convert to grams so the JS modal shows correct grams. + if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); + $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } + } // Sanity check: qty_number should not exceed available if ($qtyNum > $invQty) { $qtyNum = $invQty; // cap to available @@ -4919,16 +4940,31 @@ function _enrichChatIngredients(array &$ingredients, array $items): void { elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $recipeUnit = 'pz'; elseif (strpos($ru, 'conf') === 0) $recipeUnit = 'conf'; } + $confAlreadyInSubUnit = false; if ($recipeUnit && $recipeUnit !== $invUnit) { if ($recipeUnit === 'g' && $invUnit === 'g') $qtyNum = $recipeVal; elseif ($recipeUnit === 'g' && $invUnit === 'kg') $qtyNum = $recipeVal / 1000; elseif ($recipeUnit === 'ml' && $invUnit === 'ml') $qtyNum = $recipeVal; elseif ($recipeUnit === 'ml' && $invUnit === 'l') $qtyNum = $recipeVal / 1000; - elseif ($invUnit === 'pz' || $invUnit === 'conf') { + elseif ($invUnit === 'conf') { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && ($recipeUnit === 'g' || $recipeUnit === 'ml')) { + $qtyNum = $recipeVal; $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; $confAlreadyInSubUnit = true; + } else { $qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : 1; } + } elseif ($invUnit === 'pz') { $defQty = (float)($bestMatch['default_quantity'] ?? 0); $qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : max(1, round($recipeVal / 100)); } } + // Conf+weight: normalise fractional conf to sub-units + if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } + } if ($qtyNum > $invQty) $qtyNum = $invQty; if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) $qtyNum = $recipeVal; $ing['qty_number'] = round($qtyNum, 3); @@ -5439,17 +5475,32 @@ PROMPT; elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $recipeUnit = 'pz'; elseif (strpos($ru, 'conf') === 0) $recipeUnit = 'conf'; } + $confAlreadyInSubUnit = false; if ($recipeUnit && $recipeUnit !== $invUnit) { if ($recipeUnit === 'g' && $invUnit === 'kg') $qtyNum = $recipeVal / 1000; elseif ($recipeUnit === 'g' && $invUnit === 'g') $qtyNum = $recipeVal; elseif ($recipeUnit === 'ml' && $invUnit === 'l') $qtyNum = $recipeVal / 1000; elseif ($recipeUnit === 'ml' && $invUnit === 'ml') $qtyNum = $recipeVal; - elseif ($invUnit === 'pz' || $invUnit === 'conf') { + elseif ($invUnit === 'conf') { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && ($recipeUnit === 'g' || $recipeUnit === 'ml')) { + $qtyNum = $recipeVal; $ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC; $confAlreadyInSubUnit = true; + } else { $qtyNum = $defQty > 0 ? max(0.25, round(($recipeVal / $defQty) * 4) / 4) : 1; } + } elseif ($invUnit === 'pz') { $defQty = (float)($bestMatch['default_quantity'] ?? 0); if ($defQty > 0) { $qtyNum = $recipeVal / $defQty; $qtyNum = max(0.25, round($qtyNum * 4) / 4); } else $qtyNum = max(1, round($recipeVal / 100)); } } + // Conf+weight: normalise fractional conf to sub-units + if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) { + $defQty = (float)($bestMatch['default_quantity'] ?? 0); + $pkgUnitLC = strtolower($bestMatch['package_unit'] ?? ''); + if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) { + $qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC; + } + } if ($qtyNum > $invQty) $qtyNum = $invQty; if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) $qtyNum = $recipeVal; $ing['qty_number'] = round($qtyNum, 3); diff --git a/assets/js/app.js b/assets/js/app.js index 1b648da..74632e0 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -12734,11 +12734,13 @@ async function submitRecipeUse(useAll) { setTimeout(() => showToast(t('recipes.finished_added_bring_toast'), 'info'), 1500); } - // Check low stock → Bring! prompt, then offer move + // Check low stock → shopping prompt, then offer move const moveCallback = result.remaining > 0 ? () => setTimeout(() => { - const ingData = _cachedRecipe?.recipe?.ingredients?.[_recipeUseContext?.idx]; - const wasVacuum = !!(ingData?.vacuum_sealed); + // Get vacuum state from the actual inventory item at this location + const cachedItems = _recipeUseContext?.items || []; + const itemAtLoc = cachedItems.find(i => i.location === location); + const wasVacuum = !!(itemAtLoc?.vacuum_sealed); showRecipeMoveModal(productId, location, result.remaining, result.opened_id, wasVacuum); }, 300) : null; @@ -12758,6 +12760,21 @@ async function submitRecipeUse(useAll) { } function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum) { + // Set context for recording the choice + _pendingMoveCtx = { productId, fromLoc, openedId }; + + // If a preference exists, skip the modal entirely + const prefMoveLoc = _getPreferredMoveLoc(productId, fromLoc); + if (prefMoveLoc) { + if (prefMoveLoc === fromLoc) { + closeModal(); + } else { + confirmRecipeMove(productId, fromLoc, prefMoveLoc, openedId, wasVacuum); + } + _pendingMoveCtx = null; + return; + } + const otherLocs = Object.entries(LOCATIONS).filter(([k]) => k !== fromLoc); const locButtons = otherLocs.map(([k, v]) => `` @@ -12776,18 +12793,25 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)

${t('move.question_short').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest'))}

${locButtons}
${vacuumRow} - + `; document.getElementById('modal-overlay').style.display = 'flex'; - // Hide the native kiosk settings button while the modal is open (prevents touch bleed-through) try { if (typeof _kioskBridge !== 'undefined') _kioskBridge.setNativeSettingsVisible(false); } catch (_) {} - startMoveModalCountdown('btn-move-stay', () => { closeModal(); }); + startMoveModalCountdown('btn-move-stay', () => { _recipeMoveCancelStay(productId, fromLoc, openedId || 0); }); } -async function confirmRecipeMove(productId, fromLoc, toLoc, openedId) { +function _recipeMoveCancelStay(productId, fromLoc, openedId) { + _recordMoveLocChoice(productId, fromLoc, fromLoc); + _pendingMoveCtx = null; + closeModal(); +} + +async function confirmRecipeMove(productId, fromLoc, toLoc, openedId, forcedVacuum) { clearMoveModalTimer(); - const newVacuum = document.getElementById('move-vacuum-check')?.checked ? 1 : 0; + _recordMoveLocChoice(productId, fromLoc, toLoc); + _pendingMoveCtx = null; + const newVacuum = forcedVacuum !== undefined ? (forcedVacuum ? 1 : 0) : (document.getElementById('move-vacuum-check')?.checked ? 1 : 0); closeModal(); try { if (openedId) {