From 61bf84b2dc68b193252cd2e2bd5152ba9980a0ea Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Tue, 26 May 2026 09:12:09 +0000 Subject: [PATCH] =?UTF-8?q?fix(alerts):=20limit=20alerts=20to=20500=20per?= =?UTF-8?q?=20type=20in=20SQL=20+=20frontend=20pagination=20with=20'Ver=20?= =?UTF-8?q?m=C3=A1s'=20+=20summary=20bar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pos/blueprints/inventory_bp.py | 13 +++++- pos/services/inventory_engine.py | 80 +++++++++++++++++++++++--------- pos/static/js/inventory.js | 68 ++++++++++++++++----------- pos/templates/inventory.html | 2 +- 4 files changed, 109 insertions(+), 54 deletions(-) diff --git a/pos/blueprints/inventory_bp.py b/pos/blueprints/inventory_bp.py index b0260e8..8a8ee2e 100644 --- a/pos/blueprints/inventory_bp.py +++ b/pos/blueprints/inventory_bp.py @@ -1111,9 +1111,18 @@ def api_alerts(): """Get stock alerts (zero, low, over).""" conn = get_tenant_conn(g.tenant_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() - 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//history', methods=['GET']) diff --git a/pos/services/inventory_engine.py b/pos/services/inventory_engine.py index b7f115e..7412483 100644 --- a/pos/services/inventory_engine.py +++ b/pos/services/inventory_engine.py @@ -272,38 +272,72 @@ def record_initial(conn, inventory_id, branch_id, quantity, cost=None): return result -def get_alerts(conn, branch_id=None): - """Get stock alerts: zero stock, below minimum, above maximum.""" - stock_map = get_stock_bulk(conn, branch_id) +def get_alerts(conn, branch_id=None, limit_per_type=500): + """Get stock alerts: zero stock, below minimum, above maximum. + Returns at most limit_per_type alerts per severity to avoid browser freeze. + """ cur = conn.cursor() - - where = "WHERE i.is_active = true" + branch_filter = "" params = [] if branch_id: - where += " AND i.branch_id = %s" + branch_filter = " AND i.branch_id = %s" params.append(branch_id) + # Use a single SQL query with window functions to rank and limit per type cur.execute(f""" - SELECT i.id, i.part_number, i.name, i.min_stock, i.max_stock, i.branch_id - FROM inventory i {where} - """, params) + WITH stock AS ( + SELECT inventory_id, COALESCE(SUM(quantity), 0) AS qty + 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 = [] for row in cur.fetchall(): - inv_id, part_num, name, min_s, max_s, br_id = row - stock = stock_map.get(inv_id, 0) - - if stock <= 0: - alerts.append({'type': 'zero', 'severity': 'critical', 'inventory_id': inv_id, - 'part_number': part_num, 'name': name, 'stock': stock, 'branch_id': br_id}) - elif min_s and stock < min_s: - alerts.append({'type': 'low', 'severity': 'warning', 'inventory_id': inv_id, - 'part_number': part_num, 'name': name, 'stock': stock, - 'min_stock': min_s, 'branch_id': br_id}) - 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}) + alerts.append({ + 'inventory_id': row[0], + 'part_number': row[1], + 'name': row[2], + 'stock': row[3], + 'min_stock': row[4], + 'max_stock': row[5], + 'branch_id': row[6], + 'type': row[7], + 'severity': row[8], + }) cur.close() return alerts diff --git a/pos/static/js/inventory.js b/pos/static/js/inventory.js index 5e9605d..8f8d1a8 100644 --- a/pos/static/js/inventory.js +++ b/pos/static/js/inventory.js @@ -686,6 +686,7 @@ apiFetch(API + '/alerts').then(function (data) { if (!data) return; var alerts = data.data || []; + var counts = data.counts || {}; if (!container) return; if (!alerts.length) { @@ -697,45 +698,56 @@ return; } - var html = ''; + // Summary bar + var html = '
' + + '
Resumen de alertas
' + + (counts.critical ? '' + counts.critical + ' crítica' + (counts.critical !== 1 ? 's' : '') + '' : '') + + (counts.warning ? '' + counts.warning + ' advertencia' + (counts.warning !== 1 ? 's' : '') + '' : '') + + (counts.info ? '' + counts.info + ' informativa' + (counts.info !== 1 ? 's' : '') + '' : '') + + '
'; // Group by severity var critical = alerts.filter(function (a) { return a.severity === 'critical'; }); var warning = alerts.filter(function (a) { return a.severity === 'warning'; }); var info = alerts.filter(function (a) { return a.severity !== 'critical' && a.severity !== 'warning'; }); - if (critical.length) { - html += '
Criticas
' + critical.length + '
'; - html += '
'; - critical.forEach(function (a) { - var icon = a.type === 'zero' ? 'AGOTADO' : (a.type === 'low' ? 'BAJO' : a.type.toUpperCase()); - html += buildAlertCard(a, icon, 'critical'); - }); - html += '
'; - } - - if (warning.length) { - html += '
Advertencias
' + warning.length + '
'; - html += '
'; - warning.forEach(function (a) { - html += buildAlertCard(a, 'EXCESO', 'warning'); - }); - html += '
'; - } - - if (info.length) { - html += '
Informativas
' + info.length + '
'; - html += '
'; - info.forEach(function (a) { - html += buildAlertCard(a, 'INFO', 'info'); - }); - html += '
'; - } + html += renderAlertSection('Criticas', critical, 'critical', 'badge--low'); + html += renderAlertSection('Advertencias', warning, 'warning', 'badge--over'); + html += renderAlertSection('Informativas', info, 'info', 'badge--ok'); 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 = '
' + title + '
' + alerts.length + '
'; + html += '
'; + display.forEach(function (a) { + var icon = a.type === 'zero' ? 'AGOTADO' : (a.type === 'low' ? 'BAJO' : a.type.toUpperCase()); + html += buildAlertCard(a, icon, level); + }); + html += '
'; + + if (remaining > 0) { + html += '
' + + '' + + '
'; + } + return html; + } + + window._showMoreAlerts = function(level) { + window._alertsShowAll = window._alertsShowAll || {}; + window._alertsShowAll[level] = true; + loadAlerts(); + }; + function buildAlertCard(a, icon, level) { var cls = level === 'critical' ? 'alert-card--critical' : (level === 'warning' ? 'alert-card--warning' : 'alert-card--info'); return '
' + diff --git a/pos/templates/inventory.html b/pos/templates/inventory.html index 85595d2..2ede72a 100644 --- a/pos/templates/inventory.html +++ b/pos/templates/inventory.html @@ -921,7 +921,7 @@ - +