1 Commits

Author SHA1 Message Date
Ernesto Herrera
f1e6fba75d 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>
2026-08-04 07:30:25 -06:00
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))

View File

@@ -8,6 +8,7 @@ from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..catalogs.models import CatalogItem
from ..common.folios import next_folio
from ..common.pricing import air_chargeable_kg
from ..service_requests.models import RateRequest, ServiceRequest
from ..suppliers.models import Supplier
from .dto import QuoteCreate, QuoteItemCreate, QuoteItemUpdate, QuoteUpdate
@@ -196,6 +197,19 @@ def create_quotes_from_service_request(
quantity=Decimal(1), unit_cost=amount, unit_sale=amount,
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
))
# Carga aérea: concepto de flete con el peso a cobrar (P/Vol) como cantidad,
# para que el ejecutivo capture la tarifa por kg.
if (variant or "").upper() == "AEREO":
chargeable = air_chargeable_kg(
sr.weight, sr.length_cm, sr.width_cm, sr.height_cm,
sr.pallets_count or sr.pieces_count or 1,
)
db.add(QuoteItem(
quote_id=quote.id, concept="flete_internacional",
description=f"Flete aéreo — peso a cobrar {chargeable.quantize(Decimal('0.01'))} kg (P/Vol)",
quantity=chargeable, unit_cost=Decimal(0), unit_sale=Decimal(0),
currency=sr.currency, tenant_id=tenant_id, company_id=company_id,
))
db.flush()
_recompute_totals(db, quote)
created.append(quote)

View File

@@ -146,6 +146,10 @@ class CostRequest(BaseModel):
on_date: date | None = None
gross_weight_kg: Decimal | None = None
volume_m3: Decimal | None = None
# Dimensiones (cm) para el peso volumétrico aéreo (P/Vol = L×A×H×cant / 6000)
length_cm: Decimal | None = None
width_cm: Decimal | None = None
height_cm: Decimal | None = None
equipment_type: str | None = None
quantity: int = 1
dangerous: bool = False

View File

@@ -20,9 +20,11 @@ from .dto import (
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")
@@ -518,11 +520,15 @@ def quote_cost(db: Session, tenant_id: int, company_id: int, req: CostRequest) -
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))
# 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"
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))

View File

@@ -0,0 +1,29 @@
"""Pruebas del cálculo de peso/volumen (P/Vol) aéreo."""
from decimal import Decimal
from api.v1.modules.crm.common.pricing import air_chargeable_kg, air_volumetric_kg
def test_air_volumetric_doc_example():
# 3 pallets 120×120×100 cm → (120*120*100*3)/6000 = 720 (ejemplo del documento)
assert air_volumetric_kg(120, 120, 100, 3) == Decimal(720)
def test_air_volumetric_zero_without_dimensions():
assert air_volumetric_kg(None, 120, 100, 3) == Decimal(0)
assert air_volumetric_kg(0, 120, 100, 3) == Decimal(0)
def test_air_qty_defaults_to_one():
assert air_volumetric_kg(100, 100, 100, 0) == air_volumetric_kg(100, 100, 100, 1)
def test_air_chargeable_takes_gross_when_larger():
# bruto 800 > volumétrico 720 → se cobra 800
assert air_chargeable_kg(800, 120, 120, 100, 3) == Decimal(800)
def test_air_chargeable_takes_volumetric_when_larger():
# bruto 200 < volumétrico 720 → se cobra 720
assert air_chargeable_kg(200, 120, 120, 100, 3) == Decimal(720)

View File

@@ -125,6 +125,24 @@ def test_quote_from_service_request_seeds_additional_services(db):
assert float(quotes[0].total_sale) == 800.0
def test_quote_from_service_request_aereo_seeds_pvol_concept(db):
sr = sr_service.create_service_request(
db,
ServiceRequestCreate(
operation_type="exportacion", load_type="AEREO", currency="USD",
weight=200, length_cm=120, width_cm=120, height_cm=100, pallets_count=3,
),
T, C,
)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)
assert len(quotes) == 1
assert quotes[0].load_type == "AEREO"
flete = [i for i in service.get_quote_items(db, quotes[0].id, T, C) if i.concept == "flete_internacional"]
assert len(flete) == 1
# cantidad del flete = peso a cobrar (P/Vol 720 > bruto 200)
assert float(flete[0].quantity) == 720.0
def test_quote_from_service_request_sets_issue_date_today(db):
sr = _sr_with_rates(db)
quotes = service.create_quotes_from_service_request(db, sr.id, T, C)

