fix(alerts): limit alerts to 500 per type in SQL + frontend pagination with 'Ver más' + summary bar

This commit is contained in:
2026-05-26 09:12:09 +00:00
parent 3009ffa1b0
commit 61bf84b2dc
4 changed files with 109 additions and 54 deletions

View File

@@ -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'])

View File

@@ -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

View File

@@ -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 + '">' +

View File

@@ -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>