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:
@@ -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
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
[](https://www.sqlite.org/)
|
[](https://www.sqlite.org/)
|
||||||
[](Dockerfile)
|
[](Dockerfile)
|
||||||
[](translations/)
|
[](translations/)
|
||||||
[](CHANGELOG.md)
|
[](CHANGELOG.md)
|
||||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||||
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
[](https://github.com/dadaloop82/EverShelf/graphs/contributors)
|
||||||
|
|||||||
+214
-544
@@ -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,237 +6458,11 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? [])]);
|
EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? [])]);
|
||||||
echo json_encode(['success' => true, 'recipe' => $recipe]);
|
echo json_encode(['success' => true, 'recipe' => $recipe]);
|
||||||
} else {
|
} else {
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user