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

@@ -16,6 +16,9 @@ _ENTITIES = [
("contact", "contactos"),
("address", "direcciones"),
("document", "documentos"),
("service_request", "solicitudes de servicio"),
("rate_request", "solicitudes de tarifa"),
("quote", "cotizaciones"),
("lead", "prospectos"),
("opportunity", "oportunidades"),
("pipeline", "embudos"),

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()

View File

@@ -17,6 +17,8 @@ from .leads.routes import router as leads_router
from .metrics.routes import router as metrics_router
from .opportunities.routes import router as opportunities_router
from .pipelines.routes import router as pipelines_router
from .quotes.routes import router as quotes_router
from .service_requests.routes import router as service_requests_router
from .suppliers.routes import router as suppliers_router
router = APIRouter()
@@ -26,6 +28,8 @@ router.include_router(suppliers_router)
router.include_router(contacts_router)
router.include_router(addresses_router)
router.include_router(documents_router)
router.include_router(service_requests_router)
router.include_router(quotes_router)
router.include_router(leads_router)
router.include_router(pipelines_router)
router.include_router(opportunities_router)

View File

@@ -0,0 +1,103 @@
from datetime import date, datetime
from decimal import Decimal
from pydantic import BaseModel, ConfigDict, Field
class ServiceRequestBase(BaseModel):
reference: str | None = Field(None, max_length=40)
account_id: int | None = None
operation_type: str = Field(..., max_length=20) # importacion | exportacion
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)
cargo_type: str | None = Field(None, max_length=120)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
load_type: str | None = Field(None, max_length=10)
container_equipment: str | None = Field(None, max_length=120)
commodity: str | None = None
required_date: date | None = None
destination_agent_id: int | None = None
requirements: str | None = None
status: str = Field("nueva", max_length=20)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class ServiceRequestCreate(ServiceRequestBase):
pass
class ServiceRequestUpdate(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)
cargo_type: str | None = Field(None, max_length=120)
weight: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
volume: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=3)
load_type: str | None = Field(None, max_length=10)
container_equipment: str | None = Field(None, max_length=120)
commodity: str | None = None
required_date: date | None = None
destination_agent_id: int | None = None
requirements: str | None = None
status: str | None = Field(None, max_length=20)
notes: str | None = None
owner_user_id: str | None = Field(None, max_length=64)
class ServiceRequestResponse(ServiceRequestBase):
model_config = ConfigDict(from_attributes=True)
id: int
tenant_id: int
company_id: int
created_by: str | None = None
updated_by: str | None = None
created_at: datetime
updated_at: datetime
class RateRequestBase(BaseModel):
service_request_id: int
supplier_id: int | None = None
concept: str = Field(..., max_length=60)
description: str | None = Field(None, max_length=255)
status: str = Field("solicitada", max_length=20)
rate_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, max_length=3)
valid_until: date | None = None
notes: str | None = None
class RateRequestCreate(RateRequestBase):
pass
class RateRequestUpdate(BaseModel):
supplier_id: int | None = None
concept: str | None = Field(None, max_length=60)
description: str | None = Field(None, max_length=255)
status: str | None = Field(None, max_length=20)
rate_amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
currency: str | None = Field(None, max_length=3)
valid_until: date | None = None
notes: str | None = None
class RateRequestResponse(RateRequestBase):
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,75 @@
from datetime import date
from sqlalchemy import Date, 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 ServiceRequest(Base, TenantScopedMixin, TimestampMixin):
"""Solicitud de cotización / levantamiento de requerimientos (Diagrama 1, pasos 3-5).
Captura los requerimientos logísticos de la operación que el cliente solicita
cotizar (tipo de operación, medio de transporte, ruta, carga, Incoterm, etc.).
"""
__tablename__ = "service_requests"
__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) # folio
account_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
)
# importacion | exportacion
operation_type: Mapped[str] = mapped_column(String(20), nullable=False)
# maritimo | aereo | terrestre | ferroviario | multimodal
transport_mode: Mapped[str | None] = mapped_column(String(20), nullable=True)
# puerto_puerto | puerto_puerta | puerta_puerto | puerta_puerta
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)
cargo_type: Mapped[str | None] = mapped_column(String(120), nullable=True)
weight: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
volume: Mapped[float | None] = mapped_column(Numeric(14, 3), nullable=True)
load_type: Mapped[str | None] = mapped_column(String(10), nullable=True) # FCL | LCL
container_equipment: Mapped[str | None] = mapped_column(String(120), nullable=True)
commodity: Mapped[str | None] = mapped_column(Text, nullable=True) # mercancía
required_date: Mapped[date | None] = mapped_column(Date, nullable=True)
# Agente en destino / contraparte (proveedor)
destination_agent_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True
)
requirements: Mapped[str | None] = mapped_column(Text, nullable=True) # otros requerimientos
# nueva | en_analisis | cotizada | aceptada | rechazada | liberada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'nueva'"), index=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 RateRequest(Base, TenantScopedMixin, TimestampMixin):
"""Solicitud de tarifa a un proveedor para una solicitud de servicio (Diagrama 1, paso 6)."""
__tablename__ = "rate_requests"
__table_args__ = {"schema": "crm"}
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
service_request_id: Mapped[int] = mapped_column(
Integer, ForeignKey("crm.service_requests.id"), nullable=False, index=True
)
supplier_id: Mapped[int | None] = mapped_column(
Integer, ForeignKey("crm.suppliers.id"), nullable=True, 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)
# solicitada | recibida | declinada
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'solicitada'"))
rate_amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
currency: Mapped[str | None] = mapped_column(String(3), nullable=True)
valid_until: Mapped[date | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)

