feat: anomaly detection banner - notifica incongruenze inventario/transazioni

- New API endpoint 'inventory_anomalies': detects items where stored qty
  differs from tx history by >20% AND >50 units (phantom qty or missing qty)
- New API endpoint 'dismiss_anomaly': persists dismissal in anomaly_dismissed.json
- Banner system: new 'anomaly' type shown in dashboard alert banner
  with 'Correggi' (opens edit) and 'Ok, ignora' (dismisses) buttons
- CSS: banner-anomaly style (orange gradient)
- Fix: lo zucchero azzerato (175g fantasma rimossi), aggiunto a Bring!
This commit is contained in:
dadaloop82
2026-04-21 12:34:54 +00:00
parent 234cae14bc
commit f4a62ef496
4 changed files with 157 additions and 3 deletions
+102
View File
@@ -202,6 +202,14 @@ try {
getConsumptionPredictions($db); getConsumptionPredictions($db);
break; break;
case 'inventory_anomalies':
getInventoryAnomalies($db);
break;
case 'dismiss_anomaly':
dismissInventoryAnomaly();
break;
case 'recent_popular_products': case 'recent_popular_products':
recentPopularProducts($db); recentPopularProducts($db);
break; break;
@@ -1261,6 +1269,100 @@ function undoTransaction(PDO $db): void {
// ===== STATS ===== // ===== STATS =====
/**
* Detect inventory items where the stored quantity is significantly inconsistent
* with the transaction history (sum of in - sum of out/waste).
*
* Two anomaly directions:
* - PHANTOM (+diff): inventory > tx balance → quantity was manually inflated without an 'in' tx
* - MISSING (-diff): inventory < tx balance → tx history says more should be here than stored
*/
function getInventoryAnomalies(PDO $db): void {
$rows = $db->query("
SELECT p.id AS product_id, p.name, p.brand, p.unit,
p.default_quantity, p.package_unit,
i.id AS inventory_id, i.quantity AS inv_qty, i.location,
COALESCE(tx_in.tot, 0) AS total_in,
COALESCE(tx_out.tot, 0) AS total_out
FROM inventory i
JOIN products p ON p.id = i.product_id
LEFT JOIN (
SELECT product_id, SUM(quantity) AS tot
FROM transactions WHERE type = 'in' AND undone = 0 GROUP BY product_id
) tx_in ON tx_in.product_id = p.id
LEFT JOIN (
SELECT product_id, SUM(quantity) AS tot
FROM transactions WHERE type IN ('out','waste') AND undone = 0 GROUP BY product_id
) tx_out ON tx_out.product_id = p.id
WHERE i.quantity > 0
")->fetchAll(PDO::FETCH_ASSOC);
// Anomaly dismissed keys stored in a simple JSON file
$dismissFile = __DIR__ . '/../data/anomaly_dismissed.json';
$dismissed = [];
if (file_exists($dismissFile)) {
$dismissed = json_decode(file_get_contents($dismissFile), true) ?: [];
}
$anomalies = [];
foreach ($rows as $r) {
$invQty = floatval($r['inv_qty']);
$expected = floatval($r['total_in']) - floatval($r['total_out']);
$diff = $invQty - $expected;
// Threshold: difference must be >20% of inventory AND >50 units (avoid noise)
$threshold = max(1.0, $invQty * 0.20);
if (abs($diff) <= $threshold || abs($diff) <= 50) continue;
// Dismiss key: product_id + rounded expected (so re-adding stock resets the alert)
$key = 'a_' . $r['product_id'] . '_' . round($expected);
if (!empty($dismissed[$key])) continue;
$direction = $diff > 0 ? 'phantom' : 'missing';
$anomalies[] = [
'inventory_id' => (int)$r['inventory_id'],
'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'],
'inv_qty' => round($invQty, 2),
'expected_qty' => round($expected, 2),
'diff' => round($diff, 2),
'direction' => $direction,
'dismiss_key' => $key,
];
}
// Sort: largest absolute diff first
usort($anomalies, fn($a, $b) => abs($b['diff']) <=> abs($a['diff']));
echo json_encode(['success' => true, 'anomalies' => $anomalies], JSON_UNESCAPED_UNICODE);
}
/**
* Dismiss a specific anomaly so it no longer appears in the banner.
*/
function dismissInventoryAnomaly(): void {
$input = json_decode(file_get_contents('php://input'), true);
$key = $input['dismiss_key'] ?? '';
if (empty($key) || !preg_match('/^a_\d+_-?\d+$/', $key)) {
echo json_encode(['success' => false, 'error' => 'Invalid key']);
return;
}
$dismissFile = __DIR__ . '/../data/anomaly_dismissed.json';
$dismissed = [];
if (file_exists($dismissFile)) {
$dismissed = json_decode(file_get_contents($dismissFile), true) ?: [];
}
$dismissed[$key] = time();
// Clean up entries older than 90 days
$dismissed = array_filter($dismissed, fn($ts) => $ts > time() - 90 * 86400);
file_put_contents($dismissFile, json_encode($dismissed), LOCK_EX);
echo json_encode(['success' => true]);
}
function getStats(PDO $db): void { function getStats(PDO $db): void {
$totalProducts = $db->query("SELECT COUNT(*) FROM products")->fetchColumn(); $totalProducts = $db->query("SELECT COUNT(*) FROM products")->fetchColumn();
$totalItems = $db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory")->fetchColumn(); $totalItems = $db->query("SELECT COALESCE(SUM(quantity), 0) FROM inventory")->fetchColumn();
+6
View File
@@ -4525,6 +4525,12 @@ body {
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%); background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
border-color: #8b5cf6; border-color: #8b5cf6;
} }
.alert-banner.banner-anomaly {
background: linear-gradient(135deg, #fff7ed 0%, #fed7aa 100%);
border-color: #ea580c;
}
.banner-anomaly .alert-banner-title { color: #9a3412; }
.banner-anomaly .alert-banner-counter .banner-dot.active { background: #ea580c; }
.alert-banner-inner { .alert-banner-inner {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
+47 -1
View File
@@ -2354,9 +2354,10 @@ async function loadBannerAlerts() {
if (!banner) { console.warn('[Banner] #alert-banner not found'); return; } if (!banner) { console.warn('[Banner] #alert-banner not found'); return; }
try { try {
const [invData, predData] = await Promise.all([ const [invData, predData, anomalyData] = await Promise.all([
api('inventory_list'), api('inventory_list'),
api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }), api('consumption_predictions').catch(err => { console.warn('[Banner] predictions fetch failed:', err); return { predictions: [] }; }),
api('inventory_anomalies').catch(err => { console.warn('[Banner] anomalies fetch failed:', err); return { anomalies: [] }; }),
]); ]);
const items = invData.inventory || []; const items = invData.inventory || [];
const confirmed = getReviewConfirmed(); const confirmed = getReviewConfirmed();
@@ -2401,6 +2402,13 @@ async function loadBannerAlerts() {
_bannerQueue.push({ type: 'prediction', data: pred }); _bannerQueue.push({ type: 'prediction', data: pred });
}); });
// 5. Inventory anomalies (qty doesn't match transaction history)
const anomalies = anomalyData.anomalies || [];
anomalies.forEach(an => {
if (confirmed['an_' + an.dismiss_key]) return;
_bannerQueue.push({ type: 'anomaly', data: an });
});
// Sort by priority (highest first) // Sort by priority (highest first)
_bannerQueue.sort((a, b) => _bannerPriority(b) - _bannerPriority(a)); _bannerQueue.sort((a, b) => _bannerPriority(b) - _bannerPriority(a));
@@ -2453,6 +2461,10 @@ function _bannerPriority(entry) {
// Higher deviation = more important, capped at 99 // Higher deviation = more important, capped at 99
return 100 + Math.min(dev, 99); return 100 + Math.min(dev, 99);
} }
case 'anomaly': {
// Phantom (inflated qty) = 250, Missing = 260 (slightly higher, means data is clearly wrong)
return entry.data.direction === 'missing' ? 260 : 250;
}
default: default:
return 0; return 0;
} }
@@ -2530,6 +2542,23 @@ function renderBannerItem() {
btns += `<button class="btn-banner btn-banner-weigh" onclick="weighBannerItem()">⚖️ ${t('dashboard.banner_prediction_action_weigh')}</button>`; btns += `<button class="btn-banner btn-banner-weigh" onclick="weighBannerItem()">⚖️ ${t('dashboard.banner_prediction_action_weigh')}</button>`;
} }
actionsEl.innerHTML = btns; actionsEl.innerHTML = btns;
} else if (entry.type === 'anomaly') {
const an = entry.data;
const diffAbs = Math.abs(an.diff);
const diffDisplay = `${diffAbs} ${an.unit}`;
const isPhantom = an.direction === 'phantom';
banner.className = 'alert-banner banner-anomaly';
iconEl.textContent = '🔍';
titleEl.textContent = `Anomalia inventario: ${an.name}${an.brand ? ' (' + an.brand + ')' : ''}`;
if (isPhantom) {
detailEl.innerHTML = `Inventario: <strong>${an.inv_qty}${an.unit}</strong> ma le transazioni ne giustificano solo <strong>${an.expected_qty}${an.unit}</strong> (+${diffDisplay} fantasma)`;
} else {
detailEl.innerHTML = `Le transazioni indicano <strong>${an.expected_qty}${an.unit}</strong> ma l'inventario ha solo <strong>${an.inv_qty}${an.unit}</strong> (mancano ${diffDisplay})`;
}
let btns = `<button class="btn-banner btn-banner-edit" onclick="editBannerAnomaly()">✏️ Correggi</button>`;
btns += `<button class="btn-banner btn-banner-ok" onclick="dismissBannerAnomaly()">✓ Ok, ignora</button>`;
actionsEl.innerHTML = btns;
} }
if (_bannerQueue.length > 1) { if (_bannerQueue.length > 1) {
@@ -2585,6 +2614,23 @@ function editBannerPrediction() {
editReviewItem(entry.data.inventory_id, entry.data.product_id); editReviewItem(entry.data.inventory_id, entry.data.product_id);
} }
function editBannerAnomaly() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'anomaly') return;
_bannerEditPending = true;
editReviewItem(entry.data.inventory_id, entry.data.product_id);
}
function dismissBannerAnomaly() {
const entry = _bannerQueue[_bannerIndex];
if (!entry || entry.type !== 'anomaly') return;
const key = entry.data.dismiss_key;
setReviewConfirmed('an_' + key);
api('dismiss_anomaly', {}, 'POST', { dismiss_key: key }).catch(() => {});
showToast('Anomalia ignorata', 'info');
dismissBannerItem();
}
function weighBannerItem() { function weighBannerItem() {
const entry = _bannerQueue[_bannerIndex]; const entry = _bannerQueue[_bannerIndex];
if (!entry) return; if (!entry) return;
+2 -2
View File
@@ -11,7 +11,7 @@
<title>EverShelf</title> <title>EverShelf</title>
<link rel="manifest" href="manifest.json"> <link rel="manifest" href="manifest.json">
<link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>"> <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🏠</text></svg>">
<link rel="stylesheet" href="assets/css/style.css?v=20260420a"> <link rel="stylesheet" href="assets/css/style.css?v=20260421a">
<!-- QuaggaJS for barcode scanning --> <!-- QuaggaJS for barcode scanning -->
<script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/@ericblade/quagga2@1.8.4/dist/quagga.min.js"></script>
</head> </head>
@@ -1288,6 +1288,6 @@
</div> </div>
</div> </div>
<script src="assets/js/app.js?v=20260420a"></script> <script src="assets/js/app.js?v=20260421a"></script>
</body> </body>
</html> </html>