From 8c7aeef1a61293f1a1f3c80a94859777b82a582b Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 7 Aug 2026 07:25:20 -0600 Subject: [PATCH] feat(crm): refinamientos de Solicitud (Fase A del PDF 07-ago) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fecha de solicitud automática (hoy) editable al crear. - Modalidad de carga dependiente del transporte: marítimo→FCL/LCL/Ambas, aéreo→Aérea (autoselección), terrestre→FTL/LTL, ferroviario/multimodal sin modalidad. - Ciudad y Puerto/Aeropuerto dependientes del país (catálogos por parent_code) con respaldo de texto; el campo Puerto/Aeropuerto une ambos catálogos. - Agente en destino filtrado a proveedores clasificados corresponsal/aduanal. - Volumen SIEMPRE en m³ (conversión desde dimensiones según unidad de medida). - P/Vol aéreo etiquetado con unidad (kg) y honra la unidad de medida. - Moneda visible junto al valor de la mercancía. - Lista de solicitudes con columna Cliente + filtro por cliente + búsqueda por nombre. - Formulario de tarifa: campo "Válida hasta" (calendario). - Catálogos globales ciudad/puerto/aeropuerto por país (seed_locations, extensible). - Nuevos load types FTL/LTL. Suite backend en verde (109). Co-Authored-By: Claude Opus 4.8 --- .../api/v1/modules/crm/catalogs/seed_data.py | 5 + .../v1/modules/crm/catalogs/seed_locations.py | 79 ++++++++++++ .../crm/ServiceRequestFields.svelte | 116 ++++++++++++++---- .../dashboard/crm/solicitudes/+page.svelte | 18 ++- .../crm/solicitudes/[id]/+page.svelte | 1 + .../crm/solicitudes/nuevo/+page.svelte | 2 +- 6 files changed, 194 insertions(+), 27 deletions(-) create mode 100644 backend/api/v1/modules/crm/catalogs/seed_locations.py diff --git a/backend/api/v1/modules/crm/catalogs/seed_data.py b/backend/api/v1/modules/crm/catalogs/seed_data.py index 30da343..c857f03 100644 --- a/backend/api/v1/modules/crm/catalogs/seed_data.py +++ b/backend/api/v1/modules/crm/catalogs/seed_data.py @@ -867,3 +867,8 @@ GLOBAL_CATALOGS.update({ for _fp in GLOBAL_CATALOGS.get('forma_pago', {}).get('items', []): if len(_fp['code']) == 1: _fp['code'] = _fp['code'].zfill(2) + +# Ubicaciones por país (ciudad/puerto/aeropuerto), dependientes de `pais`. +from .seed_locations import LOCATION_CATALOGS # noqa: E402 + +GLOBAL_CATALOGS.update(LOCATION_CATALOGS) diff --git a/backend/api/v1/modules/crm/catalogs/seed_locations.py b/backend/api/v1/modules/crm/catalogs/seed_locations.py new file mode 100644 index 0000000..759f4a7 --- /dev/null +++ b/backend/api/v1/modules/crm/catalogs/seed_locations.py @@ -0,0 +1,79 @@ +"""Catálogos de ubicaciones por país: ciudad, puerto (UN/LOCODE), aeropuerto (IATA). + +Dependientes de `pais` (`parent_catalog='pais'`, `parent_code=`). Curado a las +rutas de comercio más usadas (extensible: agregar países/nodos según tarifarios). +Los códigos de puerto/aeropuerto se alinean con los que usan las lanes del tarifario +para que el Cotizador encuentre ruta. +""" + +# (ISO3, ciudades[(code,label)], puertos[(code,label)], aeropuertos[(code,label)]) +_LOC = [ + ("MEX", + [("MX-CDMX", "Ciudad de México"), ("MX-GDL", "Guadalajara"), ("MX-MTY", "Monterrey"), + ("MX-QRO", "Querétaro"), ("MX-TIJ", "Tijuana"), ("MX-VER", "Veracruz")], + [("MXZLO", "Manzanillo"), ("MXVER", "Veracruz"), ("MXATM", "Altamira"), + ("MXLZC", "Lázaro Cárdenas"), ("MXPGO", "Progreso"), ("MXESE", "Ensenada")], + [("MEX", "AICM Ciudad de México"), ("NLU", "AIFA Santa Lucía"), ("GDL", "Guadalajara"), + ("MTY", "Monterrey"), ("TIJ", "Tijuana"), ("CUN", "Cancún")]), + ("USA", + [("US-LAX", "Los Ángeles"), ("US-NYC", "Nueva York"), ("US-HOU", "Houston"), + ("US-CHI", "Chicago"), ("US-MIA", "Miami"), ("US-LRD", "Laredo")], + [("USLAX", "Los Angeles"), ("USLGB", "Long Beach"), ("USNYC", "Nueva York/NJ"), + ("USHOU", "Houston"), ("USSAV", "Savannah"), ("USSEA", "Seattle"), ("USOAK", "Oakland")], + [("LAX", "Los Ángeles"), ("JFK", "Nueva York JFK"), ("ORD", "Chicago O'Hare"), + ("MIA", "Miami"), ("DFW", "Dallas Fort Worth"), ("ATL", "Atlanta")]), + ("CHN", + [("CN-SHA", "Shanghái"), ("CN-SZX", "Shenzhen"), ("CN-CAN", "Guangzhou"), + ("CN-NGB", "Ningbo"), ("CN-TAO", "Qingdao"), ("CN-PEK", "Pekín")], + [("CNSHA", "Shanghái"), ("CNNGB", "Ningbo"), ("CNSZX", "Shenzhen"), + ("CNTAO", "Qingdao"), ("CNCAN", "Guangzhou"), ("CNXMN", "Xiamen"), ("CNTXG", "Tianjin")], + [("PVG", "Shanghái Pudong"), ("PEK", "Pekín Capital"), ("CAN", "Guangzhou"), + ("SZX", "Shenzhen"), ("HKG", "Hong Kong")]), + ("DEU", + [("DE-HAM", "Hamburgo"), ("DE-FRA", "Fráncfort"), ("DE-MUC", "Múnich"), ("DE-BER", "Berlín")], + [("DEHAM", "Hamburgo"), ("DEBRV", "Bremerhaven")], + [("FRA", "Fráncfort"), ("MUC", "Múnich"), ("HAM", "Hamburgo")]), + ("ESP", + [("ES-MAD", "Madrid"), ("ES-BCN", "Barcelona"), ("ES-VLC", "Valencia")], + [("ESVLC", "Valencia"), ("ESBCN", "Barcelona"), ("ESALG", "Algeciras")], + [("MAD", "Madrid Barajas"), ("BCN", "Barcelona")]), + ("NLD", + [("NL-RTM", "Róterdam"), ("NL-AMS", "Ámsterdam")], + [("NLRTM", "Róterdam")], + [("AMS", "Ámsterdam Schiphol")]), + ("BRA", + [("BR-SAO", "São Paulo"), ("BR-SSZ", "Santos"), ("BR-RIO", "Río de Janeiro")], + [("BRSSZ", "Santos"), ("BRPNG", "Paranaguá"), ("BRRIG", "Rio Grande")], + [("GRU", "São Paulo Guarulhos"), ("GIG", "Río de Janeiro")]), + ("CAN", + [("CA-YVR", "Vancouver"), ("CA-YYZ", "Toronto"), ("CA-YMQ", "Montreal")], + [("CAVAN", "Vancouver"), ("CAMTR", "Montreal"), ("CAHAL", "Halifax")], + [("YVR", "Vancouver"), ("YYZ", "Toronto Pearson")]), + ("JPN", + [("JP-TYO", "Tokio"), ("JP-OSA", "Osaka"), ("JP-YOK", "Yokohama")], + [("JPYOK", "Yokohama"), ("JPTYO", "Tokio"), ("JPNGO", "Nagoya"), ("JPKOB", "Kobe")], + [("NRT", "Tokio Narita"), ("HND", "Tokio Haneda"), ("KIX", "Osaka Kansai")]), + ("KOR", + [("KR-SEL", "Seúl"), ("KR-PUS", "Busan")], + [("KRPUS", "Busan"), ("KRINC", "Incheon")], + [("ICN", "Seúl Incheon")]), +] + + +def _build() -> dict: + ciudad, puerto, aeropuerto = [], [], [] + for iso3, cities, ports, airports in _LOC: + for code, label in cities: + ciudad.append({"code": code, "label": label, "parent_catalog": "pais", "parent_code": iso3}) + for code, label in ports: + puerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3}) + for code, label in airports: + aeropuerto.append({"code": code, "label": f"{label} ({code})", "parent_catalog": "pais", "parent_code": iso3}) + return { + "ciudad": {"label": "Ciudad", "is_system": True, "items": ciudad}, + "puerto": {"label": "Puerto", "is_system": True, "items": puerto}, + "aeropuerto": {"label": "Aeropuerto", "is_system": True, "items": aeropuerto}, + } + + +LOCATION_CATALOGS = _build() diff --git a/frontend/src/lib/components/crm/ServiceRequestFields.svelte b/frontend/src/lib/components/crm/ServiceRequestFields.svelte index f5177ed..41c24c8 100644 --- a/frontend/src/lib/components/crm/ServiceRequestFields.svelte +++ b/frontend/src/lib/components/crm/ServiceRequestFields.svelte @@ -21,25 +21,85 @@ 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; "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 + // Modalidad de carga según el tipo de transporte (solo se habilita lo que corresponde) + const MODALIDAD_BY_TRANSPORT: Record = { + maritimo: ['FCL', 'LCL', 'AMBAS'], + aereo: ['AEREO'], + terrestre: ['FTL', 'LTL'] + // ferroviario / multimodal: sin modalidad + }; + const modalidadOptions = $derived( + LOAD_TYPES.filter((l) => (MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []).includes(l.value)) ); - const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric)); + const showModalidad = $derived(modalidadOptions.length > 0); + // Reglas: al cambiar el transporte, la modalidad inválida se limpia; si solo hay una (aéreo), se autoselecciona + $effect(() => { + const allowed = MODALIDAD_BY_TRANSPORT[form.transport_mode ?? ''] ?? []; + if (allowed.length === 0) { + if (form.load_type) form.load_type = undefined; + return; + } + if (form.load_type && !allowed.includes(form.load_type)) form.load_type = undefined; + if (!form.load_type && allowed.length === 1) form.load_type = allowed[0]; + }); // 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'; }); + 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'); + + // Conversión de dimensiones a cm según la unidad de medida (para volumen m³ y P/Vol) + const UNIT_TO_CM: Record = { cm: 1, m: 100, in: 2.54, ft: 30.48 }; + const unitCm = $derived(UNIT_TO_CM[form.measurement_unit ?? 'cm'] ?? 1); + const airQty = $derived(Number(form.pallets_count) || Number(form.pieces_count) || 1); + const dimL = $derived((Number(form.length_cm) || 0) * unitCm); + const dimW = $derived((Number(form.width_cm) || 0) * unitCm); + const dimH = $derived((Number(form.height_cm) || 0) * unitCm); + const hasDims = $derived(dimL > 0 && dimW > 0 && dimH > 0); + // Volumen SIEMPRE en m³ (cm³ / 1,000,000) + const volumeM3 = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 1_000_000 : 0); + // P/Vol aéreo (kg) = (L×A×H cm × bultos) / 6000; a cobrar = max(bruto, P/Vol) + const airVolumetric = $derived(hasDims ? (dimL * dimW * dimH * airQty) / 6000 : 0); + const airChargeable = $derived(Math.max(Number(form.weight) || 0, airVolumetric)); + + // Autocompletar el volumen en m³ a partir de las dimensiones/unidad + $effect(() => { + if (hasDims) form.volume = Math.round(volumeM3 * 1000) / 1000; + }); + + // Ciudad y puerto/aeropuerto dependen del país (catálogos dependientes, como estado←país) + $effect(() => { + if (form.origin_country) { + void crmCatalogs.ensure('ciudad', form.origin_country); + void crmCatalogs.ensure('puerto', form.origin_country); + void crmCatalogs.ensure('aeropuerto', form.origin_country); + } + }); + $effect(() => { + if (form.destination_country) { + void crmCatalogs.ensure('ciudad', form.destination_country); + void crmCatalogs.ensure('puerto', form.destination_country); + void crmCatalogs.ensure('aeropuerto', form.destination_country); + } + }); + // Campo "Puerto/Aeropuerto": une puertos + aeropuertos del país + function portOptions(country: string | null | undefined) { + return [ + ...crmCatalogs.options('puerto', country ?? undefined), + ...crmCatalogs.options('aeropuerto', country ?? undefined) + ]; + } + + // Agente en destino: solo proveedores clasificados como corresponsal/aduanal (fallback: todos) + const destinationAgents = $derived( + suppliers.filter((s) => (s.classifications ?? []).some((c) => c === 'agente_corresponsal' || c === 'agente_aduanal')) + ); + const agentList = $derived(destinationAgents.length ? destinationAgents : suppliers); + // Contactos del cliente seleccionado (o todos si no hay cliente) const clientContacts = $derived( form.account_id ? contacts.filter((c) => c.account_id === form.account_id) : contacts @@ -91,13 +151,19 @@ - + {#if showModalidad} + + {/if}

Origen

- - {#if crmCatalogs.options('puerto').length} - + {#if crmCatalogs.options('ciudad', form.origin_country ?? undefined).length} + + {:else} + + {/if} + {#if portOptions(form.origin_country).length} + {:else} {/if} @@ -105,9 +171,13 @@

Destino

- - {#if crmCatalogs.options('puerto').length} - + {#if crmCatalogs.options('ciudad', form.destination_country ?? undefined).length} + + {:else} + + {/if} + {#if portOptions(form.destination_country).length} + {:else} {/if} @@ -115,14 +185,14 @@ - + {:else if tab === 'mercancia'}
- +
@@ -172,9 +242,9 @@

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 volumétrico (P/Vol){airVolumetric.toFixed(2)} kg
Peso bruto{(Number(form.weight) || 0).toFixed(2)} kg
-
Peso a cobrar{airChargeable.toFixed(2)} kg
+
Peso a cobrar (P/Vol){airChargeable.toFixed(2)} kg
{/if} diff --git a/frontend/src/routes/dashboard/crm/solicitudes/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/+page.svelte index 5eac753..63bfe54 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/+page.svelte @@ -4,22 +4,28 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; - import { serviceRequestsAPI, type ServiceRequest } from '$lib/api/crm'; + import { serviceRequestsAPI, accountsAPI, type ServiceRequest, type Account } from '$lib/api/crm'; import { OPERATION_TYPES, SR_STATUS, TRANSPORT_MODES, labelOf } from '$lib/components/crm/format'; import { toast } from 'svelte-sonner'; let items = $state([]); + let accounts = $state([]); let loading = $state(false); let search = $state(''); let statusFilter = $state(''); + let clientFilter = $state(''); const companyId = $derived(companyStore.activeCompany?.id ?? null); + function accountName(id: number | null): string { + return accounts.find((a) => a.id === id)?.name ?? '—'; + } const filtered = $derived( items.filter((r) => { if (statusFilter && r.status !== statusFilter) return false; + if (clientFilter && String(r.account_id ?? '') !== clientFilter) return false; if (search.trim()) { const q = search.trim().toLowerCase(); - return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''}`.toLowerCase().includes(q); + return `${r.reference ?? ''} ${r.origin ?? ''} ${r.destination ?? ''} ${accountName(r.account_id)}`.toLowerCase().includes(q); } return true; }) @@ -34,7 +40,7 @@ async function load(cid: number) { loading = true; try { - items = await serviceRequestsAPI.list(cid); + [items, accounts] = await Promise.all([serviceRequestsAPI.list(cid), accountsAPI.list(cid)]); } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudieron cargar las solicitudes'); } finally { @@ -76,6 +82,10 @@ {#each SR_STATUS as s (s.value)}{/each} +
@@ -89,6 +99,7 @@ Folio + Cliente Operación Medio Ruta @@ -100,6 +111,7 @@ {#each filtered as r (r.id)} {r.reference ?? `#${r.id}`} + {accountName(r.account_id)} {labelOf(OPERATION_TYPES, r.operation_type)} {labelOf(TRANSPORT_MODES, r.transport_mode)} {[r.origin, r.destination].filter(Boolean).join(' → ') || '—'} diff --git a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte index 49befff..07097d9 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/[id]/+page.svelte @@ -186,6 +186,7 @@ +
diff --git a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte index f5187ea..1c4be37 100644 --- a/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte +++ b/frontend/src/routes/dashboard/crm/solicitudes/nuevo/+page.svelte @@ -9,7 +9,7 @@ import ServiceRequestFields from '$lib/components/crm/ServiceRequestFields.svelte'; import { toast } from 'svelte-sonner'; - let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {} }); + let form = $state({ operation_type: 'exportacion', status: 'nueva', additional_services: [], additional_service_costs: {}, request_date: new Date().toISOString().slice(0, 10) }); let accounts = $state([]); let suppliers = $state([]); let contacts = $state([]);