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:
232
backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py
Normal file
232
backend/alembic/versions/b3c4d5e6f7a8_crm_commercial_and_ops.py
Normal 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")
|
||||
@@ -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"),
|
||||
|
||||
0
backend/api/v1/modules/crm/quotes/__init__.py
Normal file
0
backend/api/v1/modules/crm/quotes/__init__.py
Normal file
102
backend/api/v1/modules/crm/quotes/dto.py
Normal file
102
backend/api/v1/modules/crm/quotes/dto.py
Normal 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))
|
||||
60
backend/api/v1/modules/crm/quotes/models.py
Normal file
60
backend/api/v1/modules/crm/quotes/models.py
Normal 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)
|
||||
151
backend/api/v1/modules/crm/quotes/routes.py
Normal file
151
backend/api/v1/modules/crm/quotes/routes.py
Normal 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)
|
||||
231
backend/api/v1/modules/crm/quotes/service.py
Normal file
231
backend/api/v1/modules/crm/quotes/service.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
103
backend/api/v1/modules/crm/service_requests/dto.py
Normal file
103
backend/api/v1/modules/crm/service_requests/dto.py
Normal 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
|
||||
75
backend/api/v1/modules/crm/service_requests/models.py
Normal file
75
backend/api/v1/modules/crm/service_requests/models.py
Normal 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)
|
||||
129
backend/api/v1/modules/crm/service_requests/routes.py
Normal file
129
backend/api/v1/modules/crm/service_requests/routes.py
Normal 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)
|
||||
176
backend/api/v1/modules/crm/service_requests/service.py
Normal file
176
backend/api/v1/modules/crm/service_requests/service.py
Normal 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()
|
||||
0
backend/api/v1/modules/ops/__init__.py
Normal file
0
backend/api/v1/modules/ops/__init__.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal file
26
backend/api/v1/modules/ops/permissions.py
Normal 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()
|
||||
13
backend/api/v1/modules/ops/router.py
Normal file
13
backend/api/v1/modules/ops/router.py
Normal 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)
|
||||
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
0
backend/api/v1/modules/ops/shipments/__init__.py
Normal file
102
backend/api/v1/modules/ops/shipments/dto.py
Normal file
102
backend/api/v1/modules/ops/shipments/dto.py
Normal 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
|
||||
73
backend/api/v1/modules/ops/shipments/models.py
Normal file
73
backend/api/v1/modules/ops/shipments/models.py
Normal 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)
|
||||
132
backend/api/v1/modules/ops/shipments/routes.py
Normal file
132
backend/api/v1/modules/ops/shipments/routes.py
Normal 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)
|
||||
230
backend/api/v1/modules/ops/shipments/service.py
Normal file
230
backend/api/v1/modules/ops/shipments/service.py
Normal 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()
|
||||
@@ -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"])
|
||||
|
||||
|
||||
|
||||
@@ -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.opportunities.models import Opportunity
|
||||
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.ops.shipments.models import Shipment, ShipmentDocument
|
||||
from core.database import CoreSessionLocal
|
||||
|
||||
# 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")
|
||||
|
||||
|
||||
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:
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
@@ -261,6 +330,7 @@ def main() -> None:
|
||||
pipeline, stages = ensure_pipeline(db)
|
||||
seed_sample_data(db, pipeline, stages)
|
||||
seed_suppliers_and_related(db)
|
||||
seed_commercial_and_ops(db)
|
||||
print("\nSeed CRM completado.")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
@@ -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.opportunities.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.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.
|
||||
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
|
||||
|
||||
46
backend/tests/test_quotes.py
Normal file
46
backend/tests/test_quotes.py
Normal 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"
|
||||
66
backend/tests/test_service_requests.py
Normal file
66
backend/tests/test_service_requests.py
Normal 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"
|
||||
60
backend/tests/test_shipments.py
Normal file
60
backend/tests/test_shipments.py
Normal 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
|
||||
Reference in New Issue
Block a user