feat: HA sensor enrichment, inventory edit guard, recipe ingredient fix, bread shelf-life
- HA sensor: expiring_list now includes full product details (location, brand, category, days_remaining, opened_at, vacuum_sealed, default_quantity, etc.) - HA sensor: new expired_list attribute with full product details per expired item - HA sensor: new low_stock_list attribute (items with quantity ≤ 1, full details) - HA sensor: new sensor=product endpoint (?action=ha_sensor&sensor=product) with optional filters: &id=, &name=, &location= - HA cron webhook: expiry alert items now carry full product details - Inventory edit: confirm dialog when quantity exceeds unit-specific threshold (prevents data loss from unit-confusion typos, e.g. 183 conf instead of 0.183) - Recipe AI: explicit rule against ingredient form substitution (fresh tomatoes ≠ passata, fresh milk ≠ UHT ≠ cream, etc.) - Shelf-life: opened bread rules (piadina 2d, bauletto/pancarrè 4d, pane 3d) - docs/wiki: HA page updated with new schema, examples, product endpoint Closes #125
This commit is contained in:
@@ -142,7 +142,9 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||
if (!file_exists($haFlagFile)) {
|
||||
$expiryDays = max(1, (int)env('HA_EXPIRY_DAYS', '3'));
|
||||
$expiringItems = $db->query(
|
||||
"SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location
|
||||
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||
@@ -150,13 +152,44 @@ if (env('HA_ENABLED', 'false') === 'true' && env('HA_WEBHOOK_ID', '') !== '') {
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$expiredItems = $db->query(
|
||||
"SELECT p.name, i.quantity, i.unit, i.expiry_date, i.location
|
||||
"SELECT p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed
|
||||
FROM inventory i JOIN products p ON i.product_id = p.id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date < date('now')
|
||||
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Normalise rows to full product format
|
||||
if (!function_exists('_haFormatProduct')) {
|
||||
function _haFormatProduct(array $row): array {
|
||||
$daysRemaining = null;
|
||||
if (!empty($row['expiry_date'])) {
|
||||
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
|
||||
$daysRemaining = (int)$diff->format('%r%a');
|
||||
}
|
||||
return [
|
||||
'product_id' => (int)($row['product_id'] ?? 0),
|
||||
'inventory_id' => (int)($row['inventory_id'] ?? 0),
|
||||
'name' => $row['name'],
|
||||
'brand' => $row['brand'] ?? null,
|
||||
'category' => $row['category'] ?? null,
|
||||
'quantity' => (float)($row['quantity'] ?? 0),
|
||||
'unit' => $row['unit'] ?? '',
|
||||
'default_quantity' => (float)($row['default_quantity'] ?? 0),
|
||||
'package_unit' => $row['package_unit'] ?? null,
|
||||
'location' => $row['location'] ?? null,
|
||||
'expiry_date' => $row['expiry_date'] ?? null,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'opened_at' => $row['opened_at'] ?? null,
|
||||
'vacuum_sealed' => !empty($row['vacuum_sealed']),
|
||||
];
|
||||
}
|
||||
}
|
||||
$expiringItems = array_map('_haFormatProduct', $expiringItems);
|
||||
$expiredItems = array_map('_haFormatProduct', $expiredItems);
|
||||
|
||||
if (!empty($expiringItems)) {
|
||||
$names = implode(', ', array_column($expiringItems, 'name'));
|
||||
_fireHaWebhook('expiry_alert', [
|
||||
|
||||
@@ -464,6 +464,14 @@ function estimateOpenedExpiryDaysPHP(string $name, string $category, string $loc
|
||||
if (preg_match('/\b(patata|patate|tubero)\b/', $n)) return 4;
|
||||
if (preg_match('/\baglio\b/', $n)) return 14;
|
||||
|
||||
// ── F.extra: Bread in fridge (opened) ──────────────────────────────────
|
||||
// Thin flatbreads (piadina, crescia, tigella) get mold very quickly
|
||||
if (preg_match('/\b(piadina|piadelle?|crescia|tigella)\b/', $n)) return 2;
|
||||
// Packaged sliced bread — preservatives help a bit
|
||||
if (preg_match('/\b(bauletto|pancarrè|pan\s+carr|tramezzin)\b/', $n)) return 4;
|
||||
// Generic bread / sandwich bread in fridge
|
||||
if (preg_match('/\bpane\b/', $cat)) return 3;
|
||||
|
||||
// ── G: Fridge condiments — medium shelf-life ─────────────────────────
|
||||
if (preg_match('/maionese|mayo|mayon/', $n)) return 90;
|
||||
if (preg_match('/\bketchup\b/', $n)) return 90;
|
||||
|
||||
+131
-22
@@ -1376,12 +1376,47 @@ function _sendHaNotify(string $message, array $data = []): void {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalise a DB inventory+product row into a full product info array
|
||||
* used consistently across all HA sensor attributes and webhook payloads.
|
||||
*/
|
||||
function _haFormatProduct(array $row): array {
|
||||
$daysRemaining = null;
|
||||
if (!empty($row['expiry_date'])) {
|
||||
$diff = (new DateTime(date('Y-m-d')))->diff(new DateTime($row['expiry_date']));
|
||||
$daysRemaining = (int)$diff->format('%r%a');
|
||||
}
|
||||
return [
|
||||
'product_id' => (int)($row['product_id'] ?? 0),
|
||||
'inventory_id' => (int)($row['inventory_id'] ?? 0),
|
||||
'name' => $row['name'],
|
||||
'brand' => $row['brand'] ?? null,
|
||||
'category' => $row['category'] ?? null,
|
||||
'quantity' => (float)($row['quantity'] ?? 0),
|
||||
'unit' => $row['unit'] ?? '',
|
||||
'default_quantity' => (float)($row['default_quantity'] ?? 0),
|
||||
'package_unit' => $row['package_unit'] ?? null,
|
||||
'location' => $row['location'] ?? null,
|
||||
'expiry_date' => $row['expiry_date'] ?? null,
|
||||
'days_remaining' => $daysRemaining,
|
||||
'opened_at' => $row['opened_at'] ?? null,
|
||||
'vacuum_sealed' => !empty($row['vacuum_sealed']),
|
||||
];
|
||||
}
|
||||
|
||||
/** Full product detail SQL fragment reused in all HA queries. */
|
||||
function _haProductSelect(): string {
|
||||
return "p.id AS product_id, i.id AS inventory_id,
|
||||
p.name, p.brand, p.category, p.unit, p.default_quantity, p.package_unit,
|
||||
i.quantity, i.location, i.expiry_date, i.opened_at, i.vacuum_sealed";
|
||||
}
|
||||
|
||||
/**
|
||||
* HA REST sensor endpoint — returns pantry state in Home Assistant-compatible format.
|
||||
* Use with platform: rest in configuration.yaml.
|
||||
*
|
||||
* GET /api/?action=ha_sensor[&sensor=NAME]
|
||||
* Available sensor names: expiring, expired, total, shopping
|
||||
* Available sensor names: expiring, expired, total, shopping, product
|
||||
*/
|
||||
function haInventorySensor(PDO $db): void {
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
@@ -1390,6 +1425,38 @@ function haInventorySensor(PDO $db): void {
|
||||
$sensor = strtolower(trim($_GET['sensor'] ?? 'overview'));
|
||||
$expiryDays = max(1, min(90, (int)($_GET['expiry_days'] ?? env('HA_EXPIRY_DAYS', 3))));
|
||||
|
||||
// ── sensor=product: full inventory details, optionally filtered ──────────
|
||||
if ($sensor === 'product') {
|
||||
try {
|
||||
$invId = (int)($_GET['id'] ?? 0);
|
||||
$search = trim($_GET['name'] ?? '');
|
||||
$loc = trim($_GET['location'] ?? '');
|
||||
$where = "WHERE i.quantity > 0";
|
||||
$params = [];
|
||||
if ($invId > 0) { $where .= " AND i.id = ?"; $params[] = $invId; }
|
||||
elseif ($search !== '') { $where .= " AND LOWER(p.name) LIKE ?"; $params[] = '%' . mb_strtolower($search, 'UTF-8') . '%'; }
|
||||
if ($loc !== '') { $where .= " AND i.location = ?"; $params[] = $loc; }
|
||||
$stmt = $db->prepare(
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
$where ORDER BY p.name ASC"
|
||||
);
|
||||
$stmt->execute($params);
|
||||
$items = array_map('_haFormatProduct', $stmt->fetchAll(PDO::FETCH_ASSOC));
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('Access-Control-Allow-Origin: *');
|
||||
echo json_encode([
|
||||
'state' => count($items),
|
||||
'items' => $items,
|
||||
'last_updated' => date('c'),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
} catch (Throwable $e) {
|
||||
http_response_code(500);
|
||||
echo json_encode(['error' => $e->getMessage()]);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$expiring = (int)$db->query(
|
||||
"SELECT COUNT(*) FROM inventory WHERE quantity > 0 AND expiry_date IS NOT NULL
|
||||
@@ -1416,14 +1483,30 @@ function haInventorySensor(PDO $db): void {
|
||||
$shoppingCount = (int)$db->query("SELECT COUNT(*) FROM shopping_list")->fetchColumn();
|
||||
}
|
||||
|
||||
// Expiring items details
|
||||
// Expiring items details (full product info, all within $expiryDays window)
|
||||
$expiringItems = $db->query(
|
||||
"SELECT p.name, i.quantity, p.unit, i.expiry_date
|
||||
FROM inventory i
|
||||
JOIN products p ON p.id = i.product_id
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+7 days')
|
||||
ORDER BY i.expiry_date ASC LIMIT 10"
|
||||
AND i.expiry_date BETWEEN date('now') AND date('now', '+{$expiryDays} days')
|
||||
ORDER BY i.expiry_date ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Expired items (full product info)
|
||||
$expiredItemsList = $db->query(
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0 AND i.expiry_date IS NOT NULL
|
||||
AND i.expiry_date < date('now')
|
||||
ORDER BY i.expiry_date ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Low-stock items (quantity <= 1 but > 0, full product info)
|
||||
$lowStockItemsList = $db->query(
|
||||
"SELECT " . _haProductSelect() . "
|
||||
FROM inventory i JOIN products p ON p.id = i.product_id
|
||||
WHERE i.quantity > 0 AND i.quantity <= 1
|
||||
ORDER BY i.quantity ASC, p.name ASC"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Opened items
|
||||
@@ -1540,13 +1623,9 @@ function haInventorySensor(PDO $db): void {
|
||||
'shopping_total' => $shoppingTotal,
|
||||
'price_tracking_enabled' => $priceEnabled,
|
||||
'price_currency' => $priceCurrency,
|
||||
'expiring_list' => array_map(fn($r) => [
|
||||
'name' => $r['name'],
|
||||
'quantity' => (float)$r['quantity'],
|
||||
'unit' => $r['unit'],
|
||||
'expiry_date' => $r['expiry_date'],
|
||||
'expires_today' => $r['expiry_date'] <= date('Y-m-d', strtotime('+1 days')),
|
||||
], $expiringItems),
|
||||
'expiring_list' => array_map('_haFormatProduct', $expiringItems),
|
||||
'expired_list' => array_map('_haFormatProduct', $expiredItemsList),
|
||||
'low_stock_list' => array_map('_haFormatProduct', $lowStockItemsList),
|
||||
'next_expiry_name' => !empty($expiringItems) ? $expiringItems[0]['name'] : null,
|
||||
'next_expiry_date' => !empty($expiringItems) ? $expiringItems[0]['expiry_date'] : null,
|
||||
'unit_of_measurement' => 'items',
|
||||
@@ -2528,10 +2607,12 @@ function addToInventory(PDO $db): void {
|
||||
return;
|
||||
}
|
||||
|
||||
// If a different unit was specified, update the product's unit
|
||||
// If a different unit was specified, update the product's unit.
|
||||
// NOTE: default_quantity is the PACKAGE SIZE, not the quantity being added —
|
||||
// do NOT overwrite it here. It is managed via product_save / the edit form.
|
||||
if ($unit) {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$unit, $quantity, $productId]);
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$unit, $productId]);
|
||||
} else {
|
||||
// Auto-set default_quantity if product has none (first add sets package size)
|
||||
$stmt = $db->prepare("SELECT default_quantity, unit FROM products WHERE id = ?");
|
||||
@@ -2995,7 +3076,7 @@ function useFromInventory(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate total remaining across ALL locations
|
||||
// Calculate total remaining across ALL locations (this product only)
|
||||
$stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0");
|
||||
$stmt->execute([$productId]);
|
||||
$totalRemaining = round((float)($stmt->fetchColumn() ?: 0), 6);
|
||||
@@ -3005,8 +3086,26 @@ function useFromInventory(PDO $db): void {
|
||||
$stmt->execute([$productId]);
|
||||
$prodInfo = $stmt->fetch();
|
||||
|
||||
// Also sum related products in the same shopping_name family (same unit) so that
|
||||
// e.g. "Uova Sfoglia Gialla" + "Uova biologiche" are evaluated together for low stock.
|
||||
$totalFamilyRemaining = $totalRemaining;
|
||||
if ($prodInfo) {
|
||||
$sNameKey = strtolower(trim($prodInfo['shopping_name'] ?? ''));
|
||||
$prodUnit = $prodInfo['unit'] ?? '';
|
||||
if ($sNameKey !== '' && $prodUnit !== '') {
|
||||
$famStmt = $db->prepare("
|
||||
SELECT SUM(i.quantity)
|
||||
FROM inventory i
|
||||
JOIN products p ON i.product_id = p.id
|
||||
WHERE LOWER(TRIM(p.shopping_name)) = ? AND i.product_id != ? AND p.unit = ? AND i.quantity > 0
|
||||
");
|
||||
$famStmt->execute([$sNameKey, $productId, $prodUnit]);
|
||||
$totalFamilyRemaining = round($totalRemaining + (float)($famStmt->fetchColumn() ?: 0), 6);
|
||||
}
|
||||
}
|
||||
|
||||
$response = ['success' => true, 'remaining' => $remaining, 'added_to_bring' => $addedToBring,
|
||||
'total_remaining' => $totalRemaining];
|
||||
'total_remaining' => $totalRemaining, 'total_family_remaining' => $totalFamilyRemaining];
|
||||
if ($prodInfo) {
|
||||
$response['product_name'] = $prodInfo['name'];
|
||||
$response['product_brand'] = $prodInfo['brand'] ?: '';
|
||||
@@ -3073,10 +3172,18 @@ function updateInventory(PDO $db): void {
|
||||
}
|
||||
}
|
||||
|
||||
// Update unit on the product if provided
|
||||
// Update unit on the product if provided.
|
||||
// When setting unit back to 'pz', also ensure default_quantity >= 1 so the
|
||||
// barcode-scan auto-detect (which only fires on default_quantity === 0) won't
|
||||
// silently revert the user's correction on the next scan.
|
||||
if (isset($input['unit']) && isset($input['product_id'])) {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
$stmt->execute([$input['unit'], $input['product_id']]);
|
||||
$newUnit = $input['unit'];
|
||||
if ($newUnit === 'pz') {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, default_quantity = CASE WHEN default_quantity < 1 THEN 1 ELSE default_quantity END, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
} else {
|
||||
$stmt = $db->prepare("UPDATE products SET unit = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||
}
|
||||
$stmt->execute([$newUnit, $input['product_id']]);
|
||||
}
|
||||
|
||||
// Update package info if provided
|
||||
@@ -5471,6 +5578,7 @@ REGOLE:
|
||||
7. Language rule: {$recipeLangName} only for all textual fields (`title`, `tags`, `expiry_note`, `ingredients.qty`, `steps`, `nutrition_note`, `tools_needed`). Keep `meal` unchanged.
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullateur"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
9. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}.
|
||||
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.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
@@ -6418,6 +6526,7 @@ REGOLE:
|
||||
8. `tools_needed`: array of kitchen tools/appliances actually required by this recipe (e.g. ["Forno","Frullatore"]). Use the same language as all other text fields. Empty array [] if only stovetop/knife/pan needed.
|
||||
9. `zero_waste_tips`: array of zero-waste tips for steps that generate reusable scraps (peels, leftover cooking water, egg whites, cheese rinds, bread crusts, vegetable tops, etc.). Each entry: {"step": 0-based_step_index, "scrap": "scrap name", "tip": "short practical reuse tip (max 20 words)"}. Use the same language as other text fields. Empty array [] if no reusable scraps are generated.
|
||||
10. `steps`: array of PLAIN TEXT STRINGS only — no objects, no JSON, no sub-fields. Each step is a single readable string. If appliances are used, include the appliance/mode information directly in the step text (e.g. "Nel Cookeo, modalità Rosolare: aggiungere la cipolla…"). NEVER output steps as objects like {"instruction":…, "appliance_function":…}.
|
||||
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.
|
||||
|
||||
DISPENSA:
|
||||
$ingredientsText
|
||||
|
||||
Reference in New Issue
Block a user