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
This commit is contained in:
228
pos/services/reorder_engine.py
Normal file
228
pos/services/reorder_engine.py
Normal file
@@ -0,0 +1,228 @@
|
||||
"""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',
|
||||
}
|
||||
Reference in New Issue
Block a user