"""Reorder alert engine (Mejora #7). Generates alerts when stock hits zero or falls below reorder_point/min_stock. Can auto-suggest purchase orders to restock. Alert lifecycle: 1. Detect low/zero stock 2. Create reorder_alert record (deduplicated per inventory_id) 3. Notify owner (push notification) 4. Employee acknowledges or generates PO 5. When PO is received, alert is auto-resolved """ from services.inventory_engine import get_stock, get_stock_bulk from services.audit import log_action ALERT_TYPES = { 'zero': {'severity': 'critical', 'message': 'Sin existencias'}, 'low': {'severity': 'warning', 'message': 'Stock bajo'}, 'over': {'severity': 'info', 'message': 'Sobre-stock'}, } def generate_alerts(conn, branch_id=None, auto_notify=True): """Scan inventory and create reorder_alerts for items that need attention. Deduplicates: won't create a new open alert for the same inventory_id if one already exists. Args: conn: psycopg2 connection branch_id: optional branch filter auto_notify: if True, sends push notification for new alerts Returns: dict: {created: int, by_type: {'zero': n, 'low': n, 'over': n}} """ cur = conn.cursor() # Get current open alert inventory_ids to avoid duplicates cur.execute(""" SELECT inventory_id, alert_type FROM reorder_alerts WHERE status = 'open' """) existing = {(r[0], r[1]) for r in cur.fetchall()} # Build inventory query where = "WHERE i.is_active = true" params = [] if branch_id: where += " AND i.branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT i.id, i.part_number, i.name, i.min_stock, i.max_stock, i.reorder_point, i.reorder_qty, i.branch_id FROM inventory i {where} """, params) inv_rows = cur.fetchall() # Batch stock lookup stock_map = get_stock_bulk(conn, branch_id) created = 0 by_type = {'zero': 0, 'low': 0, 'over': 0} new_alerts = [] for row in inv_rows: inv_id, part_num, name, min_s, max_s, reorder_pt, reorder_qty, br_id = row stock = stock_map.get(inv_id, 0) alert_type = None threshold = None if stock <= 0: alert_type = 'zero' threshold = 0 elif reorder_pt is not None and stock <= reorder_pt: alert_type = 'low' threshold = reorder_pt elif min_s and stock < min_s: alert_type = 'low' threshold = min_s elif max_s and stock > max_s: alert_type = 'over' threshold = max_s if alert_type and (inv_id, alert_type) not in existing: cur.execute(""" INSERT INTO reorder_alerts (inventory_id, branch_id, alert_type, stock_at_alert, threshold, status) VALUES (%s, %s, %s, %s, %s, 'open') """, (inv_id, br_id, alert_type, stock, threshold)) created += 1 by_type[alert_type] += 1 new_alerts.append({ 'inventory_id': inv_id, 'part_number': part_num, 'name': name, 'type': alert_type, 'stock': stock, }) cur.close() # Push notifications (best-effort) if auto_notify and new_alerts: try: from services.push_service import notify_owner for alert in new_alerts[:5]: # limit to first 5 to avoid spam notify_owner( conn, f"Alerta: {ALERT_TYPES[alert['type']]['message']}", f"{alert['name'] or alert['part_number']} — Stock: {alert['stock']}", '/inventory' ) except Exception: pass return {'created': created, 'by_type': by_type} def list_alerts(conn, status=None, branch_id=None, limit=50, offset=0): """List reorder alerts with inventory details.""" cur = conn.cursor() filters = [] params = [] if status: filters.append("ra.status = %s") params.append(status) if branch_id: filters.append("ra.branch_id = %s") params.append(branch_id) where = "WHERE " + " AND ".join(filters) if filters else "" cur.execute(f""" SELECT ra.id, ra.inventory_id, i.part_number, i.name, ra.alert_type, ra.stock_at_alert, ra.threshold, ra.status, ra.created_at, ra.resolved_at, b.name as branch_name FROM reorder_alerts ra JOIN inventory i ON ra.inventory_id = i.id LEFT JOIN branches b ON ra.branch_id = b.id {where} ORDER BY CASE ra.alert_type WHEN 'zero' THEN 0 WHEN 'low' THEN 1 ELSE 2 END, ra.created_at DESC LIMIT %s OFFSET %s """, params + [limit, offset]) rows = cur.fetchall() cur.close() return [{ 'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3], 'alert_type': r[4], 'stock_at_alert': r[5], 'threshold': r[6], 'status': r[7], 'created_at': str(r[8]), 'resolved_at': str(r[9]) if r[9] else None, 'branch_name': r[10], } for r in rows] def acknowledge_alert(conn, alert_id, employee_id=None, notes=None): """Mark an alert as acknowledged.""" cur = conn.cursor() cur.execute(""" UPDATE reorder_alerts SET status = 'acknowledged', employee_id = %s, notes = COALESCE(notes || ' | ', '') || %s WHERE id = %s AND status = 'open' """, (employee_id, notes or 'Revisado', alert_id)) updated = cur.rowcount > 0 cur.close() return updated def resolve_alert(conn, alert_id, po_id=None, notes=None): """Resolve an alert (e.g., when PO is received).""" cur = conn.cursor() cur.execute(""" UPDATE reorder_alerts SET status = 'resolved', po_id = COALESCE(%s, po_id), notes = COALESCE(notes || ' | ', '') || %s, resolved_at = NOW() WHERE id = %s """, (po_id, notes or 'Resuelto', alert_id)) updated = cur.rowcount > 0 cur.close() return updated def suggest_po_from_alerts(conn, supplier_id=None, branch_id=None): """Generate a suggested PO based on open low/zero stock alerts. Returns a dict ready to be passed to supplier_engine.create_po(). """ cur = conn.cursor() where = "WHERE ra.status = 'open' AND ra.alert_type IN ('zero', 'low')" params = [] if branch_id: where += " AND ra.branch_id = %s" params.append(branch_id) cur.execute(f""" SELECT ra.inventory_id, i.part_number, i.name, i.reorder_qty, i.min_stock, ra.stock_at_alert FROM reorder_alerts ra JOIN inventory i ON ra.inventory_id = i.id {where} ORDER BY i.name """, params) rows = cur.fetchall() cur.close() items = [] for r in rows: inv_id, part_num, name, reorder_qty, min_stock, stock = r # Suggested qty: reorder_qty if set, otherwise min_stock * 2 - stock suggested = reorder_qty if reorder_qty else max((min_stock or 1) * 2 - (stock or 0), 1) items.append({ 'inventory_id': inv_id, 'part_number': part_num, 'name': name, 'quantity': suggested, 'unit_price': 0, # employee must fill in }) return { 'supplier_id': supplier_id, 'items': items, 'notes': 'Orden sugerida automaticamente desde alertas de reorden', }