from datetime import datetime, timezone from fastapi import HTTPException, status 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 ( ShipmentCreate, ShipmentDocumentCreate, ShipmentDocumentUpdate, ShipmentEventCreate, ShipmentEventUpdate, ShipmentUpdate, ) from .models import Shipment, ShipmentDocument, ShipmentEvent # Hitos por defecto según el tipo de operación (Diagramas 2 y 3) _DEFAULT_MILESTONES = { "exportacion": [ ("recoleccion", "Recolección de mercancía"), ("despacho_exportacion", "Despacho de exportación"), ("embarque", "Embarque"), ("zarpe", "Zarpe / Salida del transporte"), ("arribo", "Arribo a destino"), ("entrega", "Entrega al consignatario"), ], "importacion": [ ("aviso_llegada", "Aviso de llegada"), ("recepcion_docs", "Recepción de documentos (MBL/MAWB)"), ("despacho_importacion", "Despacho de importación"), ("liberacion", "Liberación de mercancía"), ("retiro", "Retiro en puerto / aeropuerto"), ("traslado", "Traslado a bodega del importador"), ("entrega", "Entrega final al cliente"), ], } 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"), ("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) obj.status = "completado" obj.actual_date = datetime.now(timezone.utc) 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 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) in enumerate(milestones): ev = ShipmentEvent( shipment_id=shipment_id, event_type=event_type, title=title, 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