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