Files
CRM_AGENTES_CARGA/backend/api/v1/modules/ops/shipments/service.py
Ernesto Herrera afe659e56a feat(crm): Expediente — referencia única de trazabilidad del trámite (Fase D)
- Tabla crm.cases (expediente) con folio EXP2026-08-001 (next_folio entidad EXP,
  sin dirección). Nace al crear la Oportunidad y se hereda vía case_id a
  solicitud → cotización → operación → factura. advance_stage solo avanza.
- case_id (FK a crm.cases) en crm.opportunities/service_requests/quotes,
  ops.shipments y fin.invoices; propagación en sus create_*. Migración
  d4e5f6a7b8c9 reversible.
- Endpoints GET /v1/crm/cases, /cases/{id}, /cases/by-ref/{ref} con timeline
  (historia completa para UI y otros sistemas).
- Frontend: casesAPI, ruta /dashboard/crm/expedientes (lista + timeline vertical),
  chip "📁 Expediente" en solicitud/cotización, "Expedientes" en el sidebar.
- Consecutivo de folios sin tope (soporta >10,000,000/mes).
- 4 pruebas de expediente (minteo, propagación, timeline, no-retroceso). Suite en verde (113).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-07 07:58:51 -06:00

553 lines
21 KiB
Python

from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from api.v1.modules.crm.accounts.models import Account
from api.v1.modules.crm.cases import service as cases_service
from api.v1.modules.crm.common.folios import next_folio
from api.v1.modules.crm.quotes.models import Quote
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
# Direcciones válidas de la operación (para validar y sembrar hitos).
_OPERATION_TYPES = ("importacion", "exportacion")
from .dto import (
ShipmentCloseInput,
ShipmentCreate,
ShipmentDocumentCreate,
ShipmentDocumentUpdate,
ShipmentEventCreate,
ShipmentEventDecisionInput,
ShipmentEventUpdate,
ShipmentRescheduleInput,
ShipmentUpdate,
)
from .models import Shipment, ShipmentDocument, ShipmentEvent
# Hitos por defecto según el tipo de operación (Diagramas 2 y 3).
# Tupla: (event_type, título, kind). kind="decision" son puntos de decisión (rombos)
# que se resuelven con autorizado/rechazado y disparan el ciclo de corrección.
_DEFAULT_MILESTONES = {
# Diagrama 2 — Proceso operativo de exportación
"exportacion": [
("coordinacion_fecha_cliente", "Coordinar fecha de operación con el cliente", "hito"),
("revision_salidas", "Revisar disponibilidad de salidas del transporte", "hito"),
("validacion_cutoff", "Validar Cut Off del transportista", "hito"),
("decision_cutoff", "¿Se alcanza el Cut Off?", "decision"),
("programacion_transporte_terrestre", "Programar transporte terrestre y recolección", "hito"),
("recoleccion", "Recolección de mercancía", "hito"),
("traslado_puerto", "Trasladar la mercancía al puerto / aeropuerto", "hito"),
("entrega_terminal", "Entregar la mercancía en la terminal", "hito"),
("entrega_docs_agente", "Entregar documentación al agente aduanal", "hito"),
("despacho_exportacion", "Despacho de exportación", "hito"),
("decision_despacho_exportacion", "¿Despacho de exportación autorizado?", "decision"),
("emision_docs_internacionales", "Emitir documentación internacional (MBL/HBL, MAWB/HAWB, CMR)", "hito"),
("embarque", "Embarque", "hito"),
("zarpe", "Zarpe / Salida del transporte", "hito"),
("coordinacion_corresponsal", "Coordinar con el agente corresponsal en destino", "hito"),
("arribo", "Arribo a destino", "hito"),
("despacho_destino", "Despacho de importación en destino (corresponsal)", "hito"),
("entrega", "Entrega al consignatario", "hito"),
("cierre_operativo", "Cierre operativo (registrar costos finales)", "hito"),
],
# Diagrama 3 — Proceso de importación
"importacion": [
("aviso_llegada", "Aviso de llegada", "hito"),
("recepcion_docs", "Recepción de documentos (MBL/MAWB)", "hito"),
("coordinacion_agente_aduanal", "Coordinar con el agente aduanal el despacho", "hito"),
("entrega_docs_agente", "Entregar documentos y requisitos al agente aduanal", "hito"),
("despacho_importacion", "Despacho de importación", "hito"),
("decision_despacho_importacion", "¿Despacho de importación autorizado?", "decision"),
("liberacion", "Liberación de mercancía", "hito"),
("retiro", "Retiro en puerto / aeropuerto", "hito"),
("traslado", "Traslado a bodega del importador", "hito"),
("entrega", "Entrega final al cliente", "hito"),
("cierre_operativo", "Cierre operativo (registrar costos finales)", "hito"),
],
}
def _exists(db: Session, model, _id: int | None, tenant_id: int, company_id: int) -> bool:
if _id is None:
return True
return (
db.query(model.id)
.filter(
model.id == _id,
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
)
.first()
is not None
)
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
checks = [
("account_id", Account, "El cliente asociado no existe"),
("quote_id", Quote, "La cotización asociada no existe"),
("service_request_id", ServiceRequest, "La solicitud asociada no existe"),
("carrier_supplier_id", Supplier, "El transportista/naviera no existe"),
("ground_carrier_supplier_id", Supplier, "El transportista terrestre no existe"),
("customs_agent_id", Supplier, "El agente aduanal no existe"),
("destination_agent_id", Supplier, "El agente en destino no existe"),
]
for field, model, msg in checks:
if field in data and not _exists(db, model, data.get(field), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
def get_shipments(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
shipment_status: str | None = None,
account_id: int | None = None,
) -> list[Shipment]:
query = db.query(Shipment).filter(
Shipment.tenant_id == tenant_id,
Shipment.company_id == company_id,
Shipment.deleted_at.is_(None),
)
if shipment_status:
query = query.filter(Shipment.status == shipment_status)
if account_id is not None:
query = query.filter(Shipment.account_id == account_id)
if search:
pattern = f"%{search}%"
query = query.filter(
Shipment.reference.ilike(pattern)
| Shipment.booking_number.ilike(pattern)
| Shipment.origin.ilike(pattern)
| Shipment.destination.ilike(pattern)
)
return query.order_by(Shipment.created_at.desc()).all()
def get_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> Shipment:
obj = (
db.query(Shipment)
.filter(
Shipment.id == shipment_id,
Shipment.tenant_id == tenant_id,
Shipment.company_id == company_id,
Shipment.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado")
return obj
def create_shipment(
db: Session, payload: ShipmentCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Shipment:
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Shipment(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment(
db: Session, shipment_id: int, payload: ShipmentUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Shipment:
obj = get_shipment(db, shipment_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_refs(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(obj, field, value)
obj.updated_by = user_id
db.commit()
db.refresh(obj)
return obj
def delete_shipment(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> None:
obj = get_shipment(db, shipment_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def create_shipment_from_quote(
db: Session, quote_id: int, tenant_id: int, company_id: int, user_id: str | None = None,
operation_type: str | None = None,
) -> Shipment:
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada.
La dirección impo/expo se confirma al liberar (``operation_type``) y, si no se
envía, se hereda de la solicitud. Con la dirección resuelta se genera el folio
``OP...`` y se siembran automáticamente los hitos del proceso (Diagramas 2 y 3).
"""
if operation_type is not None and operation_type not in _OPERATION_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tipo de operación inválido: usa 'importacion' o 'exportacion'",
)
quote = (
db.query(Quote)
.filter(
Quote.id == quote_id,
Quote.tenant_id == tenant_id,
Quote.company_id == company_id,
Quote.deleted_at.is_(None),
)
.first()
)
if not quote:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada")
if quote.status != "aceptada":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La cotización debe estar aceptada para liberarse a Operaciones",
)
sr = None
if quote.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
# La dirección enviada al liberar manda; si no viene, se hereda de la solicitud
resolved = operation_type or (sr.operation_type if sr else None)
shipment = Shipment(
reference=next_folio(db, tenant_id, company_id, "OP", resolved),
case_id=quote.case_id,
quote_id=quote.id,
service_request_id=quote.service_request_id,
account_id=quote.account_id,
operation_type=resolved,
transport_mode=sr.transport_mode if sr else None,
service_type=sr.service_type if sr else None,
incoterm=sr.incoterm if sr else None,
origin=sr.origin if sr else None,
destination=sr.destination if sr else None,
destination_agent_id=sr.destination_agent_id if sr else None,
status="abierta",
owner_user_id=quote.owner_user_id,
tenant_id=tenant_id,
company_id=company_id,
created_by=user_id,
updated_by=user_id,
)
db.add(shipment)
if sr:
sr.status = "liberada"
cases_service.advance_stage(db, quote.case_id, "operacion")
db.flush()
# Siembra automática de hitos si ya se conoce la dirección de la operación
for position, (event_type, title, kind) in enumerate(_DEFAULT_MILESTONES.get(resolved or "", [])):
db.add(ShipmentEvent(
shipment_id=shipment.id, event_type=event_type, title=title, kind=kind,
status="pendiente", position=position, tenant_id=tenant_id, company_id=company_id,
))
db.commit()
db.refresh(shipment)
return shipment
# ----- Documentos del embarque -----
def get_shipment_documents(
db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None
) -> list[ShipmentDocument]:
query = db.query(ShipmentDocument).filter(
ShipmentDocument.tenant_id == tenant_id,
ShipmentDocument.company_id == company_id,
ShipmentDocument.deleted_at.is_(None),
)
if shipment_id is not None:
query = query.filter(ShipmentDocument.shipment_id == shipment_id)
return query.order_by(ShipmentDocument.id.asc()).all()
def _get_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> ShipmentDocument:
obj = (
db.query(ShipmentDocument)
.filter(
ShipmentDocument.id == doc_id,
ShipmentDocument.tenant_id == tenant_id,
ShipmentDocument.company_id == company_id,
ShipmentDocument.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento no encontrado")
return obj
def create_shipment_document(
db: Session, payload: ShipmentDocumentCreate, tenant_id: int, company_id: int
) -> ShipmentDocument:
get_shipment(db, payload.shipment_id, tenant_id, company_id) # valida scope
obj = ShipmentDocument(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment_document(
db: Session, doc_id: int, payload: ShipmentDocumentUpdate, tenant_id: int, company_id: int
) -> ShipmentDocument:
obj = _get_document(db, doc_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def delete_shipment_document(db: Session, doc_id: int, tenant_id: int, company_id: int) -> None:
obj = _get_document(db, doc_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
# ----- Bitácora / hitos del embarque -----
def get_shipment_events(db: Session, tenant_id: int, company_id: int, shipment_id: int | None = None) -> list[ShipmentEvent]:
query = db.query(ShipmentEvent).filter(
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
if shipment_id is not None:
query = query.filter(ShipmentEvent.shipment_id == shipment_id)
return query.order_by(ShipmentEvent.position.asc(), ShipmentEvent.id.asc()).all()
def _get_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = (
db.query(ShipmentEvent)
.filter(
ShipmentEvent.id == event_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Hito no encontrado")
return obj
def create_shipment_event(db: Session, payload: ShipmentEventCreate, tenant_id: int, company_id: int) -> ShipmentEvent:
get_shipment(db, payload.shipment_id, tenant_id, company_id)
obj = ShipmentEvent(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_shipment_event(db: Session, event_id: int, payload: ShipmentEventUpdate, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = _get_event(db, event_id, tenant_id, company_id)
for field, value in payload.model_dump(exclude_unset=True).items():
setattr(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def complete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> ShipmentEvent:
obj = _get_event(db, event_id, tenant_id, company_id)
# Un punto de decisión no se "completa" a mano: se resuelve con decide_shipment_event
if obj.kind == "decision":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Este hito es un punto de decisión: resuélvelo como autorizado o rechazado",
)
obj.status = "completado"
obj.actual_date = datetime.now(timezone.utc)
db.commit()
db.refresh(obj)
return obj
def decide_shipment_event(
db: Session, event_id: int, payload: ShipmentEventDecisionInput, tenant_id: int, company_id: int
) -> ShipmentEvent:
"""Resuelve un punto de decisión del flujo (Cut Off / despacho autorizado).
- autorizado → la decisión queda completada y el flujo continúa.
- rechazado → la decisión queda 'rechazada' y se genera automáticamente un hito
de corrección (rehacer trámite) que apunta a esta decisión, implementando el
ciclo de corrección de los diagramas 2 (R-E-14) y 3 (R-I-07).
"""
obj = _get_event(db, event_id, tenant_id, company_id)
if obj.kind != "decision":
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Solo los puntos de decisión aceptan un resultado (autorizado/rechazado)",
)
obj.outcome = payload.outcome
obj.actual_date = datetime.now(timezone.utc)
if payload.notes:
obj.notes = payload.notes
if payload.outcome == "autorizado":
obj.status = "completado"
db.commit()
db.refresh(obj)
return obj
# Rechazado: se abre el ciclo de corrección
obj.status = "rechazado"
correction = ShipmentEvent(
shipment_id=obj.shipment_id,
event_type=f"{obj.event_type or 'tramite'}_correccion",
title=f"Corrección: rehacer trámite — {obj.title}",
kind="hito",
status="en_correccion",
parent_event_id=obj.id,
attempt=(obj.attempt or 1) + 1,
# Se inserta justo después de la decisión rechazada para conservar el orden del flujo
position=obj.position,
tenant_id=tenant_id,
company_id=company_id,
)
# Empuja una posición los hitos posteriores para dejar hueco a la corrección
db.query(ShipmentEvent).filter(
ShipmentEvent.shipment_id == obj.shipment_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
ShipmentEvent.position > obj.position,
).update({ShipmentEvent.position: ShipmentEvent.position + 1})
correction.position = obj.position + 1
db.add(correction)
db.commit()
db.refresh(obj)
return obj
def delete_shipment_event(db: Session, event_id: int, tenant_id: int, company_id: int) -> None:
obj = _get_event(db, event_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def reschedule_departure(
db: Session, shipment_id: int, payload: ShipmentRescheduleInput, tenant_id: int, company_id: int,
user_id: str | None = None,
) -> Shipment:
"""Reprograma la salida cuando no se alcanza el Cut Off (R-E-06).
Conserva la salida anterior en ``previous_etd`` y deja constancia en la bitácora.
"""
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
if payload.etd is not None:
shipment.previous_etd = shipment.etd
shipment.etd = payload.etd
if payload.cutoff_date is not None:
shipment.cutoff_date = payload.cutoff_date
shipment.updated_by = user_id
last_pos = (
db.query(func.max(ShipmentEvent.position))
.filter(
ShipmentEvent.shipment_id == shipment_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
)
.scalar()
)
detail = payload.reason or "Reprogramación de salida por Cut Off no alcanzado"
db.add(
ShipmentEvent(
shipment_id=shipment_id,
event_type="reprogramacion",
title="Reprogramación de salida (nuevo Cut Off / ETD)",
kind="hito",
status="completado",
actual_date=datetime.now(timezone.utc),
position=(last_pos or 0) + 1,
notes=detail,
tenant_id=tenant_id,
company_id=company_id,
)
)
db.commit()
db.refresh(shipment)
return shipment
def close_shipment(
db: Session, shipment_id: int, payload: ShipmentCloseInput, tenant_id: int, company_id: int,
user_id: str | None = None,
) -> Shipment:
"""Cierre operativo del embarque con costos finales (R-E-22).
Marca el embarque como 'cerrada' y registra los costos reales; el cierre es el
disparador válido de la facturación (R-F-01). No permite cerrar si quedan puntos
de decisión sin resolver.
"""
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
if shipment.status == "cancelada":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT, detail="El embarque está cancelado"
)
pending_decision = (
db.query(ShipmentEvent.id)
.filter(
ShipmentEvent.shipment_id == shipment_id,
ShipmentEvent.tenant_id == tenant_id,
ShipmentEvent.company_id == company_id,
ShipmentEvent.deleted_at.is_(None),
ShipmentEvent.kind == "decision",
ShipmentEvent.status.in_(["pendiente", "rechazado", "en_correccion"]),
)
.first()
)
if pending_decision:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="No se puede cerrar: hay puntos de decisión pendientes o en corrección",
)
shipment.actual_cost_total = payload.actual_cost_total
shipment.cost_currency = payload.cost_currency
shipment.status = "cerrada"
shipment.closed_at = datetime.now(timezone.utc)
shipment.closed_by = user_id
shipment.updated_by = user_id
db.commit()
db.refresh(shipment)
return shipment
def seed_default_milestones(db: Session, shipment_id: int, tenant_id: int, company_id: int) -> list[ShipmentEvent]:
"""Crea los hitos por defecto del embarque según su tipo de operación (import/export)."""
shipment = get_shipment(db, shipment_id, tenant_id, company_id)
existing = get_shipment_events(db, tenant_id, company_id, shipment_id)
if existing:
return existing
milestones = _DEFAULT_MILESTONES.get(shipment.operation_type or "", [])
if not milestones:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Define el tipo de operación (importación/exportación) para generar los hitos",
)
created = []
for position, (event_type, title, kind) in enumerate(milestones):
ev = ShipmentEvent(
shipment_id=shipment_id, event_type=event_type, title=title, kind=kind,
status="pendiente", position=position, tenant_id=tenant_id, company_id=company_id,
)
db.add(ev)
created.append(ev)
db.commit()
for ev in created:
db.refresh(ev)
return created