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,232 @@
"""Logistics Engine: shipment tracking and courier management.
Supports multiple couriers: DHL, FedEx, Estafeta, 99 Minutos, Uber Direct.
Provides tracking URL generation, status updates, and history logging.
"""
from datetime import datetime
def create_shipment(conn, data):
"""Create a new shipment record.
data: {
tenant_id, branch_id, shipment_type, related_type, related_id,
courier_id, tracking_number, origin_address, destination_address,
recipient_name, recipient_phone, estimated_delivery,
shipping_cost, weight_kg, dimensions_cm, notes, created_by
}
"""
cur = conn.cursor()
# Generate tracking URL if template exists
tracking_url = None
if data.get('courier_id') and data.get('tracking_number'):
cur.execute("SELECT tracking_url_template FROM couriers WHERE id = %s", (data['courier_id'],))
row = cur.fetchone()
if row and row[0]:
tracking_url = row[0].replace('{tracking_number}', data['tracking_number'])
cur.execute("""
INSERT INTO shipments
(tenant_id, branch_id, shipment_type, related_type, related_id,
courier_id, tracking_number, tracking_url, status,
origin_address, destination_address, recipient_name, recipient_phone,
estimated_delivery, shipping_cost, weight_kg, dimensions_cm, notes, created_by)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'pending',
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
data.get('tenant_id'), data.get('branch_id'), data.get('shipment_type', 'outbound'),
data.get('related_type'), data.get('related_id'),
data.get('courier_id'), data.get('tracking_number'), tracking_url,
data.get('origin_address'), data.get('destination_address'),
data.get('recipient_name'), data.get('recipient_phone'),
data.get('estimated_delivery'), data.get('shipping_cost', 0),
data.get('weight_kg'), data.get('dimensions_cm'),
data.get('notes'), data.get('created_by'),
))
shipment_id = cur.fetchone()[0]
# Log initial tracking entry
if tracking_url:
cur.execute("""
INSERT INTO shipment_tracking (shipment_id, status, description)
VALUES (%s, 'pending', 'Envío registrado')
""", (shipment_id,))
conn.commit()
cur.close()
return {'shipment_id': shipment_id, 'tracking_url': tracking_url}
def get_shipment(conn, shipment_id):
cur = conn.cursor()
cur.execute("""
SELECT s.id, s.shipment_type, s.related_type, s.related_id,
s.courier_id, c.name as courier_name, s.tracking_number, s.tracking_url,
s.status, s.origin_address, s.destination_address,
s.recipient_name, s.recipient_phone, s.estimated_delivery,
s.actual_delivery, s.shipping_cost, s.weight_kg, s.dimensions_cm,
s.notes, s.created_at, s.updated_at
FROM shipments s
LEFT JOIN couriers c ON s.courier_id = c.id
WHERE s.id = %s
""", (shipment_id,))
row = cur.fetchone()
if not row:
cur.close()
return None
shipment = {
'id': row[0], 'shipment_type': row[1], 'related_type': row[2],
'related_id': row[3], 'courier_id': row[4], 'courier_name': row[5],
'tracking_number': row[6], 'tracking_url': row[7],
'status': row[8], 'origin_address': row[9], 'destination_address': row[10],
'recipient_name': row[11], 'recipient_phone': row[12],
'estimated_delivery': str(row[13]) if row[13] else None,
'actual_delivery': str(row[14]) if row[14] else None,
'shipping_cost': float(row[15]) if row[15] else 0,
'weight_kg': float(row[16]) if row[16] else None,
'dimensions_cm': row[17], 'notes': row[18],
'created_at': str(row[19]), 'updated_at': str(row[20]),
}
# Tracking history
cur.execute("""
SELECT id, status, location, description, tracked_at
FROM shipment_tracking
WHERE shipment_id = %s
ORDER BY tracked_at DESC
""", (shipment_id,))
shipment['tracking_history'] = []
for r in cur.fetchall():
shipment['tracking_history'].append({
'id': r[0], 'status': r[1], 'location': r[2],
'description': r[3], 'tracked_at': str(r[4]),
})
cur.close()
return shipment
def list_shipments(conn, tenant_id, status=None, courier_id=None, related_type=None,
related_id=None, page=1, per_page=50):
cur = conn.cursor()
where = ["tenant_id = %s"]
params = [tenant_id]
if status:
where.append("status = %s")
params.append(status)
if courier_id:
where.append("courier_id = %s")
params.append(courier_id)
if related_type:
where.append("related_type = %s")
params.append(related_type)
if related_id:
where.append("related_id = %s")
params.append(related_id)
where_str = " AND ".join(where)
cur.execute(f"SELECT count(*) FROM shipments WHERE {where_str}", params)
total = cur.fetchone()[0]
extra_where = ""
if len(where) > 1:
extra_where = " AND " + " AND ".join(where[1:])
cur.execute(f"""
SELECT s.id, s.shipment_type, s.related_type, s.related_id,
c.name as courier_name, s.tracking_number, s.status,
s.recipient_name, s.estimated_delivery, s.created_at
FROM shipments s
LEFT JOIN couriers c ON s.courier_id = c.id
WHERE s.tenant_id = %s {extra_where}
ORDER BY s.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
shipments = []
for r in cur.fetchall():
shipments.append({
'id': r[0], 'shipment_type': r[1], 'related_type': r[2],
'related_id': r[3], 'courier_name': r[4], 'tracking_number': r[5],
'status': r[6], 'recipient_name': r[7],
'estimated_delivery': str(r[8]) if r[8] else None,
'created_at': str(r[9]),
})
cur.close()
return {
'data': shipments,
'pagination': {'page': page, 'per_page': per_page, 'total': total}
}
def update_shipment_status(conn, shipment_id, new_status, location=None,
description=None, raw_response=None):
"""Update shipment status and log tracking history."""
cur = conn.cursor()
cur.execute("SELECT status FROM shipments WHERE id = %s", (shipment_id,))
row = cur.fetchone()
if not row:
cur.close()
raise ValueError("Shipment not found")
old_status = row[0]
# Update shipment
extra_sets = []
extra_vals = []
if new_status == 'delivered':
extra_sets.append("actual_delivery = NOW()")
set_clause = ", ".join(["status = %s"] + extra_sets)
cur.execute(f"""
UPDATE shipments SET {set_clause} WHERE id = %s
""", [new_status] + extra_vals + [shipment_id])
# Log tracking history
cur.execute("""
INSERT INTO shipment_tracking (shipment_id, status, location, description, raw_response)
VALUES (%s, %s, %s, %s, %s)
""", (shipment_id, new_status, location, description, raw_response))
conn.commit()
cur.close()
return {'old_status': old_status, 'new_status': new_status}
def get_couriers(conn, tenant_id):
cur = conn.cursor()
cur.execute("""
SELECT id, name, code, tracking_url_template, api_endpoint, is_active
FROM couriers
WHERE tenant_id = %s
ORDER BY name
""", (tenant_id,))
couriers = []
for r in cur.fetchall():
couriers.append({
'id': r[0], 'name': r[1], 'code': r[2],
'tracking_url_template': r[3], 'api_endpoint': r[4], 'is_active': r[5],
})
cur.close()
return couriers
def add_courier(conn, tenant_id, name, code, tracking_url_template=None,
api_endpoint=None, is_active=True):
cur = conn.cursor()
cur.execute("""
INSERT INTO couriers (tenant_id, name, code, tracking_url_template, api_endpoint, is_active)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (tenant_id, name, code, tracking_url_template, api_endpoint, is_active))
cid = cur.fetchone()[0]
conn.commit()
cur.close()
return cid