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

Diagrama 1 (CRM comercial) y Diagrama 2 (Operaciones) del spec de agente de carga:
- crm.service_requests (RFQ) + crm.rate_requests (solicitud de tarifas a proveedores)
- crm.quotes + crm.quote_items: conceptos costo/venta/margen, totales automáticos,
  estados borrador→enviada→aceptada/rechazada
- schema ops: ops.shipments (booking, Cut Off, ETD/ETA, naviera/agente aduanal/destino)
  y ops.shipment_documents (MBL/HBL, MAWB/HAWB, CMR…)
- liberar-a-operaciones: crea el embarque desde la cotización aceptada
- migración b3c4d5e6f7a8, routers/permisos, seed del flujo completo
- 52 tests pytest en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Aduanasoft
2026-07-14 18:21:25 -06:00
parent c26e04bcff
commit a196c44fae
27 changed files with 2090 additions and 1 deletions

View File

@@ -0,0 +1,232 @@
"""crm commercial (service_requests, rate_requests, quotes, quote_items) + ops (shipments, documents)
Revision ID: b3c4d5e6f7a8
Revises: a7b8c9d0e1f2
Create Date: 2026-07-14 00:00:00.000000
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "b3c4d5e6f7a8"
down_revision: Union[str, None] = "a7b8c9d0e1f2"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _scoped_columns() -> list[sa.Column]:
return [
sa.Column("tenant_id", sa.Integer(), nullable=False),
sa.Column("company_id", sa.Integer(), nullable=False),
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
sa.Column("deleted_at", sa.DateTime(), nullable=True),
]
def _scoped_indexes(table: str, schema: str) -> None:
op.create_index(f"ix_{schema}_{table}_id", table, ["id"], schema=schema)
op.create_index(f"ix_{schema}_{table}_tenant_id", table, ["tenant_id"], schema=schema)
op.create_index(f"ix_{schema}_{table}_company_id", table, ["company_id"], schema=schema)
def upgrade() -> None:
# ---------- crm.service_requests ----------
op.create_table(
"service_requests",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("operation_type", sa.String(length=20), nullable=False),
sa.Column("transport_mode", sa.String(length=20), nullable=True),
sa.Column("service_type", sa.String(length=20), nullable=True),
sa.Column("incoterm", sa.String(length=10), nullable=True),
sa.Column("origin", sa.String(length=160), nullable=True),
sa.Column("destination", sa.String(length=160), nullable=True),
sa.Column("cargo_type", sa.String(length=120), nullable=True),
sa.Column("weight", sa.Numeric(precision=14, scale=3), nullable=True),
sa.Column("volume", sa.Numeric(precision=14, scale=3), nullable=True),
sa.Column("load_type", sa.String(length=10), nullable=True),
sa.Column("container_equipment", sa.String(length=120), nullable=True),
sa.Column("commodity", sa.Text(), nullable=True),
sa.Column("required_date", sa.Date(), nullable=True),
sa.Column("destination_agent_id", sa.Integer(), nullable=True),
sa.Column("requirements", sa.Text(), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'nueva'")),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
sa.ForeignKeyConstraint(["destination_agent_id"], ["crm.suppliers.id"]),
schema="crm",
)
_scoped_indexes("service_requests", "crm")
op.create_index("ix_crm_service_requests_reference", "service_requests", ["reference"], schema="crm")
op.create_index("ix_crm_service_requests_account_id", "service_requests", ["account_id"], schema="crm")
op.create_index("ix_crm_service_requests_status", "service_requests", ["status"], schema="crm")
op.create_index("ix_crm_service_requests_owner_user_id", "service_requests", ["owner_user_id"], schema="crm")
# ---------- crm.rate_requests ----------
op.create_table(
"rate_requests",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("service_request_id", sa.Integer(), nullable=False),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("concept", sa.String(length=60), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'solicitada'")),
sa.Column("rate_amount", sa.Numeric(precision=14, scale=2), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=True),
sa.Column("valid_until", sa.Date(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]),
sa.ForeignKeyConstraint(["supplier_id"], ["crm.suppliers.id"]),
schema="crm",
)
_scoped_indexes("rate_requests", "crm")
op.create_index("ix_crm_rate_requests_service_request_id", "rate_requests", ["service_request_id"], schema="crm")
op.create_index("ix_crm_rate_requests_supplier_id", "rate_requests", ["supplier_id"], schema="crm")
# ---------- crm.quotes ----------
op.create_table(
"quotes",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("service_request_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'USD'")),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
sa.Column("issue_date", sa.Date(), nullable=True),
sa.Column("valid_until", sa.Date(), nullable=True),
sa.Column("total_cost", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("total_sale", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("sent_at", sa.DateTime(), nullable=True),
sa.Column("accepted_at", sa.DateTime(), nullable=True),
sa.Column("rejected_at", sa.DateTime(), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("terms", sa.Text(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
schema="crm",
)
_scoped_indexes("quotes", "crm")
op.create_index("ix_crm_quotes_reference", "quotes", ["reference"], schema="crm")
op.create_index("ix_crm_quotes_service_request_id", "quotes", ["service_request_id"], schema="crm")
op.create_index("ix_crm_quotes_account_id", "quotes", ["account_id"], schema="crm")
op.create_index("ix_crm_quotes_status", "quotes", ["status"], schema="crm")
op.create_index("ix_crm_quotes_owner_user_id", "quotes", ["owner_user_id"], schema="crm")
# ---------- crm.quote_items ----------
op.create_table(
"quote_items",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("quote_id", sa.Integer(), nullable=False),
sa.Column("concept", sa.String(length=60), nullable=False),
sa.Column("description", sa.String(length=255), nullable=True),
sa.Column("supplier_id", sa.Integer(), nullable=True),
sa.Column("quantity", sa.Numeric(precision=12, scale=2), nullable=False, server_default=sa.text("1")),
sa.Column("unit_cost", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("unit_sale", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
sa.Column("currency", sa.String(length=3), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]),
sa.ForeignKeyConstraint(["supplier_id"], ["crm.suppliers.id"]),
schema="crm",
)
_scoped_indexes("quote_items", "crm")
op.create_index("ix_crm_quote_items_quote_id", "quote_items", ["quote_id"], schema="crm")
# ---------- schema ops ----------
op.execute("CREATE SCHEMA IF NOT EXISTS ops")
op.create_table(
"shipments",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("reference", sa.String(length=40), nullable=True),
sa.Column("quote_id", sa.Integer(), nullable=True),
sa.Column("service_request_id", sa.Integer(), nullable=True),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("operation_type", sa.String(length=20), nullable=True),
sa.Column("transport_mode", sa.String(length=20), nullable=True),
sa.Column("service_type", sa.String(length=20), nullable=True),
sa.Column("incoterm", sa.String(length=10), nullable=True),
sa.Column("origin", sa.String(length=160), nullable=True),
sa.Column("destination", sa.String(length=160), nullable=True),
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierta'")),
sa.Column("booking_number", sa.String(length=60), nullable=True),
sa.Column("carrier_supplier_id", sa.Integer(), nullable=True),
sa.Column("customs_agent_id", sa.Integer(), nullable=True),
sa.Column("destination_agent_id", sa.Integer(), nullable=True),
sa.Column("cutoff_date", sa.DateTime(), nullable=True),
sa.Column("etd", sa.Date(), nullable=True),
sa.Column("eta", sa.Date(), nullable=True),
sa.Column("vessel_flight", sa.String(length=120), nullable=True),
sa.Column("container_number", sa.String(length=60), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("owner_user_id", sa.String(length=64), nullable=True),
sa.Column("created_by", sa.String(length=64), nullable=True),
sa.Column("updated_by", sa.String(length=64), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["quote_id"], ["crm.quotes.id"]),
sa.ForeignKeyConstraint(["service_request_id"], ["crm.service_requests.id"]),
sa.ForeignKeyConstraint(["account_id"], ["crm.accounts.id"]),
sa.ForeignKeyConstraint(["carrier_supplier_id"], ["crm.suppliers.id"]),
sa.ForeignKeyConstraint(["customs_agent_id"], ["crm.suppliers.id"]),
sa.ForeignKeyConstraint(["destination_agent_id"], ["crm.suppliers.id"]),
schema="ops",
)
_scoped_indexes("shipments", "ops")
op.create_index("ix_ops_shipments_reference", "shipments", ["reference"], schema="ops")
op.create_index("ix_ops_shipments_quote_id", "shipments", ["quote_id"], schema="ops")
op.create_index("ix_ops_shipments_account_id", "shipments", ["account_id"], schema="ops")
op.create_index("ix_ops_shipments_status", "shipments", ["status"], schema="ops")
op.create_index("ix_ops_shipments_owner_user_id", "shipments", ["owner_user_id"], schema="ops")
op.create_table(
"shipment_documents",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("shipment_id", sa.Integer(), nullable=False),
sa.Column("doc_kind", sa.String(length=10), nullable=False, server_default=sa.text("'otro'")),
sa.Column("doc_type", sa.String(length=30), nullable=False),
sa.Column("number", sa.String(length=80), nullable=True),
sa.Column("issue_date", sa.Date(), nullable=True),
sa.Column("file_url", sa.String(length=1024), nullable=True),
sa.Column("file_key", sa.String(length=512), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
*_scoped_columns(),
sa.PrimaryKeyConstraint("id"),
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
sa.ForeignKeyConstraint(["shipment_id"], ["ops.shipments.id"]),
schema="ops",
)
_scoped_indexes("shipment_documents", "ops")
op.create_index("ix_ops_shipment_documents_shipment_id", "shipment_documents", ["shipment_id"], schema="ops")
def downgrade() -> None:
op.drop_table("shipment_documents", schema="ops")
op.drop_table("shipments", schema="ops")
op.execute("DROP SCHEMA IF EXISTS ops")
op.drop_table("quote_items", schema="crm")
op.drop_table("quotes", schema="crm")
op.drop_table("rate_requests", schema="crm")
op.drop_table("service_requests", schema="crm")

View File

@@ -16,6 +16,9 @@ _ENTITIES = [
("contact", "contactos"), ("contact", "contactos"),
("address", "direcciones"), ("address", "direcciones"),
("document", "documentos"), ("document", "documentos"),
("service_request", "solicitudes de servicio"),
("rate_request", "solicitudes de tarifa"),
("quote", "cotizaciones"),
("lead", "prospectos"), ("lead", "prospectos"),
("opportunity", "oportunidades"), ("opportunity", "oportunidades"),
("pipeline", "embudos"), ("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 .metrics.routes import router as metrics_router
from .opportunities.routes import router as opportunities_router from .opportunities.routes import router as opportunities_router
from .pipelines.routes import router as pipelines_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 from .suppliers.routes import router as suppliers_router
router = APIRouter() router = APIRouter()
@@ -26,6 +28,8 @@ router.include_router(suppliers_router)
router.include_router(contacts_router) router.include_router(contacts_router)
router.include_router(addresses_router) router.include_router(addresses_router)
router.include_router(documents_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(leads_router)
router.include_router(pipelines_router) router.include_router(pipelines_router)
router.include_router(opportunities_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.core.router import router as core_router
from .modules.crm.router import router as crm_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 from .modules.example.routes import router as example_router
@@ -13,6 +14,7 @@ router = APIRouter()
router.include_router(core_router) router.include_router(core_router)
router.include_router(crm_router, prefix="/crm", tags=["crm"]) 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"]) router.include_router(example_router, prefix="/example", tags=["example"])

View File

@@ -22,7 +22,10 @@ from api.v1.modules.crm.documents.models import Document
from api.v1.modules.crm.leads.models import Lead from api.v1.modules.crm.leads.models import Lead
from api.v1.modules.crm.opportunities.models import Opportunity from api.v1.modules.crm.opportunities.models import Opportunity
from api.v1.modules.crm.pipelines.models import Pipeline, PipelineStage from api.v1.modules.crm.pipelines.models import Pipeline, PipelineStage
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
from api.v1.modules.crm.service_requests.models import ServiceRequest
from api.v1.modules.crm.suppliers.models import Supplier from api.v1.modules.crm.suppliers.models import Supplier
from api.v1.modules.ops.shipments.models import Shipment, ShipmentDocument
from core.database import CoreSessionLocal from core.database import CoreSessionLocal
# Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID # Deben coincidir con DEV_LOCAL_AUTH_TENANT_ID / DEV_LOCAL_AUTH_COMPANY_ID
@@ -254,6 +257,72 @@ def seed_suppliers_and_related(db) -> None:
print("✓ 2 proveedores, 3 direcciones, 2 documentos y 1 contacto de proveedor") print("✓ 2 proveedores, 3 direcciones, 2 documentos y 1 contacto de proveedor")
def seed_commercial_and_ops(db) -> None:
"""Demo del flujo comercial: Solicitud → Cotización (aceptada) → Embarque liberado."""
if db.query(ServiceRequest).filter(
ServiceRequest.tenant_id == TENANT_ID, ServiceRequest.company_id == COMPANY_ID,
ServiceRequest.deleted_at.is_(None),
).first():
print("• Ya existe flujo comercial; se omite")
return
account = (
db.query(Account)
.filter(Account.tenant_id == TENANT_ID, Account.company_id == COMPANY_ID, Account.deleted_at.is_(None))
.order_by(Account.id.asc())
.first()
)
account_id = account.id if account else None
# 1. Solicitud de servicio (RFQ)
sr = ServiceRequest(
reference="SOL-0001", account_id=account_id, operation_type="exportacion",
transport_mode="maritimo", service_type="puerta_puerta", incoterm="FOB",
origin="Manzanillo, MX", destination="Long Beach, US", cargo_type="Carga general",
load_type="FCL", container_equipment="1x40'HC", status="cotizada",
tenant_id=TENANT_ID, company_id=COMPANY_ID,
)
db.add(sr)
db.flush()
# 2. Cotización aceptada con conceptos
quote = Quote(
reference="COT-0001", service_request_id=sr.id, account_id=account_id, currency="USD",
status="aceptada", tenant_id=TENANT_ID, company_id=COMPANY_ID,
)
db.add(quote)
db.flush()
items = [
("flete_internacional", 1, 1800, 2200),
("transporte_terrestre", 1, 350, 500),
("despacho_aduanal", 1, 200, 320),
]
total_cost = total_sale = 0
for concept, qty, cost, sale in items:
db.add(QuoteItem(quote_id=quote.id, concept=concept, quantity=qty, unit_cost=cost,
unit_sale=sale, currency="USD", tenant_id=TENANT_ID, company_id=COMPANY_ID))
total_cost += qty * cost
total_sale += qty * sale
quote.total_cost = total_cost
quote.total_sale = total_sale
sr.status = "liberada"
# 3. Embarque liberado a Operaciones
shipment = Shipment(
reference="EMB-0001", quote_id=quote.id, service_request_id=sr.id, account_id=account_id,
operation_type=sr.operation_type, transport_mode=sr.transport_mode, service_type=sr.service_type,
incoterm=sr.incoterm, origin=sr.origin, destination=sr.destination,
status="booking", booking_number="BKG-778812", tenant_id=TENANT_ID, company_id=COMPANY_ID,
)
db.add(shipment)
db.flush()
db.add(ShipmentDocument(shipment_id=shipment.id, doc_kind="master", doc_type="MBL",
number="MBLU12345678", tenant_id=TENANT_ID, company_id=COMPANY_ID))
db.commit()
print("✓ Flujo comercial: 1 solicitud, 1 cotización aceptada (3 conceptos), 1 embarque + documento MBL")
def main() -> None: def main() -> None:
db = CoreSessionLocal() db = CoreSessionLocal()
try: try:
@@ -261,6 +330,7 @@ def main() -> None:
pipeline, stages = ensure_pipeline(db) pipeline, stages = ensure_pipeline(db)
seed_sample_data(db, pipeline, stages) seed_sample_data(db, pipeline, stages)
seed_suppliers_and_related(db) seed_suppliers_and_related(db)
seed_commercial_and_ops(db)
print("\nSeed CRM completado.") print("\nSeed CRM completado.")
finally: finally:
db.close() db.close()

View File

@@ -33,9 +33,12 @@ import api.v1.modules.crm.documents.models # noqa: E402,F401
import api.v1.modules.crm.leads.models # noqa: E402,F401 import api.v1.modules.crm.leads.models # noqa: E402,F401
import api.v1.modules.crm.opportunities.models # noqa: E402,F401 import api.v1.modules.crm.opportunities.models # noqa: E402,F401
import api.v1.modules.crm.pipelines.models # noqa: E402,F401 import api.v1.modules.crm.pipelines.models # noqa: E402,F401
import api.v1.modules.crm.quotes.models # noqa: E402,F401
import api.v1.modules.crm.service_requests.models # noqa: E402,F401
import api.v1.modules.crm.suppliers.models # noqa: E402,F401 import api.v1.modules.crm.suppliers.models # noqa: E402,F401
import api.v1.modules.ops.shipments.models # noqa: E402,F401
_SCHEMA_MAP = {"crm": None, "core": None} _SCHEMA_MAP = {"crm": None, "core": None, "ops": None}
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm. # Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core. # En CI (PostgreSQL) la tabla real la crea la migración inicial del core.

View File

@@ -0,0 +1,46 @@
from decimal import Decimal
from api.v1.modules.crm.quotes import service
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
T, C = 1, 1
def test_quote_totals_recompute_on_items(db):
q = service.create_quote(db, QuoteCreate(reference="COT-001", currency="USD"), T, C)
service.create_quote_item(
db, QuoteItemCreate(quote_id=q.id, concept="flete_internacional", quantity=2, unit_cost=100, unit_sale=150), T, C
)
service.create_quote_item(
db, QuoteItemCreate(quote_id=q.id, concept="despacho_aduanal", quantity=1, unit_cost=50, unit_sale=90), T, C
)
q = service.get_quote(db, q.id, T, C)
assert float(q.total_cost) == 250.0 # 2*100 + 1*50
assert float(q.total_sale) == 390.0 # 2*150 + 1*90
def test_quote_totals_update_and_delete_item(db):
q = service.create_quote(db, QuoteCreate(reference="COT-002"), T, C)
item = service.create_quote_item(
db, QuoteItemCreate(quote_id=q.id, concept="otros", quantity=1, unit_cost=100, unit_sale=200), T, C
)
service.update_quote_item(db, item.id, QuoteItemUpdate(unit_sale=Decimal("300")), T, C)
q = service.get_quote(db, q.id, T, C)
assert float(q.total_sale) == 300.0
service.delete_quote_item(db, item.id, T, C)
q = service.get_quote(db, q.id, T, C)
assert float(q.total_sale) == 0.0
def test_accept_quote_updates_service_request(db):
sr = sr_service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
q = service.create_quote(db, QuoteCreate(reference="COT-003", service_request_id=sr.id), T, C)
service.send_quote(db, q.id, T, C)
accepted = service.accept_quote(db, q.id, T, C)
assert accepted.status == "aceptada"
assert accepted.accepted_at is not None
# la solicitud asociada queda aceptada
sr = sr_service.get_service_request(db, sr.id, T, C)
assert sr.status == "aceptada"

View File

@@ -0,0 +1,66 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.service_requests import service
from api.v1.modules.crm.service_requests.dto import (
RateRequestCreate,
ServiceRequestCreate,
ServiceRequestUpdate,
)
T, C = 1, 1
def test_create_service_request(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
sr = service.create_service_request(
db,
ServiceRequestCreate(
account_id=acc.id, operation_type="exportacion", transport_mode="maritimo",
service_type="puerta_puerta", origin="Manzanillo", destination="Long Beach",
incoterm="FOB", load_type="FCL",
),
T, C, user_id="dev",
)
assert sr.id is not None
assert sr.status == "nueva"
assert sr.created_by == "dev"
def test_service_request_rejects_unknown_account(db):
with pytest.raises(HTTPException) as exc:
service.create_service_request(db, ServiceRequestCreate(account_id=999, operation_type="importacion"), T, C)
assert exc.value.status_code == 422
def test_filter_by_operation_and_status(db):
service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
service.create_service_request(db, ServiceRequestCreate(operation_type="importacion"), T, C)
exp = service.get_service_requests(db, T, C, operation_type="exportacion")
assert len(exp) == 1 and exp[0].operation_type == "exportacion"
def test_rate_request_requires_service_request(db):
with pytest.raises(HTTPException) as exc:
service.create_rate_request(
db, RateRequestCreate(service_request_id=999, concept="flete_internacional"), T, C
)
assert exc.value.status_code == 404
def test_rate_request_ok_and_listed(db):
sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
service.create_rate_request(
db, RateRequestCreate(service_request_id=sr.id, concept="flete_internacional", rate_amount=1200, currency="USD"),
T, C,
)
rates = service.get_rate_requests(db, T, C, service_request_id=sr.id)
assert len(rates) == 1 and rates[0].concept == "flete_internacional"
def test_update_service_request_status(db):
sr = service.create_service_request(db, ServiceRequestCreate(operation_type="exportacion"), T, C)
upd = service.update_service_request(db, sr.id, ServiceRequestUpdate(status="en_analisis"), T, C)
assert upd.status == "en_analisis"

View File

@@ -0,0 +1,60 @@
import pytest
from fastapi import HTTPException
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.quotes import service as quotes_service
from api.v1.modules.crm.quotes.dto import QuoteCreate
from api.v1.modules.crm.service_requests import service as sr_service
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
from api.v1.modules.ops.shipments import service
from api.v1.modules.ops.shipments.dto import ShipmentCreate, ShipmentDocumentCreate
T, C = 1, 1
def test_release_requires_accepted_quote(db):
q = quotes_service.create_quote(db, QuoteCreate(reference="COT-A"), T, C)
with pytest.raises(HTTPException) as exc:
service.create_shipment_from_quote(db, q.id, T, C)
assert exc.value.status_code == 422 # aún no aceptada
def test_release_from_accepted_quote_copies_data(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
sr = sr_service.create_service_request(
db,
ServiceRequestCreate(account_id=acc.id, operation_type="exportacion", transport_mode="maritimo",
origin="Veracruz", destination="Rotterdam"),
T, C,
)
q = quotes_service.create_quote(db, QuoteCreate(reference="COT-B", service_request_id=sr.id, account_id=acc.id), T, C)
quotes_service.accept_quote(db, q.id, T, C)
shipment = service.create_shipment_from_quote(db, q.id, T, C, user_id="dev")
assert shipment.quote_id == q.id
assert shipment.account_id == acc.id
assert shipment.operation_type == "exportacion"
assert shipment.transport_mode == "maritimo"
assert shipment.origin == "Veracruz"
assert shipment.status == "abierta"
# la solicitud queda liberada
sr = sr_service.get_service_request(db, sr.id, T, C)
assert sr.status == "liberada"
def test_shipment_crud_and_documents(db):
shipment = service.create_shipment(db, ShipmentCreate(reference="EMB-001", status="abierta", origin="MX"), T, C)
assert shipment.id is not None
doc = service.create_shipment_document(
db, ShipmentDocumentCreate(shipment_id=shipment.id, doc_kind="master", doc_type="MBL", number="MBL123"), T, C
)
assert doc.doc_type == "MBL"
docs = service.get_shipment_documents(db, T, C, shipment_id=shipment.id)
assert len(docs) == 1
def test_shipment_rejects_unknown_quote(db):
with pytest.raises(HTTPException) as exc:
service.create_shipment(db, ShipmentCreate(quote_id=999), T, C)
assert exc.value.status_code == 422