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:
Nexus Dev
2026-04-27 05:23:30 +00:00
parent b70cb3042b
commit 9ff3dc4c8b
71 changed files with 10939 additions and 420 deletions

View File

@@ -0,0 +1,448 @@
"""Supplier and purchase order engine.
Provides CRUD for suppliers and the full purchase order lifecycle:
create_po → send_po → receive_po → (optional: cancel_po)
On receive, automatically:
1. Updates inventory stock via inventory_engine.record_purchase()
2. Creates accounting entry via accounting_engine.record_purchase_entry()
"""
from decimal import Decimal, ROUND_HALF_UP
from datetime import date
from services.inventory_engine import record_purchase
from services.accounting_engine import record_purchase_entry
from services.audit import log_action
TWO = Decimal('0.01')
def _to_dec(val):
if val is None:
return Decimal('0')
return Decimal(str(val))
# ── SUPPLIER CRUD ──────────────────────────────────────────────────────────
def create_supplier(conn, data):
"""Create a new supplier."""
cur = conn.cursor()
cur.execute("""
INSERT INTO suppliers (name, contact_name, phone, email, rfc, address,
payment_terms, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
data['name'], data.get('contact_name'), data.get('phone'),
data.get('email'), data.get('rfc'), data.get('address'),
data.get('payment_terms'), data.get('notes')
))
supplier_id = cur.fetchone()[0]
cur.close()
log_action(conn, 'SUPPLIER_CREATE', 'supplier', supplier_id,
new_value={'name': data['name']})
return supplier_id
def update_supplier(conn, supplier_id, data):
"""Update supplier fields."""
allowed = ['name', 'contact_name', 'phone', 'email', 'rfc',
'address', 'payment_terms', 'notes', 'is_active']
sets = []
vals = []
for k in allowed:
if k in data:
sets.append(f"{k} = %s")
vals.append(data[k])
if not sets:
return False
vals.append(supplier_id)
cur = conn.cursor()
cur.execute(f"""
UPDATE suppliers SET {', '.join(sets)}, updated_at = NOW()
WHERE id = %s
""", vals)
updated = cur.rowcount > 0
cur.close()
if updated:
log_action(conn, 'SUPPLIER_UPDATE', 'supplier', supplier_id,
new_value=data)
return updated
def get_supplier(conn, supplier_id):
"""Get single supplier by ID."""
cur = conn.cursor()
cur.execute("""
SELECT id, name, contact_name, phone, email, rfc, address,
payment_terms, notes, is_active, created_at
FROM suppliers WHERE id = %s
""", (supplier_id,))
row = cur.fetchone()
cur.close()
if not row:
return None
return {
'id': row[0], 'name': row[1], 'contact_name': row[2],
'phone': row[3], 'email': row[4], 'rfc': row[5],
'address': row[6], 'payment_terms': row[7],
'notes': row[8], 'is_active': row[9], 'created_at': str(row[10]),
}
def list_suppliers(conn, active_only=True, limit=100, offset=0):
"""List suppliers."""
cur = conn.cursor()
where = "WHERE is_active = true" if active_only else ""
cur.execute(f"""
SELECT id, name, contact_name, phone, email, rfc, is_active
FROM suppliers {where}
ORDER BY name
LIMIT %s OFFSET %s
""", (limit, offset))
rows = cur.fetchall()
cur.close()
return [{
'id': r[0], 'name': r[1], 'contact_name': r[2],
'phone': r[3], 'email': r[4], 'rfc': r[5], 'is_active': r[6],
} for r in rows]
# ── PURCHASE ORDERS ────────────────────────────────────────────────────────
def create_po(conn, data, branch_id=None, employee_id=None):
"""Create a purchase order with items.
Args:
data: dict with keys:
supplier_id: int
items: [{inventory_id|part_number|name, quantity, unit_price, notes}]
notes: str (optional)
expected_date: str 'YYYY-MM-DD' (optional)
currency: 'MXN'|'USD' (default 'MXN')
exchange_rate: float (optional)
Returns:
dict: {po_id, status, total, item_count}
"""
supplier_id = data.get('supplier_id')
items = data.get('items', [])
if not items:
raise ValueError("No items in purchase order")
currency = data.get('currency', 'MXN')
exchange_rate = float(data.get('exchange_rate', 1.0))
# Calculate totals
subtotal = Decimal('0')
po_items = []
for item in items:
qty = int(item.get('quantity', 1))
price = _to_dec(item.get('unit_price', 0))
line_sub = (price * qty).quantize(TWO, ROUND_HALF_UP)
subtotal += line_sub
po_items.append({
'inventory_id': item.get('inventory_id'),
'part_number': item.get('part_number', ''),
'name': item.get('name', ''),
'quantity': qty,
'unit_price': float(price),
'subtotal': float(line_sub),
'notes': item.get('notes'),
})
tax_rate = Decimal('0.16')
tax_total = (subtotal * tax_rate).quantize(TWO, ROUND_HALF_UP)
total = (subtotal + tax_total).quantize(TWO, ROUND_HALF_UP)
cur = conn.cursor()
cur.execute("""
INSERT INTO purchase_orders
(supplier_id, branch_id, employee_id, status, subtotal, tax_total, total,
currency, exchange_rate, notes, expected_date)
VALUES (%s, %s, %s, 'draft', %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
supplier_id, branch_id, employee_id,
float(subtotal), float(tax_total), float(total),
currency, exchange_rate,
data.get('notes'), data.get('expected_date')
))
po_id = cur.fetchone()[0]
for item in po_items:
cur.execute("""
INSERT INTO purchase_order_items
(po_id, inventory_id, part_number, name, quantity,
unit_price, subtotal, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
""", (
po_id, item['inventory_id'], item['part_number'], item['name'],
item['quantity'], item['unit_price'], item['subtotal'], item['notes']
))
cur.close()
log_action(conn, 'PO_CREATE', 'purchase_order', po_id,
new_value={'supplier_id': supplier_id, 'total': float(total)})
return {
'po_id': po_id,
'status': 'draft',
'total': float(total),
'item_count': len(po_items),
}
def send_po(conn, po_id):
"""Mark PO as sent to supplier."""
cur = conn.cursor()
cur.execute("""
UPDATE purchase_orders
SET status = 'sent', sent_at = NOW()
WHERE id = %s AND status = 'draft'
""", (po_id,))
updated = cur.rowcount > 0
cur.close()
if updated:
log_action(conn, 'PO_SEND', 'purchase_order', po_id)
return updated
def receive_po(conn, po_id, received_items, supplier_invoice=None, notes=None):
"""Receive items from a PO. Updates stock and accounting.
Args:
received_items: list of {po_item_id, quantity} (quantity = qty received now)
Returns:
dict: {po_id, status, received_total}
"""
cur = conn.cursor()
# Lock PO row
cur.execute("""
SELECT id, supplier_id, branch_id, status, subtotal, tax_total, total,
currency, exchange_rate
FROM purchase_orders WHERE id = %s FOR UPDATE
""", (po_id,))
po = cur.fetchone()
if not po:
raise ValueError("Purchase order not found")
if po[3] == 'cancelled':
raise ValueError("Cannot receive a cancelled PO")
if po[3] == 'received':
raise ValueError("PO already fully received")
po_supplier_id = po[1]
po_branch_id = po[2]
po_currency = po[7]
po_rate = float(po[8])
# Process each received item
total_received_qty = 0
purchase_total_mxn = Decimal('0')
for recv in received_items:
poi_id = recv['po_item_id']
qty = int(recv['quantity'])
if qty <= 0:
continue
cur.execute("""
SELECT inventory_id, part_number, name, quantity, received_qty,
unit_price
FROM purchase_order_items WHERE id = %s AND po_id = %s
""", (poi_id, po_id))
row = cur.fetchone()
if not row:
raise ValueError(f"PO item {poi_id} not found")
inv_id, part_num, name, ordered_qty, already_received, unit_price = row
already_received = already_received or 0
new_received = already_received + qty
if new_received > ordered_qty:
raise ValueError(
f"Cannot receive {qty} of {name or part_num}: "
f"ordered={ordered_qty}, already_received={already_received}"
)
# Update received quantity
cur.execute("""
UPDATE purchase_order_items
SET received_qty = %s
WHERE id = %s
""", (new_received, poi_id))
# Record inventory purchase (if linked to inventory)
if inv_id and po_branch_id:
record_purchase(
conn, inv_id, po_branch_id, qty, unit_price,
supplier_invoice=supplier_invoice,
notes=f"Recepcion OC #{po_id}: {notes or ''}"
)
# Accumulate for accounting (convert to MXN if needed)
line_mxn = _to_dec(unit_price) * qty * _to_dec(po_rate)
purchase_total_mxn += line_mxn.quantize(TWO, ROUND_HALF_UP)
total_received_qty += qty
# Determine new PO status
cur.execute("""
SELECT SUM(quantity), SUM(received_qty)
FROM purchase_order_items WHERE po_id = %s
""", (po_id,))
totals = cur.fetchone()
ordered_total = totals[0] or 0
received_total = totals[1] or 0
new_status = 'partial' if received_total < ordered_total else 'received'
cur.execute("""
UPDATE purchase_orders
SET status = %s, received_at = NOW(),
supplier_invoice = COALESCE(%s, supplier_invoice),
notes = COALESCE(notes || ' | ', '') || %s
WHERE id = %s
""", (new_status, supplier_invoice,
f"Recepcion: {received_total} de {ordered_total} uds" +
(f" | Factura: {supplier_invoice}" if supplier_invoice else ""),
po_id))
cur.close()
# Accounting entry (non-blocking)
if purchase_total_mxn > 0:
try:
tax_mxn = (purchase_total_mxn * Decimal('0.16')).quantize(TWO, ROUND_HALF_UP)
total_mxn = (purchase_total_mxn + tax_mxn).quantize(TWO, ROUND_HALF_UP)
record_purchase_entry(conn, {
'reference_id': po_id,
'subtotal': float(purchase_total_mxn),
'tax_amount': float(tax_mxn),
'total': float(total_mxn),
'supplier_name': _get_supplier_name(conn, po_supplier_id),
})
except Exception:
pass # Accounting errors never block receiving
log_action(conn, 'PO_RECEIVE', 'purchase_order', po_id,
new_value={'received_qty': received_total, 'status': new_status})
return {
'po_id': po_id,
'status': new_status,
'received_total': received_total,
'ordered_total': ordered_total,
}
def cancel_po(conn, po_id, reason):
"""Cancel a PO. Only allowed if not fully received."""
if not reason or len(reason.strip()) < 3:
raise ValueError("Cancellation reason is mandatory (min 3 characters)")
cur = conn.cursor()
cur.execute("SELECT status FROM purchase_orders WHERE id = %s", (po_id,))
row = cur.fetchone()
if not row:
raise ValueError("PO not found")
if row[0] == 'received':
raise ValueError("Cannot cancel a fully received PO")
if row[0] == 'cancelled':
raise ValueError("PO is already cancelled")
cur.execute("""
UPDATE purchase_orders
SET status = 'cancelled', cancelled_at = NOW(),
notes = COALESCE(notes || ' | ', '') || %s
WHERE id = %s
""", (f"CANCELADA: {reason}", po_id))
cur.close()
log_action(conn, 'PO_CANCEL', 'purchase_order', po_id,
new_value={'reason': reason})
return True
def get_po(conn, po_id):
"""Get full PO with items."""
cur = conn.cursor()
cur.execute("""
SELECT po.id, po.status, po.subtotal, po.tax_total, po.total,
po.currency, po.exchange_rate, po.notes, po.supplier_invoice,
po.expected_date, po.sent_at, po.received_at, po.cancelled_at,
po.created_at, s.name as supplier_name
FROM purchase_orders po
LEFT JOIN suppliers s ON po.supplier_id = s.id
WHERE po.id = %s
""", (po_id,))
po_row = cur.fetchone()
if not po_row:
cur.close()
return None
cur.execute("""
SELECT id, inventory_id, part_number, name, quantity, received_qty,
unit_price, subtotal, notes
FROM purchase_order_items WHERE po_id = %s
""", (po_id,))
items = []
for r in cur.fetchall():
items.append({
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
'name': r[3], 'quantity': r[4], 'received_qty': r[5],
'unit_price': float(r[6]), 'subtotal': float(r[7]), 'notes': r[8],
})
cur.close()
return {
'id': po_row[0], 'status': po_row[1],
'subtotal': float(po_row[2]), 'tax_total': float(po_row[3]),
'total': float(po_row[4]), 'currency': po_row[5],
'exchange_rate': float(po_row[6]), 'notes': po_row[7],
'supplier_invoice': po_row[8], 'expected_date': str(po_row[9]) if po_row[9] else None,
'sent_at': str(po_row[10]) if po_row[10] else None,
'received_at': str(po_row[11]) if po_row[11] else None,
'cancelled_at': str(po_row[12]) if po_row[12] else None,
'created_at': str(po_row[13]), 'supplier_name': po_row[14],
'items': items,
}
def list_pos(conn, status=None, supplier_id=None, limit=50, offset=0):
"""List purchase orders."""
cur = conn.cursor()
filters = []
vals = []
if status:
filters.append("po.status = %s")
vals.append(status)
if supplier_id:
filters.append("po.supplier_id = %s")
vals.append(supplier_id)
where = "WHERE " + " AND ".join(filters) if filters else ""
cur.execute(f"""
SELECT po.id, po.status, po.total, po.currency, s.name as supplier_name,
po.created_at
FROM purchase_orders po
LEFT JOIN suppliers s ON po.supplier_id = s.id
{where}
ORDER BY po.created_at DESC
LIMIT %s OFFSET %s
""", vals + [limit, offset])
rows = cur.fetchall()
cur.close()
return [{
'id': r[0], 'status': r[1], 'total': float(r[2]),
'currency': r[3], 'supplier_name': r[4], 'created_at': str(r[5]),
} for r in rows]
# ── HELPERS ────────────────────────────────────────────────────────────────
def _get_supplier_name(conn, supplier_id):
if not supplier_id:
return 'Proveedor'
cur = conn.cursor()
cur.execute("SELECT name FROM suppliers WHERE id = %s", (supplier_id,))
row = cur.fetchone()
cur.close()
return row[0] if row else 'Proveedor'