feat(crm,ops): proceso comercial (solicitudes/RFQ, tarifas, cotizaciones) + Operaciones (embarques)

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>
This commit is contained in:
Aduanasoft
2026-07-14 18:21:25 -06:00
parent c26e04bcff
commit a196c44fae
27 changed files with 2090 additions and 1 deletions

View File

@@ -0,0 +1,102 @@
from datetime import date, datetime
from pydantic import BaseModel, ConfigDict, Field
class ShipmentBase(BaseModel):
reference: str | None = Field(None, max_length=40)
quote_id: int | None = None
service_request_id: int | None = None
account_id: int | None = None
operation_type: str | None = Field(None, max_length=20)
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
incoterm: str | None = Field(None, max_length=10)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
status: str = Field("abierta", max_length=20)
booking_number: str | None = Field(None, max_length=60)
carrier_supplier_id: int | None = None
customs_agent_id: int | None = None
destination_agent_id: int | None = None
cutoff_date: datetime | None = None
etd: date | None = None
eta: date | None = None
vessel_flight: str | None = Field(None, max_length=120)
container_number: str | None = Field(None, max_length=60)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class ShipmentCreate(ShipmentBase):
pass
class ShipmentUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
operation_type: str | None = Field(None, max_length=20)
transport_mode: str | None = Field(None, max_length=20)
service_type: str | None = Field(None, max_length=20)
incoterm: str | None = Field(None, max_length=10)
origin: str | None = Field(None, max_length=160)
destination: str | None = Field(None, max_length=160)
status: str | None = Field(None, max_length=20)
booking_number: str | None = Field(None, max_length=60)
carrier_supplier_id: int | None = None
customs_agent_id: int | None = None
destination_agent_id: int | None = None
cutoff_date: datetime | None = None
etd: date | None = None
eta: date | None = None
vessel_flight: str | None = Field(None, max_length=120)
container_number: str | None = Field(None, max_length=60)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class ShipmentResponse(ShipmentBase):
model_config = ConfigDict(from_attributes=True)
id: int
created_by: str | None = None
updated_by: str | None = None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
class ShipmentDocumentBase(BaseModel):
shipment_id: int
doc_kind: str = Field("otro", max_length=10)
doc_type: str = Field(..., max_length=30)
number: str | None = Field(None, max_length=80)
issue_date: date | None = None
file_url: str | None = Field(None, max_length=1024)
file_key: str | None = Field(None, max_length=512)
notes: str | None = None
class ShipmentDocumentCreate(ShipmentDocumentBase):
pass
class ShipmentDocumentUpdate(BaseModel):
doc_kind: str | None = Field(None, max_length=10)
doc_type: str | None = Field(None, max_length=30)
number: str | None = Field(None, max_length=80)
issue_date: date | None = None
file_url: str | None = Field(None, max_length=1024)
file_key: str | None = Field(None, max_length=512)
notes: str | None = None
class ShipmentDocumentResponse(ShipmentDocumentBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime

View File

@@ -0,0 +1,73 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, String, Text, text
from sqlalchemy.orm import Mapped, mapped_column
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
class Shipment(Base, TenantScopedMixin, TimestampMixin):
"""Operación / Embarque (Diagrama 2). Se crea al liberar una cotización aceptada."""
__tablename__ = "shipments"
__table_args__ = {"schema": "ops"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio de embarque
quote_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=True, index=True
)
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True
)
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
operation_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
service_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
incoterm: Mapped[str | None] = mapped_column(String(10), nullable=True)
origin: Mapped[str | None] = mapped_column(String(160), nullable=True)
destination: Mapped[str | None] = mapped_column(String(160), nullable=True)
# abierta | booking | en_transito | arribado | entregada | cerrada | cancelada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierta'"), index=True)
booking_number: Mapped[str | None] = mapped_column(String(60), nullable=True)
carrier_supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
) # naviera / aerolínea / transportista
customs_agent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
) # agente aduanal
destination_agent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
) # agente en destino
cutoff_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # Cut Off
etd: Mapped[date | None] = mapped_column(Date, nullable=True) # salida estimada
eta: Mapped[date | None] = mapped_column(Date, nullable=True) # llegada estimada
vessel_flight: Mapped[str | None] = mapped_column(String(120), nullable=True) # buque / vuelo
container_number: Mapped[str | None] = mapped_column(String(60), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin):
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.)."""
__tablename__ = "shipment_documents"
__table_args__ = {"schema": "ops"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
shipment_id: Mapped[int] = mapped_column(
Integer, ForeignKey("ops.shipments.id"), nullable=False, index=True
)
doc_kind: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text("'otro'")) # master|house|otro
# MBL | HBL | MAWB | HAWB | CMR | factura_comercial | packing_list | carta_encomienda | carta_garantia | otro
doc_type: Mapped[str] = mapped_column(String(30), nullable=False)
number: Mapped[str | None] = mapped_column(String(80), nullable=True)
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
file_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,132 @@
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from core.security import get_current_user
from . import service
from .dto import (
ShipmentCreate,
ShipmentDocumentCreate,
ShipmentDocumentResponse,
ShipmentDocumentUpdate,
ShipmentResponse,
ShipmentUpdate,
)
router = APIRouter()
def _user_id(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
@router.get("/shipments", response_model=list[ShipmentResponse])
def list_shipments(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
shipment_status: str | None = Query(None, alias="status"),
account_id: int | None = Query(None),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.get_shipments(db, tenant_id, company_id, search, shipment_status, account_id)
@router.get("/shipments/{shipment_id}", response_model=ShipmentResponse)
def get_shipment(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
@router.post("/shipments", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED)
def create_shipment(
payload: ShipmentCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.create_shipment(db, payload, tenant_id, company_id, _user_id(current_user))
@router.post("/shipments/from-quote", response_model=ShipmentResponse, status_code=status.HTTP_201_CREATED)
def create_shipment_from_quote(
quote_id: int = Query(..., description="Cotización aceptada a liberar"),
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.create_shipment_from_quote(db, quote_id, tenant_id, company_id, _user_id(current_user))
@router.patch("/shipments/{shipment_id}", response_model=ShipmentResponse)
def update_shipment(
shipment_id: int,
payload: ShipmentUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
tenant_id = current_user["tenant_id"]
return service.update_shipment(db, shipment_id, payload, tenant_id, company_id, _user_id(current_user))
@router.delete("/shipments/{shipment_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shipment(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_shipment(db, shipment_id, current_user["tenant_id"], company_id)
# ----- Documentos del embarque -----
@router.get("/shipments/{shipment_id}/documents", response_model=list[ShipmentDocumentResponse])
def list_shipment_documents(
shipment_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.get_shipment(db, shipment_id, current_user["tenant_id"], company_id)
return service.get_shipment_documents(db, current_user["tenant_id"], company_id, shipment_id)
@router.post("/shipment-documents", response_model=ShipmentDocumentResponse, status_code=status.HTTP_201_CREATED)
def create_shipment_document(
payload: ShipmentDocumentCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.create_shipment_document(db, payload, current_user["tenant_id"], company_id)
@router.patch("/shipment-documents/{doc_id}", response_model=ShipmentDocumentResponse)
def update_shipment_document(
doc_id: int,
payload: ShipmentDocumentUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.update_shipment_document(db, doc_id, payload, current_user["tenant_id"], company_id)
@router.delete("/shipment-documents/{doc_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_shipment_document(
doc_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_shipment_document(db, doc_id, current_user["tenant_id"], company_id)

View File

@@ -0,0 +1,230 @@
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()