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.
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
[](https://www.sqlite.org/)
|
||||
[](Dockerfile)
|
||||
[](translations/)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://github.com/dadaloop82/EverShelf/stargazers)
|
||||
[](https://github.com/dadaloop82/EverShelf/commits/main)
|
||||
[](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) {
|
||||
if (empty($ing['from_pantry']) || empty($ing['product_id'])) continue;
|
||||
$totalStock = recipeGetProductTotalStock($db, (int)$ing['product_id']);
|
||||
if ($totalStock <= 0) continue;
|
||||
if ($totalStock <= 0) {
|
||||
recipeClearPantryIngredient($ing);
|
||||
continue;
|
||||
}
|
||||
$ing['inventory_qty_total'] = $totalStock;
|
||||
recipeFinalizeIngQty($ing, $totalStock);
|
||||
}
|
||||
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 =====
|
||||
function generateRecipe(PDO $db): void {
|
||||
EverLog::debug('generateRecipe start');
|
||||
@@ -5960,17 +6159,10 @@ function generateRecipe(PDO $db): void {
|
||||
4 => 'ALTRI CON SCADENZA',
|
||||
6 => 'DISPENSA',
|
||||
];
|
||||
// Limit groups to keep prompt compact:
|
||||
// 1-3 (urgent+opened): all items; 4 (has expiry): max 40; 6 (pantry): max 20
|
||||
// Include all in-stock items in the prompt (no truncation — AI must not invent products).
|
||||
foreach ($priorityHeaders as $g => $header) {
|
||||
if (empty($priorityGroups[$g])) continue;
|
||||
$groupItems = $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);
|
||||
$ingredientSections[] = "[$header]\n" . implode("\n", $priorityGroups[$g]);
|
||||
}
|
||||
$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.
|
||||
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.
|
||||
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:
|
||||
$ingredientsText
|
||||
@@ -6264,237 +6458,11 @@ PROMPT;
|
||||
$recipe = json_decode($text, true);
|
||||
|
||||
if ($recipe && !empty($recipe['title'])) {
|
||||
// Enrich from_pantry ingredients with product_id and location for "use" feature
|
||||
if (!empty($recipe['ingredients'])) {
|
||||
// Build a category map for better fuzzy matching
|
||||
$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);
|
||||
recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
}
|
||||
|
||||
|
||||
EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? [])]);
|
||||
echo json_encode(['success' => true, 'recipe' => $recipe]);
|
||||
} else {
|
||||
@@ -6589,7 +6557,7 @@ PROMPT;
|
||||
|
||||
// Enrich ingredients with product_id/location — same fuzzy-match as generateRecipe
|
||||
if (!empty($recipe['ingredients'])) {
|
||||
_enrichChatIngredients($recipe['ingredients'], $items);
|
||||
_enrichChatIngredients($recipe['ingredients'], $items, $db);
|
||||
}
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
|
||||
@@ -6704,7 +6672,7 @@ PROMPT;
|
||||
}
|
||||
|
||||
if (!empty($recipe['ingredients'])) {
|
||||
_enrichChatIngredients($recipe['ingredients'], $items);
|
||||
_enrichChatIngredients($recipe['ingredients'], $items, $db);
|
||||
}
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
|
||||
@@ -6713,173 +6681,8 @@ PROMPT;
|
||||
}
|
||||
|
||||
|
||||
function _enrichChatIngredients(array &$ingredients, array $items): void {
|
||||
if (empty($ingredients) || empty($items)) return;
|
||||
|
||||
// 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);
|
||||
function _enrichChatIngredients(array &$ingredients, array $items, PDO $db): void {
|
||||
recipeEnrichIngredientsFromPantry($db, $ingredients, $items);
|
||||
}
|
||||
|
||||
// ===== RECIPE GENERATION — STREAMING AGENT =====
|
||||
@@ -6974,21 +6777,13 @@ function generateRecipeStream(PDO $db): void {
|
||||
$priorityGroups[$group][] = $line;
|
||||
}
|
||||
|
||||
// Limiti ingredienti per gruppo: con piano pasto attivo passa TUTTO (l'AI deve combinare liberamente)
|
||||
// Senza piano pasto: limiti moderati per ridurre token (ora safe grazie a thinkingBudget:0)
|
||||
$hasMealPlan = !empty($mealPlanType);
|
||||
// Send the full in-stock list — AI must not invent products outside this list.
|
||||
$ingredientSections = [];
|
||||
$priorityHeaders = [1=>'SCADUTI — usa subito',2=>'SCADENZA ≤3gg — priorità alta',3=>'SCADENZA ≤7gg / APERTI — usa presto',4=>'ALTRI CON SCADENZA',6=>'DISPENSA'];
|
||||
$totalIngredientsSent = 0;
|
||||
foreach ($priorityHeaders as $g => $header) {
|
||||
if (empty($priorityGroups[$g])) continue;
|
||||
$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);
|
||||
$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.
|
||||
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.
|
||||
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:
|
||||
$ingredientsText
|
||||
@@ -7321,134 +7118,7 @@ PROMPT;
|
||||
}, $recipe['steps']));
|
||||
}
|
||||
if (!empty($recipe['ingredients'])) {
|
||||
$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'),
|
||||
];
|
||||
}
|
||||
$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);
|
||||
recipeEnrichIngredientsFromPantry($db, $recipe['ingredients'], $items);
|
||||
recipeApplyStockHintsToRecipe($db, $recipe);
|
||||
}
|
||||
|
||||
|
||||
+8
-2
@@ -13840,7 +13840,13 @@ async function enrichRecipeIngredientsStock(recipe) {
|
||||
if (!ing.from_pantry || !ing.product_id) continue;
|
||||
const rows = inv.filter(i => i.product_id == ing.product_id);
|
||||
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);
|
||||
ing.inventory_qty_total = totalStock;
|
||||
const opened = activeRows.find(_isOpenedInventoryItem);
|
||||
@@ -14388,9 +14394,9 @@ async function renderRecipe(r) {
|
||||
html += `<h3>${t('recipes.ingredients_title')}</h3><ul class="recipe-ingredients">`;
|
||||
(r.ingredients || []).forEach((ing, idx) => {
|
||||
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 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 += `<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
|
||||
|
||||
+3
-3
@@ -72,7 +72,7 @@
|
||||
<div id="preloader-warnings" class="preloader-warnings" 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>
|
||||
<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>
|
||||
|
||||
@@ -85,7 +85,7 @@
|
||||
<!-- Title — left-aligned; grows to fill space -->
|
||||
<div class="header-title-wrap">
|
||||
<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>
|
||||
<!-- Update badge — shown alongside title, never replaces it -->
|
||||
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
|
||||
@@ -1970,6 +1970,6 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="assets/js/app.js?v=20260604c"></script>
|
||||
<script src="assets/js/app.js?v=20260604e"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
"name": "EverShelf",
|
||||
"short_name": "EverShelf",
|
||||
"description": "Gestione completa della dispensa di casa con scansione barcode",
|
||||
"version": "1.7.36",
|
||||
"version": "1.7.37",
|
||||
"start_url": "/evershelf/",
|
||||
"display": "standalone",
|
||||
"background_color": "#f0f4e8",
|
||||
|
||||
@@ -28,6 +28,17 @@ if (!is_array($recipe)) {
|
||||
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);
|
||||
|
||||
$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";
|
||||
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]' : '';
|
||||
echo sprintf(
|
||||
" %s: %s | hai %.1f %s | restano %.1f %s%s\n",
|
||||
|
||||
Reference in New Issue
Block a user