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 decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field, computed_field
# ----- Quote items -----
class QuoteItemBase(BaseModel):
concept: str = Field(..., max_length=60)
description: str | None = Field(None, max_length=255)
supplier_id: int | None = None
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
unit_cost: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2)
unit_sale: Decimal = Field(Decimal(0), ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, max_length=3)
class QuoteItemCreate(QuoteItemBase):
quote_id: int
class QuoteItemUpdate(BaseModel):
concept: str | None = Field(None, max_length=60)
description: str | None = Field(None, max_length=255)
supplier_id: int | None = None
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
unit_cost: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
unit_sale: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, max_length=3)
class QuoteItemResponse(QuoteItemBase):
model_config = ConfigDict(from_attributes=True)
id: int
quote_id: int
tenant_id: int
company_id: int
@computed_field
@property
def line_cost(self) -> Decimal:
return (self.quantity or Decimal(0)) * (self.unit_cost or Decimal(0))
@computed_field
@property
def line_sale(self) -> Decimal:
return (self.quantity or Decimal(0)) * (self.unit_sale or Decimal(0))
# ----- Quotes -----
class QuoteBase(BaseModel):
reference: str | None = Field(None, max_length=40)
service_request_id: int | None = None
account_id: int | None = None
currency: str = Field("USD", max_length=3)
issue_date: date | None = None
valid_until: date | None = None
notes: str | None = None
terms: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class QuoteCreate(QuoteBase):
pass
class QuoteUpdate(BaseModel):
reference: str | None = Field(None, max_length=40)
service_request_id: int | None = None
account_id: int | None = None
currency: str | None = Field(None, max_length=3)
issue_date: date | None = None
valid_until: date | None = None
notes: str | None = None
terms: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class QuoteResponse(QuoteBase):
model_config = ConfigDict(from_attributes=True)
id: int
status: str
total_cost: Decimal
total_sale: Decimal
sent_at: datetime | None = None
accepted_at: datetime | None = None
rejected_at: datetime | None = None
created_by: str | None = None
updated_by: str | None = None
tenant_id: int
company_id: int
created_at: datetime
updated_at: datetime
@computed_field
@property
def margin(self) -> Decimal:
return (self.total_sale or Decimal(0)) - (self.total_cost or Decimal(0))

View File

@@ -0,0 +1,60 @@
from datetime import date, datetime
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, 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 Quote(Base, TenantScopedMixin, TimestampMixin):
"""Cotización (Diagrama 1, pasos 7-9). Integra los conceptos de costo/venta."""
__tablename__ = "quotes"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
service_request_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
)
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'USD'"))
# borrador | enviada | aceptada | rechazada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"), index=True)
issue_date: Mapped[date | None] = mapped_column(Date, nullable=True)
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
total_cost: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
total_sale: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
accepted_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
rejected_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
terms: 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 QuoteItem(Base, TenantScopedMixin, TimestampMixin):
"""Concepto de una cotización (flete, transporte terrestre, despacho, gastos destino, otros)."""
__tablename__ = "quote_items"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
quote_id: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.quotes.id"), nullable=False, index=True
)
# flete_internacional | transporte_terrestre | despacho_aduanal | gastos_destino | otros
concept: Mapped[str] = mapped_column(String(60), nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
)
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
unit_cost: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
unit_sale: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)

View File

