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:
232
pos/services/logistics_engine.py
Normal file
232
pos/services/logistics_engine.py
Normal 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
|
||||
Reference in New Issue
Block a user