Files
CRM_AGENTES_CARGA/backend/api/v1/modules/ops/shipments/service.py
Aduanasoft e79705e6e3 feat(ops,fin,crm): reglas de negocio del PDF (decisiones, cierre, facturación, continuidad, RBAC)
Cierra los huecos de la auditoría contra "SOFTWARE PARA AGENTES DE CARGA":

- ops (Diag. 2/3): bitácora con puntos de decisión (kind=decision) y ciclo de
  corrección (parent_event_id/attempt) para ¿Cut Off? y ¿despacho autorizado?
  (R-E-05/13, R-I-06). Reprogramación de salida (previous_etd, R-E-06). Hitos
  operativos completos export/import. Cierre operativo con costos finales
  (close_shipment, R-E-22).
- fin (Diag. 4): facturación con gate por cierre operativo y sin duplicar
  (R-F-01), costos de operación arrastrados (ops_cost_total, R-F-02), envío con
  PDF generado y guardado en MinIO (send_invoice + pdf.py sin dependencias,
  R-F-05) y revisión del cliente (en_revision_cliente + aprobación, R-F-06).
- crm (Diag. 1): opportunity_id enlaza embudo→RFQ (R-C-02), contacto como etapa
  (first_contact_at, R-C-04), re-cotización (clone_quote + reopen, R-C-12).
- transversal: catálogo de Incoterms y participantes/actores incl. autoridad
  aduanera (R-T-01/10), enforcement de permisos por carril (RBAC) con roles
  sembrados y dependencias dev-safe (R-T-07).
- Migración d5e6f7a8b9c0 con downgrade. Seed extendido. 70 tests (12 nuevos).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-15 08:46:53 -06:00

525 lines
20 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.quotes.models import Quote
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier
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
) -> Shipment:
"""Liberar a Operaciones: crea el embarque a partir de una cotización aceptada."""
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()
shipment = Shipment(
reference=quote.reference,
quote_id=quote.id,
service_request_id=quote.service_request_id,
account_id=quote.account_id,
operation_type=sr.operation_type if sr else None,
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"
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