diff --git a/backend/api/v1/modules/crm/common/pricing.py b/backend/api/v1/modules/crm/common/pricing.py new file mode 100644 index 0000000..98d152c --- /dev/null +++ b/backend/api/v1/modules/crm/common/pricing.py @@ -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)) diff --git a/backend/api/v1/modules/crm/quotes/service.py b/backend/api/v1/modules/crm/quotes/service.py index 7c39673..5afeea2 100644 --- a/backend/api/v1/modules/crm/quotes/service.py +++ b/backend/api/v1/modules/crm/quotes/service.py @@ -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) diff --git a/backend/api/v1/modules/crm/rates/dto.py b/backend/api/v1/modules/crm/rates/dto.py index a5aec50..da4a025 100644 --- a/backend/api/v1/modules/crm/rates/dto.py +++ b/backend/api/v1/modules/crm/rates/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/rates/service.py b/backend/api/v1/modules/crm/rates/service.py index 8804e30..aff1881 100644 --- a/backend/api/v1/modules/crm/rates/service.py +++ b/backend/api/v1/modules/crm/rates/service.py @@ -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)) diff --git a/backend/tests/test_pricing.py b/backend/tests/test_pricing.py new file mode 100644 index 0000000..b352715 --- /dev/null +++ b/backend/tests/test_pricing.py @@ -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) diff --git a/backend/tests/test_quotes.py b/backend/tests/test_quotes.py index b2ccd9f..4a08191 100644 --- a/backend/tests/test_quotes.py +++ b/backend/tests/test_quotes.py @@ -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) diff --git a/frontend/src/lib/api/crm/rates.ts b/frontend/src/lib/api/crm/rates.ts index 9c4c8dd..57f878e 100644 --- a/frontend/src/lib/api/crm/rates.ts +++ b/frontend/src/lib/api/crm/rates.ts @@ -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 { diff --git a/frontend/src/lib/components/crm/ServiceRequestFields.svelte b/frontend/src/lib/components/crm/ServiceRequestFields.svelte index 7f8d7b2..d712ac9 100644 --- a/frontend/src/lib/components/crm/ServiceRequestFields.svelte +++ b/frontend/src/lib/components/crm/ServiceRequestFields.svelte @@ -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 @@ {/if} + {#if isAir} +
+

Aéreo — Peso / Volumen (P/Vol)

+

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.

+
+
Cantidad de bultos{airQty}
+
Peso volumétrico (P/Vol){airVolumetric.toFixed(2)}
+
Peso bruto{(Number(form.weight) || 0).toFixed(2)} kg
+
Peso a cobrar{airChargeable.toFixed(2)} kg
+
+
+ {/if} {:else if tab === 'servicios'}

Servicios adicionales

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.

diff --git a/frontend/src/lib/components/crm/format.ts b/frontend/src/lib/components/crm/format.ts index 8095bc9..147e66b 100644 --- a/frontend/src/lib/components/crm/format.ts +++ b/frontend/src/lib/components/crm/format.ts @@ -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[] = [ diff --git a/frontend/src/routes/dashboard/crm/cotizador/+page.svelte b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte index 0704dcb..de1f072 100644 --- a/frontend/src/routes/dashboard/crm/cotizador/+page.svelte +++ b/frontend/src/routes/dashboard/crm/cotizador/+page.svelte @@ -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([]); @@ -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} + {#if isAir} + + + + + {/if} {/if} + {#if isAir && airVolumetric > 0} +
+ P/Vol (volumétrico): {airVolumetric.toFixed(2)} + Peso bruto: {(Number(f.gross_weight_kg) || 0).toFixed(2)} kg + Peso a cobrar: {airChargeable.toFixed(2)} kg + P/Vol = (L×A×H) × bultos ÷ 6000 +
+ {/if}