feat(crm): módulo Tarifario — modelos, CRUD, import Excel y motor de costeo
- Tablas rate_sheets/lanes/breaks/charges (esquema crm) + migración con down(). - Catálogos nuevos: modo_tarifario, unidad_tarifa, concepto_cargo. - CRUD de tarifarios y rutas; descarga de plantilla Excel por modo; import con vista previa y validación; alta directa desde Excel. - Motor de costeo /rate-quote: aéreo (peso facturable + quiebres + optimización), marítimo FCL (por contenedor), LCL (W/M) y terrestre; suma cargos adicionales. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
131
backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py
Normal file
131
backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""crm rates: tarifarios (rate_sheets/lanes/breaks/charges)
|
||||||
|
|
||||||
|
Revision ID: f8a9b0c1d2e3
|
||||||
|
Revises: e7f8a9b0c1d2
|
||||||
|
Create Date: 2026-07-27 00:00:00.000000
|
||||||
|
|
||||||
|
Módulo Tarifario: base de costos para Cotizaciones (import por Excel + motor de costeo).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "f8a9b0c1d2e3"
|
||||||
|
down_revision: Union[str, None] = "e7f8a9b0c1d2"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
SCHEMA = "crm"
|
||||||
|
|
||||||
|
|
||||||
|
def _scoped() -> 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 _idx(table: 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:
|
||||||
|
# ----- rate_sheets -----
|
||||||
|
op.create_table(
|
||||||
|
"rate_sheets",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("supplier_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("mode", sa.String(length=20), nullable=False),
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("currency", sa.String(length=3), nullable=True, server_default=sa.text("'USD'")),
|
||||||
|
sa.Column("valid_from", sa.Date(), nullable=True),
|
||||||
|
sa.Column("valid_to", sa.Date(), nullable=True),
|
||||||
|
sa.Column("default_origin", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'borrador'")),
|
||||||
|
sa.Column("source_file", sa.String(length=512), nullable=True),
|
||||||
|
sa.Column("source_url", sa.String(length=1024), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||||
|
*_scoped(),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["supplier_id"], [f"{SCHEMA}.suppliers.id"]),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
_idx("rate_sheets")
|
||||||
|
op.create_index("ix_crm_rate_sheets_mode", "rate_sheets", ["mode"], schema=SCHEMA)
|
||||||
|
op.create_index("ix_crm_rate_sheets_supplier_id", "rate_sheets", ["supplier_id"], schema=SCHEMA)
|
||||||
|
|
||||||
|
# ----- rate_lanes -----
|
||||||
|
op.create_table(
|
||||||
|
"rate_lanes",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("rate_sheet_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("origin", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("destination", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("region", sa.String(length=60), nullable=True),
|
||||||
|
sa.Column("equipment_type", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("rate_unit", sa.String(length=20), nullable=True),
|
||||||
|
sa.Column("min_charge", sa.Numeric(precision=14, scale=4), nullable=True),
|
||||||
|
sa.Column("flat_rate", sa.Numeric(precision=14, scale=4), nullable=True),
|
||||||
|
sa.Column("transit_days", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
*_scoped(),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
_idx("rate_lanes")
|
||||||
|
op.create_index("ix_crm_rate_lanes_rate_sheet_id", "rate_lanes", ["rate_sheet_id"], schema=SCHEMA)
|
||||||
|
op.create_index("ix_crm_rate_lanes_origin", "rate_lanes", ["origin"], schema=SCHEMA)
|
||||||
|
op.create_index("ix_crm_rate_lanes_destination", "rate_lanes", ["destination"], schema=SCHEMA)
|
||||||
|
|
||||||
|
# ----- rate_breaks -----
|
||||||
|
op.create_table(
|
||||||
|
"rate_breaks",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("rate_lane_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("from_qty", sa.Numeric(precision=12, scale=3), nullable=False, server_default=sa.text("0")),
|
||||||
|
sa.Column("rate", sa.Numeric(precision=14, scale=4), nullable=False, server_default=sa.text("0")),
|
||||||
|
*_scoped(),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
_idx("rate_breaks")
|
||||||
|
op.create_index("ix_crm_rate_breaks_rate_lane_id", "rate_breaks", ["rate_lane_id"], schema=SCHEMA)
|
||||||
|
|
||||||
|
# ----- rate_charges -----
|
||||||
|
op.create_table(
|
||||||
|
"rate_charges",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("rate_sheet_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("rate_lane_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("concept", sa.String(length=60), nullable=False),
|
||||||
|
sa.Column("charge_type", sa.String(length=20), nullable=False, server_default=sa.text("'fijo'")),
|
||||||
|
sa.Column("value", sa.Numeric(precision=14, scale=4), nullable=True),
|
||||||
|
sa.Column("condition", sa.Text(), nullable=True),
|
||||||
|
*_scoped(),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["rate_sheet_id"], [f"{SCHEMA}.rate_sheets.id"]),
|
||||||
|
sa.ForeignKeyConstraint(["rate_lane_id"], [f"{SCHEMA}.rate_lanes.id"]),
|
||||||
|
schema=SCHEMA,
|
||||||
|
)
|
||||||
|
_idx("rate_charges")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("rate_charges", schema=SCHEMA)
|
||||||
|
op.drop_table("rate_breaks", schema=SCHEMA)
|
||||||
|
op.drop_table("rate_lanes", schema=SCHEMA)
|
||||||
|
op.drop_table("rate_sheets", schema=SCHEMA)
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
"""Datos semilla de los catálogos de referencia del CRM (T2026-07-081/082).
|
"""Datos semilla de los catálogos de referencia del CRM.
|
||||||
|
|
||||||
Generado de CATALOGOS CRM.xlsx (SAT/ISO) + catálogos estándar + Medidas de
|
SAT/ISO + estándar + Medidas de Equipos (tipo_equipo con dimensiones en extra)
|
||||||
Equipos (tipo_equipo con dimensiones en extra). Globales (Aduanasoft) se siembran
|
+ catálogos del módulo Tarifario. Globales con tenant_id NULL.
|
||||||
con tenant_id NULL; los catálogos "cliente" los llena cada tenant.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro',
|
GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro',
|
||||||
@@ -735,7 +734,33 @@ GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro',
|
|||||||
'alto_m': 2.3,
|
'alto_m': 2.3,
|
||||||
'capacidad_m3': 99.11,
|
'capacidad_m3': 99.11,
|
||||||
'carga_max_kg': 19958,
|
'carga_max_kg': 19958,
|
||||||
'pallets': 24}}]}}
|
'pallets': 24}}]},
|
||||||
|
'modo_tarifario': {'label': 'Modo de tarifario',
|
||||||
|
'is_system': True,
|
||||||
|
'items': [{'code': 'aereo', 'label': 'Aéreo'},
|
||||||
|
{'code': 'maritimo_fcl', 'label': 'Marítimo FCL'},
|
||||||
|
{'code': 'maritimo_lcl', 'label': 'Marítimo LCL'},
|
||||||
|
{'code': 'terrestre', 'label': 'Terrestre'}]},
|
||||||
|
'unidad_tarifa': {'label': 'Unidad de tarifa',
|
||||||
|
'is_system': True,
|
||||||
|
'items': [{'code': 'per_kg', 'label': 'Por kg'},
|
||||||
|
{'code': 'per_wm', 'label': 'Por peso/medida (W/M)'},
|
||||||
|
{'code': 'per_container', 'label': 'Por contenedor'},
|
||||||
|
{'code': 'flat', 'label': 'Tarifa plana'}]},
|
||||||
|
'concepto_cargo': {'label': 'Concepto de cargo',
|
||||||
|
'is_system': False,
|
||||||
|
'items': [{'code': 'combustible', 'label': 'Combustible (BAF/FSC)'},
|
||||||
|
{'code': 'dgr', 'label': 'Mercancía peligrosa (DGR)'},
|
||||||
|
{'code': 'moc', 'label': 'MOC (mínimo origen)'},
|
||||||
|
{'code': 'afs', 'label': 'AFS'},
|
||||||
|
{'code': 'thc', 'label': 'THC (manejo en terminal)'},
|
||||||
|
{'code': 'maniobras', 'label': 'Maniobras'},
|
||||||
|
{'code': 'almacenaje', 'label': 'Almacenaje'},
|
||||||
|
{'code': 'seguro', 'label': 'Seguro'},
|
||||||
|
{'code': 'despacho', 'label': 'Despacho aduanal'},
|
||||||
|
{'code': 'documentacion', 'label': 'Documentación'},
|
||||||
|
{'code': 'custodia', 'label': 'Custodia'},
|
||||||
|
{'code': 'otro', 'label': 'Otro'}]}}
|
||||||
|
|
||||||
TENANT_CATALOG_LABELS = {'servicio': 'Servicios que ofrece',
|
TENANT_CATALOG_LABELS = {'servicio': 'Servicios que ofrece',
|
||||||
'puerto': 'Puertos donde opera',
|
'puerto': 'Puertos donde opera',
|
||||||
|
|||||||
0
backend/api/v1/modules/crm/rates/__init__.py
Normal file
0
backend/api/v1/modules/crm/rates/__init__.py
Normal file
148
backend/api/v1/modules/crm/rates/dto.py
Normal file
148
backend/api/v1/modules/crm/rates/dto.py
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
"""Schemas del módulo Tarifario."""
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Quiebres y cargos ----------
|
||||||
|
class RateBreakDTO(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
from_qty: Decimal = Field(0)
|
||||||
|
rate: Decimal = Field(0)
|
||||||
|
|
||||||
|
|
||||||
|
class RateChargeDTO(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
concept: str = Field(..., max_length=60)
|
||||||
|
charge_type: str = Field("fijo", max_length=20)
|
||||||
|
value: Decimal | None = None
|
||||||
|
condition: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Rutas ----------
|
||||||
|
class RateLaneBase(BaseModel):
|
||||||
|
origin: str | None = Field(None, max_length=20)
|
||||||
|
destination: str | None = Field(None, max_length=20)
|
||||||
|
region: str | None = Field(None, max_length=60)
|
||||||
|
equipment_type: str | None = Field(None, max_length=20)
|
||||||
|
rate_unit: str | None = Field(None, max_length=20)
|
||||||
|
min_charge: Decimal | None = None
|
||||||
|
flat_rate: Decimal | None = None
|
||||||
|
transit_days: int | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RateLaneCreate(RateLaneBase):
|
||||||
|
breaks: list[RateBreakDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLaneUpdate(RateLaneBase):
|
||||||
|
breaks: list[RateBreakDTO] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RateLaneResponse(RateLaneBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
rate_sheet_id: int
|
||||||
|
breaks: list[RateBreakDTO] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Tarifario (cabecera) ----------
|
||||||
|
class RateSheetBase(BaseModel):
|
||||||
|
supplier_id: int | None = None
|
||||||
|
mode: str = Field(..., max_length=20)
|
||||||
|
name: str = Field(..., min_length=1, max_length=255)
|
||||||
|
currency: str | None = Field("USD", max_length=3)
|
||||||
|
valid_from: date | None = None
|
||||||
|
valid_to: date | None = None
|
||||||
|
default_origin: str | None = Field(None, max_length=20)
|
||||||
|
status: str = Field("borrador", max_length=20)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RateSheetCreate(RateSheetBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RateSheetUpdate(BaseModel):
|
||||||
|
supplier_id: int | None = None
|
||||||
|
mode: str | None = Field(None, max_length=20)
|
||||||
|
name: str | None = Field(None, max_length=255)
|
||||||
|
currency: str | None = Field(None, max_length=3)
|
||||||
|
valid_from: date | None = None
|
||||||
|
valid_to: date | None = None
|
||||||
|
default_origin: str | None = Field(None, max_length=20)
|
||||||
|
status: str | None = Field(None, max_length=20)
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RateSheetResponse(RateSheetBase):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
id: int
|
||||||
|
tenant_id: int
|
||||||
|
company_id: int
|
||||||
|
source_file: str | None = None
|
||||||
|
created_by: str | None = None
|
||||||
|
updated_by: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
lane_count: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Importación ----------
|
||||||
|
class ImportPreviewRow(BaseModel):
|
||||||
|
row: int
|
||||||
|
data: dict
|
||||||
|
ok: bool
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
errors: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class ImportPreview(BaseModel):
|
||||||
|
mode: str
|
||||||
|
total: int
|
||||||
|
valid: int
|
||||||
|
rows: list[ImportPreviewRow]
|
||||||
|
columns: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class ImportConfirm(RateSheetCreate):
|
||||||
|
lanes: list[RateLaneCreate]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Costeo ----------
|
||||||
|
class CostRequest(BaseModel):
|
||||||
|
mode: str
|
||||||
|
origin: str | None = None
|
||||||
|
destination: str | None = None
|
||||||
|
on_date: date | None = None
|
||||||
|
gross_weight_kg: Decimal | None = None
|
||||||
|
volume_m3: Decimal | None = None
|
||||||
|
equipment_type: str | None = None
|
||||||
|
quantity: int = 1
|
||||||
|
dangerous: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class CostChargeLine(BaseModel):
|
||||||
|
concept: str
|
||||||
|
amount: Decimal
|
||||||
|
|
||||||
|
|
||||||
|
class CostOption(BaseModel):
|
||||||
|
rate_sheet_id: int
|
||||||
|
rate_sheet_name: str
|
||||||
|
supplier_id: int | None
|
||||||
|
currency: str | None
|
||||||
|
chargeable: Decimal | None = None # peso/wm facturable usado
|
||||||
|
base_cost: Decimal
|
||||||
|
charges: list[CostChargeLine] = Field(default_factory=list)
|
||||||
|
total_cost: Decimal
|
||||||
|
transit_days: int | None = None
|
||||||
|
detail: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CostResult(BaseModel):
|
||||||
|
request: CostRequest
|
||||||
|
options: list[CostOption]
|
||||||
93
backend/api/v1/modules/crm/rates/models.py
Normal file
93
backend/api/v1/modules/crm/rates/models.py
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
"""Modelos del módulo Tarifario (base de costos para Cotizaciones).
|
||||||
|
|
||||||
|
Un ``RateSheet`` (tarifario) pertenece a un proveedor y agrupa muchas
|
||||||
|
``RateLane`` (rutas origen→destino). Cada ruta tiene, según el modo:
|
||||||
|
- Aéreo / LCL: varios ``RateBreak`` (quiebres de peso/volumen con su tarifa).
|
||||||
|
- FCL / terrestre: una tarifa plana por contenedor/unidad (``flat_rate``).
|
||||||
|
Los ``RateCharge`` son cargos adicionales a nivel tarifario o ruta.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
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 RateSheet(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
__tablename__ = "rate_sheets"
|
||||||
|
__table_args__ = {"schema": "crm"}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
supplier_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("crm.suppliers.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
# aereo | maritimo_fcl | maritimo_lcl | terrestre
|
||||||
|
mode: Mapped[str] = mapped_column(String(20), nullable=False, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
currency: Mapped[str | None] = mapped_column(String(3), nullable=True, server_default=text("'USD'"))
|
||||||
|
valid_from: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
valid_to: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
default_origin: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
# borrador | activo | vencido | reemplazado
|
||||||
|
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'borrador'"))
|
||||||
|
source_file: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||||
|
source_url: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RateLane(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
__tablename__ = "rate_lanes"
|
||||||
|
__table_args__ = {"schema": "crm"}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
rate_sheet_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("crm.rate_sheets.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
origin: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||||
|
destination: Mapped[str | None] = mapped_column(String(20), nullable=True, index=True)
|
||||||
|
region: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||||
|
# Solo FCL/terrestre (código del catálogo tipo_equipo). Nulo en aéreo/LCL.
|
||||||
|
equipment_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
# per_kg | per_wm | per_container | flat
|
||||||
|
rate_unit: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
min_charge: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
|
||||||
|
flat_rate: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
|
||||||
|
transit_days: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class RateBreak(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
__tablename__ = "rate_breaks"
|
||||||
|
__table_args__ = {"schema": "crm"}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
rate_lane_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("crm.rate_lanes.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# Umbral del quiebre (kg en aéreo; W/M en LCL)
|
||||||
|
from_qty: Mapped[Decimal] = mapped_column(Numeric(12, 3), nullable=False, server_default=text("0"))
|
||||||
|
rate: Mapped[Decimal] = mapped_column(Numeric(14, 4), nullable=False, server_default=text("0"))
|
||||||
|
|
||||||
|
|
||||||
|
class RateCharge(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
__tablename__ = "rate_charges"
|
||||||
|
__table_args__ = {"schema": "crm"}
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
rate_sheet_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("crm.rate_sheets.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
rate_lane_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("crm.rate_lanes.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
concept: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
|
# fijo | por_kg | por_guia | por_contenedor | porcentaje
|
||||||
|
charge_type: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'fijo'"))
|
||||||
|
value: Mapped[Decimal | None] = mapped_column(Numeric(14, 4), nullable=True)
|
||||||
|
condition: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
204
backend/api/v1/modules/crm/rates/routes.py
Normal file
204
backend/api/v1/modules/crm/rates/routes.py
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
"""Endpoints del módulo Tarifario."""
|
||||||
|
|
||||||
|
from datetime import date
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, File, Form, Query, Response, UploadFile, 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 (
|
||||||
|
CostRequest,
|
||||||
|
CostResult,
|
||||||
|
ImportPreview,
|
||||||
|
RateBreakDTO,
|
||||||
|
RateLaneCreate,
|
||||||
|
RateLaneResponse,
|
||||||
|
RateSheetCreate,
|
||||||
|
RateSheetResponse,
|
||||||
|
RateSheetUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/rate-sheets", tags=["Tarifario"])
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx(current_user: dict):
|
||||||
|
return current_user["tenant_id"], current_user.get("sub") or current_user.get("id")
|
||||||
|
|
||||||
|
|
||||||
|
def _sheet_out(db: Session, tenant_id: int, sheet) -> RateSheetResponse:
|
||||||
|
out = RateSheetResponse.model_validate(sheet)
|
||||||
|
out.lane_count = service.lane_count(db, tenant_id, sheet.id)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _lane_out(db: Session, lane) -> RateLaneResponse:
|
||||||
|
out = RateLaneResponse.model_validate(lane)
|
||||||
|
out.breaks = [RateBreakDTO.model_validate(b) for b in service.breaks_of(db, lane.id)]
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Tarifarios ----------------
|
||||||
|
@router.get("", response_model=list[RateSheetResponse])
|
||||||
|
def list_sheets(
|
||||||
|
company_id: int = Query(...),
|
||||||
|
mode: str | None = Query(None),
|
||||||
|
supplier_id: int | None = Query(None),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id, _ = _ctx(current_user)
|
||||||
|
sheets = service.list_sheets(db, tenant_id, company_id, mode=mode, supplier_id=supplier_id)
|
||||||
|
return [_sheet_out(db, tenant_id, s) for s in sheets]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_sheet(
|
||||||
|
data: RateSheetCreate,
|
||||||
|
company_id: int = Query(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id, user_id = _ctx(current_user)
|
||||||
|
sheet = service.create_sheet(db, tenant_id, company_id, data, user_id)
|
||||||
|
return _sheet_out(db, tenant_id, sheet)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/template")
|
||||||
|
def download_template(
|
||||||
|
mode: str = Query(..., description="aereo | maritimo_fcl | maritimo_lcl | terrestre"),
|
||||||
|
company_id: int = Query(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
content = service.build_template(mode)
|
||||||
|
return Response(
|
||||||
|
content=content,
|
||||||
|
media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||||
|
headers={"Content-Disposition": f'attachment; filename="plantilla_tarifario_{mode}.xlsx"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import/preview", response_model=ImportPreview)
|
||||||
|
async def import_preview(
|
||||||
|
company_id: int = Query(...),
|
||||||
|
mode: str = Form(...),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
):
|
||||||
|
content = await file.read()
|
||||||
|
return service.parse_excel(mode, content)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/import", response_model=RateSheetResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def import_sheet(
|
||||||
|
company_id: int = Query(...),
|
||||||
|
mode: str = Form(...),
|
||||||
|
name: str = Form(...),
|
||||||
|
supplier_id: int | None = Form(None),
|
||||||
|
currency: str = Form("USD"),
|
||||||
|
valid_from: date | None = Form(None),
|
||||||
|
valid_to: date | None = Form(None),
|
||||||
|
default_origin: str | None = Form(None),
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id, user_id = _ctx(current_user)
|
||||||
|
content = await file.read()
|
||||||
|
header = RateSheetCreate(
|
||||||
|
mode=mode, name=name, supplier_id=supplier_id, currency=currency,
|
||||||
|
valid_from=valid_from, valid_to=valid_to, default_origin=default_origin,
|
||||||
|
)
|
||||||
|
sheet = service.import_from_excel(db, tenant_id, company_id, mode, content, header, user_id)
|
||||||
|
return _sheet_out(db, tenant_id, sheet)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{sheet_id}", response_model=RateSheetResponse)
|
||||||
|
def get_sheet(
|
||||||
|
sheet_id: int,
|
||||||
|
company_id: int = Query(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id, _ = _ctx(current_user)
|
||||||
|
return _sheet_out(db, tenant_id, service.get_sheet(db, tenant_id, company_id, sheet_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{sheet_id}", response_model=RateSheetResponse)
|
||||||
|
def update_sheet(
|
||||||
|
sheet_id: int,
|
||||||
|
data: RateSheetUpdate,
|
||||||
|
company_id: int = Query(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id, user_id = _ctx(current_user)
|
||||||
|
return _sheet_out(db, tenant_id, service.update_sheet(db, tenant_id, company_id, sheet_id, data, user_id))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sheet_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_sheet(
|
||||||
|
sheet_id: int,
|
||||||
|
company_id: int = Query(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id, _ = _ctx(current_user)
|
||||||
|
service.delete_sheet(db, tenant_id, company_id, sheet_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Rutas (lanes) ----------------
|
||||||
|
@router.get("/{sheet_id}/lanes", response_model=list[RateLaneResponse])
|
||||||
|
def list_lanes(
|
||||||
|
sheet_id: int,
|
||||||
|
company_id: int = Query(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id, _ = _ctx(current_user)
|
||||||
|
service.get_sheet(db, tenant_id, company_id, sheet_id)
|
||||||
|
return [_lane_out(db, lane) for lane in service.list_lanes(db, tenant_id, sheet_id)]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{sheet_id}/lanes", response_model=RateLaneResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
def create_lane(
|
||||||
|
sheet_id: int,
|
||||||
|
data: RateLaneCreate,
|
||||||
|
company_id: int = Query(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id, _ = _ctx(current_user)
|
||||||
|
lane = service.create_lane(db, tenant_id, company_id, sheet_id, data)
|
||||||
|
return _lane_out(db, lane)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/{sheet_id}/lanes/{lane_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
|
def delete_lane(
|
||||||
|
sheet_id: int,
|
||||||
|
lane_id: int,
|
||||||
|
company_id: int = Query(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
tenant_id, _ = _ctx(current_user)
|
||||||
|
service.delete_lane(db, tenant_id, sheet_id, lane_id)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------- Motor de costeo ----------------
|
||||||
|
cost_router = APIRouter(tags=["Tarifario"])
|
||||||
|
|
||||||
|
|
||||||
|
@cost_router.post("/rate-quote", response_model=CostResult)
|
||||||
|
def rate_quote(
|
||||||
|
req: CostRequest,
|
||||||
|
company_id: int = Query(...),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""Calcula opciones de costo (por proveedor) para una ruta/carga."""
|
||||||
|
tenant_id, _ = _ctx(current_user)
|
||||||
|
options = service.quote_cost(db, tenant_id, company_id, req)
|
||||||
|
return CostResult(request=req, options=options)
|
||||||
481
backend/api/v1/modules/crm/rates/service.py
Normal file
481
backend/api/v1/modules/crm/rates/service.py
Normal file
@@ -0,0 +1,481 @@
|
|||||||
|
"""Lógica del módulo Tarifario: CRUD, importación por Excel y motor de costeo."""
|
||||||
|
|
||||||
|
import io
|
||||||
|
from datetime import date
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import and_, or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .dto import (
|
||||||
|
CostChargeLine,
|
||||||
|
CostOption,
|
||||||
|
CostRequest,
|
||||||
|
ImportConfirm,
|
||||||
|
ImportPreview,
|
||||||
|
ImportPreviewRow,
|
||||||
|
RateLaneCreate,
|
||||||
|
RateSheetCreate,
|
||||||
|
RateSheetUpdate,
|
||||||
|
)
|
||||||
|
from .models import RateBreak, RateCharge, RateLane, RateSheet
|
||||||
|
|
||||||
|
# Factor volumétrico aéreo: 1 m³ = 167 kg (equivale a 6000 cm³/kg).
|
||||||
|
AIR_VOLUMETRIC_FACTOR = Decimal("167")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ CRUD tarifarios
|
||||||
|
def _sheet_query(db: Session, tenant_id: int, company_id: int):
|
||||||
|
return db.query(RateSheet).filter(
|
||||||
|
RateSheet.tenant_id == tenant_id,
|
||||||
|
RateSheet.company_id == company_id,
|
||||||
|
RateSheet.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def list_sheets(db: Session, tenant_id: int, company_id: int, mode: str | None = None,
|
||||||
|
supplier_id: int | None = None) -> list[RateSheet]:
|
||||||
|
q = _sheet_query(db, tenant_id, company_id)
|
||||||
|
if mode:
|
||||||
|
q = q.filter(RateSheet.mode == mode)
|
||||||
|
if supplier_id:
|
||||||
|
q = q.filter(RateSheet.supplier_id == supplier_id)
|
||||||
|
return q.order_by(RateSheet.created_at.desc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def lane_count(db: Session, tenant_id: int, sheet_id: int) -> int:
|
||||||
|
return (
|
||||||
|
db.query(RateLane)
|
||||||
|
.filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id,
|
||||||
|
RateLane.deleted_at.is_(None))
|
||||||
|
.count()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> RateSheet:
|
||||||
|
sheet = _sheet_query(db, tenant_id, company_id).filter(RateSheet.id == sheet_id).first()
|
||||||
|
if not sheet:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Tarifario no encontrado")
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def create_sheet(db: Session, tenant_id: int, company_id: int, data: RateSheetCreate,
|
||||||
|
user_id: str | None) -> RateSheet:
|
||||||
|
sheet = RateSheet(
|
||||||
|
tenant_id=tenant_id, company_id=company_id,
|
||||||
|
**data.model_dump(),
|
||||||
|
created_by=user_id, updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(sheet)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sheet)
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def update_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int,
|
||||||
|
data: RateSheetUpdate, user_id: str | None) -> RateSheet:
|
||||||
|
sheet = get_sheet(db, tenant_id, company_id, sheet_id)
|
||||||
|
for field, value in data.model_dump(exclude_unset=True).items():
|
||||||
|
setattr(sheet, field, value)
|
||||||
|
sheet.updated_by = user_id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sheet)
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def delete_sheet(db: Session, tenant_id: int, company_id: int, sheet_id: int) -> None:
|
||||||
|
from sqlalchemy import func
|
||||||
|
sheet = get_sheet(db, tenant_id, company_id, sheet_id)
|
||||||
|
sheet.deleted_at = func.now()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Rutas (lanes)
|
||||||
|
def list_lanes(db: Session, tenant_id: int, sheet_id: int) -> list[RateLane]:
|
||||||
|
return (
|
||||||
|
db.query(RateLane)
|
||||||
|
.filter(RateLane.rate_sheet_id == sheet_id, RateLane.tenant_id == tenant_id,
|
||||||
|
RateLane.deleted_at.is_(None))
|
||||||
|
.order_by(RateLane.region, RateLane.destination)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def breaks_of(db: Session, lane_id: int) -> list[RateBreak]:
|
||||||
|
return (
|
||||||
|
db.query(RateBreak)
|
||||||
|
.filter(RateBreak.rate_lane_id == lane_id, RateBreak.deleted_at.is_(None))
|
||||||
|
.order_by(RateBreak.from_qty)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int,
|
||||||
|
lane_data: RateLaneCreate) -> RateLane:
|
||||||
|
payload = lane_data.model_dump(exclude={"breaks"})
|
||||||
|
lane = RateLane(tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id, **payload)
|
||||||
|
db.add(lane)
|
||||||
|
db.flush() # id
|
||||||
|
for br in lane_data.breaks:
|
||||||
|
db.add(RateBreak(
|
||||||
|
tenant_id=tenant_id, company_id=company_id, rate_lane_id=lane.id,
|
||||||
|
from_qty=br.from_qty, rate=br.rate,
|
||||||
|
))
|
||||||
|
return lane
|
||||||
|
|
||||||
|
|
||||||
|
def create_lane(db: Session, tenant_id: int, company_id: int, sheet_id: int,
|
||||||
|
lane_data: RateLaneCreate) -> RateLane:
|
||||||
|
get_sheet(db, tenant_id, company_id, sheet_id) # valida pertenencia
|
||||||
|
lane = _add_lane(db, tenant_id, company_id, sheet_id, lane_data)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(lane)
|
||||||
|
return lane
|
||||||
|
|
||||||
|
|
||||||
|
def delete_lane(db: Session, tenant_id: int, sheet_id: int, lane_id: int) -> None:
|
||||||
|
from sqlalchemy import func
|
||||||
|
lane = (
|
||||||
|
db.query(RateLane)
|
||||||
|
.filter(RateLane.id == lane_id, RateLane.rate_sheet_id == sheet_id,
|
||||||
|
RateLane.tenant_id == tenant_id)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if not lane:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ruta no encontrada")
|
||||||
|
lane.deleted_at = func.now()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Importación Excel
|
||||||
|
# Plantillas por modo: encabezados esperados (orden libre, se detectan por nombre).
|
||||||
|
TEMPLATES: dict[str, list[str]] = {
|
||||||
|
"aereo": ["Region", "Origen", "Destino", "IATA", "Min", "100", "300", "500", "1000"],
|
||||||
|
"maritimo_fcl": ["Origen", "Destino", "Tipo contenedor", "Tarifa", "Transito", "Notas"],
|
||||||
|
"maritimo_lcl": ["Origen", "Destino", "Tarifa W/M", "Minimo", "Notas"],
|
||||||
|
"terrestre": ["Origen", "Destino", "Tarifa", "Transito", "Notas"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_template(mode: str) -> bytes:
|
||||||
|
"""Genera un .xlsx con los encabezados del modo + una fila de ejemplo."""
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
if mode not in TEMPLATES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido")
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = mode
|
||||||
|
headers = TEMPLATES[mode]
|
||||||
|
ws.append(headers)
|
||||||
|
examples = {
|
||||||
|
"aereo": ["EUROPA", "NLU", "Frankfurt", "FRA", 190, 1.00, 1.00, 0.95, 0.90],
|
||||||
|
"maritimo_fcl": ["MXZLO", "CNSHA", "40HC", 2500, 28, "THC no incluido"],
|
||||||
|
"maritimo_lcl": ["MXZLO", "USLAX", 45, 80, "1 W/M = 1 ton o 1 m3"],
|
||||||
|
"terrestre": ["Monterrey", "Laredo", 850, 1, ""],
|
||||||
|
}
|
||||||
|
ws.append(examples[mode])
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _num(v: Any) -> Decimal | None:
|
||||||
|
if v is None or v == "":
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return Decimal(str(v).replace("$", "").replace(",", "").strip())
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def parse_excel(mode: str, content: bytes) -> ImportPreview:
|
||||||
|
"""Lee el Excel y devuelve una vista previa con validaciones (no persiste)."""
|
||||||
|
import openpyxl
|
||||||
|
|
||||||
|
if mode not in TEMPLATES:
|
||||||
|
raise HTTPException(status_code=400, detail=f"Modo '{mode}' no válido")
|
||||||
|
try:
|
||||||
|
wb = openpyxl.load_workbook(io.BytesIO(content), data_only=True, read_only=True)
|
||||||
|
except Exception:
|
||||||
|
raise HTTPException(status_code=400, detail="No se pudo leer el archivo Excel")
|
||||||
|
ws = wb.active
|
||||||
|
rows_iter = ws.iter_rows(values_only=True)
|
||||||
|
header = next(rows_iter, None)
|
||||||
|
if not header:
|
||||||
|
raise HTTPException(status_code=400, detail="El archivo está vacío")
|
||||||
|
cols = [str(c).strip() if c is not None else "" for c in header]
|
||||||
|
idx = {name.lower(): i for i, name in enumerate(cols)}
|
||||||
|
|
||||||
|
def cell(row, name):
|
||||||
|
i = idx.get(name.lower())
|
||||||
|
return row[i] if i is not None and i < len(row) else None
|
||||||
|
|
||||||
|
preview_rows: list[ImportPreviewRow] = []
|
||||||
|
valid = 0
|
||||||
|
for n, row in enumerate(rows_iter, start=2):
|
||||||
|
if row is None or all(c is None or str(c).strip() == "" for c in row):
|
||||||
|
continue
|
||||||
|
errors: list[str] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
data: dict = {}
|
||||||
|
if mode == "aereo":
|
||||||
|
data = {
|
||||||
|
"region": cell(row, "Region"),
|
||||||
|
"origin": cell(row, "Origen"),
|
||||||
|
"destination": cell(row, "Destino") or cell(row, "IATA"),
|
||||||
|
"iata": cell(row, "IATA"),
|
||||||
|
"min_charge": _num(cell(row, "Min")),
|
||||||
|
"breaks": {b: _num(cell(row, b)) for b in ("100", "300", "500", "1000")},
|
||||||
|
}
|
||||||
|
if not data["destination"]:
|
||||||
|
errors.append("Falta destino/IATA")
|
||||||
|
if not any(v is not None for v in data["breaks"].values()):
|
||||||
|
errors.append("Sin tarifas por quiebre")
|
||||||
|
elif mode == "maritimo_fcl":
|
||||||
|
data = {
|
||||||
|
"origin": cell(row, "Origen"),
|
||||||
|
"destination": cell(row, "Destino"),
|
||||||
|
"equipment_type": cell(row, "Tipo contenedor"),
|
||||||
|
"flat_rate": _num(cell(row, "Tarifa")),
|
||||||
|
"transit_days": _num(cell(row, "Transito")),
|
||||||
|
"notes": cell(row, "Notas"),
|
||||||
|
}
|
||||||
|
if data["flat_rate"] is None:
|
||||||
|
errors.append("Falta la tarifa")
|
||||||
|
if not data["equipment_type"]:
|
||||||
|
warnings.append("Sin tipo de contenedor")
|
||||||
|
elif mode == "maritimo_lcl":
|
||||||
|
data = {
|
||||||
|
"origin": cell(row, "Origen"),
|
||||||
|
"destination": cell(row, "Destino"),
|
||||||
|
"wm_rate": _num(cell(row, "Tarifa W/M")),
|
||||||
|
"min_charge": _num(cell(row, "Minimo")),
|
||||||
|
"notes": cell(row, "Notas"),
|
||||||
|
}
|
||||||
|
if data["wm_rate"] is None:
|
||||||
|
errors.append("Falta la tarifa W/M")
|
||||||
|
else: # terrestre
|
||||||
|
data = {
|
||||||
|
"origin": cell(row, "Origen"),
|
||||||
|
"destination": cell(row, "Destino"),
|
||||||
|
"flat_rate": _num(cell(row, "Tarifa")),
|
||||||
|
"transit_days": _num(cell(row, "Transito")),
|
||||||
|
"notes": cell(row, "Notas"),
|
||||||
|
}
|
||||||
|
if data["flat_rate"] is None:
|
||||||
|
errors.append("Falta la tarifa")
|
||||||
|
if not data.get("destination"):
|
||||||
|
errors.append("Falta destino")
|
||||||
|
ok = not errors
|
||||||
|
if ok:
|
||||||
|
valid += 1
|
||||||
|
preview_rows.append(ImportPreviewRow(row=n, data=_jsonable(data), ok=ok,
|
||||||
|
warnings=warnings, errors=errors))
|
||||||
|
return ImportPreview(mode=mode, total=len(preview_rows), valid=valid,
|
||||||
|
rows=preview_rows, columns=cols)
|
||||||
|
|
||||||
|
|
||||||
|
def _jsonable(d: dict) -> dict:
|
||||||
|
out = {}
|
||||||
|
for k, v in d.items():
|
||||||
|
if isinstance(v, Decimal):
|
||||||
|
out[k] = float(v)
|
||||||
|
elif isinstance(v, dict):
|
||||||
|
out[k] = {kk: (float(vv) if isinstance(vv, Decimal) else vv) for kk, vv in v.items()}
|
||||||
|
else:
|
||||||
|
out[k] = v
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _rows_to_lanes(mode: str, rows: list[ImportPreviewRow], default_origin: str | None) -> list[RateLaneCreate]:
|
||||||
|
lanes: list[RateLaneCreate] = []
|
||||||
|
for r in rows:
|
||||||
|
if not r.ok:
|
||||||
|
continue
|
||||||
|
d = r.data
|
||||||
|
origin = d.get("origin") or default_origin
|
||||||
|
if mode == "aereo":
|
||||||
|
breaks = [
|
||||||
|
{"from_qty": Decimal(b), "rate": Decimal(str(v))}
|
||||||
|
for b, v in (d.get("breaks") or {}).items() if v is not None
|
||||||
|
]
|
||||||
|
lanes.append(RateLaneCreate(
|
||||||
|
origin=str(origin) if origin else None,
|
||||||
|
destination=str(d.get("destination")),
|
||||||
|
region=d.get("region"), rate_unit="per_kg",
|
||||||
|
min_charge=_num(d.get("min_charge")),
|
||||||
|
breaks=breaks, # type: ignore[arg-type]
|
||||||
|
))
|
||||||
|
elif mode == "maritimo_fcl":
|
||||||
|
lanes.append(RateLaneCreate(
|
||||||
|
origin=str(origin) if origin else None, destination=str(d.get("destination")),
|
||||||
|
equipment_type=d.get("equipment_type"), rate_unit="per_container",
|
||||||
|
flat_rate=_num(d.get("flat_rate")),
|
||||||
|
transit_days=int(d["transit_days"]) if d.get("transit_days") else None,
|
||||||
|
notes=d.get("notes"),
|
||||||
|
))
|
||||||
|
elif mode == "maritimo_lcl":
|
||||||
|
lanes.append(RateLaneCreate(
|
||||||
|
origin=str(origin) if origin else None, destination=str(d.get("destination")),
|
||||||
|
rate_unit="per_wm", min_charge=_num(d.get("min_charge")),
|
||||||
|
breaks=[{"from_qty": Decimal(0), "rate": Decimal(str(d["wm_rate"]))}], # type: ignore[arg-type]
|
||||||
|
notes=d.get("notes"),
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
lanes.append(RateLaneCreate(
|
||||||
|
origin=str(origin) if origin else None, destination=str(d.get("destination")),
|
||||||
|
rate_unit="flat", flat_rate=_num(d.get("flat_rate")),
|
||||||
|
transit_days=int(d["transit_days"]) if d.get("transit_days") else None,
|
||||||
|
notes=d.get("notes"),
|
||||||
|
))
|
||||||
|
return lanes
|
||||||
|
|
||||||
|
|
||||||
|
def confirm_import(db: Session, tenant_id: int, company_id: int, data: ImportConfirm,
|
||||||
|
user_id: str | None) -> RateSheet:
|
||||||
|
"""Crea el tarifario + rutas a partir de la vista previa confirmada."""
|
||||||
|
sheet = RateSheet(
|
||||||
|
tenant_id=tenant_id, company_id=company_id,
|
||||||
|
supplier_id=data.supplier_id, mode=data.mode, name=data.name,
|
||||||
|
currency=data.currency, valid_from=data.valid_from, valid_to=data.valid_to,
|
||||||
|
default_origin=data.default_origin, status=data.status or "borrador",
|
||||||
|
notes=data.notes, created_by=user_id, updated_by=user_id,
|
||||||
|
)
|
||||||
|
db.add(sheet)
|
||||||
|
db.flush()
|
||||||
|
for lane in data.lanes:
|
||||||
|
_add_lane(db, tenant_id, company_id, sheet.id, lane)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(sheet)
|
||||||
|
return sheet
|
||||||
|
|
||||||
|
|
||||||
|
def import_from_excel(db: Session, tenant_id: int, company_id: int, mode: str,
|
||||||
|
content: bytes, header: RateSheetCreate, user_id: str | None) -> RateSheet:
|
||||||
|
"""Atajo: parsea el Excel y crea el tarifario en un solo paso."""
|
||||||
|
preview = parse_excel(mode, content)
|
||||||
|
lanes = _rows_to_lanes(mode, preview.rows, header.default_origin)
|
||||||
|
return confirm_import(
|
||||||
|
db, tenant_id, company_id,
|
||||||
|
ImportConfirm(**header.model_dump(), lanes=lanes), user_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================ Motor de costeo
|
||||||
|
def _volumetric_kg(volume_m3: Decimal | None) -> Decimal:
|
||||||
|
return (volume_m3 or Decimal(0)) * AIR_VOLUMETRIC_FACTOR
|
||||||
|
|
||||||
|
|
||||||
|
def _rate_for(breaks: list[RateBreak], qty: Decimal) -> Decimal | None:
|
||||||
|
"""Tarifa aplicable al peso/wm 'qty' (mayor quiebre cuyo umbral <= qty)."""
|
||||||
|
if not breaks:
|
||||||
|
return None
|
||||||
|
applicable = None
|
||||||
|
for b in breaks:
|
||||||
|
if b.from_qty <= qty:
|
||||||
|
applicable = b.rate
|
||||||
|
if applicable is None:
|
||||||
|
applicable = breaks[0].rate # por debajo del primer quiebre → tarifa base (gobierna el mínimo)
|
||||||
|
return applicable
|
||||||
|
|
||||||
|
|
||||||
|
def _best_break_cost(breaks: list[RateBreak], qty: Decimal) -> Decimal:
|
||||||
|
"""Costo base con optimización de quiebre (declarar peso mayor si conviene)."""
|
||||||
|
base_rate = _rate_for(breaks, qty)
|
||||||
|
base = (qty * base_rate) if base_rate is not None else Decimal(0)
|
||||||
|
for b in breaks:
|
||||||
|
if b.from_qty > qty:
|
||||||
|
candidate = b.from_qty * b.rate
|
||||||
|
if candidate < base:
|
||||||
|
base = candidate
|
||||||
|
return base
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_charges(db: Session, sheet: RateSheet, lane: RateLane, base: Decimal,
|
||||||
|
chargeable: Decimal, quantity: int, dangerous: bool) -> list[CostChargeLine]:
|
||||||
|
charges = (
|
||||||
|
db.query(RateCharge)
|
||||||
|
.filter(
|
||||||
|
RateCharge.deleted_at.is_(None),
|
||||||
|
or_(RateCharge.rate_sheet_id == sheet.id, RateCharge.rate_lane_id == lane.id),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
lines: list[CostChargeLine] = []
|
||||||
|
for c in charges:
|
||||||
|
if c.concept == "dgr" and not dangerous:
|
||||||
|
continue
|
||||||
|
v = c.value or Decimal(0)
|
||||||
|
if c.charge_type == "fijo" or c.charge_type == "por_guia":
|
||||||
|
amt = v
|
||||||
|
elif c.charge_type == "por_kg":
|
||||||
|
amt = v * chargeable
|
||||||
|
elif c.charge_type == "por_contenedor":
|
||||||
|
amt = v * quantity
|
||||||
|
elif c.charge_type == "porcentaje":
|
||||||
|
amt = base * v / Decimal(100)
|
||||||
|
else:
|
||||||
|
amt = v
|
||||||
|
lines.append(CostChargeLine(concept=c.concept, amount=amt))
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -> list[CostOption]:
|
||||||
|
on_date = req.on_date or date.today()
|
||||||
|
sheets = _sheet_query(db, tenant_id, company_id).filter(
|
||||||
|
RateSheet.mode == req.mode,
|
||||||
|
RateSheet.status == "activo",
|
||||||
|
or_(RateSheet.valid_from.is_(None), RateSheet.valid_from <= on_date),
|
||||||
|
or_(RateSheet.valid_to.is_(None), RateSheet.valid_to >= on_date),
|
||||||
|
).all()
|
||||||
|
|
||||||
|
gross = req.gross_weight_kg or Decimal(0)
|
||||||
|
options: list[CostOption] = []
|
||||||
|
for sheet in sheets:
|
||||||
|
lanes_q = db.query(RateLane).filter(
|
||||||
|
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if req.destination:
|
||||||
|
lanes_q = lanes_q.filter(RateLane.destination == req.destination)
|
||||||
|
for lane in lanes_q.all():
|
||||||
|
# Origen: match exacto o el default del tarifario.
|
||||||
|
lane_origin = lane.origin or sheet.default_origin
|
||||||
|
if req.origin and lane_origin and lane_origin != req.origin:
|
||||||
|
continue
|
||||||
|
if req.mode == "maritimo_fcl":
|
||||||
|
if req.equipment_type and lane.equipment_type and lane.equipment_type != req.equipment_type:
|
||||||
|
continue
|
||||||
|
chargeable = Decimal(req.quantity)
|
||||||
|
base = (lane.flat_rate or Decimal(0)) * req.quantity
|
||||||
|
detail = f"{req.quantity} x {lane.equipment_type or 'contenedor'}"
|
||||||
|
elif req.mode == "terrestre":
|
||||||
|
chargeable = Decimal(req.quantity)
|
||||||
|
base = (lane.flat_rate or Decimal(0)) * req.quantity
|
||||||
|
detail = "tarifa por ruta"
|
||||||
|
elif req.mode == "maritimo_lcl":
|
||||||
|
tons = gross / Decimal(1000)
|
||||||
|
wm = max(tons, req.volume_m3 or Decimal(0))
|
||||||
|
brks = breaks_of(db, lane.id)
|
||||||
|
base = _best_break_cost(brks, wm) if brks else Decimal(0)
|
||||||
|
chargeable = wm
|
||||||
|
base = max(base, lane.min_charge or Decimal(0))
|
||||||
|
detail = f"W/M {wm.quantize(Decimal('0.01'))}"
|
||||||
|
else: # aereo
|
||||||
|
chargeable = max(gross, _volumetric_kg(req.volume_m3))
|
||||||
|
brks = breaks_of(db, lane.id)
|
||||||
|
base = _best_break_cost(brks, chargeable)
|
||||||
|
base = max(base, lane.min_charge or Decimal(0))
|
||||||
|
detail = f"facturable {chargeable.quantize(Decimal('0.01'))} kg"
|
||||||
|
|
||||||
|
charge_lines = _apply_charges(db, sheet, lane, base, chargeable, req.quantity, req.dangerous)
|
||||||
|
total = base + sum((c.amount for c in charge_lines), Decimal(0))
|
||||||
|
options.append(CostOption(
|
||||||
|
rate_sheet_id=sheet.id, rate_sheet_name=sheet.name, supplier_id=sheet.supplier_id,
|
||||||
|
currency=sheet.currency, chargeable=chargeable, base_cost=base,
|
||||||
|
charges=charge_lines, total_cost=total, transit_days=lane.transit_days, detail=detail,
|
||||||
|
))
|
||||||
|
options.sort(key=lambda o: o.total_cost)
|
||||||
|
return options
|
||||||
@@ -21,6 +21,8 @@ from .metrics.routes import router as metrics_router
|
|||||||
from .opportunities.routes import router as opportunities_router
|
from .opportunities.routes import router as opportunities_router
|
||||||
from .pipelines.routes import router as pipelines_router
|
from .pipelines.routes import router as pipelines_router
|
||||||
from .quotes.routes import router as quotes_router
|
from .quotes.routes import router as quotes_router
|
||||||
|
from .rates.routes import cost_router as rates_cost_router
|
||||||
|
from .rates.routes import router as rates_router
|
||||||
from .service_requests.routes import router as service_requests_router
|
from .service_requests.routes import router as service_requests_router
|
||||||
from .suppliers.routes import router as suppliers_router
|
from .suppliers.routes import router as suppliers_router
|
||||||
from .uploads.routes import router as uploads_router
|
from .uploads.routes import router as uploads_router
|
||||||
@@ -44,3 +46,5 @@ router.include_router(activities_router)
|
|||||||
router.include_router(metrics_router)
|
router.include_router(metrics_router)
|
||||||
router.include_router(catalogs_router)
|
router.include_router(catalogs_router)
|
||||||
router.include_router(uploads_router)
|
router.include_router(uploads_router)
|
||||||
|
router.include_router(rates_router)
|
||||||
|
router.include_router(rates_cost_router)
|
||||||
|
|||||||
Reference in New Issue
Block a user