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

View File

View File

@@ -0,0 +1,26 @@
"""Registro de permisos del módulo Operaciones (ops)."""
from api.v1.modules.core.permissions.registry import registry
MODULE = "ops"
_ENTITIES = [
("shipment", "embarques"),
("document", "documentos de embarque"),
]
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
def register_permissions() -> None:
registry.register(code=f"{MODULE}.access", description="Acceso a Operaciones", module=MODULE, action="access")
for entity, label in _ENTITIES:
for action, verb in _ACTIONS:
registry.register(
code=f"{MODULE}.{entity}.{action}",
description=f"{verb} {label}",
module=MODULE,
action=action,
)
register_permissions()

View File

@@ -0,0 +1,13 @@
"""Router agregador del módulo Operaciones (Diagramas 2-4).
Se monta bajo el prefijo ``/ops`` en ``api/v1/router.py``.
"""
from fastapi import APIRouter
from . import permissions # noqa: F401 (side-effect: registra permisos de ops)
from .shipments.routes import router as shipments_router
router = APIRouter()
router.include_router(shipments_router)

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

View File

@@ -6,6 +6,7 @@ from fastapi import APIRouter
from .modules.core.router import router as core_router
from .modules.crm.router import router as crm_router
from .modules.ops.router import router as ops_router
from .modules.example.routes import router as example_router
@@ -13,6 +14,7 @@ router = APIRouter()
router.include_router(core_router)
router.include_router(crm_router, prefix="/crm", tags=["crm"])
router.include_router(ops_router, prefix="/ops", tags=["ops"])
router.include_router(example_router, prefix="/example", tags=["example"])