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>
525 lines
20 KiB
Python
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
|