fix(alerts): limit alerts to 500 per type in SQL + frontend pagination with 'Ver más' + summary bar
This commit is contained in:
@@ -1111,9 +1111,18 @@ def api_alerts():
|
|||||||
"""Get stock alerts (zero, low, over)."""
|
"""Get stock alerts (zero, low, over)."""
|
||||||
conn = get_tenant_conn(g.tenant_id)
|
conn = get_tenant_conn(g.tenant_id)
|
||||||
branch_id = request.args.get('branch_id', g.branch_id)
|
branch_id = request.args.get('branch_id', g.branch_id)
|
||||||
alerts = get_alerts(conn, branch_id)
|
limit = min(int(request.args.get('limit', 500)), 2000)
|
||||||
|
alerts = get_alerts(conn, branch_id, limit_per_type=limit)
|
||||||
conn.close()
|
conn.close()
|
||||||
return jsonify({'data': alerts, 'count': len(alerts)})
|
|
||||||
|
# Count totals by severity for UI summary
|
||||||
|
counts = {'critical': 0, 'warning': 0, 'info': 0, 'total': 0}
|
||||||
|
for a in alerts:
|
||||||
|
counts['total'] += 1
|
||||||
|
if a['severity'] in counts:
|
||||||
|
counts[a['severity']] += 1
|
||||||
|
|
||||||
|
return jsonify({'data': alerts, 'count': len(alerts), 'counts': counts, 'limit_per_type': limit})
|
||||||
|
|
||||||
|
|
||||||
@inventory_bp.route('/items/<int:item_id>/history', methods=['GET'])
|
@inventory_bp.route('/items/<int:item_id>/history', methods=['GET'])
|
||||||
|
|||||||
@@ -272,38 +272,72 @@ def record_initial(conn, inventory_id, branch_id, quantity, cost=None):
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def get_alerts(conn, branch_id=None):
|
def get_alerts(conn, branch_id=None, limit_per_type=500):
|
||||||
"""Get stock alerts: zero stock, below minimum, above maximum."""
|
"""Get stock alerts: zero stock, below minimum, above maximum.
|
||||||
stock_map = get_stock_bulk(conn, branch_id)
|
Returns at most limit_per_type alerts per severity to avoid browser freeze.
|
||||||
|
"""
|
||||||
cur = conn.cursor()
|
cur = conn.cursor()
|
||||||
|
branch_filter = ""
|
||||||
where = "WHERE i.is_active = true"
|
|
||||||
params = []
|
params = []
|
||||||
if branch_id:
|
if branch_id:
|
||||||
where += " AND i.branch_id = %s"
|
branch_filter = " AND i.branch_id = %s"
|
||||||
params.append(branch_id)
|
params.append(branch_id)
|
||||||
|
|
||||||
|
# Use a single SQL query with window functions to rank and limit per type
|
||||||
cur.execute(f"""
|
cur.execute(f"""
|
||||||
SELECT i.id, i.part_number, i.name, i.min_stock, i.max_stock, i.branch_id
|
WITH stock AS (
|
||||||
FROM inventory i {where}
|
SELECT inventory_id, COALESCE(SUM(quantity), 0) AS qty
|
||||||
""", params)
|
FROM inventory_operations
|
||||||
|
GROUP BY inventory_id
|
||||||
|
),
|
||||||
|
alerts_raw AS (
|
||||||
|
SELECT
|
||||||
|
i.id AS inventory_id,
|
||||||
|
i.part_number,
|
||||||
|
i.name,
|
||||||
|
COALESCE(s.qty, 0) AS stock,
|
||||||
|
i.min_stock,
|
||||||
|
i.max_stock,
|
||||||
|
i.branch_id,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(s.qty, 0) <= 0 THEN 'zero'
|
||||||
|
WHEN i.min_stock IS NOT NULL AND COALESCE(s.qty, 0) < i.min_stock THEN 'low'
|
||||||
|
WHEN i.max_stock IS NOT NULL AND COALESCE(s.qty, 0) > i.max_stock THEN 'over'
|
||||||
|
END AS alert_type,
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(s.qty, 0) <= 0 THEN 'critical'
|
||||||
|
WHEN i.min_stock IS NOT NULL AND COALESCE(s.qty, 0) < i.min_stock THEN 'warning'
|
||||||
|
WHEN i.max_stock IS NOT NULL AND COALESCE(s.qty, 0) > i.max_stock THEN 'info'
|
||||||
|
END AS severity
|
||||||
|
FROM inventory i
|
||||||
|
LEFT JOIN stock s ON s.inventory_id = i.id
|
||||||
|
WHERE i.is_active = true {branch_filter}
|
||||||
|
),
|
||||||
|
ranked AS (
|
||||||
|
SELECT *,
|
||||||
|
ROW_NUMBER() OVER (PARTITION BY alert_type ORDER BY inventory_id) AS rn
|
||||||
|
FROM alerts_raw
|
||||||
|
WHERE alert_type IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT inventory_id, part_number, name, stock, min_stock, max_stock, branch_id, alert_type, severity
|
||||||
|
FROM ranked
|
||||||
|
WHERE rn <= %s
|
||||||
|
ORDER BY severity DESC, inventory_id
|
||||||
|
""", params + [limit_per_type])
|
||||||
|
|
||||||
alerts = []
|
alerts = []
|
||||||
for row in cur.fetchall():
|
for row in cur.fetchall():
|
||||||
inv_id, part_num, name, min_s, max_s, br_id = row
|
alerts.append({
|
||||||
stock = stock_map.get(inv_id, 0)
|
'inventory_id': row[0],
|
||||||
|
'part_number': row[1],
|
||||||
if stock <= 0:
|
'name': row[2],
|
||||||
alerts.append({'type': 'zero', 'severity': 'critical', 'inventory_id': inv_id,
|
'stock': row[3],
|
||||||
'part_number': part_num, 'name': name, 'stock': stock, 'branch_id': br_id})
|
'min_stock': row[4],
|
||||||
elif min_s and stock < min_s:
|
'max_stock': row[5],
|
||||||
alerts.append({'type': 'low', 'severity': 'warning', 'inventory_id': inv_id,
|
'branch_id': row[6],
|
||||||
'part_number': part_num, 'name': name, 'stock': stock,
|
'type': row[7],
|
||||||
'min_stock': min_s, 'branch_id': br_id})
|
'severity': row[8],
|
||||||
elif max_s and stock > max_s:
|
})
|
||||||
alerts.append({'type': 'over', 'severity': 'info', 'inventory_id': inv_id,
|
|
||||||
'part_number': part_num, 'name': name, 'stock': stock,
|
|
||||||
'max_stock': max_s, 'branch_id': br_id})
|
|
||||||
|
|
||||||
cur.close()
|
cur.close()
|
||||||
return alerts
|
return alerts
|
||||||
|
|||||||
@@ -686,6 +686,7 @@
|
|||||||
apiFetch(API + '/alerts').then(function (data) {
|
apiFetch(API + '/alerts').then(function (data) {
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
var alerts = data.data || [];
|
var alerts = data.data || [];
|
||||||
|
var counts = data.counts || {};
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
if (!alerts.length) {
|
if (!alerts.length) {
|
||||||
@@ -697,45 +698,56 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var html = '';
|
// Summary bar
|
||||||
|
var html = '<div style="display:flex;gap:var(--space-3);flex-wrap:wrap;margin-bottom:var(--space-6);padding:var(--space-4);background:var(--color-surface-2);border-radius:var(--radius-lg);">' +
|
||||||
|
'<div style="font-size:var(--text-sm);font-weight:700;">Resumen de alertas</div>' +
|
||||||
|
(counts.critical ? '<span class="badge badge--low">' + counts.critical + ' crítica' + (counts.critical !== 1 ? 's' : '') + '</span>' : '') +
|
||||||
|
(counts.warning ? '<span class="badge badge--over">' + counts.warning + ' advertencia' + (counts.warning !== 1 ? 's' : '') + '</span>' : '') +
|
||||||
|
(counts.info ? '<span class="badge badge--ok">' + counts.info + ' informativa' + (counts.info !== 1 ? 's' : '') + '</span>' : '') +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
// Group by severity
|
// Group by severity
|
||||||
var critical = alerts.filter(function (a) { return a.severity === 'critical'; });
|
var critical = alerts.filter(function (a) { return a.severity === 'critical'; });
|
||||||
var warning = alerts.filter(function (a) { return a.severity === 'warning'; });
|
var warning = alerts.filter(function (a) { return a.severity === 'warning'; });
|
||||||
var info = alerts.filter(function (a) { return a.severity !== 'critical' && a.severity !== 'warning'; });
|
var info = alerts.filter(function (a) { return a.severity !== 'critical' && a.severity !== 'warning'; });
|
||||||
|
|
||||||
if (critical.length) {
|
html += renderAlertSection('Criticas', critical, 'critical', 'badge--low');
|
||||||
html += '<div class="section-heading"><span class="section-heading__title">Criticas</span><div class="section-heading__line"></div><span class="badge badge--low">' + critical.length + '</span></div>';
|
html += renderAlertSection('Advertencias', warning, 'warning', 'badge--over');
|
||||||
html += '<div class="alerts-grid" style="margin-bottom:var(--space-6);">';
|
html += renderAlertSection('Informativas', info, 'info', 'badge--ok');
|
||||||
critical.forEach(function (a) {
|
|
||||||
var icon = a.type === 'zero' ? 'AGOTADO' : (a.type === 'low' ? 'BAJO' : a.type.toUpperCase());
|
|
||||||
html += buildAlertCard(a, icon, 'critical');
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (warning.length) {
|
|
||||||
html += '<div class="section-heading"><span class="section-heading__title">Advertencias</span><div class="section-heading__line"></div><span class="badge badge--over">' + warning.length + '</span></div>';
|
|
||||||
html += '<div class="alerts-grid" style="margin-bottom:var(--space-6);">';
|
|
||||||
warning.forEach(function (a) {
|
|
||||||
html += buildAlertCard(a, 'EXCESO', 'warning');
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (info.length) {
|
|
||||||
html += '<div class="section-heading"><span class="section-heading__title">Informativas</span><div class="section-heading__line"></div><span class="badge badge--ok">' + info.length + '</span></div>';
|
|
||||||
html += '<div class="alerts-grid" style="margin-bottom:var(--space-6);">';
|
|
||||||
info.forEach(function (a) {
|
|
||||||
html += buildAlertCard(a, 'INFO', 'info');
|
|
||||||
});
|
|
||||||
html += '</div>';
|
|
||||||
}
|
|
||||||
|
|
||||||
container.innerHTML = html;
|
container.innerHTML = html;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAlertSection(title, alerts, level, badgeClass) {
|
||||||
|
if (!alerts.length) return '';
|
||||||
|
var initialLimit = 30;
|
||||||
|
var showAll = window._alertsShowAll && window._alertsShowAll[level];
|
||||||
|
var display = showAll ? alerts : alerts.slice(0, initialLimit);
|
||||||
|
var remaining = alerts.length - display.length;
|
||||||
|
|
||||||
|
var html = '<div class="section-heading"><span class="section-heading__title">' + title + '</span><div class="section-heading__line"></div><span class="badge ' + badgeClass + '">' + alerts.length + '</span></div>';
|
||||||
|
html += '<div class="alerts-grid" style="margin-bottom:var(--space-6);">';
|
||||||
|
display.forEach(function (a) {
|
||||||
|
var icon = a.type === 'zero' ? 'AGOTADO' : (a.type === 'low' ? 'BAJO' : a.type.toUpperCase());
|
||||||
|
html += buildAlertCard(a, icon, level);
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
|
||||||
|
if (remaining > 0) {
|
||||||
|
html += '<div style="text-align:center;margin-bottom:var(--space-6);">' +
|
||||||
|
'<button class="btn btn--ghost btn--sm" onclick="window._showMoreAlerts(\'' + level + '\')">Ver ' + remaining + ' más</button>' +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
window._showMoreAlerts = function(level) {
|
||||||
|
window._alertsShowAll = window._alertsShowAll || {};
|
||||||
|
window._alertsShowAll[level] = true;
|
||||||
|
loadAlerts();
|
||||||
|
};
|
||||||
|
|
||||||
function buildAlertCard(a, icon, level) {
|
function buildAlertCard(a, icon, level) {
|
||||||
var cls = level === 'critical' ? 'alert-card--critical' : (level === 'warning' ? 'alert-card--warning' : 'alert-card--info');
|
var cls = level === 'critical' ? 'alert-card--critical' : (level === 'warning' ? 'alert-card--warning' : 'alert-card--info');
|
||||||
return '<div class="alert-card ' + cls + '">' +
|
return '<div class="alert-card ' + cls + '">' +
|
||||||
|
|||||||
@@ -921,7 +921,7 @@
|
|||||||
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
<script src="/pos/static/js/pos-utils.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/sidebar.js" defer></script>
|
<script src="/pos/static/js/sidebar.js" defer></script>
|
||||||
<script src="/pos/static/js/virtual-scroll.js?v=2" defer></script>
|
<script src="/pos/static/js/virtual-scroll.js?v=2" defer></script>
|
||||||
<script src="/pos/static/js/inventory.js?v=16" defer></script>
|
<script src="/pos/static/js/inventory.js?v=17" defer></script>
|
||||||
<script src="/pos/static/js/offline-banner.js" defer></script>
|
<script src="/pos/static/js/offline-banner.js" defer></script>
|
||||||
<script src="/pos/static/js/sync-engine.js" defer></script>
|
<script src="/pos/static/js/sync-engine.js" defer></script>
|
||||||
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
<script>if('serviceWorker' in navigator){navigator.serviceWorker.register('/pos/sw.js',{scope:'/pos/'});}</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user