Files
Autoparts-DB/pos/services/reorder_engine.py
Nexus Dev 9ff3dc4c8b FASE 4-5-6: Infraestructura, CRM, Service Orders, Notificaciones, Ahorro, Logistica, API Publica
FASE 4:
- Redis cache de stock con fallback graceful
- Multi-moneda (MXN/USD) con contabilidad en MXN
- Proveedores y ordenes de compra completo
- Meilisearch 1.5M+ partes indexadas
- Metabase KPIs con dashboard auto-generado

FASE 5:
- CRM mejorado: activities, tags, loyalty program, analytics
- Imagenes de partes: upload, resize, thumbnails WebP
- Ordenes de servicio Kanban: received->diagnosis->repair->ready->delivered
- Garantias/RMA, alertas de reorden, multi-sucursal
- Stubs BNPL (APLAZO) y ERP Sync (Aspel/Contpaqi)

FASE 6:
- Notificaciones automaticas: push/WhatsApp/email/in-app
- Reportes de ahorro vs retail_price
- Logistica + tracking: DHL, FedEx, Estafeta, 99min, Uber
- API Publica: API keys, rate limiting, catalog search

Migraciones: v1.9-v3.0
Tests: 93/93 pasando
Backup: nexus_backup_20260427_045859.tar.gz
2026-04-27 05:23:30 +00:00

229 lines
7.4 KiB
Python

"""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',
}