View File

@@ -35,8 +35,9 @@ export interface ImportPreview { mode: RateMode; total: number; valid: number; r
export interface CostRequest {
mode: RateMode; origin?: string | null; destination?: string | null; on_date?: string | null;
gross_weight_kg?: number | null; volume_m3?: number | null; equipment_type?: string | null;
quantity?: number; dangerous?: boolean;
gross_weight_kg?: number | null; volume_m3?: number | null;
length_cm?: number | null; width_cm?: number | null; height_cm?: number | null;
equipment_type?: string | null; quantity?: number; dangerous?: boolean;
}
export interface CostChargeLine { concept: string; amount: number; }
export interface CostOption {

View File

@@ -21,9 +21,24 @@
const inputCls =
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
// FCL/LCL condicionales; "AMBAS" muestra ambas secciones
// FCL/LCL condicionales; "AMBAS" muestra ambas secciones; "AEREO" muestra la sección aérea
const isFcl = $derived(form.load_type === 'FCL' || form.load_type === 'AMBAS');
const isLcl = $derived(form.load_type === 'LCL' || form.load_type === 'AMBAS');
const isAir = $derived(form.load_type === 'AEREO');
// Peso/Volumen aéreo (P/Vol) = (L×A×H cm × cantidad de bultos) / 6000; a cobrar = max(bruto, P/Vol)
const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1);
const airVolumetric = $derived(
Number(form.length_cm) > 0 && Number(form.width_cm) > 0 && Number(form.height_cm) > 0
? (Number(form.length_cm) * Number(form.width_cm) * Number(form.height_cm) * airQty) / 6000
: 0
);
const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric));
// La modalidad aérea fija el medio de transporte en "aéreo"
$effect(() => {
if (form.load_type === 'AEREO' && form.transport_mode !== 'aereo') form.transport_mode = 'aereo';
});
// Contactos del cliente seleccionado (o todos si no hay cliente)
const clientContacts = $derived(
@@ -151,6 +166,18 @@
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen por pallet (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={form.volume_per_pallet} /></label>
</div>
{/if}
{#if isAir}
<div class="mt-4 grid gap-3 rounded-md border p-4 sm:grid-cols-2">
<p class="text-sm font-semibold sm:col-span-2">Aéreo — Peso / Volumen (P/Vol)</p>
<p class="text-xs text-muted-foreground sm:col-span-2">P/Vol = (Largo × Ancho × Alto en cm) × cantidad de bultos ÷ 6000 (factor internacional). Se cobra el mayor entre el peso bruto y el P/Vol. Captura Largo/Ancho/Alto y piezas/pallets arriba; el resultado se recalcula solo.</p>
<div class="rounded-md bg-muted/40 p-3 text-sm sm:col-span-2">
<div class="flex justify-between"><span class="text-muted-foreground">Cantidad de bultos</span><span class="font-medium">{airQty}</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Peso volumétrico (P/Vol)</span><span class="font-medium">{airVolumetric.toFixed(2)}</span></div>
<div class="flex justify-between"><span class="text-muted-foreground">Peso bruto</span><span class="font-medium">{(Number(form.weight) || 0).toFixed(2)} kg</span></div>
<div class="mt-1 flex justify-between border-t pt-1"><span class="font-medium">Peso a cobrar</span><span class="font-semibold">{airChargeable.toFixed(2)} kg</span></div>
</div>
</div>
{/if}
{:else if tab === 'servicios'}
<p class="mb-1 text-sm font-medium">Servicios adicionales</p>
<p class="mb-3 text-xs text-muted-foreground">Marca los servicios requeridos e indica su costo estimado (opcional). Al cotizar, cada servicio marcado se agrega como concepto de la cotización con ese costo de partida.</p>

View File

@@ -182,7 +182,8 @@ export const SERVICE_TYPES: Option[] = [
export const LOAD_TYPES: Option[] = [
{ value: 'FCL', label: 'FCL (contenedor completo)' },
{ value: 'LCL', label: 'LCL (carga consolidada)' },
{ value: 'AMBAS', label: 'Ambas (comparar FCL y LCL)' }
{ value: 'AMBAS', label: 'Ambas (comparar FCL y LCL)' },
{ value: 'AEREO', label: 'Aéreo (carga aérea)' }
];
export const PRIORITIES: Option[] = [

View File

@@ -16,6 +16,7 @@
let f = $state({
mode: 'aereo' as RateMode, origin: '', destination: '', on_date: '',
gross_weight_kg: null as number | null, volume_m3: null as number | null,
length_cm: null as number | null, width_cm: null as number | null, height_cm: null as number | null,
equipment_type: '', quantity: 1, dangerous: false
});
let options = $state<CostOption[]>([]);
@@ -23,6 +24,15 @@
let working = $state(false);
const isFcl = $derived(f.mode === 'maritimo_fcl' || f.mode === 'terrestre');
const isAir = $derived(f.mode === 'aereo');
// P/Vol aéreo en vivo: (L×A×H cm × cantidad) / 6000; a cobrar = max(bruto, P/Vol)
const airVolumetric = $derived(
Number(f.length_cm) > 0 && Number(f.width_cm) > 0 && Number(f.height_cm) > 0
? (Number(f.length_cm) * Number(f.width_cm) * Number(f.height_cm) * (Number(f.quantity) || 1)) / 6000
: 0
);
const airChargeable = $derived(Math.max(Number(f.gross_weight_kg) || 0, airVolumetric));
onMount(() => void crmCatalogs.preload(['modo_tarifario', 'tipo_equipo']));
@@ -34,6 +44,7 @@
const res = await rateSheetsAPI.quote({
mode: f.mode, origin: f.origin || null, destination: f.destination || null,
on_date: f.on_date || null, gross_weight_kg: f.gross_weight_kg, volume_m3: f.volume_m3,
length_cm: f.length_cm, width_cm: f.width_cm, height_cm: f.height_cm,
equipment_type: f.equipment_type || null, quantity: f.quantity || 1, dangerous: f.dangerous
}, companyId);
options = res.options; calculated = true;
@@ -70,11 +81,25 @@
{:else}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Peso bruto (kg)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.gross_weight_kg} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Volumen (m³)</span><input type="number" min="0" step="0.001" class={inputCls} bind:value={f.volume_m3} /></label>
{#if isAir}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Largo (cm)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.length_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Ancho (cm)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.width_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Alto (cm)</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={f.height_cm} /></label>
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad de bultos</span><input type="number" min="1" class={inputCls} bind:value={f.quantity} /></label>
{/if}
{/if}
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Fecha embarque</span><input type="date" class={inputCls} bind:value={f.on_date} /></label>
<label class="flex items-center gap-2 pt-6 text-sm"><input type="checkbox" class="h-4 w-4 rounded border" bind:checked={f.dangerous} /><span>Mercancía peligrosa (DGR)</span></label>
</div>
{#if isAir && airVolumetric > 0}
<div class="mt-3 flex flex-wrap gap-6 rounded-md bg-muted/40 p-3 text-sm">
<span>P/Vol (volumétrico): <b>{airVolumetric.toFixed(2)}</b></span>
<span>Peso bruto: <b>{(Number(f.gross_weight_kg) || 0).toFixed(2)} kg</b></span>
<span>Peso a cobrar: <b>{airChargeable.toFixed(2)} kg</b></span>
<span class="text-xs text-muted-foreground">P/Vol = (L×A×H) × bultos ÷ 6000</span>
</div>
{/if}
<div class="mt-4 flex justify-end"><Button onclick={calc} disabled={working}>{working ? 'Calculando…' : 'Calcular costo'}</Button></div>
</Card.Content>
</Card.Root>