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:
37
backend/api/v1/modules/crm/common/pricing.py
Normal file
37
backend/api/v1/modules/crm/common/pricing.py
Normal 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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
29
backend/tests/test_pricing.py
Normal file
29
backend/tests/test_pricing.py
Normal 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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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[] = [
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user