Diagrama 1 (CRM comercial) y Diagrama 2 (Operaciones) del spec de agente de carga: - crm.service_requests (RFQ) + crm.rate_requests (solicitud de tarifas a proveedores) - crm.quotes + crm.quote_items: conceptos costo/venta/margen, totales automáticos, estados borrador→enviada→aceptada/rechazada - schema ops: ops.shipments (booking, Cut Off, ETD/ETA, naviera/agente aduanal/destino) y ops.shipment_documents (MBL/HBL, MAWB/HAWB, CMR…) - liberar-a-operaciones: crea el embarque desde la cotización aceptada - migración b3c4d5e6f7a8, routers/permisos, seed del flujo completo - 52 tests pytest en verde Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
231 lines
7.9 KiB
Python
231 lines
7.9 KiB
Python
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, ShipmentUpdate
|
|
from .models import Shipment, ShipmentDocument
|
|
|
|
|
|
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()
|