Release v1.7.36: recipe stock hints, ghost products, and shopping total fix.

Adds pantry stock/remainder lines on recipe ingredients with zero-waste use-all on sealed package leftovers, ghost product restore in the dashboard, unified shopping totals, i18n sync, and maintenance scripts.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dadaloop82
2026-06-04 17:22:59 +00:00
parent a0385cfb9b
commit cf65e79010
15 changed files with 1908 additions and 287 deletions
+470 -55
View File
@@ -650,6 +650,7 @@ if ($rateLimitAction) {
// the explicit header is an additional defence-in-depth check for POST writes.
$_writeActions = [
'inventory_add','inventory_use','inventory_update','inventory_remove',
'inventory_confirm_finished','inventory_restore_ghost',
'product_save','product_delete','product_merge',
'bring_add','bring_remove','bring_sync','bring_set_spec','bring_migrate_names',
'shopping_add','shopping_remove',
@@ -715,6 +716,9 @@ try {
case 'product_delete':
deleteProduct($db);
break;
case 'product_merge':
mergeProduct($db);
break;
case 'products_list':
listProducts($db);
break;
@@ -747,6 +751,9 @@ try {
case 'inventory_confirm_finished':
confirmFinished($db);
break;
case 'inventory_restore_ghost':
restoreGhostInventory($db);
break;
case 'inventory_summary':
inventorySummary($db);
break;
@@ -2512,8 +2519,18 @@ function saveProduct(PDO $db): void {
? $input['shopping_name']
: computeShoppingName($input['name'], $input['category'] ?? '', $input['brand'] ?? '');
if (!empty($input['id'])) {
// Update existing
$id = !empty($input['id']) ? (int)$input['id'] : 0;
$merged = false;
if (!$id) {
$dupId = findDuplicateProductId($db, $input['name'], $input['brand'] ?? '', $input['barcode'] ?? null, null);
if ($dupId) {
$id = $dupId;
$merged = true;
}
}
if ($id) {
// Update existing (or matched duplicate)
$stmt = $db->prepare("
UPDATE products SET name=?, brand=?, category=?, image_url=?, unit=?,
default_quantity=?, notes=?, barcode=?, package_unit=?, shopping_name=?,
@@ -2526,9 +2543,9 @@ function saveProduct(PDO $db): void {
$input['image_url'] ?? '', $input['unit'] ?? 'pz',
$input['default_quantity'] ?? 1, $input['notes'] ?? '',
$input['barcode'] ?? null, $input['package_unit'] ?? '',
$shoppingName, $nutriJson, $input['id']
$shoppingName, $nutriJson, $id
]);
echo json_encode(['success' => true, 'id' => $input['id']]);
echo json_encode(['success' => true, 'id' => $id, 'merged' => $merged]);
} else {
// Insert new
$stmt = $db->prepare("
@@ -2863,8 +2880,19 @@ function useFromInventory(PDO $db): void {
// Guard against accidental double-consume triggers (scale jitter, double tap,
// delayed/offline replay burst). We only apply this stricter gate to manual
// uses with empty notes, so recipe uses (notes="Ricetta: ...") remain unaffected.
if (!$useAll) {
$dedupWindow = ($notes === '') ? 120 : 12;
$dedupWindow = $useAll ? 60 : (($notes === '') ? 120 : 12);
if ($useAll) {
$dedup = $db->prepare(
"SELECT id, quantity, created_at FROM transactions
WHERE product_id = ?
AND type IN ('out','waste')
AND undone = 0
AND created_at >= datetime('now', '-' || ? || ' seconds')
ORDER BY id DESC
LIMIT 1"
);
$dedup->execute([$productId, $dedupWindow]);
} else {
$dedup = $db->prepare(
"SELECT id, quantity, created_at FROM transactions
WHERE product_id = ?
@@ -2877,25 +2905,26 @@ function useFromInventory(PDO $db): void {
LIMIT 1"
);
$dedup->execute([$productId, $location, $notes, $dedupWindow]);
$recent = $dedup->fetch();
if ($recent) {
EverLog::warn('useFromInventory duplicate blocked', [
'product_id' => $productId,
'location' => $location,
'window_s' => $dedupWindow,
'recent_tx_id' => $recent['id'] ?? null,
'recent_qty' => $recent['quantity'] ?? null,
'recent_created_at' => $recent['created_at'] ?? null,
'requested_qty' => $quantity,
'notes' => $notes,
]);
echo json_encode([
'success' => false,
'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.',
'duplicate' => true,
]);
return;
}
}
$recent = $dedup->fetch();
if ($recent) {
EverLog::warn('useFromInventory duplicate blocked', [
'product_id' => $productId,
'location' => $location,
'use_all' => $useAll,
'window_s' => $dedupWindow,
'recent_tx_id' => $recent['id'] ?? null,
'recent_qty' => $recent['quantity'] ?? null,
'recent_created_at' => $recent['created_at'] ?? null,
'requested_qty' => $quantity,
'notes' => $notes,
]);
echo json_encode([
'success' => false,
'error' => 'Operazione già registrata di recente — verifica prima la quantità rimasta.',
'duplicate' => true,
]);
return;
}
// ─────────────────────────────────────────────────────────────────────
@@ -3325,17 +3354,168 @@ function updateInventory(PDO $db): void {
function deleteInventory(PDO $db): void {
EverLog::info('deleteInventory');
$input = json_decode(file_get_contents('php://input'), true);
$id = $input['id'] ?? 0;
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
$id = (int)($input['id'] ?? 0);
if (!$id) {
http_response_code(400);
echo json_encode(['error' => 'Inventory ID required']);
return;
}
$stmt = $db->prepare("SELECT id, product_id, quantity, location FROM inventory WHERE id = ?");
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row) {
http_response_code(404);
echo json_encode(['error' => 'Inventory row not found']);
return;
}
$qty = (float)$row['quantity'];
if ($qty > 0.0001) {
$db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)")
->execute([(int)$row['product_id'], $qty, $row['location'], '[Eliminazione inventario]']);
}
$db->prepare("DELETE FROM inventory WHERE id = ?")->execute([$id]);
echo json_encode(['success' => true]);
}
function productQtyThreshold(string $unit): float {
static $thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5];
return $thresholds[$unit] ?? 0.5;
}
function normalizeProductName(string $name): string {
return mb_strtolower(trim($name));
}
function normalizeProductBrand(string $brand): string {
return mb_strtolower(trim($brand));
}
function brandsCompatible(string $a, string $b): bool {
$na = normalizeProductBrand($a);
$nb = normalizeProductBrand($b);
return $na === $nb || $na === '' || $nb === '';
}
function findDuplicateProductId(PDO $db, string $name, string $brand, ?string $barcode, ?int $excludeId = null): ?int {
if ($barcode !== null && trim($barcode) !== '') {
$sql = "SELECT id FROM products WHERE barcode = ? AND barcode IS NOT NULL AND TRIM(barcode) != ''";
$params = [$barcode];
if ($excludeId) {
$sql .= " AND id != ?";
$params[] = $excludeId;
}
$sql .= " ORDER BY id ASC LIMIT 1";
$stmt = $db->prepare($sql);
$stmt->execute($params);
$id = $stmt->fetchColumn();
if ($id) {
return (int)$id;
}
}
$nName = normalizeProductName($name);
if ($nName === '') {
return null;
}
$sql = "SELECT id, brand FROM products WHERE lower(trim(name)) = ?";
$params = [$nName];
if ($excludeId) {
$sql .= " AND id != ?";
$params[] = $excludeId;
}
$stmt = $db->prepare($sql);
$stmt->execute($params);
$candidates = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (!$candidates) {
return null;
}
$targetBrand = normalizeProductBrand($brand);
$compatible = null;
foreach ($candidates as $c) {
$cBrand = normalizeProductBrand($c['brand'] ?? '');
if ($cBrand === $targetBrand) {
return (int)$c['id'];
}
if ($compatible === null && brandsCompatible($brand, $c['brand'] ?? '')) {
$compatible = (int)$c['id'];
}
}
return $compatible;
}
function getProductLedgerBalance(PDO $db, int $productId): array {
$stmt = $db->prepare("
SELECT
COALESCE(SUM(CASE WHEN type = 'in' AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_in,
COALESCE(SUM(CASE WHEN type IN ('out','waste') AND undone = 0 THEN quantity ELSE 0 END), 0) AS total_out
FROM transactions
WHERE product_id = ?
");
$stmt->execute([$productId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC) ?: ['total_in' => 0, 'total_out' => 0];
$stockStmt = $db->prepare("SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ?");
$stockStmt->execute([$productId]);
return [
'total_in' => (float)$row['total_in'],
'total_out' => (float)$row['total_out'],
'stock' => (float)$stockStmt->fetchColumn(),
];
}
function mergeProducts(PDO $db, int $keepId, int $dropId): void {
if ($keepId === $dropId) {
return;
}
$check = $db->prepare("SELECT id FROM products WHERE id IN (?, ?)");
$check->execute([$keepId, $dropId]);
if ($check->rowCount() < 2) {
throw new RuntimeException('One or both products not found');
}
$db->beginTransaction();
try {
$db->prepare("UPDATE inventory SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]);
$db->prepare("UPDATE transactions SET product_id = ? WHERE product_id = ?")->execute([$keepId, $dropId]);
$db->prepare("DELETE FROM products WHERE id = ?")->execute([$dropId]);
$db->commit();
} catch (Throwable $e) {
if ($db->inTransaction()) {
$db->rollBack();
}
throw $e;
}
}
function mergeProduct(PDO $db): void {
EverLog::info('mergeProduct');
$input = json_decode(file_get_contents('php://input'), true);
$keepId = (int)($input['keep_id'] ?? $input['canonical_id'] ?? 0);
$dropId = (int)($input['drop_id'] ?? $input['duplicate_id'] ?? 0);
if (!$keepId || !$dropId) {
http_response_code(400);
echo json_encode(['error' => 'keep_id and drop_id required']);
return;
}
try {
mergeProducts($db, $keepId, $dropId);
echo json_encode(['success' => true, 'keep_id' => $keepId, 'drop_id' => $dropId]);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
}
}
/**
* Returns products whose entire inventory is at quantity = 0 AND whose
* Returns products whose ledger balance exceeds stock (including vanished rows).
* transaction balance (total_in - total_out) is still significantly positive
* meaning the system suspects the product ran out prematurely (scale drift,
* missed registration, etc.).
* missed registration, deleted inventory row, etc.).
*
* Products where the balance is at/near zero are legitimately finished by the
* user; those rows are silently deleted here (no banner needed).
@@ -3344,30 +3524,27 @@ function getFinishedItems(PDO $db): void {
EverLog::debug('getFinishedItems');
$rows = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, p.image_url, p.barcode,
MIN(i.location) AS location,
MAX(i.updated_at) AS updated_at,
COALESCE(SUM(CASE WHEN t.type = 'in' AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_in,
COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out
COALESCE(SUM(CASE WHEN t.type IN ('out','waste') AND t.undone = 0 THEN t.quantity ELSE 0 END), 0) AS total_out,
COALESCE((SELECT SUM(i2.quantity) FROM inventory i2 WHERE i2.product_id = p.id), 0) AS stock_qty,
(SELECT COUNT(*) FROM inventory i3 WHERE i3.product_id = p.id) AS inv_rows,
(SELECT i4.location FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_location,
(SELECT i4.updated_at FROM inventory i4 WHERE i4.product_id = p.id ORDER BY i4.updated_at DESC LIMIT 1) AS inv_updated,
(SELECT t2.location FROM transactions t2 WHERE t2.product_id = p.id AND t2.undone = 0 ORDER BY t2.created_at DESC LIMIT 1) AS tx_location
FROM products p
JOIN inventory i ON i.product_id = p.id
LEFT JOIN transactions t ON t.product_id = p.id
WHERE NOT EXISTS (
SELECT 1 FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0
)
GROUP BY p.id
ORDER BY MAX(i.updated_at) DESC
HAVING stock_qty <= 0.001 AND total_in > 0
ORDER BY (total_in - total_out) DESC
")->fetchAll(PDO::FETCH_ASSOC);
// Per-unit threshold: residue below this is considered normal rounding/finish
$thresholds = ['g' => 20, 'ml' => 20, 'kg' => 0.02, 'l' => 0.02, 'conf' => 0.1, 'pz' => 0.5];
$suspicious = [];
foreach ($rows as $r) {
$expected = (float)$r['total_in'] - (float)$r['total_out'];
$threshold = $thresholds[$r['unit']] ?? 0.5;
$threshold = productQtyThreshold($r['unit']);
if ($expected > $threshold) {
// Transaction balance says stock should remain — show banner
$location = $r['inv_location'] ?: $r['tx_location'] ?: 'dispensa';
$suspicious[] = [
'product_id' => (int)$r['product_id'],
'name' => $r['name'],
@@ -3377,13 +3554,14 @@ function getFinishedItems(PDO $db): void {
'package_unit' => $r['package_unit'],
'image_url' => $r['image_url'],
'barcode' => $r['barcode'],
'location' => $r['location'],
'updated_at' => $r['updated_at'],
'location' => $location,
'updated_at' => $r['inv_updated'],
'expected_qty' => round($expected, 3),
'ghost' => true,
'vanished' => ((int)$r['inv_rows']) === 0,
];
} else {
// Legitimately finished — delete silently, no banner
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0")
->execute([$r['product_id']]);
}
}
@@ -3392,7 +3570,8 @@ function getFinishedItems(PDO $db): void {
}
/**
* Permanently delete all qty=0 inventory rows for a product after user confirms it is finished.
* Permanently reconcile a finished/ghost product: log the missing quantity as
* an explicit out transaction, then delete any zero-qty inventory rows.
*/
function confirmFinished(PDO $db): void {
$input = json_decode(file_get_contents('php://input'), true);
@@ -3403,10 +3582,89 @@ function confirmFinished(PDO $db): void {
echo json_encode(['error' => 'product_id required']);
return;
}
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")->execute([$productId]);
$prod = $db->prepare("SELECT unit FROM products WHERE id = ?");
$prod->execute([$productId]);
$unit = $prod->fetchColumn();
if (!$unit) {
http_response_code(404);
echo json_encode(['error' => 'Product not found']);
return;
}
$bal = getProductLedgerBalance($db, $productId);
$expected = $bal['total_in'] - $bal['total_out'];
$threshold = productQtyThreshold((string)$unit);
if ($expected > $threshold) {
$locStmt = $db->prepare("SELECT location FROM inventory WHERE product_id = ? ORDER BY updated_at DESC LIMIT 1");
$locStmt->execute([$productId]);
$location = $locStmt->fetchColumn();
if (!$location) {
$locStmt = $db->prepare("SELECT location FROM transactions WHERE product_id = ? AND undone = 0 ORDER BY created_at DESC LIMIT 1");
$locStmt->execute([$productId]);
$location = $locStmt->fetchColumn();
}
$location = $location ?: 'dispensa';
$db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, ?)")
->execute([$productId, round($expected, 3), $location, '[Riconciliazione] Confermato esaurito']);
}
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0")->execute([$productId]);
echo json_encode(['success' => true]);
}
/**
* Restore stock for a ghost product without adding a new purchase (in) transaction.
*/
function restoreGhostInventory(PDO $db): void {
EverLog::info('restoreGhostInventory');
$input = json_decode(file_get_contents('php://input'), true);
$productId = (int)($input['product_id'] ?? 0);
$quantity = (float)($input['quantity'] ?? 0);
$location = trim((string)($input['location'] ?? 'dispensa')) ?: 'dispensa';
if (!$productId || $quantity <= 0) {
http_response_code(400);
echo json_encode(['error' => 'product_id and quantity required']);
return;
}
$prod = $db->prepare("SELECT id FROM products WHERE id = ?");
$prod->execute([$productId]);
if (!$prod->fetchColumn()) {
http_response_code(404);
echo json_encode(['error' => 'Product not found']);
return;
}
$stmt = $db->prepare("
SELECT id, quantity FROM inventory
WHERE product_id = ? AND location = ? AND opened_at IS NULL
ORDER BY CASE WHEN quantity > 0 THEN 0 ELSE 1 END, updated_at DESC
LIMIT 1
");
$stmt->execute([$productId, $location]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row) {
$db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")
->execute([$quantity, (int)$row['id']]);
$invId = (int)$row['id'];
} else {
$db->prepare("INSERT INTO inventory (product_id, location, quantity) VALUES (?, ?, ?)")
->execute([$productId, $location, $quantity]);
$invId = (int)$db->lastInsertId();
}
echo json_encode([
'success' => true,
'inventory_id' => $invId,
'quantity' => $quantity,
'location' => $location,
]);
}
function inventorySummary(PDO $db): void {
EverLog::debug('inventorySummary');
$stmt = $db->query("
@@ -5452,6 +5710,142 @@ PROMPT;
return $text;
}
/** Parse "200 g" / "2 pz" style recipe qty strings. */
function recipeParseQtyString(string $qty): array {
$val = 0.0;
$unit = '';
if (preg_match('/(\d+[.,]?\d*)\s*(g|gr|gramm|kg|ml|l|litri|cl|pz|pezz|conf)/i', $qty, $qm)) {
$val = (float)str_replace(',', '.', $qm[1]);
$ru = strtolower($qm[2]);
if (strpos($ru, 'g') === 0) $unit = 'g';
elseif ($ru === 'kg') { $unit = 'g'; $val *= 1000; }
elseif ($ru === 'ml') $unit = 'ml';
elseif ($ru === 'cl') { $unit = 'ml'; $val *= 10; }
elseif ($ru === 'l' || strpos($ru, 'litr') === 0) { $unit = 'ml'; $val *= 1000; }
elseif (strpos($ru, 'pz') === 0 || strpos($ru, 'pezz') === 0) $unit = 'pz';
elseif (strpos($ru, 'conf') === 0) $unit = 'conf';
}
return ['val' => $val, 'unit' => $unit];
}
function recipeGetProductTotalStock(PDO $db, int $productId): float {
$stmt = $db->prepare('SELECT COALESCE(SUM(quantity), 0) FROM inventory WHERE product_id = ? AND quantity > 0');
$stmt->execute([$productId]);
return (float)$stmt->fetchColumn();
}
/** Full sealed unit size for % remainder (conf → default_quantity in g/ml per conf). */
function recipeGetClosedProductBaseQty(array $ing): float {
$unit = $ing['inventory_unit'] ?? 'pz';
$pkgSize = (float)($ing['default_quantity'] ?? 0);
$pkgUnit = strtolower($ing['package_unit'] ?? '');
if ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true)) {
return $pkgSize;
}
if ($unit === 'conf' && $pkgSize > 0) {
return $pkgSize;
}
if ($pkgSize > 0 && in_array($unit, ['g', 'ml', 'pz'], true)) {
return $pkgSize;
}
if ($unit === 'conf') {
return 1.0;
}
return 0.0;
}
/** Use-all when leftover is < 5% of the sealed package (not current stock). */
function recipeShouldUseAllRemainder(float $remainDisp, array $ing, float $stockDisp = 0): bool {
if ($remainDisp <= 0) {
return false;
}
$packageBase = recipeGetClosedProductBaseQty($ing);
if ($packageBase <= 0) {
return false;
}
$pct = $remainDisp / $packageBase;
if ($pct < 0.05) {
return true;
}
// Opened/partial: less than one full sealed unit on hand — allow up to 10% tail waste
if ($stockDisp > 0 && $stockDisp < $packageBase && $pct < 0.10) {
return true;
}
return false;
}
/** Normalize use qty, apply <5% remainder → use-all, set stock_have/stock_remain hints. */
function recipeFinalizeIngQty(array &$ing, float $totalStockQty): void {
$parsed = recipeParseQtyString($ing['qty'] ?? '');
$recipeVal = $parsed['val'];
$recipeUnit = $parsed['unit'];
$unit = $ing['inventory_unit'] ?? 'pz';
$pkgSize = (float)($ing['default_quantity'] ?? 0);
$pkgUnit = strtolower($ing['package_unit'] ?? '');
$isConfSub = ($unit === 'conf' && $pkgSize > 0 && in_array($pkgUnit, ['g', 'ml'], true));
$useQty = (float)($ing['qty_number'] ?? 0);
// conf+weight: always prefer the recipe amount from the qty string (not inventory conf count)
if ($isConfSub && $recipeVal > 0 && $recipeUnit === $pkgUnit) {
$useQty = $recipeVal;
$ing['qty_number'] = round($useQty, 3);
$ing['qty'] = round($useQty) . ' ' . $pkgUnit;
}
if ($isConfSub) {
$stockDisp = $totalStockQty * $pkgSize;
$useDisp = $useQty;
$dispUnit = $pkgUnit;
} else {
$stockDisp = $totalStockQty;
$useDisp = $useQty;
$dispUnit = $unit;
}
if ($stockDisp <= 0 || $useDisp <= 0) {
$ing['stock_have'] = round($stockDisp, 2);
$ing['stock_remain'] = max(0, round($stockDisp - $useDisp, 2));
$ing['stock_unit'] = $dispUnit;
return;
}
$remainDisp = $stockDisp - $useDisp;
if (recipeShouldUseAllRemainder($remainDisp, $ing, $stockDisp)) {
$ing['use_all_suggested'] = true;
$useDisp = $stockDisp;
$remainDisp = 0;
if ($isConfSub) {
$ing['qty_number'] = round($useDisp, 1);
$ing['qty'] = round($useDisp) . ' ' . $pkgUnit;
} else {
$ing['qty_number'] = round($totalStockQty, 3);
if ($unit === 'pz') {
$ing['qty'] = round($totalStockQty, 2) . ' pz';
} else {
$ing['qty'] = round($totalStockQty, ($unit === 'g' || $unit === 'ml') ? 0 : 2) . ' ' . $unit;
}
}
}
$ing['stock_have'] = round($stockDisp, 2);
$ing['stock_remain'] = round($remainDisp, 2);
$ing['stock_unit'] = $dispUnit;
}
function recipeApplyStockHintsToRecipe(PDO $db, array &$recipe): void {
if (empty($recipe['ingredients']) || !is_array($recipe['ingredients'])) return;
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;
$ing['inventory_qty_total'] = $totalStock;
recipeFinalizeIngQty($ing, $totalStock);
}
unset($ing);
}
// ===== RECIPE GENERATION WITH GEMINI =====
function generateRecipe(PDO $db): void {
EverLog::debug('generateRecipe start');
@@ -6071,9 +6465,14 @@ PROMPT;
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
$defQty = (float)($bestMatch['default_quantity'] ?? 0);
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) {
$qtyNum = round($qtyNum * $defQty);
$ing['qty'] = $qtyNum . ' ' . $pkgUnitLC;
if ($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
@@ -6093,6 +6492,7 @@ PROMPT;
}
}
unset($ing);
recipeApplyStockHintsToRecipe($db, $recipe);
}
EverLog::info('recipe generated', ['title' => $recipe['title'] ?? '?', 'meal' => $mealType, 'persons' => $persons, 'ingredients' => count($recipe['ingredients'] ?? [])]);
@@ -6191,6 +6591,7 @@ PROMPT;
if (!empty($recipe['ingredients'])) {
_enrichChatIngredients($recipe['ingredients'], $items);
}
recipeApplyStockHintsToRecipe($db, $recipe);
echo json_encode(['success' => true, 'recipe' => $recipe]);
}
@@ -6305,6 +6706,7 @@ PROMPT;
if (!empty($recipe['ingredients'])) {
_enrichChatIngredients($recipe['ingredients'], $items);
}
recipeApplyStockHintsToRecipe($db, $recipe);
EverLog::info('recipe_from_ingredient ok', ['ingredient' => $ingredientName, 'title' => $recipe['title'] ?? '?', 'persons' => $persons]);
echo json_encode(['success' => true, 'recipe' => $recipe]);
@@ -6461,8 +6863,14 @@ function _enrichChatIngredients(array &$ingredients, array $items): void {
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
$defQty = (float)($bestMatch['default_quantity'] ?? 0);
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) {
$qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC;
if ($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;
@@ -7024,8 +7432,14 @@ PROMPT;
if (!$confAlreadyInSubUnit && $invUnit === 'conf' && $qtyNum > 0) {
$defQty = (float)($bestMatch['default_quantity'] ?? 0);
$pkgUnitLC = strtolower($bestMatch['package_unit'] ?? '');
if ($defQty > 0 && ($pkgUnitLC === 'g' || $pkgUnitLC === 'ml') && $qtyNum <= $invQty) {
$qtyNum = round($qtyNum * $defQty); $ing['qty'] = $qtyNum . ' ' . $pkgUnitLC;
if ($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;
@@ -7035,6 +7449,7 @@ PROMPT;
}
}
unset($ing);
recipeApplyStockHintsToRecipe($db, $recipe);
}
$send('status', ['step' => 4, 'message' => '✅ Ricetta pronta!']);