"""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