Release v1.7.38: stable shopping total and finished-product Bring sync.

Add depleted products under generic shopping names, unify weekly canonical price total across all surfaces, and fix screensaver amount mismatch.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dadaloop82
2026-06-04 18:10:24 +00:00
parent b63deca795
commit 7eda4a5eb9
7 changed files with 413 additions and 391 deletions
+344 -344
View File
@@ -1570,66 +1570,49 @@ function haInventorySensor(PDO $db): void {
$daysToNextExpiry = (int)$diff->format('%r%a');
}
// 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.
// Shopping total from canonical weekly cache (same source as UI and screensaver).
$priceEnabled = env('PRICE_ENABLED', 'false') === 'true';
$priceCurrency = env('PRICE_CURRENCY', 'EUR');
$shoppingTotal = null;
if ($priceEnabled) {
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
if (file_exists($totalCachePath)) {
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
$best = null; $bestTs = 0;
foreach ($tc as $entry) {
if (isset($entry['ts']) && $entry['ts'] > $bestTs) {
$bestTs = $entry['ts'];
$best = $entry;
$country = env('PRICE_COUNTRY', 'Italia');
$shopNames = [];
if (isShoppingBringMode()) {
$auth = bringAuth();
if ($auth) {
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}");
foreach ($listData['purchase'] ?? [] as $item) {
$shopNames[] = bringToItalian($item['name'] ?? '');
}
}
if ($best && (time() - $bestTs) < 86400) {
$shoppingTotal = round((float)($best['result']['total'] ?? 0), 2);
} else {
$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);
$seenNames = [];
foreach ($shopRows as $r) {
$sname = $r['sname'] ?? $r['name'];
if (isset($seenNames[$sname])) continue;
$seenNames[$sname] = true;
$shopNames[] = $sname;
}
}
// 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));
}
if (!empty($shopNames)) {
$listHash = _shoppingListHash($shopNames, $country, $priceCurrency);
$cached = _loadCanonicalShoppingTotal($listHash);
if ($cached !== null) {
$shoppingTotal = round((float)($cached['total'] ?? 0), 2);
} else {
$computed = _computeAllShoppingPrices(
array_map(static fn($n) => ['name' => $n], $shopNames),
$country,
$priceCurrency,
'it',
false
);
$shoppingTotal = round((float)($computed['total'] ?? 0), 2);
}
}
}
@@ -1810,15 +1793,15 @@ function haRefreshPrices(PDO $db): void {
try {
$country = env('PRICE_COUNTRY', 'Italia');
$currency = env('PRICE_CURRENCY', 'EUR');
$lang = 'it';
// Get shopping list
$shoppingItems = [];
$clientItems = [];
if (isShoppingBringMode()) {
$auth = bringAuth();
if ($auth) {
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$auth['bringListUUID']}");
foreach ($listData['purchase'] ?? [] as $item) {
$shoppingItems[] = ['name' => $item['name'], 'quantity' => 1, 'unit' => 'pz', 'default_quantity' => 0, 'package_unit' => ''];
$clientItems[] = ['name' => bringToItalian($item['name'] ?? '')];
}
}
} else {
@@ -1827,45 +1810,24 @@ function haRefreshPrices(PDO $db): void {
FROM shopping_list sl
LEFT JOIN products p ON lower(p.name) = lower(sl.name)
")->fetchAll(PDO::FETCH_ASSOC);
$seenSnamesHa = [];
$seen = [];
foreach ($rows as $r) {
$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' => ''];
if (isset($seen[$sname])) continue;
$seen[$sname] = true;
$clientItems[] = ['name' => $sname];
}
}
$priceCache = _loadPriceCache();
$total = 0.0;
$priced = 0;
$missing = [];
foreach ($shoppingItems as $item) {
$key = _priceKey($item['name'], $country);
$key0 = md5(mb_strtolower(trim($item['name'])) . '|' . mb_strtolower(trim($country)));
$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']);
$total += $est ?? 0;
$priced++;
} else {
$missing[] = $item['name'];
}
}
$total = round($total, 2);
// Persist to total cache
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
$result = ['success' => true, 'total' => $total, 'total_label' => _formatPrice($total, $currency), 'priced_items' => $priced, 'missing_items' => count($missing)];
$tc = file_exists($totalCachePath) ? (json_decode(file_get_contents($totalCachePath), true) ?? []) : [];
$key = 'ha_refresh_' . date('Ymd');
$tc[$key] = ['ts' => time(), 'result' => $result];
if (count($tc) >= 10) $tc = array_slice($tc, -9, null, true);
file_put_contents($totalCachePath, json_encode($tc, JSON_UNESCAPED_UNICODE));
echo json_encode($result, JSON_UNESCAPED_UNICODE);
$result = _computeAllShoppingPrices($clientItems, $country, $currency, $lang, false);
$priced = count(array_filter($result['prices'] ?? [], static fn($e) => !empty($e['price_per_unit'])));
echo json_encode([
'success' => true,
'total' => $result['total'] ?? 0,
'total_label' => $result['total_label'] ?? _formatPrice(0, $currency),
'priced_items' => $priced,
'missing_items' => max(0, count($clientItems) - $priced),
], JSON_UNESCAPED_UNICODE);
} catch (Throwable $e) {
http_response_code(500);
echo json_encode(['error' => $e->getMessage()]);
@@ -3134,83 +3096,10 @@ function useFromInventory(PDO $db): void {
$stmt = $db->prepare("SELECT SUM(quantity) as total FROM inventory WHERE product_id = ? AND quantity > 0");
$stmt->execute([$productId]);
$totalLeft = (float)($stmt->fetchColumn() ?: 0);
if ($totalLeft <= 0) {
// Get product name, brand and shopping_name for Bring!
$stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?");
$stmt->execute([$productId]);
$product = $stmt->fetch();
if ($product) {
// Before adding to Bring!, check if the shopping_name family already
// has adequate stock from OTHER products (e.g. "Sale marino iodato" depleted
// but "Sale alimentare" has 1kg → no need to add to shopping list).
$sNameKey = strtolower(trim($product['shopping_name'] ?? ''));
$familyCoverage = 0;
if ($sNameKey !== '') {
$covStmt = $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 i.quantity > 0
");
$covStmt->execute([$sNameKey, $productId]);
$familyCoverage = (float)($covStmt->fetchColumn() ?: 0);
}
if ($familyCoverage > 0) {
// Family has stock — no need to restock, suppress Bring! add.
// Set addedToBring=true so the JS fallback is also suppressed.
$addedToBring = true;
} else {
try {
$auth = bringAuth();
if ($auth) {
$listUUID = $auth['bringListUUID'];
// Use the generic shopping name for Bring! (e.g. "Latte", "Affettato")
$genericName = $product['shopping_name'] ?: computeShoppingName($product['name'], '', $product['brand']);
$bringName = italianToBring($genericName);
// Check if already on the Bring! list
$alreadyOnList = false;
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
if ($listData && isset($listData['purchase'])) {
foreach ($listData['purchase'] as $existingItem) {
if (strcasecmp($existingItem['name'] ?? '', $bringName) === 0) {
$alreadyOnList = true;
break;
}
}
}
if ($alreadyOnList) {
// Already on the list, skip adding
$addedToBring = false;
} else {
// Specification: specific product name (and brand) so the user knows which variant
// Add 🛒 marker so the cron cleanup can auto-remove if no longer needed.
$spec = $genericName !== $product['name']
? $product['name'] . ($product['brand'] ? ' · ' . $product['brand'] : '') . ' · 🛒 Esaurito'
: ($product['brand'] ?: $product['name']) . ' · 🛒 Esaurito';
$body = http_build_query([
'uuid' => $listUUID,
'purchase' => $bringName,
'specification' => $spec,
]);
$result = bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body);
$addedToBring = ($result !== null);
// Log Bring! addition
if ($addedToBring) {
$logStmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'bring', 0, '', 'Auto-aggiunto a Bring!')");
$logStmt->execute([$productId]);
}
} // end else (not already on list)
}
} catch (Exception $e) {
// Silently fail — don't block inventory operation
}
} // end else (family not covered)
}
$bringResult = bringAddDepletedProduct($db, $productId);
$addedToBring = !empty($bringResult['added']) || !empty($bringResult['updated']);
}
}
@@ -3611,7 +3500,9 @@ function confirmFinished(PDO $db): void {
}
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity <= 0")->execute([$productId]);
echo json_encode(['success' => true]);
$bring = bringAddDepletedProduct($db, $productId);
echo json_encode(['success' => true, 'bring' => $bring], JSON_UNESCAPED_UNICODE);
}
/**
@@ -4762,6 +4653,7 @@ function getServerSettings(): void {
'price_country' => env('PRICE_COUNTRY', 'Italia'),
'price_currency' => env('PRICE_CURRENCY', 'EUR'),
'price_update_months' => (int)env('PRICE_UPDATE_MONTHS', '3'),
'price_update_weeks' => (int)env('PRICE_UPDATE_WEEKS', '1'),
'recipe_retention_days' => (int)env('RECIPE_RETENTION_DAYS', '7'),
'transaction_retention_days' => (int)env('TRANSACTION_RETENTION_DAYS', '90'),
'vacuum_expiry_extension_days' => (int)env('VACUUM_EXPIRY_EXTENSION_DAYS', '30'),
@@ -8624,6 +8516,116 @@ function bringGetList(): void {
}
}
/**
* Add or update a depleted product on Bring! under its generic shopping_name.
* If the generic item is already on the list, appends the specific variant to the specification.
*/
function bringAddDepletedProduct(PDO $db, int $productId): array {
$out = ['added' => false, 'updated' => false, 'skipped' => false, 'generic_name' => ''];
$stmt = $db->prepare("SELECT name, brand, shopping_name FROM products WHERE id = ?");
$stmt->execute([$productId]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$product) {
$out['skipped'] = true;
return $out;
}
$sNameKey = strtolower(trim($product['shopping_name'] ?? ''));
if ($sNameKey !== '') {
$covStmt = $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 i.quantity > 0
");
$covStmt->execute([$sNameKey, $productId]);
if ((float)($covStmt->fetchColumn() ?: 0) > 0) {
$out['skipped'] = true;
return $out;
}
}
$auth = bringAuth();
if (!$auth) {
$out['skipped'] = true;
return $out;
}
$listUUID = $auth['bringListUUID'] ?? '';
if ($listUUID === '') {
$out['skipped'] = true;
return $out;
}
$genericName = $product['shopping_name'] ?: computeShoppingName($product['name'], '', $product['brand'] ?? '');
$out['generic_name'] = $genericName;
$bringName = italianToBring($genericName);
$bringKey = strtolower($bringName);
$specificLine = $genericName !== $product['name']
? $product['name'] . (!empty($product['brand']) ? ' · ' . $product['brand'] : '')
: (!empty($product['brand']) ? $product['brand'] : $product['name']);
$finishedMarker = '🛒 Esaurito';
$listData = bringRequest('GET', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}");
$existingSpec = '';
$alreadyOnList = false;
if ($listData && isset($listData['purchase'])) {
foreach ($listData['purchase'] as $existingItem) {
if (strcasecmp($existingItem['name'] ?? '', $bringName) === 0) {
$alreadyOnList = true;
$existingSpec = $existingItem['specification'] ?? '';
break;
}
}
}
if ($alreadyOnList) {
$newSpec = $existingSpec;
if ($specificLine !== '' && mb_stripos($existingSpec, $specificLine) === false) {
$base = trim(preg_replace('/\s*·\s*🛒\s*Esaurito\s*$/u', '', $existingSpec) ?? $existingSpec);
$newSpec = $base !== ''
? $base . ' · ' . $specificLine . ' · ' . $finishedMarker
: $specificLine . ' · ' . $finishedMarker;
} elseif ($existingSpec === '' || mb_stripos($existingSpec, $finishedMarker) === false) {
$newSpec = trim($existingSpec) !== ''
? trim($existingSpec) . ' · ' . $finishedMarker
: $specificLine . ' · ' . $finishedMarker;
}
if ($newSpec === $existingSpec) {
$out['skipped'] = true;
return $out;
}
$body = http_build_query([
'uuid' => $listUUID,
'purchase' => $bringName,
'specification' => $newSpec,
]);
if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) {
$out['updated'] = true;
@unlink(__DIR__ . '/../data/smart_shopping_cache.json');
}
return $out;
}
$spec = $genericName !== $product['name']
? $specificLine . ' · ' . $finishedMarker
: $specificLine . ' · ' . $finishedMarker;
$body = http_build_query([
'uuid' => $listUUID,
'purchase' => $bringName,
'specification' => $spec,
]);
if (bringRequest('PUT', "https://api.getbring.com/rest/v2/bringlists/{$listUUID}", $body) !== null) {
$out['added'] = true;
$logStmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'bring', 0, '', 'Auto-aggiunto a Bring!')");
$logStmt->execute([$productId]);
@unlink(__DIR__ . '/../data/smart_shopping_cache.json');
_fireHaWebhook('shopping_add', ['item' => $genericName, 'specification' => $spec]);
}
return $out;
}
function bringAddItems(): void {
$auth = bringAuth();
if (!$auth) {
@@ -10936,6 +10938,179 @@ function _priceKey(string $name, string $country): string {
return md5(mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($country)) . '|v3');
}
/** Max age for cached unit prices and canonical shopping total (default: 1 week). */
function _shoppingPriceMaxAgeSeconds(): int {
$weeks = (int)env('PRICE_UPDATE_WEEKS', '1');
if ($weeks > 0) return $weeks * 7 * 86400;
$months = (int)env('PRICE_UPDATE_MONTHS', '3');
return max(7 * 86400, $months * 30 * 86400);
}
function _shoppingListHash(array $names, string $country, string $currency): string {
$sorted = array_values(array_unique(array_map(
static fn($n) => mb_strtolower(trim((string)$n)),
array_filter($names, static fn($n) => trim((string)$n) !== '')
)));
sort($sorted);
return md5(json_encode($sorted, JSON_UNESCAPED_UNICODE) . '|' . mb_strtolower(trim($country)) . '|' . mb_strtolower(trim($currency)));
}
function _loadCanonicalShoppingTotal(string $listHash): ?array {
$path = __DIR__ . '/../data/shopping_total_cache.json';
if (!file_exists($path)) return null;
$tc = json_decode(file_get_contents($path), true) ?? [];
$entry = $tc['_canonical'] ?? null;
if (!$entry || ($entry['list_hash'] ?? '') !== $listHash) return null;
if (time() - (int)($entry['ts'] ?? 0) >= _shoppingPriceMaxAgeSeconds()) return null;
$result = $entry['result'] ?? null;
return is_array($result) ? $result : null;
}
function _saveCanonicalShoppingTotal(string $listHash, array $result): void {
$path = __DIR__ . '/../data/shopping_total_cache.json';
$tc = file_exists($path) ? (json_decode(file_get_contents($path), true) ?? []) : [];
$tc['_canonical'] = ['ts' => time(), 'list_hash' => $listHash, 'result' => $result];
file_put_contents($path, json_encode($tc, JSON_UNESCAPED_UNICODE));
}
/**
* Stable shopping-list items for price totals: one retail unit per list entry.
* Avoids day-to-day swings from smart-shopping suggested quantities.
*/
function _shoppingListPriceItems(array $clientItems): array {
$items = [];
foreach ($clientItems as $ci) {
$name = trim($ci['name'] ?? '');
if ($name === '') continue;
$items[] = [
'name' => $name,
'quantity' => 1,
'unit' => 'conf',
'default_quantity' => 0,
'package_unit' => '',
];
}
return $items;
}
/**
* Compute shopping list prices + canonical total (shared by UI, HA and screensaver).
*/
function _computeAllShoppingPrices(array $clientItems, string $country, string $currency, string $lang, bool $forceRefresh): array {
$items = _shoppingListPriceItems($clientItems);
if (empty($items)) {
return [
'success' => true,
'prices' => [],
'total' => 0,
'total_label' => _formatPrice(0, $currency),
'from_total_cache' => false,
];
}
$names = array_column($items, 'name');
$listHash = _shoppingListHash($names, $country, $currency);
if (!$forceRefresh) {
$cached = _loadCanonicalShoppingTotal($listHash);
if ($cached !== null) {
$cached['from_total_cache'] = true;
return $cached;
}
}
$priceCache = _loadPriceCache();
$now = time();
$maxAge = _shoppingPriceMaxAgeSeconds();
$prices = [];
$total = 0.0;
$missing = [];
foreach ($items as $item) {
$name = $item['name'];
$key = _priceKey($name, $country);
$key0 = md5(mb_strtolower(trim($name)) . '|' . mb_strtolower(trim($country)));
$entry = $priceCache[$key] ?? $priceCache[$key0] ?? null;
if ($entry !== null && !$forceRefresh) {
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => true,
'_resolved_qty' => $item['quantity'],
'_resolved_unit' => $item['unit'],
]);
$total += $est ?? 0;
continue;
}
if ($entry !== null && $forceRefresh && ($now - (int)($entry['cached_at'] ?? 0)) < $maxAge) {
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => true,
'_resolved_qty' => $item['quantity'],
'_resolved_unit' => $item['unit'],
]);
$total += $est ?? 0;
continue;
}
if ($entry === null || $forceRefresh) {
$missing[] = $item;
}
}
if (!empty($missing)) {
$missingNames = array_column($missing, 'name');
$batchPrices = _fetchPricesBatchFromAI($missingNames, $country, $currency, $lang);
$missingByName = [];
foreach ($missing as $item) $missingByName[$item['name']] = $item;
foreach ($missingNames as $name) {
$item = $missingByName[$name];
$key = _priceKey($name, $country);
$priceData = $batchPrices[$name] ?? null;
if ($priceData && isset($priceData['price_per_unit'])) {
$entry = [
'name' => $name,
'price_per_unit' => (float)$priceData['price_per_unit'],
'unit_label' => $priceData['unit_label'] ?? 'pz',
'currency' => $currency,
'source_note' => $priceData['source_note'] ?? '',
'country' => $country,
'cached_at' => $now,
];
$priceCache[$key] = $entry;
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $item['quantity'], $item['unit'], $item['default_quantity'], $item['package_unit']);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => false,
'_resolved_qty' => $item['quantity'],
'_resolved_unit' => $item['unit'],
]);
$total += $est ?? 0;
} else {
$prices[$name] = ['name' => $name, 'error' => 'not_found', 'estimated_total' => null];
}
}
_savePriceCache($priceCache);
}
$total = round($total, 2);
$result = [
'success' => true,
'prices' => $prices,
'total' => $total,
'total_label' => _formatPrice($total, $currency),
'from_total_cache' => false,
'priced_at' => $now,
'valid_until' => $now + $maxAge,
];
_saveCanonicalShoppingTotal($listHash, $result);
return $result;
}
/**
* Ask Gemini for the estimated retail price per unit (kg, l, pz as appropriate)
* for a product in a given country/currency. Returns an array:
@@ -11082,7 +11257,7 @@ function getShoppingPrice(PDO $db): void {
$currency= trim($input['currency'] ?? env('PRICE_CURRENCY', 'EUR'));
$lang = trim($input['lang'] ?? 'it');
$forceRefresh = !empty($input['force_refresh']);
$updateMonths = (int)env('PRICE_UPDATE_MONTHS', '3');
$maxAge = _shoppingPriceMaxAgeSeconds();
if (empty($name)) {
echo json_encode(['success' => false, 'error' => 'missing name']);
@@ -11098,7 +11273,6 @@ function getShoppingPrice(PDO $db): void {
$cache = _loadPriceCache();
$key = _priceKey($name, $country);
$now = time();
$maxAge = $updateMonths * 30 * 86400;
// Use cache if fresh
if (!$forceRefresh && isset($cache[$key])) {
@@ -11148,7 +11322,6 @@ function getShoppingPrice(PDO $db): void {
*/
function getAllShoppingPrices(PDO $db): void {
EverLog::info('getAllShoppingPrices');
// This endpoint may call the AI for many items at once — extend timeout.
set_time_limit(120);
$input = json_decode(file_get_contents('php://input'), true) ?? [];
@@ -11156,182 +11329,9 @@ function getAllShoppingPrices(PDO $db): void {
$country = trim($input['country'] ?? env('PRICE_COUNTRY', 'Italia'));
$currency = trim($input['currency'] ?? env('PRICE_CURRENCY', 'EUR'));
$lang = trim($input['lang'] ?? 'it');
$forceRefresh = !empty($input['force_refresh']); // re-fetch AI prices (expensive, rarely used)
$forceTotal = !empty($input['force_total']); // bust only the 5-min total cache (fast)
$updateMonths = (int)env('PRICE_UPDATE_MONTHS', '3');
if (empty($clientItems)) {
echo json_encode(['success' => true, 'prices' => [], 'total' => 0, 'total_label' => _formatPrice(0, $currency)]);
return;
}
// ── Resolve qty/unit from server-side smart cache (source of truth) ──────
$smartItems = [];
$smartCacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
if (file_exists($smartCacheFile)) {
$raw = file_get_contents($smartCacheFile);
if ($raw) {
$sc = json_decode($raw, true);
if ($sc && isset($sc['items'])) $smartItems = $sc['items'];
}
}
// Build lookup: lowercase name/shopping_name → smart item
$smartByName = [];
foreach ($smartItems as $si) {
$smartByName[mb_strtolower($si['name'] ?? '')] = $si;
if (!empty($si['shopping_name'])) {
$smartByName[mb_strtolower($si['shopping_name'])] = $si;
}
}
// Build canonical items array using server-side qty/unit
$items = [];
foreach ($clientItems as $ci) {
$name = trim($ci['name'] ?? '');
if ($name === '') continue;
// 1) Exact match by name or shopping_name
$si = $smartByName[mb_strtolower($name)] ?? null;
// 2) Prefix-word fallback: "Salame" → "Salame Paesano", "Penne" → "Penne rigate"
// Match when the Bring! name is a word-prefix of a smart key (case-insensitive).
if ($si === null) {
$nameLower = mb_strtolower($name);
foreach ($smartByName as $smartKey => $candidate) {
// smartKey starts with the Bring! name (exact word boundary)
if (str_starts_with($smartKey, $nameLower)
&& (strlen($smartKey) === strlen($nameLower) || $smartKey[strlen($nameLower)] === ' ')) {
$si = $candidate;
break;
}
}
}
$items[] = [
'name' => $name,
'quantity' => (float)(($si['suggested_qty'] ?? $si['buy_qty'] ?? null) ?? ($ci['quantity'] ?? 1)),
'unit' => trim(($si['suggested_unit'] ?? $si['unit'] ?? null) ?? ($ci['unit'] ?? 'conf')),
'default_quantity' => (float)(($si['default_qty'] ?? null) ?? ($ci['default_quantity'] ?? 0)),
'package_unit' => trim(($si['package_unit'] ?? null) ?? ($ci['package_unit'] ?? '')),
];
}
// ── 5-minute server-side total cache ──────────────────────────────────────
// Key = hash of item names + resolved qty/unit + country (not force_refresh)
$totalCachePath = __DIR__ . '/../data/shopping_total_cache.json';
$totalCacheKey = md5(json_encode(array_map(
fn($i) => [$i['name'], $i['quantity'], $i['unit']],
$items
)) . $country . $currency);
if (!$forceRefresh && !$forceTotal && file_exists($totalCachePath)) {
$tc = json_decode(file_get_contents($totalCachePath), true) ?? [];
if (isset($tc[$totalCacheKey]) && (time() - ($tc[$totalCacheKey]['ts'] ?? 0)) < 300) {
$cached = $tc[$totalCacheKey]['result'];
$cached['from_total_cache'] = true;
echo json_encode($cached, JSON_UNESCAPED_UNICODE);
return;
}
}
// ── Price computation ─────────────────────────────────────────────────────
$priceCache = _loadPriceCache();
$now = time();
$maxAge = $updateMonths * 30 * 86400;
$prices = [];
$total = 0.0;
$missing = [];
// First pass: serve from cache
foreach ($items as $item) {
$name = $item['name'];
$qty = $item['quantity'];
$unit = $item['unit'];
$defQty = $item['default_quantity'];
$pkgUnit = $item['package_unit'];
$key = _priceKey($name, $country);
if (!$forceRefresh && isset($priceCache[$key])) {
$age = $now - ($priceCache[$key]['cached_at'] ?? 0);
if ($age < $maxAge) {
$entry = $priceCache[$key];
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'] ?? '', $qty, $unit, $defQty, $pkgUnit);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => true,
'_resolved_qty' => $qty,
'_resolved_unit' => $unit,
]);
$total += $est ?? 0;
continue;
}
}
$missing[] = $item;
}
// Second pass: fetch ALL missing items in ONE batch Gemini call
if (!empty($missing)) {
$missingNames = array_column($missing, 'name');
$batchPrices = _fetchPricesBatchFromAI($missingNames, $country, $currency, $lang);
// Build a lookup from item name → item params
$missingByName = [];
foreach ($missing as $item) $missingByName[$item['name']] = $item;
foreach ($missingNames as $name) {
$item = $missingByName[$name];
$qty = $item['quantity'];
$unit = $item['unit'];
$defQty = $item['default_quantity'];
$pkgUnit = $item['package_unit'];
$key = _priceKey($name, $country);
$priceData = $batchPrices[$name] ?? null;
if ($priceData && isset($priceData['price_per_unit'])) {
$entry = [
'name' => $name,
'price_per_unit' => (float)$priceData['price_per_unit'],
'unit_label' => $priceData['unit_label'] ?? 'pz',
'currency' => $currency,
'source_note' => $priceData['source_note'] ?? '',
'country' => $country,
'cached_at' => $now,
];
$priceCache[$key] = $entry;
$est = _calcEstimatedTotal($entry['price_per_unit'], $entry['unit_label'], $qty, $unit, $defQty, $pkgUnit);
$prices[$name] = array_merge($entry, [
'estimated_total' => $est,
'estimated_total_label' => $est !== null ? _formatPrice($est, $currency) : null,
'from_cache' => false,
'_resolved_qty' => $qty,
'_resolved_unit' => $unit,
]);
$total += $est ?? 0;
} else {
$prices[$name] = ['name' => $name, 'error' => 'not_found', 'estimated_total' => null];
}
}
}
_savePriceCache($priceCache);
$total = round($total, 2);
$result = [
'success' => true,
'prices' => $prices,
'total' => $total,
'total_label' => _formatPrice($total, $currency),
'from_total_cache' => false,
];
// Persist to total cache
$tc = file_exists($totalCachePath) ? (json_decode(file_get_contents($totalCachePath), true) ?? []) : [];
// Keep cache small: max 10 keys (different list configurations)
if (count($tc) >= 10) $tc = array_slice($tc, -9, null, true);
$tc[$totalCacheKey] = ['ts' => $now, 'result' => $result];
file_put_contents($totalCachePath, json_encode($tc, JSON_UNESCAPED_UNICODE));
$forceRefresh = !empty($input['force_refresh']);
$result = _computeAllShoppingPrices($clientItems, $country, $currency, $lang, $forceRefresh);
echo json_encode($result, JSON_UNESCAPED_UNICODE);
}