Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7947f47e6d | |||
| 758eb93e20 | |||
| ff1175451a | |||
| 42630c3e3e | |||
| 637eaa20d6 | |||
| 5e307f79b8 | |||
| a6478b20e1 | |||
| 223457bbdf | |||
| 12c6a8977a | |||
| c7a69d8379 | |||
| c7f3c95d75 | |||
| a6f90a07e5 |
@@ -11,6 +11,37 @@ 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.33] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **HA sensor `shopping_total` always null** — `haInventorySensor` was reading `shopping_total_cache.json` with a 1-hour TTL (cache populated only by the JS frontend, so it was often empty). Extended TTL to 24 hours and added an inline fallback: when the cache is absent or stale, the sensor now computes the total directly from `shopping_price_cache.json` without any AI calls. Queries `shopping_list` joined to `products` for the canonical `shopping_name`, then looks up both v3 and legacy v0 cache key formats to maximise hit rate. Works in both internal and Bring shopping modes.
|
||||||
|
- **HA `ha_refresh_prices` using non-existent columns** — `haInventorySensor` and `haRefreshPrices` were querying `quantity`, `unit`, `checked` from `shopping_list` — columns that do not exist in that table (schema: `id, name, raw_name, specification, added_at, sort_order`). Changed to `SELECT name` with `shopping_name` join and default `qty=1 / unit=pz`.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.32] - 2026-05-29
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Smarter expiry u2192 shopping list logic** — The "expiring soon" threshold is now 7 days (was 3), giving enough time to plan the next shopping trip. Items expiring soon are only flagged for restocking when the user is a **regular buyer** (`isRegular`) and either stock is low (<50%) or the consumption rate predicts the item will expire before being used. Non-regular products keep the old 3-day safety-net. Expired items are now only added to the shopping list when `isRegular || buyCount >= 2` — products that expired unused without ever being a staple no longer pollute the list; the expiry banner handles them.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.31] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.30] - 2026-05-29
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **False consumption anomaly with multi-row stock** — The anomaly detection banner was evaluating each inventory row in isolation. Products split across multiple rows (e.g. one opened pack with 1 pz + one sealed pack with 6 pz) incorrectly triggered a "consumed faster than expected" warning because only the opened row (1 pz) was compared against the model. The check now aggregates the total quantity across all rows for the same product before deciding to flag an anomaly. If the combined total ≥ expected remaining, the anomaly is suppressed.
|
||||||
|
|
||||||
|
|
||||||
|
## [1.7.29] - 2026-05-29
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Buy-cycle consumption prediction** — Products that are never tracked per-use (salt, spices, cleaning supplies, etc.) now use the average time between restocks as a proxy for consumption rate. When a product has ≥ 3 purchase events and no individual `out` events, EverShelf calculates the average buy cycle (`(lastBuy - firstBuy) / (buyCount - 1)`) and estimates how many days of stock remain in the current cycle. The product appears in the smart shopping list with a reason like "Finisce tra ~12gg (ciclo medio 75gg)" before it runs out, rather than only after. These products are now also treated as `isRegular` so all stock-level urgency checks apply correctly.
|
||||||
|
|
||||||
|
|
||||||
## [1.7.28] - 2026-05-30
|
## [1.7.28] - 2026-05-30
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
+161
-29
@@ -1570,7 +1570,9 @@ function haInventorySensor(PDO $db): void {
|
|||||||
$daysToNextExpiry = (int)$diff->format('%r%a');
|
$daysToNextExpiry = (int)$diff->format('%r%a');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Shopping total from server-side total cache (max 1 hour old)
|
// Shopping total from server-side total cache (max 24 hours old).
|
||||||
|
// The cache is populated by the JS frontend or by the ha_refresh_prices action.
|
||||||
|
// 24h TTL is sufficient: the total changes slowly and HA polls frequently.
|
||||||
$priceEnabled = env('PRICE_ENABLED', 'false') === 'true';
|
$priceEnabled = env('PRICE_ENABLED', 'false') === 'true';
|
||||||
$priceCurrency = env('PRICE_CURRENCY', 'EUR');
|
$priceCurrency = env('PRICE_CURRENCY', 'EUR');
|
||||||
$shoppingTotal = null;
|
$shoppingTotal = null;
|
||||||
@@ -1578,19 +1580,58 @@ function haInventorySensor(PDO $db): void {
|
|||||||
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
|
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
|
||||||
if (file_exists($totalCachePath)) {
|
if (file_exists($totalCachePath)) {
|
||||||
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
|
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
|
||||||
// Find the most recent entry not older than 1 hour
|
$best = null; $bestTs = 0;
|
||||||
$best = null;
|
|
||||||
$bestTs = 0;
|
|
||||||
foreach ($tc as $entry) {
|
foreach ($tc as $entry) {
|
||||||
if (isset($entry['ts']) && $entry['ts'] > $bestTs) {
|
if (isset($entry['ts']) && $entry['ts'] > $bestTs) {
|
||||||
$bestTs = $entry['ts'];
|
$bestTs = $entry['ts'];
|
||||||
$best = $entry;
|
$best = $entry;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($best && (time() - $bestTs) < 3600) {
|
if ($best && (time() - $bestTs) < 86400) {
|
||||||
$shoppingTotal = round((float)($best['result']['total'] ?? 0), 2);
|
$shoppingTotal = round((float)($best['result']['total'] ?? 0), 2);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// If cache is absent or older than 24h, compute inline from the existing
|
||||||
|
// per-item price cache (no AI calls — fast, uses already-stored prices).
|
||||||
|
if ($shoppingTotal === null) {
|
||||||
|
$country = env('PRICE_COUNTRY', 'Italia');
|
||||||
|
$priceCache = _loadPriceCache();
|
||||||
|
if (!empty($priceCache)) {
|
||||||
|
$shopRows = $db->query("
|
||||||
|
SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname
|
||||||
|
FROM shopping_list sl
|
||||||
|
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$inlineTotal = 0.0;
|
||||||
|
$inlinePriced = 0;
|
||||||
|
$seenNames = [];
|
||||||
|
foreach ($shopRows as $r) {
|
||||||
|
$sname = $r['sname'] ?? $r['name'];
|
||||||
|
if (isset($seenNames[$sname])) continue; // deduplicate
|
||||||
|
$seenNames[$sname] = true;
|
||||||
|
$pk = _priceKey($sname, $country);
|
||||||
|
$pk0 = md5(mb_strtolower(trim($sname)) . '|' . mb_strtolower(trim($country)));
|
||||||
|
$e = $priceCache[$pk] ?? $priceCache[$pk0] ?? null;
|
||||||
|
if ($e !== null) {
|
||||||
|
$est = _calcEstimatedTotal(
|
||||||
|
$e['price_per_unit'], $e['unit_label'] ?? '',
|
||||||
|
1, 'pz', 0, ''
|
||||||
|
);
|
||||||
|
$inlineTotal += $est ?? 0;
|
||||||
|
$inlinePriced++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($inlinePriced > 0) {
|
||||||
|
$shoppingTotal = round($inlineTotal, 2);
|
||||||
|
// Persist so next call is instant
|
||||||
|
$tc2 = file_exists($totalCachePath) ? (json_decode(file_get_contents($totalCachePath), true) ?? []) : [];
|
||||||
|
$ckey2 = 'ha_inline_' . date('Ymd_His');
|
||||||
|
$tc2[$ckey2] = ['ts' => time(), 'result' => ['total' => $shoppingTotal, 'priced_items' => $inlinePriced]];
|
||||||
|
if (count($tc2) >= 10) $tc2 = array_slice($tc2, -9, null, true);
|
||||||
|
file_put_contents($totalCachePath, json_encode($tc2, JSON_UNESCAPED_UNICODE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$stateValue = match($sensor) {
|
$stateValue = match($sensor) {
|
||||||
@@ -1786,9 +1827,17 @@ function haRefreshPrices(PDO $db): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$rows = $db->query("SELECT name, quantity, unit FROM shopping_list WHERE checked = 0")->fetchAll(PDO::FETCH_ASSOC);
|
$rows = $db->query("
|
||||||
|
SELECT sl.name, COALESCE(p.shopping_name, sl.name) AS sname
|
||||||
|
FROM shopping_list sl
|
||||||
|
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
|
||||||
|
")->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$seenSnamesHa = [];
|
||||||
foreach ($rows as $r) {
|
foreach ($rows as $r) {
|
||||||
$shoppingItems[] = ['name' => $r['name'], 'quantity' => (float)($r['quantity'] ?? 1), 'unit' => $r['unit'] ?? 'pz', 'default_quantity' => 0, 'package_unit' => ''];
|
$sname = $r['sname'] ?? $r['name'];
|
||||||
|
if (isset($seenSnamesHa[$sname])) continue;
|
||||||
|
$seenSnamesHa[$sname] = true;
|
||||||
|
$shoppingItems[] = ['name' => $sname, 'quantity' => 1, 'unit' => 'pz', 'default_quantity' => 0, 'package_unit' => ''];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1798,9 +1847,10 @@ function haRefreshPrices(PDO $db): void {
|
|||||||
$missing = [];
|
$missing = [];
|
||||||
|
|
||||||
foreach ($shoppingItems as $item) {
|
foreach ($shoppingItems as $item) {
|
||||||
$key = _priceKey($item['name'], $country);
|
$key = _priceKey($item['name'], $country);
|
||||||
if (isset($priceCache[$key])) {
|
$key0 = md5(mb_strtolower(trim($item['name'])) . '|' . mb_strtolower(trim($country)));
|
||||||
$entry = $priceCache[$key];
|
$entry = $priceCache[$key] ?? $priceCache[$key0] ?? null;
|
||||||
|
if ($entry !== null) {
|
||||||
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
|
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
|
||||||
$total += $est ?? 0;
|
$total += $est ?? 0;
|
||||||
$priced++;
|
$priced++;
|
||||||
@@ -2634,19 +2684,26 @@ function addToInventory(PDO $db): void {
|
|||||||
|
|
||||||
$vacuumSealed = (int)($input['vacuum_sealed'] ?? 0);
|
$vacuumSealed = (int)($input['vacuum_sealed'] ?? 0);
|
||||||
|
|
||||||
// Check if product already exists in this location
|
// Check if a SEALED (not yet opened) row exists for this product+location.
|
||||||
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ?");
|
// We merge new stock into a sealed row only — never into an already-opened
|
||||||
|
// pack, because that would conflate two physically distinct containers and
|
||||||
|
// corrupt the opened_at timestamp tracking.
|
||||||
|
$stmt = $db->prepare("
|
||||||
|
SELECT id, quantity FROM inventory
|
||||||
|
WHERE product_id = ? AND location = ? AND opened_at IS NULL
|
||||||
|
ORDER BY added_at ASC LIMIT 1
|
||||||
|
");
|
||||||
$stmt->execute([$productId, $location]);
|
$stmt->execute([$productId, $location]);
|
||||||
$existing = $stmt->fetch();
|
$existing = $stmt->fetch();
|
||||||
|
|
||||||
if ($existing) {
|
if ($existing) {
|
||||||
// Update quantity
|
// Merge into the existing sealed row
|
||||||
$newQty = $existing['quantity'] + $quantity;
|
$newQty = $existing['quantity'] + $quantity;
|
||||||
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
|
||||||
$stmt->execute([$newQty, $expiry, $vacuumSealed, $existing['id']]);
|
$stmt->execute([$newQty, $expiry, $vacuumSealed, $existing['id']]);
|
||||||
} else {
|
} else {
|
||||||
$newQty = $quantity;
|
$newQty = $quantity;
|
||||||
// Insert new inventory entry
|
// All existing rows (if any) are opened packs — insert a new sealed row
|
||||||
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)");
|
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)");
|
||||||
$stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]);
|
$stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]);
|
||||||
}
|
}
|
||||||
@@ -4155,6 +4212,24 @@ function getConsumptionPredictions(PDO $db): void {
|
|||||||
$expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
|
$expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
|
||||||
$actualQty = floatval($item['quantity']);
|
$actualQty = floatval($item['quantity']);
|
||||||
|
|
||||||
|
// Aggregate total stock for this product across ALL inventory rows.
|
||||||
|
// A product may be split into multiple rows (e.g. one opened pack + one
|
||||||
|
// sealed pack at a different location). The opened row alone may look
|
||||||
|
// depleted while the total is healthy — do not flag in that case.
|
||||||
|
$totalQtyStmt = $db->prepare("
|
||||||
|
SELECT COALESCE(SUM(quantity), 0)
|
||||||
|
FROM inventory
|
||||||
|
WHERE product_id = ? AND quantity > 0
|
||||||
|
");
|
||||||
|
$totalQtyStmt->execute([$pid]);
|
||||||
|
$totalQtyAllRows = floatval($totalQtyStmt->fetchColumn() ?: 0);
|
||||||
|
// If the aggregate total is above the expected remaining, the "depletion"
|
||||||
|
// is just stock spread across rows — suppress the anomaly.
|
||||||
|
if ($totalQtyAllRows >= $expectedQty) continue;
|
||||||
|
// Use the aggregate total as the visible actual qty so the banner shows
|
||||||
|
// the real combined stock, not just the single opened row.
|
||||||
|
$actualQty = $totalQtyAllRows;
|
||||||
|
|
||||||
// Need at least some post-restock usage observations before warning.
|
// Need at least some post-restock usage observations before warning.
|
||||||
if ($txSinceRestock < 2) continue;
|
if ($txSinceRestock < 2) continue;
|
||||||
|
|
||||||
@@ -8813,6 +8888,29 @@ function smartShopping(PDO $db): void {
|
|||||||
$dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0;
|
$dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Buy-cycle proxy (for products tracked without individual 'out' events) ---
|
||||||
|
// Products like salt, spices, cleaning products are never logged per-use.
|
||||||
|
// When the user buys them again it implicitly means the previous pack ran out.
|
||||||
|
// If we have ≥ 3 buy events and no (or very few) out events, we estimate
|
||||||
|
// the average cycle duration = (lastIn - firstIn) / (buyCount - 1) and
|
||||||
|
// project how many days of stock are likely left in the current cycle.
|
||||||
|
// estimatedDaysLeft = avgCycleDays − daysSinceLastBuy
|
||||||
|
// This dailyRate proxy is ONLY used when the regular out-based rate is 0.
|
||||||
|
$buyCycleDays = null; // avg days per buy cycle
|
||||||
|
$buyCycleDaysLeft = null; // estimated days remaining in current cycle
|
||||||
|
if ($dailyRate == 0 && $buyCount >= 3 && $firstIn && $lastIn && $lastIn > $firstIn) {
|
||||||
|
$buyCycleDays = ($lastIn - $firstIn) / 86400 / ($buyCount - 1);
|
||||||
|
if ($buyCycleDays >= 7) { // ignore implausible < 1-week cycles
|
||||||
|
$daysSinceLastBuyFloat = ($now - $lastIn) / 86400;
|
||||||
|
$buyCycleDaysLeft = max(0, $buyCycleDays - $daysSinceLastBuyFloat);
|
||||||
|
// Derive a synthetic dailyRate so existing daysLeft / pctLeft logic works naturally
|
||||||
|
// 1 restock event ≈ consuming 1 "average package" over avgCycleDays
|
||||||
|
if ($qty > 0 && $buyCycleDays > 0) {
|
||||||
|
$dailyRate = $qty / max(1, $buyCycleDaysLeft > 0 ? $buyCycleDaysLeft : $buyCycleDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Days of stock remaining
|
// Days of stock remaining
|
||||||
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
|
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
|
||||||
|
|
||||||
@@ -8820,7 +8918,9 @@ function smartShopping(PDO $db): void {
|
|||||||
$expiryDate = $inv ? $inv['nearest_expiry'] : null;
|
$expiryDate = $inv ? $inv['nearest_expiry'] : null;
|
||||||
$daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999;
|
$daysToExpiry = $expiryDate ? (strtotime($expiryDate) - $now) / 86400 : 999;
|
||||||
$isExpired = $daysToExpiry < 0;
|
$isExpired = $daysToExpiry < 0;
|
||||||
$isExpiringSoon = !$isExpired && $daysToExpiry <= 3;
|
// 7-day warning window: enough to plan the next shopping trip.
|
||||||
|
// The tighter 3-day threshold was often too late for staple products.
|
||||||
|
$isExpiringSoon = !$isExpired && $daysToExpiry <= 7;
|
||||||
|
|
||||||
// Fresh (non-expired) quantity — used for suppression when only part of stock is expired
|
// Fresh (non-expired) quantity — used for suppression when only part of stock is expired
|
||||||
$freshQty = $inv ? (float)($inv['fresh_qty'] ?? $qty) : 0;
|
$freshQty = $inv ? (float)($inv['fresh_qty'] ?? $qty) : 0;
|
||||||
@@ -8853,7 +8953,9 @@ function smartShopping(PDO $db): void {
|
|||||||
// Is this a frequently used product? (≥ 1.5 uses/month)
|
// Is this a frequently used product? (≥ 1.5 uses/month)
|
||||||
$isFrequent = $usesPerMonth >= 1.5;
|
$isFrequent = $usesPerMonth >= 1.5;
|
||||||
// Is it a regular product? (≥ 0.5 uses/month = at least once every 2 months)
|
// Is it a regular product? (≥ 0.5 uses/month = at least once every 2 months)
|
||||||
$isRegular = $usesPerMonth >= 0.5;
|
// Also treat buy-cycle products (≥3 buys, no out events) as regular — they are
|
||||||
|
// by definition products the user buys periodically.
|
||||||
|
$isRegular = $usesPerMonth >= 0.5 || ($buyCycleDays !== null && $buyCount >= 3);
|
||||||
// Is it recently relevant? (used/bought in last 60 days)
|
// Is it recently relevant? (used/bought in last 60 days)
|
||||||
$isRecent = $daysSinceLastUse <= 60;
|
$isRecent = $daysSinceLastUse <= 60;
|
||||||
|
|
||||||
@@ -8951,31 +9053,48 @@ function smartShopping(PDO $db): void {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Expiring soon or expired (needs replacement) — valid regardless of frequency
|
// Expiring soon or expired (needs replacement)
|
||||||
if ($isExpired && $qty > 0) {
|
if ($isExpired && $qty > 0) {
|
||||||
// Check if the product's shopping_name FAMILY has adequate FRESH stock
|
// Check if the product's shopping_name FAMILY has adequate FRESH stock
|
||||||
// from other (non-expired) products. If so, no need to buy more.
|
// from other (non-expired) products. If so, no need to buy more.
|
||||||
$sNameKey = strtolower(trim($p['shopping_name'] ?? ''));
|
$sNameKey = strtolower(trim($p['shopping_name'] ?? ''));
|
||||||
$familyFreshQty = $sNameKey !== '' ? ($freshStockByShoppingName[$sNameKey] ?? 0) : 0;
|
$familyFreshQty = $sNameKey !== '' ? ($freshStockByShoppingName[$sNameKey] ?? 0) : 0;
|
||||||
// Subtract this product's own qty (it is expired, so fresh_qty=0 for it anyway)
|
|
||||||
$refQtyLocal = $refQty > 0 ? $refQty : 1;
|
$refQtyLocal = $refQty > 0 ? $refQty : 1;
|
||||||
$familyFreshPct = min(200, ($familyFreshQty / $refQtyLocal) * 100);
|
$familyFreshPct = min(200, ($familyFreshQty / $refQtyLocal) * 100);
|
||||||
|
|
||||||
if (($justRestocked && $freshPctLeft >= 50) || $familyFreshPct >= 50) {
|
if (($justRestocked && $freshPctLeft >= 50) || $familyFreshPct >= 50) {
|
||||||
// Fresh stock from this product or same-family products is adequate.
|
// Fresh stock from this product or same-family products is adequate.
|
||||||
// The expired batch will show in the dashboard expiry banner — don't add to shopping list.
|
// The expired batch will show in the dashboard expiry banner — don't add to shopping list.
|
||||||
} else {
|
} elseif ($isRegular || $buyCount >= 2) {
|
||||||
|
// Only suggest restocking if this is a product the user buys regularly.
|
||||||
|
// If it expired without ever being a staple, the expiry banner is enough.
|
||||||
$urgency = 'critical';
|
$urgency = 'critical';
|
||||||
$reasons[] = 'Scaduto!';
|
$reasons[] = 'Scaduto!';
|
||||||
$score += 90;
|
$score += 90;
|
||||||
}
|
}
|
||||||
} elseif ($isExpiringSoon && $qty > 0 && $pctLeft < 50) {
|
// else: one-off product expired unused → expiry banner handles it, no shopping noise
|
||||||
// Only flag "expiring soon" if stock is also low (<50%). If you have plenty of
|
} elseif ($isExpiringSoon && $qty > 0) {
|
||||||
// stock (e.g. just bought fresh produce that naturally expires in 3 days), the
|
// Flag if:
|
||||||
// shopping list is not the right place — the expiry banner handles it.
|
// (a) regular consumer + stock low (<50%) → needs restock soon
|
||||||
if ($urgency === 'none') $urgency = 'medium';
|
// (b) regular consumer + will expire before finishing it
|
||||||
$reasons[] = 'Scade tra ' . max(0, round($daysToExpiry)) . 'gg';
|
// (daysLeft based on consumption rate > days to expiry)
|
||||||
$score += 40;
|
// (c) non-regular + within 3 days + low stock → minimal safety net
|
||||||
|
$willExpireBeforeUsed = $dailyRate > 0 && $daysToExpiry < $daysLeft;
|
||||||
|
if ($isRegular && ($pctLeft < 50 || $willExpireBeforeUsed)) {
|
||||||
|
if ($urgency === 'none') $urgency = 'medium';
|
||||||
|
if ($willExpireBeforeUsed && $pctLeft >= 50) {
|
||||||
|
// Has stock but won't finish it in time → buy fresh and use this one now
|
||||||
|
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg — ricompra';
|
||||||
|
} else {
|
||||||
|
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg';
|
||||||
|
}
|
||||||
|
$score += 40;
|
||||||
|
} elseif (!$isRegular && $daysToExpiry <= 3 && $pctLeft < 50) {
|
||||||
|
// Non-regular product: only flag when very close and running low
|
||||||
|
if ($urgency === 'none') $urgency = 'low';
|
||||||
|
$reasons[] = 'Scade in ' . max(1, round($daysToExpiry)) . 'gg';
|
||||||
|
$score += 20;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frequently used but stock getting low (predictive) — scale urgency by imminence
|
// Frequently used but stock getting low (predictive) — scale urgency by imminence
|
||||||
@@ -8983,11 +9102,24 @@ function smartShopping(PDO $db): void {
|
|||||||
$daysLeftDisplay = (int)round($daysLeft);
|
$daysLeftDisplay = (int)round($daysLeft);
|
||||||
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg';
|
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg';
|
||||||
if ($daysLeftDisplay <= 3) {
|
if ($daysLeftDisplay <= 3) {
|
||||||
// Running out within 3 days for a frequent product → high urgency
|
|
||||||
$urgency = 'high';
|
$urgency = 'high';
|
||||||
$score += 70;
|
$score += 70;
|
||||||
} elseif ($daysLeftDisplay <= 7) {
|
} elseif ($daysLeftDisplay <= 7) {
|
||||||
// Running out within a week → medium
|
$urgency = 'medium';
|
||||||
|
$score += 45;
|
||||||
|
} else {
|
||||||
|
$urgency = 'low';
|
||||||
|
$score += 25;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Buy-cycle prediction for products not tracked per-use (e.g. salt, spices):
|
||||||
|
// if daily rate was derived from buy cycles and we have < 21 days left → flag.
|
||||||
|
if ($urgency === 'none' && $buyCycleDays !== null && $dailyRate > 0
|
||||||
|
&& $daysLeft <= 21 && $isRegular && !$justRestocked) {
|
||||||
|
$daysLeftDisplay = (int)round($daysLeft);
|
||||||
|
$cycleDisplay = (int)round($buyCycleDays);
|
||||||
|
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg (ciclo medio ' . $cycleDisplay . 'gg)';
|
||||||
|
if ($daysLeftDisplay <= 7) {
|
||||||
$urgency = 'medium';
|
$urgency = 'medium';
|
||||||
$score += 45;
|
$score += 45;
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
Reference in New Issue
Block a user