fix: recipe quantities for conf+weight; move modal remembers location

- 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)
This commit is contained in:
dadaloop82
2026-05-19 16:51:37 +00:00
parent f77b3259ad
commit 87eac171bf
2 changed files with 95 additions and 20 deletions
+63 -12
View File
@@ -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);
+32 -8
View File
@@ -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]) =>
`<button type="button" class="loc-btn" onclick="clearMoveModalTimer();confirmRecipeMove(${productId}, '${fromLoc}', '${k}', ${openedId || 0})">${v.icon} ${v.label}</button>`
@@ -12776,18 +12793,25 @@ function showRecipeMoveModal(productId, fromLoc, remaining, openedId, wasVacuum)
<p style="margin-bottom:12px">${t('move.question_short').replace('{thing}', openedId ? t('move.thing_opened') : t('move.thing_rest'))}</p>
<div class="location-selector">${locButtons}</div>
${vacuumRow}
<button type="button" id="btn-move-stay" class="btn btn-secondary full-width move-countdown-btn" style="margin-top:12px" onclick="clearMoveModalTimer();closeModal()">${t('move.stay_btn').replace('{location}', LOCATIONS[fromLoc]?.label || fromLoc)}</button>
<button type="button" id="btn-move-stay" class="btn btn-secondary full-width move-countdown-btn" style="margin-top:12px" onclick="clearMoveModalTimer();_recipeMoveCancelStay(${productId}, '${fromLoc}', ${openedId || 0})">${t('move.stay_btn').replace('{location}', LOCATIONS[fromLoc]?.label || fromLoc)}</button>
</div>
`;
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) {