View File

@@ -0,0 +1,129 @@
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 (
RateRequestCreate,
RateRequestResponse,
RateRequestUpdate,
ServiceRequestCreate,
ServiceRequestResponse,
ServiceRequestUpdate,
)
router = APIRouter()
def _user_id(current_user: dict) -> str | None:
return current_user.get("sub") or current_user.get("id")
# ----- Solicitudes de servicio (RFQ) -----
@router.get("/service-requests", response_model=list[ServiceRequestResponse])
def list_service_requests(
company_id: int = Query(..., description="Company ID"),
search: str | None = Query(None),
req_status: str | None = Query(None, alias="status"),
operation_type: str | None = Query(None),
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_service_requests(db, tenant_id, company_id, search, req_status, operation_type, account_id)
@router.get("/service-requests/{request_id}", response_model=ServiceRequestResponse)
def get_service_request(
request_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_service_request(db, request_id, tenant_id, company_id)
@router.post("/service-requests", response_model=ServiceRequestResponse, status_code=status.HTTP_201_CREATED)
def create_service_request(
payload: ServiceRequestCreate,
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_service_request(db, payload, tenant_id, company_id, _user_id(current_user))
@router.patch("/service-requests/{request_id}", response_model=ServiceRequestResponse)
def update_service_request(
request_id: int,
payload: ServiceRequestUpdate,
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_service_request(db, request_id, payload, tenant_id, company_id, _user_id(current_user))
@router.delete("/service-requests/{request_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_service_request(
request_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"]
service.delete_service_request(db, request_id, tenant_id, company_id)
# ----- Solicitudes de tarifa -----
@router.get("/rate-requests", response_model=list[RateRequestResponse])
def list_rate_requests(
company_id: int = Query(..., description="Company ID"),
service_request_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_rate_requests(db, tenant_id, company_id, service_request_id)
@router.post("/rate-requests", response_model=RateRequestResponse, status_code=status.HTTP_201_CREATED)
def create_rate_request(
payload: RateRequestCreate,
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_rate_request(db, payload, tenant_id, company_id)
@router.patch("/rate-requests/{rate_id}", response_model=RateRequestResponse)
def update_rate_request(
rate_id: int,
payload: RateRequestUpdate,
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_rate_request(db, rate_id, payload, tenant_id, company_id)
@router.delete("/rate-requests/{rate_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_rate_request(
rate_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"]
service.delete_rate_request(db, rate_id, tenant_id, company_id)

View File

@@ -0,0 +1,176 @@
from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..suppliers.models import Supplier
from .dto import RateRequestCreate, RateRequestUpdate, ServiceRequestCreate, ServiceRequestUpdate
from .models import RateRequest, ServiceRequest
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_request_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, Supplier, data.get("destination_agent_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El agente en destino no existe")
# ----- Service requests (RFQ) -----
def get_service_requests(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
req_status: str | None = None,
operation_type: str | None = None,
account_id: int | None = None,
) -> list[ServiceRequest]:
query = db.query(ServiceRequest).filter(
ServiceRequest.tenant_id == tenant_id,
ServiceRequest.company_id == company_id,
ServiceRequest.deleted_at.is_(None),
)
if req_status:
query = query.filter(ServiceRequest.status == req_status)
if operation_type:
query = query.filter(ServiceRequest.operation_type == operation_type)
if account_id is not None:
query = query.filter(ServiceRequest.account_id == account_id)
if search:
pattern = f"%{search}%"
query = query.filter(
ServiceRequest.reference.ilike(pattern)
| ServiceRequest.origin.ilike(pattern)
| ServiceRequest.destination.ilike(pattern)
)
return query.order_by(ServiceRequest.created_at.desc()).all()
def get_service_request(db: Session, request_id: int, tenant_id: int, company_id: int) -> ServiceRequest:
obj = (
db.query(ServiceRequest)
.filter(
ServiceRequest.id == request_id,
ServiceRequest.tenant_id == tenant_id,
ServiceRequest.company_id == company_id,
ServiceRequest.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
return obj
def create_service_request(
db: Session, payload: ServiceRequestCreate, tenant_id: int, company_id: int, user_id: str | None = None
) -> ServiceRequest:
data = payload.model_dump()
_validate_request_refs(db, data, tenant_id, company_id)
obj = ServiceRequest(**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_service_request(
db: Session, request_id: int, payload: ServiceRequestUpdate, tenant_id: int, company_id: int, user_id: str | None = None
) -> ServiceRequest:
obj = get_service_request(db, request_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
_validate_request_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_service_request(db: Session, request_id: int, tenant_id: int, company_id: int) -> None:
obj = get_service_request(db, request_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()
# ----- Rate requests -----
def get_rate_requests(
db: Session, tenant_id: int, company_id: int, service_request_id: int | None = None
) -> list[RateRequest]:
query = db.query(RateRequest).filter(
RateRequest.tenant_id == tenant_id,
RateRequest.company_id == company_id,
RateRequest.deleted_at.is_(None),
)
if service_request_id is not None:
query = query.filter(RateRequest.service_request_id == service_request_id)
return query.order_by(RateRequest.id.asc()).all()
def get_rate_request(db: Session, rate_id: int, tenant_id: int, company_id: int) -> RateRequest:
obj = (
db.query(RateRequest)
.filter(
RateRequest.id == rate_id,
RateRequest.tenant_id == tenant_id,
RateRequest.company_id == company_id,
RateRequest.deleted_at.is_(None),
)
.first()
)
if not obj:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud de tarifa no encontrada")
return obj
def create_rate_request(db: Session, payload: RateRequestCreate, tenant_id: int, company_id: int) -> RateRequest:
data = payload.model_dump()
# La solicitud de servicio debe existir en el tenant/company
get_service_request(db, data["service_request_id"], tenant_id, company_id)
if not _exists(db, Supplier, data.get("supplier_id"), tenant_id, company_id):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="El proveedor no existe")
obj = RateRequest(**data, tenant_id=tenant_id, company_id=company_id)
db.add(obj)
db.commit()
db.refresh(obj)
return obj
def update_rate_request(
db: Session, rate_id: int, payload: RateRequestUpdate, tenant_id: int, company_id: int
) -> RateRequest:
obj = get_rate_request(db, rate_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(obj, field, value)
db.commit()
db.refresh(obj)
return obj
def delete_rate_request(db: Session, rate_id: int, tenant_id: int, company_id: int) -> None:
obj = get_rate_request(db, rate_id, tenant_id, company_id)
obj.deleted_at = datetime.now(timezone.utc)
db.commit()