Catálogos y selects:
- Incoterm como catálogo (nuevo catálogo global 'incoterm') en solicitud y modal
de conversión de oportunidad.
- Moneda como catálogo en Cotizaciones (nuevo/detalle) y Facturación.
- "Tipo de transporte" desde catálogo medio_transporte (antes lista fija).
- Cotizador: origen/destino como selects alineados a las rutas de los tarifarios
activos (endpoint /rate-locations), para que el costeo siempre encuentre ruta.
Bugs de la sesión:
- Direcciones no guardaban: DTO country String(2)→String(3) (ISO alfa-3); se
amplía accounts.country y se normaliza 'MX'→'MEX' (migración).
- Contacto de proveedor mal filtrado: contacts.ts ahora envía supplier_id.
- Selects ilegibles en modo oscuro: regla global select option en app.css.
- Formas de pago SAT a 2 dígitos (01/04/08) en catálogo y valores guardados.
- RelatedManager: editar direcciones/contactos/documentos (antes solo eliminar).
Oportunidades:
- Se quitan etapas Prospecto/Contactado del embudo semilla.
- Fechas separadas won_date/lost_date + motivo de pérdida, con modal al mover a
Ganada/Perdida (migración).
Facturación:
- Folio automático F{AAAA}-{MM}-{NNN} (next_folio entidad F, sin dirección).
- Moneda como catálogo.
UI:
- Giro "otro" habilita campo para especificar (accounts.industry_other, migración).
- Lista de contactos muestra a quién pertenece (cliente/prospecto/proveedor).
- Proveedores: países/puertos/aeropuertos/aduanas por catálogo (select + chips).
Migraciones reversibles (c2d3e4f5a6b7 ya existía; d3e4f5a6b7c8, e4f5a6b7c8d9).
Suite backend en verde (109). svelte-check sin errores nuevos.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
566 lines
22 KiB
Python
566 lines
22 KiB
Python
"""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 ..common.pricing import air_volumetric_kg
|
||
from .models import RateBreak, RateCharge, RateLane, RateSheet
|
||
|
||
# Factor volumétrico aéreo: 1 m³ = 167 kg (equivale a 6000 cm³/kg).
|
||
# Respaldo cuando solo se conoce el volumen en m³ (sin dimensiones cm).
|
||
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()
|
||
|
||
|
||
# ============================================================ Cargos adicionales
|
||
def list_charges(db: Session, tenant_id: int, sheet_id: int) -> list[RateCharge]:
|
||
return (
|
||
db.query(RateCharge)
|
||
.filter(RateCharge.rate_sheet_id == sheet_id, RateCharge.tenant_id == tenant_id,
|
||
RateCharge.deleted_at.is_(None))
|
||
.order_by(RateCharge.concept)
|
||
.all()
|
||
)
|
||
|
||
|
||
def create_charge(db: Session, tenant_id: int, company_id: int, sheet_id: int, data) -> RateCharge:
|
||
get_sheet(db, tenant_id, company_id, sheet_id)
|
||
ch = RateCharge(
|
||
tenant_id=tenant_id, company_id=company_id, rate_sheet_id=sheet_id,
|
||
rate_lane_id=data.rate_lane_id, concept=data.concept, charge_type=data.charge_type,
|
||
value=data.value, condition=data.condition,
|
||
)
|
||
db.add(ch)
|
||
db.commit()
|
||
db.refresh(ch)
|
||
return ch
|
||
|
||
|
||
def update_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int, data) -> RateCharge:
|
||
ch = (
|
||
db.query(RateCharge)
|
||
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
|
||
RateCharge.tenant_id == tenant_id)
|
||
.first()
|
||
)
|
||
if not ch:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
|
||
for field, value in data.model_dump(exclude_unset=True).items():
|
||
setattr(ch, field, value)
|
||
db.commit()
|
||
db.refresh(ch)
|
||
return ch
|
||
|
||
|
||
def delete_charge(db: Session, tenant_id: int, sheet_id: int, charge_id: int) -> None:
|
||
from sqlalchemy import func
|
||
ch = (
|
||
db.query(RateCharge)
|
||
.filter(RateCharge.id == charge_id, RateCharge.rate_sheet_id == sheet_id,
|
||
RateCharge.tenant_id == tenant_id)
|
||
.first()
|
||
)
|
||
if not ch:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cargo no encontrado")
|
||
ch.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 lane_locations(db: Session, tenant_id: int, company_id: int, mode: str) -> dict[str, list[str]]:
|
||
"""Orígenes/destinos existentes en los tarifarios activos de un modo.
|
||
|
||
Alinea el cotizador con las rutas realmente cotizables (los códigos provienen
|
||
de las lanes, por lo que el costeo siempre encontrará ruta).
|
||
"""
|
||
sheets = _sheet_query(db, tenant_id, company_id).filter(
|
||
RateSheet.mode == mode, RateSheet.status == "activo",
|
||
).all()
|
||
origins: set[str] = set()
|
||
destinations: set[str] = set()
|
||
for sheet in sheets:
|
||
lanes = db.query(RateLane).filter(
|
||
RateLane.rate_sheet_id == sheet.id, RateLane.deleted_at.is_(None),
|
||
).all()
|
||
for lane in lanes:
|
||
origin = lane.origin or sheet.default_origin
|
||
if origin:
|
||
origins.add(origin)
|
||
if lane.destination:
|
||
destinations.add(lane.destination)
|
||
return {"origins": sorted(origins), "destinations": sorted(destinations)}
|
||
|
||
|
||
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
|
||
# P/Vol por dimensiones (L×A×H×cant / 6000); si no hay dimensiones,
|
||
# respaldo con el volumen en m³ × 167.
|
||
vol_by_dims = air_volumetric_kg(req.length_cm, req.width_cm, req.height_cm, req.quantity)
|
||
volumetric = vol_by_dims if vol_by_dims > 0 else _volumetric_kg(req.volume_m3)
|
||
chargeable = max(gross, volumetric)
|
||
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 (P/Vol)"
|
||
|
||
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
|