diff --git a/backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py b/backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py new file mode 100644 index 0000000..0f53d88 --- /dev/null +++ b/backend/alembic/versions/f8a9b0c1d2e3_crm_rates.py @@ -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) diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py index b0cce3c..9f970bd 100644 --- a/backend/api/v1/modules/crm/catalogs/seed_data.py +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -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 -Equipos (tipo_equipo con dimensiones en extra). Globales (Aduanasoft) se siembran -con tenant_id NULL; los catálogos "cliente" los llena cada tenant. +SAT/ISO + estándar + Medidas de Equipos (tipo_equipo con dimensiones en extra) ++ catálogos del módulo Tarifario. Globales con tenant_id NULL. """ GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro', @@ -735,7 +734,33 @@ GLOBAL_CATALOGS = {'tipo_registro': {'label': 'Tipo de registro', 'alto_m': 2.3, 'capacidad_m3': 99.11, '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', 'puerto': 'Puertos donde opera', diff --git a/backend/api/v1/modules/crm/rates/__init__.py b/backend/api/v1/modules/crm/rates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/rates/dto.py b/backend/api/v1/modules/crm/rates/dto.py new file mode 100644 index 0000000..7daf47a --- /dev/null +++ b/backend/api/v1/modules/crm/rates/dto.py @@ -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] diff --git a/backend/api/v1/modules/crm/rates/models.py b/backend/api/v1/modules/crm/rates/models.py new file mode 100644 index 0000000..b06c936 --- /dev/null +++ b/backend/api/v1/modules/crm/rates/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/rates/routes.py b/backend/api/v1/modules/crm/rates/routes.py new file mode 100644 index 0000000..7fb32d5 --- /dev/null +++ b/backend/api/v1/modules/crm/rates/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/rates/service.py b/backend/api/v1/modules/crm/rates/service.py new file mode 100644 index 0000000..49f15c1 --- /dev/null +++ b/backend/api/v1/modules/crm/rates/service.py @@ -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 diff --git a/backend/api/v1/modules/crm/router.py b/backend/api/v1/modules/crm/router.py index 2a47678..c21a287 100644 --- a/backend/api/v1/modules/crm/router.py +++ b/backend/api/v1/modules/crm/router.py @@ -21,6 +21,8 @@ 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 .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 .suppliers.routes import router as suppliers_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(catalogs_router) router.include_router(uploads_router) +router.include_router(rates_router) +router.include_router(rates_cost_router)