Release v1.7.37: strict recipe pantry matching and renderRecipe fix.

Prevent false  pantry links via strict name matching and full inventory prompts; fix qtyNum crash when reopening archived recipes.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dadaloop82
2026-06-04 17:38:12 +00:00
parent cf65e79010
commit b63deca795
7 changed files with 251 additions and 552 deletions
+9
View File
@@ -11,6 +11,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
## [1.7.37] - 2026-06-04
### Fixed
- **Recipe pantry false positives** — Generated recipes no longer mark ingredients as ✅ in pantry when the product is not in stock or the name does not strictly match an inventory item (score ≥ 80, no generic alias expansion like *formaggio* → any cheese). AI prompt now receives the full in-stock list and explicit rules forbidding invented ingredient names.
- **`renderRecipe` crash** — Restored missing `qtyNum` variable when reopening archived recipes with pantry ingredients (ReferenceError on the "Use ingredient" button).
### Changed
- **`re-enrich-recipe.php`** — Re-applies strict pantry matching before stock hints when fixing archived recipes.
## [1.7.36] - 2026-06-04 ## [1.7.36] - 2026-06-04
### Added ### Added
+1 -1
View File
@@ -25,7 +25,7 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/) [![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile) [![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/) [![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.36-brightgreen.svg)](CHANGELOG.md) [![Version](https://img.shields.io/badge/version-1.7.37-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers) [![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main) [![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors) [![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
+213 -543
View File
@@ -5839,13 +5839,212 @@ function recipeApplyStockHintsToRecipe(PDO $db, array &$recipe): void {
foreach ($recipe['ingredients'] as &$ing) { foreach ($recipe['ingredients'] as &$ing) {
if (empty($ing['from_pantry']) || empty($ing['product_id'])) continue; if (empty($ing['from_pantry']) || empty($ing['product_id'])) continue;
$totalStock = recipeGetProductTotalStock($db, (int)$ing['product_id']); $totalStock = recipeGetProductTotalStock($db, (int)$ing['product_id']);
if ($totalStock <= 0) continue; if ($totalStock <= 0) {
recipeClearPantryIngredient($ing);
continue;
}
$ing['inventory_qty_total'] = $totalStock; $ing['inventory_qty_total'] = $totalStock;
recipeFinalizeIngQty($ing, $totalStock); recipeFinalizeIngQty($ing, $totalStock);
} }
unset($ing); unset($ing);
} }
const RECIPE_PANTRY_MIN_MATCH_SCORE = 80;
function recipeNormalizeName(string $name): string {
$n = mb_strtolower(trim($name), 'UTF-8');
return preg_replace('/\s+/u', ' ', $n) ?? $n;
}
/** Always-available staples — never link to a pantry product row. */
function recipeIsFreeStaple(string $name): bool {
$n = recipeNormalizeName($name);
return (bool)preg_match('/^(acqua|sale|pepe|peper|olio(\s|$|e)|extraverg|evoo)\b/u', $n);
}
/** Strict name match — no generic alias expansion (formaggio ≠ grana). */
function recipeScorePantryMatch(string $ingName, string $productName): int {
$a = recipeNormalizeName($ingName);
$b = recipeNormalizeName($productName);
if ($a === '' || $b === '') return 0;
if ($a === $b) return 100;
if (mb_strpos($a, $b) !== false) {
return mb_strlen($b) >= 4 ? 92 : 0;
}
if (mb_strpos($b, $a) !== false) {
return mb_strlen($a) >= 4 ? 88 : 0;
}
$aw = preg_split('/[\s,.\-\/]+/u', $a, -1, PREG_SPLIT_NO_EMPTY);
$bw = preg_split('/[\s,.\-\/]+/u', $b, -1, PREG_SPLIT_NO_EMPTY);
if (!empty($aw[0]) && !empty($bw[0]) && mb_strlen($aw[0]) >= 4 && $aw[0] === $bw[0]) {
return 80;
}
return 0;
}
function recipePickBestInventoryRow(array $rows): array {
usort($rows, static function (array $a, array $b): int {
$aOpen = !empty($a['opened_at'])
|| ((float)($a['quantity'] ?? 0) > 0 && (float)($a['quantity'] ?? 0) < 1 && ($a['unit'] ?? '') === 'conf');
$bOpen = !empty($b['opened_at'])
|| ((float)($b['quantity'] ?? 0) > 0 && (float)($b['quantity'] ?? 0) < 1 && ($b['unit'] ?? '') === 'conf');
if ($aOpen !== $bOpen) return $bOpen <=> $aOpen;
$da = (float)($a['days_left'] ?? 999);
$db = (float)($b['days_left'] ?? 999);
if ($da !== $db) return $da <=> $db;
return (float)($b['quantity'] ?? 0) <=> (float)($a['quantity'] ?? 0);
});
return $rows[0];
}
function recipeClearPantryIngredient(array &$ing): void {
$ing['from_pantry'] = false;
foreach ([
'product_id', 'location', 'inventory_unit', 'inventory_qty', 'inventory_qty_total',
'default_quantity', 'package_unit', 'available_qty', 'vacuum_sealed', 'brand', 'expiry_date',
'stock_have', 'stock_remain', 'stock_unit', 'package_base', 'use_all_suggested', 'used',
] as $k) {
unset($ing[$k]);
}
}
function recipeApplyPantryQtyFields(array &$ing, array $bestMatch): void {
$qtyNum = (float)($ing['qty_number'] ?? 0);
$invUnit = $bestMatch['unit'] ?? 'pz';
$invQty = (float)$bestMatch['quantity'];
if ($qtyNum <= 0) return;
$recipeQty = $ing['qty'] ?? '';
$recipeUnit = '';
$recipeVal = 0;
if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $recipeQty, $qm)) {
$recipeVal = (float)str_replace(',', '.', $qm[1]);
$ru = strtolower($qm[2]);
if (strpos($ru, 'g') === 0) $recipeUnit = 'g';
elseif ($ru === 'kg') { $recipeUnit = 'g'; $recipeVal *= 1000; }
elseif ($ru === 'ml') $recipeUnit = 'ml';
elseif ($ru === 'cl') { $recipeUnit = 'ml'; $recipeVal *= 10; }
elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $recipeUnit = 'ml'; $recipeVal *= 1000; }
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 === '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 = max(0.25, round(($recipeVal / $defQty) * 4) / 4);
} else {
$origQtyNum = (float)($ing['qty_number'] ?? 0);
$qtyNum = ($origQtyNum >= 1 && $origQtyNum <= $invQty && $origQtyNum <= 100)
? $origQtyNum : max(1, round($recipeVal / 100));
}
}
} elseif ($invUnit === 'pz' && !$recipeUnit) {
if ($qtyNum > $invQty || $qtyNum > 100) {
$qtyNum = max(1, round($qtyNum / 100));
}
}
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
$defQty = (float)($bestMatch['default_quantity'] ?? 0);
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml')) {
if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) {
$qtyNum = $recipeVal;
$ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC;
} elseif ($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);
}
/** Link recipe ingredients ONLY to real in-stock pantry products (strict name match). */
function recipeEnrichIngredientsFromPantry(PDO $db, array &$ingredients, array $items): void {
if (empty($ingredients) || empty($items)) return;
$catalog = [];
foreach ($items as $item) {
if ((float)($item['quantity'] ?? 0) <= 0) continue;
$pid = (int)$item['product_id'];
if (!isset($catalog[$pid])) {
$catalog[$pid] = ['name' => $item['name'], 'rows' => []];
}
$catalog[$pid]['rows'][] = $item;
}
foreach ($ingredients as &$ing) {
$ingName = trim($ing['name'] ?? '');
if ($ingName === '' || recipeIsFreeStaple($ingName)) {
recipeClearPantryIngredient($ing);
continue;
}
$bestPid = null;
$bestScore = 0;
foreach ($catalog as $pid => $meta) {
$score = recipeScorePantryMatch($ingName, $meta['name']);
if ($score > $bestScore) {
$bestScore = $score;
$bestPid = $pid;
}
}
if ($bestScore < RECIPE_PANTRY_MIN_MATCH_SCORE || !$bestPid) {
recipeClearPantryIngredient($ing);
continue;
}
$totalStock = recipeGetProductTotalStock($db, $bestPid);
if ($totalStock <= 0) {
recipeClearPantryIngredient($ing);
continue;
}
$bestMatch = recipePickBestInventoryRow($catalog[$bestPid]['rows']);
$ing['from_pantry'] = true;
$ing['name'] = $catalog[$bestPid]['name'];
$ing['product_id'] = $bestPid;
$ing['location'] = $bestMatch['location'];
$ing['inventory_unit'] = $bestMatch['unit'];
$ing['inventory_qty'] = (float)$bestMatch['quantity'];
$ing['default_quantity'] = (float)($bestMatch['default_quantity'] ?? 0);
$ing['package_unit'] = $bestMatch['package_unit'] ?? '';
$ing['available_qty'] = $bestMatch['quantity'] . ' ' . $bestMatch['unit'];
$ing['vacuum_sealed'] = !empty($bestMatch['vacuum_sealed']) ? 1 : 0;
if (!empty($bestMatch['brand'])) $ing['brand'] = $bestMatch['brand'];
if (!empty($bestMatch['expiry_date'])) $ing['expiry_date'] = $bestMatch['expiry_date'];
recipeApplyPantryQtyFields($ing, $bestMatch);
}
unset($ing);
}
// ===== RECIPE GENERATION WITH GEMINI ===== // ===== RECIPE GENERATION WITH GEMINI =====
function generateRecipe(PDO $db): void { function generateRecipe(PDO $db): void {
EverLog::debug('generateRecipe start'); EverLog::debug('generateRecipe start');
@@ -5960,17 +6159,10 @@ function generateRecipe(PDO $db): void {
4 => 'ALTRI CON SCADENZA', 4 => 'ALTRI CON SCADENZA',
6 => 'DISPENSA', 6 => 'DISPENSA',
]; ];
// Limit groups to keep prompt compact: // Include all in-stock items in the prompt (no truncation — AI must not invent products).
// 1-3 (urgent+opened): all items; 4 (has expiry): max 40; 6 (pantry): max 20
foreach ($priorityHeaders as $g => $header) { foreach ($priorityHeaders as $g => $header) {
if (empty($priorityGroups[$g])) continue; if (empty($priorityGroups[$g])) continue;
$groupItems = $priorityGroups[$g]; $ingredientSections[] = "[$header]\n" . implode("\n", $priorityGroups[$g]);
if ($g === 4 && count($groupItems) > 40) {
$groupItems = array_slice($groupItems, 0, 40);
} elseif ($g === 6 && count($groupItems) > 20) {
$groupItems = array_slice($groupItems, 0, 20);
}
$ingredientSections[] = "[$header]\n" . implode("\n", $groupItems);
} }
$ingredientsText = implode("\n", $ingredientSections); $ingredientsText = implode("\n", $ingredientSections);
@@ -6221,6 +6413,8 @@ REGOLE:
10. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' 'Latte UHT' 'Panna'; 'Farina 00' 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile. 10. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' 'Latte UHT' 'Panna'; 'Farina 00' 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
11. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used. 11. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used.
12. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly. 12. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly.
13. VIETATO inventare ingredienti: ogni ingrediente con from_pantry:true DEVE avere "name" IDENTICO (copia-incolla) a un prodotto nella lista DISPENSA. Se un ingrediente NON è in lista, imposta from_pantry:false (verrà mostrato come da comprare 🛒).
14. Acqua, sale, pepe e olio sono sempre disponibili ma NON vanno nell'array ingredients (citili solo nei passi se serve).
DISPENSA: DISPENSA:
$ingredientsText $ingredientsText
@@ -6264,234 +6458,8 @@ PROMPT;
$recipe = json_decode($text, true); $recipe = json_decode($text, true);
if ($recipe && !empty($recipe['title'])) { if ($recipe && !empty($recipe['title'])) {
// Enrich from_pantry ingredients with product_id and location for "use" feature
if (!empty($recipe['ingredients'])) { if (!empty($recipe['ingredients'])) {
// Build a category map for better fuzzy matching recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
$itemsLookup = [];
foreach ($items as $item) {
$itemsLookup[] = [
'item' => $item,
'lower' => mb_strtolower(trim($item['name']), 'UTF-8'),
'words' => preg_split('/[\s,.\-\/]+/', mb_strtolower(trim($item['name']), 'UTF-8')),
'cat' => mb_strtolower($item['category'] ?? '', 'UTF-8'),
];
}
// Common Italian food name aliases for better matching
$aliases = [
'uovo' => ['uova','uovo','egg'],
'uova' => ['uovo','uova','egg'],
'latte' => ['latte','milk'],
'formaggio' => ['formaggio','cheese','philadelphia','mozzarella','parmigiano','grana','pecorino','ricotta','mascarpone','stracchino','gorgonzola'],
'pasta' => ['pasta','spaghetti','penne','fusilli','rigatoni','farfalle','tagliatelle','linguine','bucatini','orecchiette','paccheri','maccheroni'],
'pomodoro' => ['pomodoro','pomodori','tomato','passata','pelati','polpa'],
'cipolla' => ['cipolla','cipolle','onion'],
'aglio' => ['aglio','garlic'],
'burro' => ['burro','butter'],
'panna' => ['panna','cream','crema'],
'zucchero' => ['zucchero','sugar'],
'farina' => ['farina','flour'],
'olio' => ['olio','oil'],
'patata' => ['patata','patate','potato'],
'carota' => ['carota','carote','carrot'],
'sedano' => ['sedano','celery'],
'prezzemolo' => ['prezzemolo','parsley'],
'basilico' => ['basilico','basil'],
];
foreach ($recipe['ingredients'] as &$ing) {
if (!empty($ing['from_pantry'])) {
$ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8');
$ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower);
$bestMatch = null;
$bestScore = 0;
foreach ($itemsLookup as $entry) {
$itemNameLower = $entry['lower'];
$itemWords = $entry['words'];
$score = 0;
// Exact match
if ($ingNameLower === $itemNameLower) {
$score = 100;
}
// Ingredient name contained in product name
elseif (mb_strpos($itemNameLower, $ingNameLower) !== false) {
$score = 80;
}
// Product name contained in ingredient name
elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) {
$score = 70;
}
else {
// Word-level matching with alias expansion
$expandedIngWords = $ingWords;
foreach ($ingWords as $w) {
foreach ($aliases as $key => $group) {
if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0) {
$expandedIngWords = array_merge($expandedIngWords, $group);
}
}
}
$expandedIngWords = array_unique($expandedIngWords);
$common = 0;
foreach ($expandedIngWords as $ew) {
foreach ($itemWords as $iw) {
// Partial stem match (min 4 chars shared prefix)
$minLen = min(mb_strlen($ew), mb_strlen($iw));
if ($minLen >= 3) {
$prefixLen = 0;
for ($c = 0; $c < $minLen; $c++) {
if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++;
else break;
}
if ($prefixLen >= min(4, $minLen)) { $common++; break; }
}
if ($ew === $iw) { $common++; break; }
}
}
if ($common > 0) {
$score = ($common / max(count($ingWords), 1)) * 65;
// Bonus: if the main/first ingredient word matches
if (count($ingWords) > 0 && $common > 0) {
foreach ($itemWords as $iw) {
if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) {
$score += 10;
break;
}
}
}
}
}
if ($score > $bestScore) {
$bestScore = $score;
$bestMatch = $entry['item'];
}
}
// Only match if score is reasonable (> 30)
if ($bestMatch && $bestScore > 30) {
$ing['product_id'] = (int)$bestMatch['product_id'];
$ing['location'] = $bestMatch['location'];
$ing['inventory_unit'] = $bestMatch['unit'];
$ing['inventory_qty'] = (float)$bestMatch['quantity'];
$ing['default_quantity'] = (float)($bestMatch['default_quantity'] ?? 0);
$ing['package_unit'] = $bestMatch['package_unit'] ?? '';
$ing['available_qty'] = $bestMatch['quantity'] . ' ' . $bestMatch['unit'];
$ing['vacuum_sealed'] = !empty($bestMatch['vacuum_sealed']) ? 1 : 0;
if (!empty($bestMatch['brand'])) {
$ing['brand'] = $bestMatch['brand'];
}
if (!empty($bestMatch['expiry_date'])) {
$ing['expiry_date'] = $bestMatch['expiry_date'];
}
// === FIX qty_number: validate and convert units ===
$qtyNum = (float)($ing['qty_number'] ?? 0);
$invUnit = $bestMatch['unit'] ?? 'pz';
$invQty = (float)$bestMatch['quantity'];
if ($qtyNum > 0) {
// Parse the recipe qty string to detect what unit Gemini intended
$recipeQty = $ing['qty'] ?? '';
$recipeUnit = '';
$recipeVal = 0;
if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $recipeQty, $qm)) {
$recipeVal = (float)str_replace(',', '.', $qm[1]);
$ru = strtolower($qm[2]);
if (strpos($ru, 'g') === 0) $recipeUnit = 'g';
elseif ($ru === 'kg') { $recipeUnit = 'g'; $recipeVal *= 1000; }
elseif ($ru === 'ml') $recipeUnit = 'ml';
elseif ($ru === 'cl') { $recipeUnit = 'ml'; $recipeVal *= 10; }
elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $recipeUnit = 'ml'; $recipeVal *= 1000; }
elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $recipeUnit = 'pz';
elseif (strpos($ru, 'conf') === 0) $recipeUnit = 'conf';
}
// 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') {
$qtyNum = $recipeVal / 1000;
} elseif ($recipeUnit === 'g' && $invUnit === 'g') {
$qtyNum = $recipeVal;
// Volume conversions (both should be 'ml' now, but handle legacy 'l')
} elseif ($recipeUnit === 'ml' && $invUnit === 'l') {
$qtyNum = $recipeVal / 1000;
} elseif ($recipeUnit === 'ml' && $invUnit === 'ml') {
$qtyNum = $recipeVal;
// 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) {
$qtyNum = max(0.25, round(($recipeVal / $defQty) * 4) / 4);
} else {
$origQtyNum = (float)($ing['qty_number'] ?? 0);
if ($origQtyNum >= 1 && $origQtyNum <= $invQty && $origQtyNum <= 100) {
$qtyNum = $origQtyNum;
} else {
$qtyNum = 1;
}
}
}
} elseif ($invUnit === 'pz' && !$recipeUnit) {
// AI returned qty_number without a parseable unit string.
// If qty_number looks like grams (>> available pz count), clamp to 1.
if ($qtyNum > $invQty || $qtyNum > 100) {
$qtyNum = max(1, round($qtyNum / 100));
}
}
// 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')) {
if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) {
$qtyNum = $recipeVal;
$ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC;
} elseif ($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
}
// Sanity check: if qty_number is absurdly small relative to recipe
// e.g. recipe says 100g but qty_number is 0.1 and unit is g → likely meant 100
if ($recipeVal > 0 && $recipeUnit === $invUnit && $qtyNum < $recipeVal * 0.01) {
$qtyNum = $recipeVal; // Gemini probably confused the units
}
$ing['qty_number'] = round($qtyNum, 3);
}
}
}
}
unset($ing);
recipeApplyStockHintsToRecipe($db, $recipe); recipeApplyStockHintsToRecipe($db, $recipe);
} }
@@ -6589,7 +6557,7 @@ PROMPT;
// Enrich ingredients with product_id/location — same fuzzy-match as generateRecipe // Enrich ingredients with product_id/location — same fuzzy-match as generateRecipe
if (!empty($recipe['ingredients'])) { if (!empty($recipe['ingredients'])) {
_enrichChatIngredients($recipe['ingredients'], $items); _enrichChatIngredients($recipe['ingredients'], $items, $db);
} }
recipeApplyStockHintsToRecipe($db, $recipe); recipeApplyStockHintsToRecipe($db, $recipe);
@@ -6704,7 +6672,7 @@ PROMPT;
} }
if (!empty($recipe['ingredients'])) { if (!empty($recipe['ingredients'])) {
_enrichChatIngredients($recipe['ingredients'], $items); _enrichChatIngredients($recipe['ingredients'], $items, $db);
} }
recipeApplyStockHintsToRecipe($db, $recipe); recipeApplyStockHintsToRecipe($db, $recipe);
@@ -6713,173 +6681,8 @@ PROMPT;
} }
function _enrichChatIngredients(array &$ingredients, array $items): void { function _enrichChatIngredients(array &$ingredients, array $items, PDO $db): void {
if (empty($ingredients) || empty($items)) return; recipeEnrichIngredientsFromPantry($db, $ingredients, $items);
// Build lookup
$itemsLookup = [];
foreach ($items as $item) {
$itemsLookup[] = [
'item' => $item,
'lower' => mb_strtolower(trim($item['name']), 'UTF-8'),
'words' => preg_split('/[\s,.\-\/]+/', mb_strtolower(trim($item['name']), 'UTF-8')),
];
}
$aliases = [
'uovo' => ['uova','uovo','egg'],
'uova' => ['uovo','uova','egg'],
'latte' => ['latte','milk'],
'formaggio' => ['formaggio','cheese','philadelphia','mozzarella','parmigiano','grana','pecorino','ricotta','mascarpone','stracchino','gorgonzola'],
'pasta' => ['pasta','spaghetti','penne','fusilli','rigatoni','farfalle','tagliatelle','linguine','bucatini','orecchiette','paccheri','maccheroni'],
'pomodoro' => ['pomodoro','pomodori','tomato','passata','pelati','polpa'],
'cipolla' => ['cipolla','cipolle','onion'],
'aglio' => ['aglio','garlic'],
'burro' => ['burro','butter'],
'panna' => ['panna','cream','crema'],
'zucchero' => ['zucchero','sugar'],
'farina' => ['farina','flour'],
'olio' => ['olio','oil'],
'patata' => ['patata','patate','potato'],
'carota' => ['carota','carote','carrot'],
'sedano' => ['sedano','celery'],
'prezzemolo' => ['prezzemolo','parsley'],
'basilico' => ['basilico','basil'],
];
foreach ($ingredients as &$ing) {
// Try to match ALL ingredients — from_pantry was set to true for all by chatExtractRecipe
// If no match is found, product_id stays unset → shown as 🛒 in frontend
$ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8');
$ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower);
$bestMatch = null;
$bestScore = 0;
foreach ($itemsLookup as $entry) {
$itemNameLower = $entry['lower'];
$itemWords = $entry['words'];
$score = 0;
if ($ingNameLower === $itemNameLower) {
$score = 100;
} elseif (mb_strpos($itemNameLower, $ingNameLower) !== false) {
$score = 80;
} elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) {
$score = 70;
} else {
$expandedIngWords = $ingWords;
foreach ($ingWords as $w) {
foreach ($aliases as $key => $group) {
if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0) {
$expandedIngWords = array_merge($expandedIngWords, $group);
}
}
}
$expandedIngWords = array_unique($expandedIngWords);
$common = 0;
foreach ($expandedIngWords as $ew) {
foreach ($itemWords as $iw) {
$minLen = min(mb_strlen($ew), mb_strlen($iw));
if ($minLen >= 3) {
$prefixLen = 0;
for ($c = 0; $c < $minLen; $c++) {
if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++;
else break;
}
if ($prefixLen >= min(4, $minLen)) { $common++; break; }
}
if ($ew === $iw) { $common++; break; }
}
}
if ($common > 0) {
$score = ($common / max(count($ingWords), 1)) * 65;
if (count($ingWords) > 0) {
foreach ($itemWords as $iw) {
if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) {
$score += 10; break;
}
}
}
}
}
if ($score > $bestScore) {
$bestScore = $score;
$bestMatch = $entry['item'];
}
}
if ($bestMatch && $bestScore > 30) {
$ing['product_id'] = (int)$bestMatch['product_id'];
$ing['location'] = $bestMatch['location'];
$ing['inventory_unit'] = $bestMatch['unit'];
$ing['inventory_qty'] = (float)$bestMatch['quantity'];
$ing['default_quantity'] = (float)($bestMatch['default_quantity'] ?? 0);
$ing['package_unit'] = $bestMatch['package_unit'] ?? '';
$ing['available_qty'] = $bestMatch['quantity'] . ' ' . $bestMatch['unit'];
$ing['vacuum_sealed'] = !empty($bestMatch['vacuum_sealed']) ? 1 : 0;
if (!empty($bestMatch['brand'])) $ing['brand'] = $bestMatch['brand'];
if (!empty($bestMatch['expiry_date'])) $ing['expiry_date'] = $bestMatch['expiry_date'];
// Validate and convert qty_number to inventory unit
$qtyNum = (float)($ing['qty_number'] ?? 0);
$invUnit = $bestMatch['unit'] ?? 'pz';
$invQty = (float)$bestMatch['quantity'];
if ($qtyNum > 0) {
$recipeQty = $ing['qty'] ?? '';
$recipeUnit = '';
$recipeVal = 0;
if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $recipeQty, $qm)) {
$recipeVal = (float)str_replace(',', '.', $qm[1]);
$ru = strtolower($qm[2]);
if (strpos($ru, 'g') === 0) $recipeUnit = 'g';
elseif ($ru === 'kg') { $recipeUnit = 'g'; $recipeVal *= 1000; }
elseif ($ru === 'ml') $recipeUnit = 'ml';
elseif ($ru === 'cl') { $recipeUnit = 'ml'; $recipeVal *= 10; }
elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $recipeUnit = 'ml'; $recipeVal *= 1000; }
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 === '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')) {
if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) {
$qtyNum = $recipeVal;
$ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC;
} elseif ($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);
}
}
}
unset($ing);
} }
// ===== RECIPE GENERATION — STREAMING AGENT ===== // ===== RECIPE GENERATION — STREAMING AGENT =====
@@ -6974,21 +6777,13 @@ function generateRecipeStream(PDO $db): void {
$priorityGroups[$group][] = $line; $priorityGroups[$group][] = $line;
} }
// Limiti ingredienti per gruppo: con piano pasto attivo passa TUTTO (l'AI deve combinare liberamente) // Send the full in-stock list — AI must not invent products outside this list.
// Senza piano pasto: limiti moderati per ridurre token (ora safe grazie a thinkingBudget:0)
$hasMealPlan = !empty($mealPlanType);
$ingredientSections = []; $ingredientSections = [];
$priorityHeaders = [1=>'SCADUTI — usa subito',2=>'SCADENZA ≤3gg — priorità alta',3=>'SCADENZA ≤7gg / APERTI — usa presto',4=>'ALTRI CON SCADENZA',6=>'DISPENSA']; $priorityHeaders = [1=>'SCADUTI — usa subito',2=>'SCADENZA ≤3gg — priorità alta',3=>'SCADENZA ≤7gg / APERTI — usa presto',4=>'ALTRI CON SCADENZA',6=>'DISPENSA'];
$totalIngredientsSent = 0; $totalIngredientsSent = 0;
foreach ($priorityHeaders as $g => $header) { foreach ($priorityHeaders as $g => $header) {
if (empty($priorityGroups[$g])) continue; if (empty($priorityGroups[$g])) continue;
$gi = $priorityGroups[$g]; $gi = $priorityGroups[$g];
if (!$hasMealPlan) {
// Senza piano: limiti moderati
if ($g === 4 && count($gi) > 25) $gi = array_slice($gi, 0, 25);
if ($g === 6 && count($gi) > 15) $gi = array_slice($gi, 0, 15);
}
// Con piano pasto attivo: nessun limite — tutti gli ingredienti disponibili
$ingredientSections[] = "[$header]\n" . implode("\n", $gi); $ingredientSections[] = "[$header]\n" . implode("\n", $gi);
$totalIngredientsSent += count($gi); $totalIngredientsSent += count($gi);
} }
@@ -7187,6 +6982,8 @@ REGOLE:
11. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' 'Latte UHT' 'Panna'; 'Farina 00' 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile. 11. NON confondere forme diverse dello stesso ingrediente di base: 'Pomodori'/'Pomodoro Piccadilly' (freschi, pz/g) 'Passata di pomodoro'/'Polpa di pomodoro'/'Sugo al pomodoro' (elaborato, conf/g); 'Latte fresco' 'Latte UHT' 'Panna'; 'Farina 00' 'Farina integrale'. Se la ricetta richiede un tipo di ingrediente che NON è disponibile nella forma giusta in lista, NON sostituirlo con una forma diversa: scegli una ricetta che usa gli ingredienti esattamente nella forma disponibile.
12. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used. 12. `nutrition`: object with estimated macro values PER SERVING for the finished dish: {"kcal":450,"protein_g":25,"carbs_g":40,"fat_g":15}. All values are integers. Estimate realistically based on the ingredients and quantities used.
13. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly. 13. `storage`: object describing how to store leftovers: {"where":"frigo","days":3,"tips":""}. `where` = one of: frigo / freezer / dispensa / temperatura ambiente (in target language). `days` = integer max days safe to keep. `tips` = one concise sentence in target language. If the dish is best eaten immediately, set days=0 and tips accordingly.
14. VIETATO inventare ingredienti: ogni ingrediente con from_pantry:true DEVE avere "name" IDENTICO (copia-incolla) a un prodotto nella lista DISPENSA. Se un ingrediente NON è in lista, imposta from_pantry:false (verrà mostrato come da comprare 🛒).
15. Acqua, sale, pepe e olio sono sempre disponibili ma NON vanno nell'array ingredients (citili solo nei passi se serve).
DISPENSA: DISPENSA:
$ingredientsText $ingredientsText
@@ -7321,134 +7118,7 @@ PROMPT;
}, $recipe['steps'])); }, $recipe['steps']));
} }
if (!empty($recipe['ingredients'])) { if (!empty($recipe['ingredients'])) {
$itemsLookup = []; recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
foreach ($items as $item) {
$itemsLookup[] = [
'item' => $item,
'lower' => mb_strtolower(trim($item['name']), 'UTF-8'),
'words' => preg_split('/[\s,.\-\/]+/', mb_strtolower(trim($item['name']), 'UTF-8')),
'cat' => mb_strtolower($item['category'] ?? '', 'UTF-8'),
];
}
$aliases = ['uovo'=>['uova','uovo','egg'],'uova'=>['uovo','uova','egg'],'latte'=>['latte','milk'],'formaggio'=>['formaggio','cheese','philadelphia','mozzarella','parmigiano','grana','pecorino','ricotta','mascarpone','stracchino','gorgonzola'],'pasta'=>['pasta','spaghetti','penne','fusilli','rigatoni','farfalle','tagliatelle','linguine','bucatini','orecchiette','paccheri','maccheroni'],'pomodoro'=>['pomodoro','pomodori','tomato','passata','pelati','polpa'],'cipolla'=>['cipolla','cipolle','onion'],'aglio'=>['aglio','garlic'],'burro'=>['burro','butter'],'panna'=>['panna','cream','crema'],'zucchero'=>['zucchero','sugar'],'farina'=>['farina','flour'],'olio'=>['olio','oil'],'patata'=>['patata','patate','potato'],'carota'=>['carota','carote','carrot'],'sedano'=>['sedano','celery'],'prezzemolo'=>['prezzemolo','parsley'],'basilico'=>['basilico','basil']];
foreach ($recipe['ingredients'] as &$ing) {
if (empty($ing['from_pantry'])) continue;
$ingNameLower = mb_strtolower(trim($ing['name']), 'UTF-8');
$ingWords = preg_split('/[\s,.\-\/]+/', $ingNameLower);
$bestMatch = null;
$bestScore = 0;
foreach ($itemsLookup as $entry) {
$itemNameLower = $entry['lower'];
$itemWords = $entry['words'];
$score = 0;
if ($ingNameLower === $itemNameLower) {
$score = 100;
} elseif (mb_strpos($itemNameLower, $ingNameLower) !== false) {
$score = 80;
} elseif (mb_strpos($ingNameLower, $itemNameLower) !== false) {
$score = 70;
} else {
$expandedIngWords = $ingWords;
foreach ($ingWords as $w) {
foreach ($aliases as $key => $group) {
if (in_array($w, $group) || mb_strpos($w, $key) === 0 || mb_strpos($key, $w) === 0)
$expandedIngWords = array_merge($expandedIngWords, $group);
}
}
$expandedIngWords = array_unique($expandedIngWords);
$common = 0;
foreach ($expandedIngWords as $ew) {
foreach ($itemWords as $iw) {
$minLen = min(mb_strlen($ew), mb_strlen($iw));
if ($minLen >= 3) {
$prefixLen = 0;
for ($c = 0; $c < $minLen; $c++) {
if (mb_substr($ew, $c, 1) === mb_substr($iw, $c, 1)) $prefixLen++; else break;
}
if ($prefixLen >= min(4, $minLen)) { $common++; break; }
}
if ($ew === $iw) { $common++; break; }
}
}
if ($common > 0) {
$score = ($common / max(count($ingWords), 1)) * 65;
if (count($ingWords) > 0) {
foreach ($itemWords as $iw) {
if (mb_strpos($iw, $ingWords[0]) === 0 || mb_strpos($ingWords[0], $iw) === 0) { $score += 10; break; }
}
}
}
}
if ($score > $bestScore) { $bestScore = $score; $bestMatch = $entry['item']; }
}
if ($bestMatch && $bestScore > 30) {
$ing['product_id'] = (int)$bestMatch['product_id'];
$ing['location'] = $bestMatch['location'];
$ing['inventory_unit'] = $bestMatch['unit'];
$ing['inventory_qty'] = (float)$bestMatch['quantity'];
$ing['default_quantity'] = (float)($bestMatch['default_quantity'] ?? 0);
$ing['package_unit'] = $bestMatch['package_unit'] ?? '';
$ing['available_qty'] = $bestMatch['quantity'] . ' ' . $bestMatch['unit'];
$ing['vacuum_sealed'] = !empty($bestMatch['vacuum_sealed']) ? 1 : 0;
if (!empty($bestMatch['brand'])) $ing['brand'] = $bestMatch['brand'];
if (!empty($bestMatch['expiry_date'])) $ing['expiry_date'] = $bestMatch['expiry_date'];
$qtyNum = (float)($ing['qty_number'] ?? 0);
$invUnit = $bestMatch['unit'] ?? 'pz';
$invQty = (float)$bestMatch['quantity'];
if ($qtyNum > 0) {
$recipeQty = $ing['qty'] ?? '';
$recipeUnit = ''; $recipeVal = 0;
if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $recipeQty, $qm)) {
$recipeVal = (float)str_replace(',', '.', $qm[1]);
$ru = strtolower($qm[2]);
if (strpos($ru, 'g') === 0) $recipeUnit = 'g';
elseif ($ru === 'kg') { $recipeUnit = 'g'; $recipeVal *= 1000; }
elseif ($ru === 'ml') $recipeUnit = 'ml';
elseif ($ru === 'cl') { $recipeUnit = 'ml'; $recipeVal *= 10; }
elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $recipeUnit = 'ml'; $recipeVal *= 1000; }
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 === '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')) {
if ($recipeVal > 0 && $recipeUnit === $pkgUnitLC) {
$qtyNum = $recipeVal;
$ing['qty'] = round($qtyNum) . ' ' . $pkgUnitLC;
} elseif ($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);
}
}
}
unset($ing);
recipeApplyStockHintsToRecipe($db, $recipe); recipeApplyStockHintsToRecipe($db, $recipe);
} }
+8 -2
View File
@@ -13840,7 +13840,13 @@ async function enrichRecipeIngredientsStock(recipe) {
if (!ing.from_pantry || !ing.product_id) continue; if (!ing.from_pantry || !ing.product_id) continue;
const rows = inv.filter(i => i.product_id == ing.product_id); const rows = inv.filter(i => i.product_id == ing.product_id);
const activeRows = rows.filter(i => parseFloat(i.quantity) > 0); const activeRows = rows.filter(i => parseFloat(i.quantity) > 0);
if (!activeRows.length) continue; if (!activeRows.length) {
ing.from_pantry = false;
delete ing.product_id;
delete ing.stock_have;
delete ing.stock_remain;
continue;
}
const totalStock = activeRows.reduce((s, i) => s + parseFloat(i.quantity), 0); const totalStock = activeRows.reduce((s, i) => s + parseFloat(i.quantity), 0);
ing.inventory_qty_total = totalStock; ing.inventory_qty_total = totalStock;
const opened = activeRows.find(_isOpenedInventoryItem); const opened = activeRows.find(_isOpenedInventoryItem);
@@ -14388,9 +14394,9 @@ async function renderRecipe(r) {
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`; html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
(r.ingredients || []).forEach((ing, idx) => { (r.ingredients || []).forEach((ing, idx) => {
if (ing.from_pantry && ing.product_id) { if (ing.from_pantry && ing.product_id) {
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
const loc = (ing.location || 'dispensa').replace(/'/g, "\\'"); const loc = (ing.location || 'dispensa').replace(/'/g, "\\'");
const alreadyUsed = ing.used === true; const alreadyUsed = ing.used === true;
const qtyNum = Math.round((ing.qty_number || 0) * 10) / 10;
html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}" data-ing-idx="${idx}" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${escapeHtml(ing.qty || '')}">`; html += `<li class="recipe-ingredient${alreadyUsed ? ' recipe-ing-used' : ''}" id="recipe-ing-${idx}" data-ing-idx="${idx}" data-base-qty="${ing.qty_number || 0}" data-base-qty-str="${escapeHtml(ing.qty || '')}">`;
html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${escapeHtml(t('btn.edit'))}">${escapeHtml(ing.name)}</strong>${ing.brand ? ' <em>(' + escapeHtml(ing.brand) + ')</em>' : ''}: <span class="recipe-ing-qty">${escapeHtml(ing.qty)}</span>${ing.use_all_suggested ? ' ♻️' : ''}`; html += `<span class="recipe-ing-text"><strong class="recipe-ing-name" onclick="openIngredientDetail(${ing.product_id}, '${loc}')" title="${escapeHtml(t('btn.edit'))}">${escapeHtml(ing.name)}</strong>${ing.brand ? ' <em>(' + escapeHtml(ing.brand) + ')</em>' : ''}: <span class="recipe-ing-qty">${escapeHtml(ing.qty)}</span>${ing.use_all_suggested ? ' ♻️' : ''}`;
// Detail line: location + expiry // Detail line: location + expiry
+3 -3
View File
@@ -72,7 +72,7 @@
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div> <div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div> <div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button> <button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
<span class="app-preloader-version" id="preloader-version">v1.7.36</span> <span class="app-preloader-version" id="preloader-version">v1.7.37</span>
</div> </div>
</div> </div>
@@ -85,7 +85,7 @@
<!-- Title — left-aligned; grows to fill space --> <!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap"> <div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')"> <h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.36</span> <img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.37</span>
</h1> </h1>
<!-- Update badge — shown alongside title, never replaces it --> <!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span> <span class="header-update-badge" id="header-update-badge" style="display:none"></span>
@@ -1970,6 +1970,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260604c"></script> <script src="assets/js/app.js?v=20260604e"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf", "name": "EverShelf",
"short_name": "EverShelf", "short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode", "description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.36", "version": "1.7.37",
"start_url": "/evershelf/", "start_url": "/evershelf/",
"display": "standalone", "display": "standalone",
"background_color": "#f0f4e8", "background_color": "#f0f4e8",
+15 -1
View File
@@ -28,6 +28,17 @@ if (!is_array($recipe)) {
exit(1); exit(1);
} }
$stmt = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.category, i.quantity, p.unit, p.default_quantity, p.package_unit, i.location, i.expiry_date, i.opened_at,
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
ORDER BY days_left ASC, p.name ASC
");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
recipeApplyStockHintsToRecipe($db, $recipe); recipeApplyStockHintsToRecipe($db, $recipe);
$upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?'); $upd = $db->prepare('UPDATE recipes SET recipe_json = ? WHERE id = ?');
@@ -35,7 +46,10 @@ $upd->execute([json_encode($recipe, JSON_UNESCAPED_UNICODE), $id]);
echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n"; echo "Updated recipe {$id}: " . ($recipe['title'] ?? '?') . "\n";
foreach ($recipe['ingredients'] ?? [] as $ing) { foreach ($recipe['ingredients'] ?? [] as $ing) {
if (empty($ing['from_pantry'])) continue; if (empty($ing['from_pantry'])) {
echo sprintf(" 🛒 %s — %s (da comprare)\n", $ing['name'] ?? '?', $ing['qty'] ?? '?');
continue;
}
$useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : ''; $useAll = !empty($ing['use_all_suggested']) ? ' [USE ALL]' : '';
echo sprintf( echo sprintf(
" %s: %s | hai %.1f %s | restano %.1f %s%s\n", " %s: %s | hai %.1f %s | restano %.1f %s%s\n",