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
233 lines
8.2 KiB
Python
233 lines
8.2 KiB
Python
"""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
|