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:
+102
@@ -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();
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user