@@ -0,0 +1,151 @@
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 (
QuoteCreate,
QuoteItemCreate,
QuoteItemResponse,
QuoteItemUpdate,
QuoteResponse,
QuoteUpdate,
)
router = APIRouter()
def _user_id(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
@router.get("/quotes", response_model=list[QuoteResponse])
def list_quotes(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
quote_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_quotes(db, tenant_id, company_id, search, quote_status, account_id)
@router.get("/quotes/{quote_id}", response_model=QuoteResponse)
def get_quote(
quote_id: int,
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.get_quote(db, quote_id, tenant_id, company_id)
@router.post("/quotes", response_model=QuoteResponse, status_code=status.HTTP_201_CREATED)
def create_quote(
payload: QuoteCreate,
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_quote(db, payload, tenant_id, company_id, _user_id(current_user))
@router.patch("/quotes/{quote_id}", response_model=QuoteResponse)
def update_quote(
quote_id: int,
payload: QuoteUpdate,
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_quote(db, quote_id, payload, tenant_id, company_id, _user_id(current_user))
@router.patch("/quotes/{quote_id}/send", response_model=QuoteResponse)
def send_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.send_quote(db, quote_id, current_user["tenant_id"], company_id)
@router.patch("/quotes/{quote_id}/accept", response_model=QuoteResponse)
def accept_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.accept_quote(db, quote_id, current_user["tenant_id"], company_id)
@router.patch("/quotes/{quote_id}/reject", response_model=QuoteResponse)
def reject_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.reject_quote(db, quote_id, current_user["tenant_id"], company_id)
@router.delete("/quotes/{quote_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_quote(
quote_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_quote(db, quote_id, current_user["tenant_id"], company_id)
# ----- Conceptos de la cotización -----
@router.get("/quotes/{quote_id}/items", response_model=list[QuoteItemResponse])
def list_quote_items(
quote_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_quote_items(db, quote_id, current_user["tenant_id"], company_id)
@router.post("/quote-items", response_model=QuoteItemResponse, status_code=status.HTTP_201_CREATED)
def create_quote_item(
payload: QuoteItemCreate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.create_quote_item(db, payload, current_user["tenant_id"], company_id)
@router.patch("/quote-items/{item_id}", response_model=QuoteItemResponse)
def update_quote_item(
item_id: int,
payload: QuoteItemUpdate,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
return service.update_quote_item(db, item_id, payload, current_user["tenant_id"], company_id)
@router.delete("/quote-items/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_quote_item(
item_id: int,
company_id: int = Query(..., description="Company ID"),
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
service.delete_quote_item(db, item_id, current_user["tenant_id"], company_id)

View File

@@ -0,0 +1,231 @@
from datetime import datetime, timezone
from decimal import Decimal
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..service_requests.models import ServiceRequest
from ..suppliers.models import Supplier
from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate
from .models import Quote, QuoteItem
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:
if not _exists(db, Account, data.get("account_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El cliente asociado no existe")
if not _exists(db, ServiceRequest, data.get("service_request_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="La solicitud asociada no existe")
def _recompute_totals(db: Session, quote: Quote) -> None:
"""Recalcula total_cost/total_sale a partir de los conceptos vigentes."""
cost, sale = (
db.query(
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_cost), 0),
func.coalesce(func.sum(QuoteItem.quantity * QuoteItem.unit_sale), 0),
)
.filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None))
.one()
)
quote.total_cost = Decimal(cost or 0)
quote.total_sale = Decimal(sale or 0)
# ----- Quotes -----
def get_quotes(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
quote_status: str | None = None,
account_id: int | None = None,
) -> list[Quote]:
query = db.query(Quote).filter(
Quote.tenant_id == tenant_id,
Quote.company_id == company_id,
Quote.deleted_at.is_(None),
)
if quote_status:
query = query.filter(Quote.status == quote_status)
if account_id is not None:
query = query.filter(Quote.account_id == account_id)
if search:
query = query.filter(Quote.reference.ilike(f"%{search}%"))
return query.order_by(Quote.created_at.desc()).all()
def get_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
obj = (
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 obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cotización no encontrada")
return obj
def create_quote(
db: Session, payload: QuoteCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
obj = Quote(**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_quote(
db: Session, quote_id: int, payload: QuoteUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> Quote:
obj = get_quote(db, quote_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_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> None:
obj = get_quote(db, quote_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
def _set_service_request_status(db: Session, quote: Quote, new_status: str) -> None:
if quote.service_request_id:
sr = db.query(ServiceRequest).filter(ServiceRequest.id == quote.service_request_id).first()
if sr:
sr.status = new_status
def send_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
quote = get_quote(db, quote_id, tenant_id, company_id)
quote.status = "enviada"
quote.sent_at = datetime.now(timezone.utc)
_set_service_request_status(db, quote, "cotizada")
db.commit()
db.refresh(quote)
return quote
def accept_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
quote = get_quote(db, quote_id, tenant_id, company_id)
quote.status = "aceptada"
quote.accepted_at = datetime.now(timezone.utc)
_set_service_request_status(db, quote, "aceptada")
db.commit()
db.refresh(quote)
return quote
def reject_quote(db: Session, quote_id: int, tenant_id: int, company_id: int) -> Quote:
quote = get_quote(db, quote_id, tenant_id, company_id)
quote.status = "rechazada"
quote.rejected_at = datetime.now(timezone.utc)
_set_service_request_status(db, quote, "rechazada")
db.commit()
db.refresh(quote)
return quote
# ----- Quote items -----
def get_quote_items(db: Session, quote_id: int, tenant_id: int, company_id: int) -> list[QuoteItem]:
get_quote(db, quote_id, tenant_id, company_id) # valida scope
return (
db.query(QuoteItem)
.filter(
QuoteItem.quote_id == quote_id,
QuoteItem.tenant_id == tenant_id,
QuoteItem.company_id == company_id,
QuoteItem.deleted_at.is_(None),
)
.order_by(QuoteItem.id.asc())
.all()
)
def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> QuoteItem:
item = (
db.query(QuoteItem)
.filter(
QuoteItem.id == item_id,
QuoteItem.tenant_id == tenant_id,
QuoteItem.company_id == company_id,
QuoteItem.deleted_at.is_(None),
)
.first()
)
if not item:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
return item
def create_quote_item(db: Session, payload: QuoteItemCreate, tenant_id: int, company_id: int) -> QuoteItem:
quote = get_quote(db, payload.quote_id, tenant_id, company_id)
if not _exists(db, Supplier, payload.supplier_id, tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
item = QuoteItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(item)
db.flush()
_recompute_totals(db, quote)
db.commit()
db.refresh(item)
return item
def update_quote_item(
db: Session, item_id: int, payload: QuoteItemUpdate, tenant_id: int, company_id: int
) -> QuoteItem:
item = _get_item(db, item_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if "supplier_id" in data and not _exists(db, Supplier, data["supplier_id"], tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
for field, value in data.items():
setattr(item, field, value)
db.flush()
quote = get_quote(db, item.quote_id, tenant_id, company_id)
_recompute_totals(db, quote)
db.commit()
db.refresh(item)
return item
def delete_quote_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> None:
item = _get_item(db, item_id, tenant_id, company_id)
quote_id = item.quote_id
item.deleted_at = datetime.now(timezone.utc)
db.flush()
quote = get_quote(db, quote_id, tenant_id, company_id)
_recompute_totals(db, quote)
db.commit()