feat(crm): cotización aérea con peso/volumen (P/Vol) operacional

- Modalidad AÉREO (4ª opción en "¿Cómo desea cotizar?"): en la solicitud
  muestra la sección aérea con el cálculo en vivo P/Vol = (L×A×H cm × bultos)/6000
  y el peso a cobrar = max(peso bruto, P/Vol). Fija el transporte en aéreo.
- Utilidad compartida crm/common/pricing.py (air_volumetric_kg / air_chargeable_kg,
  factor internacional 6000).
- Motor de costeo (rates): la rama aérea usa el P/Vol por dimensiones si vienen
  (CostRequest ahora acepta length/width/height_cm); respaldo m³×167 cuando no.
- Cotizador: captura por dimensiones (L×A×H + bultos) en modo aéreo y muestra el P/Vol.
- Solicitud→Cotización: si es AÉREO, siembra el concepto de flete con cantidad =
  peso a cobrar (P/Vol) para capturar la tarifa por kg.
- Pruebas: test_pricing (ejemplo del doc → 720; max bruto/volumétrico) + cotización
  aérea desde solicitud. Suite en verde (108).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-08-04 07:30:25 -06:00
parent 36e98ee976
commit f1e6fba75d
10 changed files with 168 additions and 6 deletions

View File

@@ -0,0 +1,37 @@
"""Cálculos de precio compartidos del proceso comercial.
Peso volumétrico / a cobrar de carga aérea (doc maestro de cotización):
P/Vol = (Largo_cm × Ancho_cm × Alto_cm × cantidad) / 6000
El peso a cobrar es el mayor entre el peso bruto y el P/Vol (estándar aéreo).
6000 cm³/kg es el factor internacional (equivale a ~167 kg/m³).
"""
from __future__ import annotations
from decimal import Decimal
# Factor internacional de peso volumétrico aéreo (cm³ por kg).
AIR_VOLUMETRIC_DIVISOR = Decimal("6000")
def _d(value) -> Decimal:
if value is None:
return Decimal(0)
return value if isinstance(value, Decimal) else Decimal(str(value))
def air_volumetric_kg(length_cm, width_cm, height_cm, qty=1) -> Decimal:
"""Peso volumétrico aéreo a partir de dimensiones (cm) y cantidad de bultos.
Devuelve 0 si falta alguna dimensión (no se puede calcular).
"""
length, width, height = _d(length_cm), _d(width_cm), _d(height_cm)
if length <= 0 or width <= 0 or height <= 0:
return Decimal(0)
quantity = _d(qty) if _d(qty) > 0 else Decimal(1)
return (length * width * height * quantity) / AIR_VOLUMETRIC_DIVISOR
def air_chargeable_kg(gross_kg, length_cm, width_cm, height_cm, qty=1) -> Decimal:
"""Peso a cobrar aéreo: max(peso bruto, peso volumétrico por dimensiones)."""
return max(_d(gross_kg), air_volumetric_kg(length_cm, width_cm, height_cm, qty))