From 8c7aeef1a61293f1a1f3c80a94859777b82a582b Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 7 Aug 2026 07:25:20 -0600 Subject: [PATCH 1/4] 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([]); -- 2.49.1 From 8431132b10084544ac525e4dad0946a58d91bd87 Mon Sep 17 00:00:00 2001 From: Ernesto Herrera Date: Fri, 7 Aug 2026 07:37:30 -0600 Subject: [PATCH 2/4] =?UTF-8?q?feat(crm):=20Prospecto=20=E2=80=94=20medio?= =?UTF-8?q?=20de=20contacto=20preferido=20+=20fix=20visualizaci=C3=B3n=20d?= =?UTF-8?q?e=20documentos=20(Fase=20C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prospecto (lead): se conserva "Origen" y se agrega "Medio de contacto preferido" (catálogo medio_contacto). Backend leads.preferred_contact_method + migración f0a1b2c3d4e5 reversible. - Bug documentos: endpoint proxy GET /v1/crm/uploads/download transmite el archivo por el backend (valida aislamiento tenant/company) — evita la URL prefirmada al host interno minio:9000. RelatedManager.openDoc usa blob→objectURL. Suite backend en verde (109). svelte-check sin errores nuevos. Co-Authored-By: Claude Opus 4.8 --- ...1b2c3d4e5_lead_preferred_contact_method.py | 25 ++++++++++++++++ backend/api/v1/modules/crm/leads/dto.py | 3 ++ backend/api/v1/modules/crm/leads/models.py | 2 ++ backend/api/v1/modules/crm/uploads/routes.py | 30 +++++++++++++++++-- frontend/src/lib/api/crm/types.ts | 1 + frontend/src/lib/api/uploads.ts | 7 +++++ .../lib/components/crm/RelatedManager.svelte | 16 +++++++--- .../dashboard/crm/prospectos/+page.svelte | 11 +++++++ 8 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py diff --git a/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py b/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py new file mode 100644 index 0000000..6504d2a --- /dev/null +++ b/backend/alembic/versions/f0a1b2c3d4e5_lead_preferred_contact_method.py @@ -0,0 +1,25 @@ +"""Medio de contacto preferido en el prospecto (lead) + +Revision ID: f0a1b2c3d4e5 +Revises: e4f5a6b7c8d9 +Create Date: 2026-08-07 01:00:00.000000 +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "f0a1b2c3d4e5" +down_revision: Union[str, None] = "e4f5a6b7c8d9" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def upgrade() -> None: + op.add_column("leads", sa.Column("preferred_contact_method", sa.String(length=20), nullable=True), schema=SCHEMA) + + +def downgrade() -> None: + op.drop_column("leads", "preferred_contact_method", schema=SCHEMA) diff --git a/backend/api/v1/modules/crm/leads/dto.py b/backend/api/v1/modules/crm/leads/dto.py index 904dd47..6173b46 100644 --- a/backend/api/v1/modules/crm/leads/dto.py +++ b/backend/api/v1/modules/crm/leads/dto.py @@ -11,6 +11,7 @@ class LeadCreate(BaseModel): phone: str | None = Field(None, max_length=40) company_name: str | None = Field(None, max_length=255) source: str | None = Field(None, max_length=60) + preferred_contact_method: str | None = Field(None, max_length=20) status: str = Field("new", max_length=20) estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) owner_user_id: str | None = Field(None, max_length=64) @@ -24,6 +25,7 @@ class LeadUpdate(BaseModel): phone: str | None = Field(None, max_length=40) company_name: str | None = Field(None, max_length=255) source: str | None = Field(None, max_length=60) + preferred_contact_method: str | None = Field(None, max_length=20) status: str | None = Field(None, max_length=20) estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2) owner_user_id: str | None = Field(None, max_length=64) @@ -50,6 +52,7 @@ class LeadResponse(BaseModel): phone: str | None company_name: str | None source: str | None + preferred_contact_method: str | None = None status: str estimated_value: Decimal | None owner_user_id: str | None diff --git a/backend/api/v1/modules/crm/leads/models.py b/backend/api/v1/modules/crm/leads/models.py index a1e4140..74f20a4 100644 --- a/backend/api/v1/modules/crm/leads/models.py +++ b/backend/api/v1/modules/crm/leads/models.py @@ -19,6 +19,8 @@ class Lead(Base, TenantScopedMixin, TimestampMixin): company_name: Mapped[str | None] = mapped_column(String(255), nullable=True) # Origen: web | referido | evento | llamada | email | otro source: Mapped[str | None] = mapped_column(String(60), nullable=True) + # Medio de contacto preferido (catálogo medio_contacto): llamada|correo|whatsapp|… + preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True) # Estado: new | contacted | qualified | unqualified | converted status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True) estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True) diff --git a/backend/api/v1/modules/crm/uploads/routes.py b/backend/api/v1/modules/crm/uploads/routes.py index 63757dd..7968a27 100644 --- a/backend/api/v1/modules/crm/uploads/routes.py +++ b/backend/api/v1/modules/crm/uploads/routes.py @@ -7,10 +7,10 @@ pide una URL firmada fresca en ``/uploads/url`` (las presignadas expiran). import re import uuid -from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile, status +from fastapi import APIRouter, Depends, File, HTTPException, Query, Response, UploadFile, status from core.security import get_current_user -from core.storage_s3 import presigned_get_url, put_object_bytes +from core.storage_s3 import get_object_bytes, presigned_get_url, put_object_bytes router = APIRouter() @@ -61,3 +61,29 @@ def get_upload_url( if not key.startswith(prefix): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") return {"url": presigned_get_url(key)} + + +@router.get("/uploads/download") +def download_file( + key: str = Query(..., description="Object key del archivo en el almacén"), + company_id: int = Query(..., description="Company ID"), + current_user: dict = Depends(get_current_user), +): + """Transmite el archivo por el backend (sin exponer MinIO al navegador). + + Evita el bug de la URL prefirmada que apunta al host interno ``minio:9000``. + """ + tenant_id = current_user["tenant_id"] + prefix = f"tenants/{tenant_id}/companies/{company_id}/" + if not key.startswith(prefix): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance") + try: + data = get_object_bytes(key) + except Exception: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Archivo no encontrado") + filename = key.rsplit("/", 1)[-1] + return Response( + content=data, + media_type="application/octet-stream", + headers={"Content-Disposition": f'inline; filename="{filename}"'}, + ) diff --git a/frontend/src/lib/api/crm/types.ts b/frontend/src/lib/api/crm/types.ts index ca0e2a1..f20f47a 100644 --- a/frontend/src/lib/api/crm/types.ts +++ b/frontend/src/lib/api/crm/types.ts @@ -192,6 +192,7 @@ export interface Lead { phone: string | null; company_name: string | null; source: string | null; + preferred_contact_method: string | null; status: LeadStatus; estimated_value: number | null; owner_user_id: string | null; diff --git a/frontend/src/lib/api/uploads.ts b/frontend/src/lib/api/uploads.ts index 31dcf25..9a399fc 100644 --- a/frontend/src/lib/api/uploads.ts +++ b/frontend/src/lib/api/uploads.ts @@ -31,3 +31,10 @@ export async function uploadUrl(fileKey: string, companyId: number): Promise { + return (api as any).getBlob( + `/v1/crm/uploads/download?key=${encodeURIComponent(fileKey)}&company_id=${companyId}` + ) as Promise; +} diff --git a/frontend/src/lib/components/crm/RelatedManager.svelte b/frontend/src/lib/components/crm/RelatedManager.svelte index fb0866e..3900dd2 100644 --- a/frontend/src/lib/components/crm/RelatedManager.svelte +++ b/frontend/src/lib/components/crm/RelatedManager.svelte @@ -11,7 +11,7 @@ import { DOC_TYPES, labelOf } from '$lib/components/crm/format'; import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { onMount } from 'svelte'; - import { uploadFile, uploadUrl } from '$lib/api/uploads'; + import { uploadFile, downloadBlob } from '$lib/api/uploads'; import { toast } from 'svelte-sonner'; onMount(() => { @@ -62,9 +62,17 @@ async function openDoc(d: Document) { if (!companyId) return; try { - const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url; - if (url) window.open(url, '_blank', 'noopener'); - else toast.error('El documento no tiene archivo'); + if (d.file_key) { + // Descarga por el backend (evita exponer MinIO / host interno) + const blob = await downloadBlob(d.file_key, companyId); + const url = URL.createObjectURL(blob); + window.open(url, '_blank', 'noopener'); + setTimeout(() => URL.revokeObjectURL(url), 60000); + } else if (d.file_url) { + window.open(d.file_url, '_blank', 'noopener'); + } else { + toast.error('El documento no tiene archivo'); + } } catch (e) { toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo'); } diff --git a/frontend/src/routes/dashboard/crm/prospectos/+page.svelte b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte index 99de7a1..6c282fe 100644 --- a/frontend/src/routes/dashboard/crm/prospectos/+page.svelte +++ b/frontend/src/routes/dashboard/crm/prospectos/+page.svelte @@ -4,10 +4,14 @@ import * as Table from '$lib/components/ui/table'; import { Button } from '$lib/components/ui/button'; import { companyStore } from '$lib/stores/company.svelte'; + import { onMount } from 'svelte'; import { leadsAPI, type Lead, type LeadInput } from '$lib/api/crm'; import { LEAD_SOURCES, LEAD_STATUS, labelOf, formatMoney } from '$lib/components/crm/format'; + import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte'; import { toast } from 'svelte-sonner'; + onMount(() => void crmCatalogs.ensure('medio_contacto')); + let items = $state([]); let loading = $state(false); let search = $state(''); @@ -233,6 +237,13 @@ {#each LEAD_SOURCES as s (s.value)}{/each} +