From a52602dedf3f9a6d45d40adacc215f9863bcd44e Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Tue, 24 Feb 2026 14:24:50 -0600 Subject: [PATCH] =?UTF-8?q?feat:=20Implement=20Ventanilla=20=C3=9Anica=20(?= =?UTF-8?q?VU)=20management=20for=20customs=20brokers,=20including=20API,?= =?UTF-8?q?=20UI,=20and=20data=20model=20updates,=20and=20add=20a=20new=20?= =?UTF-8?q?state=20selection=20dialog.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../7937209f9718_seed_initial_data.py | 3 +- .../api/v1/modules/a76/customs_brokers/dto.py | 55 +- .../v1/modules/a76/customs_brokers/models.py | 2 +- .../modules/a76/customs_brokers/services.py | 38 +- .../lib/api/dashboard/a76/customs-brokers.ts | 1 + .../dashboard/customs_brokers/columns.ts | 18 +- .../edit/items/fa/state-dialog.svelte | 153 ++++ .../dashboard/customs_brokers/edit.ts | 6 + .../customs_brokers/edit/[[id]]/+page.svelte | 673 +++++++++++++++++- 9 files changed, 871 insertions(+), 78 deletions(-) create mode 100644 frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py index 5bffa511..1efba5f4 100644 --- a/backend/alembic/versions/7937209f9718_seed_initial_data.py +++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py @@ -94,9 +94,8 @@ depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: - """Upgrade schema.""" + """Upgrade schema.""" - # --- UTILIDAD DE FORMATEO --- def format_value(val): if val is None or str(val).strip() == "" or str(val).upper() == "NONE": return "NULL" diff --git a/backend/api/v1/modules/a76/customs_brokers/dto.py b/backend/api/v1/modules/a76/customs_brokers/dto.py index 7f65b264..e7f38cba 100644 --- a/backend/api/v1/modules/a76/customs_brokers/dto.py +++ b/backend/api/v1/modules/a76/customs_brokers/dto.py @@ -43,6 +43,7 @@ class CustomsBrokerResponseDTO(CustomsBrokerBaseDTO): broker_key: str tenant_id: int company_id: int + vu: Optional["CustomsBrokerVUCreateDTO"] = None class Config: from_attributes = True @@ -75,25 +76,25 @@ class CustomsBrokerDTO(BaseModel): class CustomsBrokerVUCreateDTO(BaseModel): - certificate_path: Optional[str] - key_path: Optional[str] - access_key: Optional[str] - fiel_format: Optional[str] - signature_read_path: Optional[str] - archive_path: Optional[str] - fiel_access_key: Optional[str] - web_service_user: Optional[str] - web_service_access_key: Optional[str] - vu_email: Optional[str] - vu_figure_type: Optional[str] - xml_files_path: Optional[str] - query_tax_id: Optional[str] - doda_certificate_path: Optional[str] - doda_key_path: Optional[str] - doda_web_service_user: Optional[str] - doda_web_service_access_key: Optional[str] - doda_fiel_access_key: Optional[str] - doda_xml_files_path: Optional[str] + certificate_path: Optional[str] = None + key_path: Optional[str] = None + access_key: Optional[str] = None + fiel_format: Optional[str] = None + signature_read_path: Optional[str] = None + archive_path: Optional[str] = None + fiel_access_key: Optional[str] = None + web_service_user: Optional[str] = None + web_service_access_key: Optional[str] = None + vu_email: Optional[str] = None + vu_figure_type: Optional[str] = None + xml_files_path: Optional[str] = None + query_tax_id: Optional[str] = None + doda_certificate_path: Optional[str] = None + doda_key_path: Optional[str] = None + doda_web_service_user: Optional[str] = None + doda_web_service_access_key: Optional[str] = None + doda_fiel_access_key: Optional[str] = None + doda_xml_files_path: Optional[str] = None class Config: from_attributes = True @@ -102,15 +103,15 @@ class CustomsBrokerVUCreateDTO(BaseModel): class CustomsBrokerPersonnelDTO(BaseModel): broker_key: str = Field(..., max_length=5, pattern=r"^[a-zA-Z0-9]+$") line: int - name: Optional[str] - tax_id: Optional[str] - personal_id: Optional[str] - position: Optional[str] + name: Optional[str] = None + tax_id: Optional[str] = None + personal_id: Optional[str] = None + position: Optional[str] = None license: Optional[str] = Field(None, max_length=4, pattern=r"^\d*$") - first_name: Optional[str] - last_name: Optional[str] - middle_name: Optional[str] - email: Optional[str] + first_name: Optional[str] = None + last_name: Optional[str] = None + middle_name: Optional[str] = None + email: Optional[str] = None class Config: from_attributes = True diff --git a/backend/api/v1/modules/a76/customs_brokers/models.py b/backend/api/v1/modules/a76/customs_brokers/models.py index a5925a62..11c29580 100644 --- a/backend/api/v1/modules/a76/customs_brokers/models.py +++ b/backend/api/v1/modules/a76/customs_brokers/models.py @@ -32,7 +32,7 @@ class CustomsBroker(Base, TenantScopedMixin, TimestampMixin): contact = Column(String(80), nullable=True) vu = relationship( - "CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete" + "CustomsBrokerVU", back_populates="customs_broker", cascade="all, delete", uselist=False ) personnel = relationship( "CustomsBrokerPersonnel", back_populates="customs_broker", cascade="all, delete" diff --git a/backend/api/v1/modules/a76/customs_brokers/services.py b/backend/api/v1/modules/a76/customs_brokers/services.py index ea72abe5..c1c10d68 100644 --- a/backend/api/v1/modules/a76/customs_brokers/services.py +++ b/backend/api/v1/modules/a76/customs_brokers/services.py @@ -76,7 +76,8 @@ class CustomsBrokerVUService: def get_by_broker_key(db: Session, broker_key: str): return ( db.query(models.CustomsBrokerVU) - .filter(models.CustomsBrokerVU.broker_key == broker_key) + .join(models.CustomsBroker) + .filter(models.CustomsBroker.broker_key == broker_key) .first() ) @@ -90,13 +91,28 @@ class CustomsBrokerVUService: @staticmethod def update_vu(db: Session, broker_key: str, vu_data: dto.CustomsBrokerVUCreateDTO): - vu = CustomsBrokerVUService.get_by_broker_key(db, broker_key) + # We need the custom broker ID to insert a new VU + broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first() + if not broker: + return None + + vu = db.query(models.CustomsBrokerVU).filter(models.CustomsBrokerVU.customs_broker_id == broker.id).first() + if vu: - for key, value in vu_data.dict(exclude_unset=True).items(): + # Update existing + for key, value in vu_data.model_dump(exclude_unset=True).items(): setattr(vu, key, value) db.commit() db.refresh(vu) - return vu + return vu + else: + # Create new + new_vu_data = vu_data.model_dump() + new_vu = models.CustomsBrokerVU(customs_broker_id=broker.id, **new_vu_data) + db.add(new_vu) + db.commit() + db.refresh(new_vu) + return new_vu @staticmethod def delete_vu(db: Session, broker_key: str): @@ -112,16 +128,22 @@ class CustomsBrokerPersonnelService: def get_by_broker_key_and_line(db: Session, broker_key: str, line: int): return ( db.query(models.CustomsBrokerPersonnel) + .join(models.CustomsBroker) .filter( - models.CustomsBrokerPersonnel.broker_key == broker_key, + models.CustomsBroker.broker_key == broker_key, models.CustomsBrokerPersonnel.line == line, ) .first() ) @staticmethod - def create_personnel(db: Session, personnel_data: dto.CustomsBrokerPersonnelDTO): - new_personnel = models.CustomsBrokerPersonnel(**personnel_data.dict()) + def create_personnel(db: Session, broker_key: str, personnel_data: dto.CustomsBrokerPersonnelDTO): + broker = db.query(models.CustomsBroker).filter(models.CustomsBroker.broker_key == broker_key).first() + if not broker: + return None + + new_personnel_data = personnel_data.model_dump() + new_personnel = models.CustomsBrokerPersonnel(customs_broker_id=broker.id, **new_personnel_data) db.add(new_personnel) db.commit() db.refresh(new_personnel) @@ -138,7 +160,7 @@ class CustomsBrokerPersonnelService: db, broker_key, line ) if personnel: - for key, value in personnel_data.dict(exclude_unset=True).items(): + for key, value in personnel_data.model_dump(exclude_unset=True).items(): setattr(personnel, key, value) db.commit() db.refresh(personnel) diff --git a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts index 981ef884..f08c1726 100644 --- a/frontend/src/lib/api/dashboard/a76/customs-brokers.ts +++ b/frontend/src/lib/api/dashboard/a76/customs-brokers.ts @@ -22,6 +22,7 @@ export interface CustomsBroker { contact?: string | null; tenant_id: string; company_id: string; + vu?: CustomsBrokerVU | null; } export interface CustomsBrokerVU { diff --git a/frontend/src/lib/components/dashboard/customs_brokers/columns.ts b/frontend/src/lib/components/dashboard/customs_brokers/columns.ts index 24400b55..3ea92e34 100644 --- a/frontend/src/lib/components/dashboard/customs_brokers/columns.ts +++ b/frontend/src/lib/components/dashboard/customs_brokers/columns.ts @@ -28,8 +28,12 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ cell: ({ row }) => { const typeSnippet = createRawSnippet<[{ type: string | null | undefined }]>((getType) => { const { type } = getType(); + let display = type || '-'; + if (type === 'MEX') display = 'Agente Aduanal Mexicano'; + else if (type === 'USA') display = 'Agente Aduanal Americano (Broker)'; + return { - render: () => `
${type || '-'}
` + render: () => `
${display}
` }; }); return renderSnippet(typeSnippet, { type: row.original.type }); @@ -68,8 +72,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ const postalSnippet = createRawSnippet<[{ postal: string | null | undefined }]>((getPostal) => { const { postal } = getPostal(); return { - render: () => - postal + render: () => + postal ? `${postal}` : `-` }; @@ -136,8 +140,8 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ const licenseSnippet = createRawSnippet<[{ license: string | null | undefined }]>((getLicense) => { const { license } = getLicense(); return { - render: () => - license + render: () => + license ? `${license}` : `-` }; @@ -175,9 +179,9 @@ export function createColumns(onSuccess?: () => void): ColumnDef[ id: "actions", header: "Acciones", cell: ({ row }) => { - return renderComponent(DataTableActions, { + return renderComponent(DataTableActions, { broker: row.original, - onSuccess + onSuccess }); } } diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte new file mode 100644 index 00000000..81f228e8 --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte @@ -0,0 +1,153 @@ + + + + + + CATALOGO DE ESTADOS + + +
+
+ + +
+
+ +
+ {#if loading} +
+ +
+ {:else if error} +
+

{error}

+
+ {:else} +
+ + + + + + + + + + + {#each filteredItems as item} + handleSelect(item)} + > + + + + + + {/each} + {#if filteredItems.length === 0} + + + + {/if} + +
Clave M3Clave MexClave AmeDescripción
{item.m3_key || ''}{item.mex_key || ''}{item.ame_key || ''}{item.description || ''}
+ No se encontraron resultados +
+
+ {/if} +
+ +
+ +
+
+
diff --git a/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts b/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts index dc716087..8902cccb 100644 --- a/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts +++ b/frontend/src/lib/config/shortcuts/dashboard/customs_brokers/edit.ts @@ -4,6 +4,7 @@ export const obtenerAtajosEdicionAgente = (acciones: { irGeneral: () => void; irContacto: () => void; irDireccion: () => void; + irVU: () => void; guardar: () => void; cancelar: () => void; }): ShortcutDef[] => [ @@ -22,6 +23,11 @@ export const obtenerAtajosEdicionAgente = (acciones: { description: 'Tab Dirección', action: acciones.irDireccion }, + { + key: 'Alt+Digit4', + description: 'Tab Ventanilla Única', + action: acciones.irVU + }, { key: 'Ctrl+S', description: 'Guardar', diff --git a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte index 264e7d8a..801303a2 100644 --- a/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte +++ b/frontend/src/routes/dashboard/customs_brokers/edit/[[id]]/+page.svelte @@ -8,6 +8,8 @@ } from '$lib/api/dashboard/a76/customs-brokers'; // UI Components + import CountryDialog from '$lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte'; + import StateDialog from '$lib/components/dashboard/invoices/edit/items/fa/state-dialog.svelte'; import { Button } from '$lib/components/ui/button'; import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; @@ -15,6 +17,7 @@ import { Badge } from '$lib/components/ui/badge'; import * as Tabs from '$lib/components/ui/tabs'; import * as Card from '$lib/components/ui/card'; + import * as Select from '$lib/components/ui/select'; import { ArrowLeft, Loader2, @@ -24,7 +27,18 @@ MapPin, Settings, FileText, - Hash + Hash, + FileKey, + Key, + Globe, + Folder, + Mail, + UserRound, + Fingerprint, + Lock, + Signature, + Archive, + ShieldCheck } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { useShortcuts } from '$lib/hooks/use-shortcuts'; @@ -40,6 +54,8 @@ let activeTab = $state('general'); let error = $state(null); let dataLoaded = $state(false); + let showCountryDialog = $state(false); + let showStateDialog = $state(false); let formData = $state({ broker_key: '', @@ -62,6 +78,28 @@ company_id: '' }); + let vuData = $state({ + certificate_path: '', + key_path: '', + xml_files_path: '', + fiel_access_key: '', + doda_web_service_user: '', + doda_web_service_access_key: '', + doda_certificate_path: '', + doda_key_path: '', + doda_fiel_access_key: '', + doda_xml_files_path: '', + web_service_user: '', + web_service_access_key: '', + query_tax_id: '', + vu_email: '', + vu_figure_type: '', + fiel_format: '', + access_key: '', + signature_read_path: '', + archive_path: '' + }); + let brokerKeyError = $state(false); let licenseError = $state(false); let brokerKeyTimeout: ReturnType; @@ -77,6 +115,16 @@ } }); + // --- 5. FUNCIONES --- + function handleLocalFileSelect(event: Event, targetKey: keyof typeof vuData) { + const input = event.target as HTMLInputElement; + const file = input.files?.[0]; + if (file) { + vuData[targetKey] = file.name; + toast.success(`Archivo ${file.name} seleccionado`); + } + } + async function loadBrokerData(key: string, cId: string) { if (!key || key === 'undefined') return; loading = true; @@ -106,9 +154,32 @@ company: d.company || '', company_id: cId }; + if (d.vu) { + vuData = { + certificate_path: d.vu.certificate_path || '', + key_path: d.vu.key_path || '', + xml_files_path: d.vu.xml_files_path || '', + fiel_access_key: d.vu.fiel_access_key || '', + doda_web_service_user: d.vu.doda_web_service_user || '', + doda_web_service_access_key: d.vu.doda_web_service_access_key || '', + doda_certificate_path: d.vu.doda_certificate_path || '', + doda_key_path: d.vu.doda_key_path || '', + doda_fiel_access_key: d.vu.doda_fiel_access_key || '', + doda_xml_files_path: d.vu.doda_xml_files_path || '', + web_service_user: d.vu.web_service_user || '', + web_service_access_key: d.vu.web_service_access_key || '', + query_tax_id: d.vu.query_tax_id || '', + vu_email: d.vu.vu_email || '', + vu_figure_type: d.vu.vu_figure_type || '', + fiel_format: d.vu.fiel_format || '', + access_key: d.vu.access_key || '', + signature_read_path: d.vu.signature_read_path || '', + archive_path: d.vu.archive_path || '' + }; + } dataLoaded = true; } else if (d.error) { - error = d.error; + error = d.error as string; toast.error(error); } } catch (e: any) { @@ -119,6 +190,18 @@ } } + function handleCountrySelect(country: any) { + const nextCountry = country.m3_key || country.mex_key || country.ame_key; + if (formData.country !== nextCountry) { + formData.state = ''; + } + formData.country = nextCountry; + } + + function handleStateSelect(state: any) { + formData.state = state.m3_key || state.mex_key || state.ame_key; + } + // --- 4. GUARDADO --- async function handleSave() { if (!companyStore.activeCompany) { @@ -151,15 +234,31 @@ formData.company_id = cId; const res = isEdit - ? await customsBrokersApi.update(routeId!, formData, cId) + ? await customsBrokersApi.update(routeId!, formData) : await customsBrokersApi.create(formData, cId); if ((res as any).error) throw new Error((res as any).error); + // UPSERT VU + try { + const vuRes = await customsBrokersApi.updateVU(formData.broker_key, vuData, cId); + if ((vuRes as any).error) { + toast.error( + 'Agente guardado, pero ocurrió un error guardando Ventanilla Única: ' + + (vuRes as any).error + ); + return; // Avoid triggering success redirection + } + } catch (vuErr: any) { + toast.error('Agente guardado, pero ocurrió un error guardando Ventanilla Única.'); + console.error(vuErr); + return; + } + toast.success(isEdit ? 'Agente actualizado' : 'Agente creado'); goto('/dashboard/customs_brokers'); } catch (e: any) { - error = e.message || 'Error al procesar la solicitud'; + error = (e.message || 'Error al procesar la solicitud') as string; toast.error(error); } finally { loading = false; @@ -176,6 +275,7 @@ irGeneral: () => (activeTab = 'general'), irContacto: () => (activeTab = 'contact'), irDireccion: () => (activeTab = 'address'), + irVU: () => (activeTab = 'vu'), guardar: handleSave, cancelar: handleCancel }) @@ -197,7 +297,7 @@ {isEdit ? 'Edición' : 'Nuevo'} -

+

{isEdit ? 'Modifica la información del agente aduanal' : 'Registra un nuevo agente aduanal en el sistema'} @@ -224,7 +324,31 @@ Identificación oficial del agente y patente. -

+
+
+ + (formData.type = v)} + disabled={loading} + > + + {formData.type === 'MEX' + ? 'Agente Aduanal Mexicano' + : formData.type === 'USA' + ? 'Agente Aduanal Americano (Broker)' + : 'Selecciona un tipo...'} + + + Agente Aduanal Mexicano + Agente Aduanal Americano (Broker) + + +
+
+ +
{#if brokerKeyError} @@ -281,8 +405,8 @@ } }} placeholder="Ej. 3421" - maxlength="5" - class={licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''} + maxlength={5} + class={`h-10 ${licenseError ? 'border-red-500 focus-visible:ring-red-500' : ''}`} disabled={loading} /> {#if licenseError} @@ -297,16 +421,22 @@
- +
-
+
@@ -315,6 +445,7 @@ bind:value={formData.personal_id} placeholder="CURP si aplica" disabled={loading} + class="h-10" />
@@ -330,13 +461,14 @@ Datos para comunicación con el agente. -
+
@@ -345,24 +477,26 @@ bind:value={formData.position} placeholder="Ej. Gerente Comercial" disabled={loading} + class="h-10" />
-
+
- +
@@ -371,6 +505,7 @@ bind:value={formData.email} placeholder="correo@empresa.com" disabled={loading} + class="h-10" />
@@ -392,26 +527,501 @@ bind:value={formData.address} placeholder="Dirección completa" disabled={loading} + class="h-10" />
-
+
- +
- +
-
+
- +
+ (showStateDialog = true)} + /> + +
- +
+ (showCountryDialog = true)} + /> + +
+
+
+ + + + + + + + + + Ventanilla Única / Web Services + Certificados y credenciales para integración con DODA/PITA. + + +
+
+ +
+ + handleLocalFileSelect(e, 'certificate_path')} + id="vu-cert-file" + /> + +
+
+
+ +
+ + handleLocalFileSelect(e, 'key_path')} + id="vu-key-file" + /> + +
+
+
+ +
+
+ + +
+
+ + + + {vuData.vu_figure_type || 'Seleccionar tipo de figura'} + + + AGENTE ADUANAL + APODERADO ADUANAL + MANDATARIO + + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + handleLocalFileSelect(e, 'xml_files_path')} + id="vu-cove-file" + /> + +
+
+
+ + + +
+

+ Configuración Adicional +

+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+
+ + + + + + DODA-PITA + Configuración de servicios DODA / PITA. + + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + handleLocalFileSelect(e, 'doda_certificate_path')} + id="doda-cert-file" + /> + +
+
+
+ +
+ + handleLocalFileSelect(e, 'doda_key_path')} + id="doda-key-file" + /> + +
+
+
+ +
+
+ + +
+
+ +
+ + handleLocalFileSelect(e, 'doda_xml_files_path')} + id="doda-xml-file" + /> + +
+
+
+
+
+
+ + + + ANAM + Configuración de acceso para ANAM. + + +
+
+ + +
+
+ +
@@ -424,22 +1034,19 @@
-
+
- - - General - - - Contacto - - - Dirección - + + General + Contacto + Domicilio + VU + DODA + ANAM