fix: finished banner only fires when transaction balance is suspicious

getFinishedItems now:
- Computes total_in - total_out for every qty=0 row
- If balance <= unit threshold (e.g. <20g, <0.1 conf): product was
  legitimately used up → silently DELETE, no banner shown
- Only if balance > threshold (unexpected zero): return to frontend
  so banner asks user to verify
Banner detail now shows the expected residual qty so user understands
why the alert fired.
This commit is contained in:
dadaloop82
2026-04-27 05:47:11 +00:00
parent 36f6fcd232
commit 61e7d7d4bf
2 changed files with 44 additions and 5 deletions
+42 -4
View File
@@ -1157,23 +1157,61 @@ function deleteInventory(PDO $db): void {
} }
/** /**
* Returns products whose entire inventory is at quantity = 0 * Returns products whose entire inventory is at quantity = 0 AND whose
* (auto-set when stock ran out, pending user confirmation to permanently remove). * transaction balance (total_in - total_out) is still significantly positive —
* meaning the system suspects the product ran out prematurely (scale drift,
* missed registration, etc.).
*
* Products where the balance is at/near zero are legitimately finished by the
* user; those rows are silently deleted here (no banner needed).
*/ */
function getFinishedItems(PDO $db): void { function getFinishedItems(PDO $db): void {
$rows = $db->query(" $rows = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, p.image_url, SELECT p.id AS product_id, p.name, p.brand, p.unit, p.default_quantity, p.package_unit, p.image_url,
MIN(i.location) AS location, MIN(i.location) AS location,
MAX(i.updated_at) AS updated_at 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
FROM products p FROM products p
JOIN inventory i ON i.product_id = p.id JOIN inventory i ON i.product_id = p.id
LEFT JOIN transactions t ON t.product_id = p.id
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0 SELECT 1 FROM inventory i2 WHERE i2.product_id = p.id AND i2.quantity > 0
) )
GROUP BY p.id GROUP BY p.id
ORDER BY MAX(i.updated_at) DESC ORDER BY MAX(i.updated_at) DESC
")->fetchAll(PDO::FETCH_ASSOC); ")->fetchAll(PDO::FETCH_ASSOC);
echo json_encode(['success' => true, 'finished' => $rows], JSON_UNESCAPED_UNICODE);
// 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;
if ($expected > $threshold) {
// Transaction balance says stock should remain — show banner
$suspicious[] = [
'product_id' => (int)$r['product_id'],
'name' => $r['name'],
'brand' => $r['brand'],
'unit' => $r['unit'],
'default_quantity' => $r['default_quantity'],
'package_unit' => $r['package_unit'],
'image_url' => $r['image_url'],
'location' => $r['location'],
'updated_at' => $r['updated_at'],
'expected_qty' => round($expected, 3),
];
} else {
// Legitimately finished — delete silently, no banner
$db->prepare("DELETE FROM inventory WHERE product_id = ? AND quantity = 0")
->execute([$r['product_id']]);
}
}
echo json_encode(['success' => true, 'finished' => $suspicious], JSON_UNESCAPED_UNICODE);
} }
/** /**
+2 -1
View File
@@ -2557,7 +2557,8 @@ function renderBannerItem() {
banner.className = 'alert-banner banner-finished'; banner.className = 'alert-banner banner-finished';
iconEl.textContent = '📦'; iconEl.textContent = '📦';
titleEl.textContent = `${fin.name}${fin.brand ? ' (' + fin.brand + ')' : ''}${t('dashboard.banner_finished_title')}`; titleEl.textContent = `${fin.name}${fin.brand ? ' (' + fin.brand + ')' : ''}${t('dashboard.banner_finished_title')}`;
detailEl.textContent = t('dashboard.banner_finished_detail', { name: fin.name }); const expectedText = fin.expected_qty ? ` Secondo le registrazioni dovresti averne ancora <strong>${fin.expected_qty} ${fin.unit}</strong>.` : '';
detailEl.innerHTML = `L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.${expectedText} Puoi controllare?`;
let btns = `<button class="btn-banner btn-banner-ok" onclick="confirmBannerFinished()">${t('dashboard.banner_finished_action_yes')}</button>`; let btns = `<button class="btn-banner btn-banner-ok" onclick="confirmBannerFinished()">${t('dashboard.banner_finished_action_yes')}</button>`;
btns += `<button class="btn-banner btn-banner-edit" onclick="notFinishedBannerAction()">${t('dashboard.banner_finished_action_no')}</button>`; btns += `<button class="btn-banner btn-banner-edit" onclick="notFinishedBannerAction()">${t('dashboard.banner_finished_action_no')}</button>`;
actionsEl.innerHTML = btns; actionsEl.innerHTML = btns;