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:
@@ -4525,6 +4525,12 @@ body {
|
||||
background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%);
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
||||
+47
-1
@@ -2354,9 +2354,10 @@ async function loadBannerAlerts() {
|
||||
if (!banner) { console.warn('[Banner] #alert-banner not found'); return; }
|
||||
|
||||
try {
|
||||
const [invData, predData] = await Promise.all([
|
||||
const [invData, predData, anomalyData] = await Promise.all([
|
||||
api('inventory_list'),
|
||||
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 confirmed = getReviewConfirmed();
|
||||
@@ -2401,6 +2402,13 @@ async function loadBannerAlerts() {
|
||||
_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)
|
||||
_bannerQueue.sort((a, b) => _bannerPriority(b) - _bannerPriority(a));
|
||||
|
||||
@@ -2453,6 +2461,10 @@ function _bannerPriority(entry) {
|
||||
// Higher deviation = more important, capped at 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:
|
||||
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>`;
|
||||
}
|
||||
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) {
|
||||
@@ -2585,6 +2614,23 @@ function editBannerPrediction() {
|
||||
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() {
|
||||
const entry = _bannerQueue[_bannerIndex];
|
||||
if (!entry) return;
|
||||
|
||||
Reference in New Issue
Block a user