From 78adde9737a291b8996bc1b7b1244d56ff55ff9c Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 3 Feb 2026 12:03:41 -0600 Subject: [PATCH 001/102] Feature/Integracion de nasvegacion free mouse para goods --- .../keyboard/KeyboardManager.svelte | 95 ++++ .../keyboard/ShortcutsHelpModal.svelte | 111 +++++ frontend/src/lib/config/shortcuts.ts | 29 ++ frontend/src/lib/hooks/use-shortcuts.ts | 17 + frontend/src/lib/stores/shortcut-store.ts | 49 ++ frontend/src/routes/+layout.svelte | 6 + .../goods/fixed-asset-classes/+page.svelte | 25 + .../routes/dashboard/goods/parts/+page.svelte | 461 ++++++++++-------- start.sh | 2 +- 9 files changed, 588 insertions(+), 207 deletions(-) create mode 100644 frontend/src/lib/components/keyboard/KeyboardManager.svelte create mode 100644 frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte create mode 100644 frontend/src/lib/config/shortcuts.ts create mode 100644 frontend/src/lib/hooks/use-shortcuts.ts create mode 100644 frontend/src/lib/stores/shortcut-store.ts diff --git a/frontend/src/lib/components/keyboard/KeyboardManager.svelte b/frontend/src/lib/components/keyboard/KeyboardManager.svelte new file mode 100644 index 00000000..2d1c6eb5 --- /dev/null +++ b/frontend/src/lib/components/keyboard/KeyboardManager.svelte @@ -0,0 +1,95 @@ + + + + + (showHelp = false)} /> diff --git a/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte b/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte new file mode 100644 index 00000000..3cb117be --- /dev/null +++ b/frontend/src/lib/components/keyboard/ShortcutsHelpModal.svelte @@ -0,0 +1,111 @@ + + +{#if open} + +{/if} diff --git a/frontend/src/lib/config/shortcuts.ts b/frontend/src/lib/config/shortcuts.ts new file mode 100644 index 00000000..6829f7fa --- /dev/null +++ b/frontend/src/lib/config/shortcuts.ts @@ -0,0 +1,29 @@ +/** + * Central Configuration for Keyboard Shortcuts + * + * Standards: + * - Alt + Key: Global Navigation (Route switching) + * - Ctrl + Key: Local Actions (Context specific) + */ + +export const GLOBAL_NAV = { + // Goods / Merchandise + 'g': '/dashboard/goods/parts', + 'c': '/dashboard/goods/fixed-asset-classes', + + // Common Actions (Navigation intents) + 'b': 'SEARCH_FOCUS', // Special case for generic focus + 'h': '/', // Home +} as const; + +export const STANDARD_ACTIONS = { + 's': 'SAVE', + 'n': 'NEW', + 'e': 'EXPORT', + 'd': 'DELETE', + 'f': 'FILTER', + 'Escape': 'CANCEL' // Special case +} as const; + +export type GlobalNavKey = keyof typeof GLOBAL_NAV; +export type ActionKey = keyof typeof STANDARD_ACTIONS; diff --git a/frontend/src/lib/hooks/use-shortcuts.ts b/frontend/src/lib/hooks/use-shortcuts.ts new file mode 100644 index 00000000..bd0cc204 --- /dev/null +++ b/frontend/src/lib/hooks/use-shortcuts.ts @@ -0,0 +1,17 @@ +import { onMount, onDestroy } from 'svelte'; +import { shortcutStore, type ShortcutDef } from '$lib/stores/shortcut-store'; + +/** + * Hook to register shortcuts for a component lifecycle. + * @param context Name of the context (e.g., 'Goods List') + * @param shortcuts Array of shortcut definitions + */ +export function useShortcuts(context: string, shortcuts: ShortcutDef[]) { + onMount(() => { + shortcutStore.register(context, shortcuts); + }); + + onDestroy(() => { + shortcutStore.clear(context); + }); +} diff --git a/frontend/src/lib/stores/shortcut-store.ts b/frontend/src/lib/stores/shortcut-store.ts new file mode 100644 index 00000000..6569814e --- /dev/null +++ b/frontend/src/lib/stores/shortcut-store.ts @@ -0,0 +1,49 @@ +import { writable, derived } from 'svelte/store'; + +export interface ShortcutDef { + key: string; // e.g., 'Ctrl+S' + description: string; + action: () => void; + group?: string; +} + +interface ShortcutState { + context: string; + shortcuts: ShortcutDef[]; +} + +function createShortcutStore() { + const { subscribe, set, update } = writable({ + context: 'Global', + shortcuts: [] + }); + + return { + subscribe, + /** + * Register local shortcuts for the current view. + * Call this on mount (or $effect). + */ + register: (context: string, shortcuts: ShortcutDef[]) => { + set({ context, shortcuts }); + }, + /** + * Clear shortcuts (on unmount) + * Only clear if the context matches (prevent clearing new page's shortcuts during transition) + */ + clear: (contextToClear?: string) => { + update(state => { + // If specific context is provided, only clear if it matches current + if (contextToClear && state.context !== contextToClear) { + return state; + } + return { context: 'Global', shortcuts: [] }; + }); + } + }; +} + +export const shortcutStore = createShortcutStore(); + +// Derived store to help UI display active shortcuts +export const activeShortcuts = derived(shortcutStore, ($state) => $state); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index f69244ba..69b575c0 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,6 +4,8 @@ import { Toaster } from 'svelte-sonner'; import { page } from '$app/stores'; import { handleApiError } from '$lib/utils/error-handler'; + import KeyboardManager from '$lib/components/keyboard/KeyboardManager.svelte'; + let { children } = $props(); @@ -21,4 +23,8 @@ + + + console.log('Global Search Focused')} /> + {@render children?.()} diff --git a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte index f6d5f3a2..04a35476 100644 --- a/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte +++ b/frontend/src/routes/dashboard/goods/fixed-asset-classes/+page.svelte @@ -419,6 +419,31 @@ toast.error('Error al eliminar la clase'); } } + + // Keyboard Shortcuts + import { useShortcuts } from '$lib/hooks/use-shortcuts'; + + useShortcuts('Goods / Classes', [ + { + key: 'Alt+Shift+N', + description: 'New Class', + action: () => { + handleNew(); + validationError = ''; + showInsertDialog = true; + } + }, + { + key: 'Alt+Shift+R', + description: 'Refresh', + action: handleRefresh + }, + { + key: 'Alt+Shift+D', + description: 'Delete', + action: handleDelete + } + ]);
diff --git a/frontend/src/routes/dashboard/goods/parts/+page.svelte b/frontend/src/routes/dashboard/goods/parts/+page.svelte index ea3f4bb0..ce9533e6 100644 --- a/frontend/src/routes/dashboard/goods/parts/+page.svelte +++ b/frontend/src/routes/dashboard/goods/parts/+page.svelte @@ -5,12 +5,14 @@ import { Plus, RefreshCw, Package } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import { partsApi, type Part } from '$lib/api/dashboard/a76/parts'; - import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; + import { clientsProvidersApi } from '$lib/api/dashboard/a76/clients-providers'; import { companyStore } from '$lib/stores/company.svelte'; + import { useShortcuts } from '$lib/hooks/use-shortcuts'; + import { goto } from '$app/navigation'; // Estado de la lista de partes let parts = $state([]); - let clientsMap = $state>({}); // Mapa ID -> Nombre + let clientsMap = $state>({}); // Mapa ID -> Nombre let selectedPart = $state(null); let isLoading = $state(false); let searchPartNumber = $state(''); @@ -22,24 +24,26 @@ const filteredParts = $derived( parts.filter((p) => { // Filtro por número de parte - const matchesPartNumber = !searchPartNumber || - p.part_number.toLowerCase().includes(searchPartNumber.toLowerCase()); - + const matchesPartNumber = + !searchPartNumber || p.part_number.toLowerCase().includes(searchPartNumber.toLowerCase()); + // Filtro por descripción (español o inglés) - const matchesDescription = !searchDescription || + const matchesDescription = + !searchDescription || (p.description_spanish?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false) || (p.description_english?.toLowerCase().includes(searchDescription.toLowerCase()) ?? false); - + // Filtro por cliente (Busca en nombre o ID) - const clientName = clientsMap[p.client_id] || ''; - const matchesClient = !searchClient || + const clientName = clientsMap[p.client_id] || ''; + const matchesClient = + !searchClient || (p.client_id?.toString().includes(searchClient) ?? false) || - clientName.toLowerCase().includes(searchClient.toLowerCase()); - + clientName.toLowerCase().includes(searchClient.toLowerCase()); + // Filtro por clase - const matchesClass = !searchClass || - (p.part_class?.toLowerCase().includes(searchClass.toLowerCase()) ?? false); - + const matchesClass = + !searchClass || (p.part_class?.toLowerCase().includes(searchClass.toLowerCase()) ?? false); + return matchesPartNumber && matchesDescription && matchesClient && matchesClass; }) ); @@ -53,43 +57,38 @@ }); async function loadData() { - await Promise.all([loadParts(), loadClients()]); - } + await Promise.all([loadParts(), loadClients()]); + } - async function loadClients() { - const companyId = companyStore.activeCompany?.id; - if (!companyId) return; + async function loadClients() { + const companyId = companyStore.activeCompany?.id; + if (!companyId) return; - try { - // Fetch all clients/providers to ensure we map "both" types as well - const response = await clientsProvidersApi.list( - companyId, - 1, - 1000 - ); - - const data = (response as any).data || response; - const items = data.items || []; - - const map: Record = {}; - items.forEach((c: any) => { - map[c.id] = c.name; - }); - clientsMap = map; + try { + // Fetch all clients/providers to ensure we map "both" types as well + const response = await clientsProvidersApi.list(companyId, 1, 1000); - } catch (e) { - console.error("Error cargando clientes:", e); - } - } + const data = (response as any).data || response; + const items = data.items || []; + + const map: Record = {}; + items.forEach((c: any) => { + map[c.id] = c.name; + }); + clientsMap = map; + } catch (e) { + console.error('Error cargando clientes:', e); + } + } async function loadParts() { const companyId = companyStore.activeCompany?.id; - if (!companyId) { + if (!companyId) { return; } isLoading = true; - try { + try { const response = await partsApi.list({ company_id: companyId, page: 1, @@ -116,32 +115,52 @@ toast.success('Partes actualizadas'); } - async function handleDelete() { if (!selectedPart) { toast.error('Selecciona una parte para borrar'); return; } - const confirmed = window.confirm(`¿Estás seguro de que deseas eliminar la parte ${selectedPart.part_number}? Esta acción no se puede deshacer.`); - if (!confirmed) return; + const confirmed = window.confirm( + `¿Estás seguro de que deseas eliminar la parte ${selectedPart.part_number}? Esta acción no se puede deshacer.` + ); + if (!confirmed) return; - isLoading = true; - try { - const companyId = companyStore.activeCompany?.id; - if (!companyId) return; + isLoading = true; + try { + const companyId = companyStore.activeCompany?.id; + if (!companyId) return; - await partsApi.delete(selectedPart.id, companyId); - toast.success('Parte eliminada exitosamente'); - selectedPart = null; - await loadData(); - } catch (e) { - console.error("Error al eliminar:", e); - toast.error('Error al eliminar la parte'); - } finally { - isLoading = false; - } + await partsApi.delete(selectedPart.id, companyId); + toast.success('Parte eliminada exitosamente'); + selectedPart = null; + await loadData(); + } catch (e) { + console.error('Error al eliminar:', e); + toast.error('Error al eliminar la parte'); + } finally { + isLoading = false; + } } + + // Keyboard Shortcuts + useShortcuts('Goods / Parts', [ + { + key: 'Alt+Shift+N', + description: 'New Part', + action: () => goto('/dashboard/goods/parts/edit') + }, + { + key: 'Alt+Shift+R', + description: 'Refresh List', + action: handleRefresh + }, + { + key: 'Alt+Shift+D', + description: 'Delete Selected', + action: handleDelete + } + ]);
@@ -169,11 +188,7 @@
- +
@@ -185,19 +200,11 @@
- +
- +
@@ -218,155 +225,190 @@
- -
- - - - - - - - - - - - - - {#if isLoading} + +
+
- - Número de ParteDescripciónClienteClaseU.M.Fracción
+ - + + + + + + + - {:else if filteredParts.length === 0} - - - - {:else} - {#each filteredParts as part (part.id)} - selectPart(part)} - > - - - - - - - + + + {#if isLoading} + + - {/each} - {/if} - -
Cargando... + + Número de ParteDescripciónClienteClaseU.M.Fracción
- No hay partes registradas -
- - - - {part.part_number} - - {part.description_spanish || ''} -
- {clientsMap[part.client_id] || 'Cargando...'} -
-
- {#if part.part_class} - - {part.part_class} - - {:else} - - - {/if} - {part.unit_of_measure || '-'}{part.fraction || '-'}
Cargando...
+ {:else if filteredParts.length === 0} + + + No hay partes registradas + + + {:else} + {#each filteredParts as part (part.id)} + selectPart(part)} + > + + + + + + {part.part_number} + + + {part.description_spanish || ''} + +
+ {clientsMap[part.client_id] || 'Cargando...'} +
+ + + {#if part.part_class} + + {part.part_class} + + {:else} + - + {/if} + + {part.unit_of_measure || '-'} + {part.fraction || '-'} + + {/each} + {/if} + + +
- -
-
-

Número de Parte

-

- {selectedPart?.part_number || '---'} -

-
+
+
+

+ Número de Parte +

+

+ {selectedPart?.part_number || '---'} +

+
-
- {#if selectedPart} -
-
- -

{selectedPart.description_spanish || 'Sin descripción'}

-
-
- -

{selectedPart.description_english || 'No translation available'}

-
-
- -
-
- -
- - {clientsMap[selectedPart.client_id] || selectedPart.client_id} -
-
-
- - {selectedPart.part_class || '-'} -
-
- -
-
- - {selectedPart.unit_of_measure || '-'} -
-
- - {selectedPart.unit_weight || '-'} -
-
- -
- -

- {selectedPart.fraction || '0000.00.00'} +

+ {#if selectedPart} +
+
+ +

+ {selectedPart.description_spanish || 'Sin descripción'}

+
+ +

+ {selectedPart.description_english || 'No translation available'} +

+
+
- {#if selectedPart.unit_cost} -
- -

- ${selectedPart.unit_cost} {selectedPart.currency_key || 'USD'} -

+
+
+ +
+ + {clientsMap[selectedPart.client_id] || selectedPart.client_id}
- {/if} - {:else} -
- -

Selecciona una parte para ver sus detalles

+
+
+ + {selectedPart.part_class || '-'} +
+
+ +
+
+ + {selectedPart.unit_of_measure || '-'} +
+
+ + {selectedPart.unit_weight || '-'} +
+
+ +
+ +

+ {selectedPart.fraction || '0000.00.00'} +

+
+ + {#if selectedPart.unit_cost} +
+ +

+ ${selectedPart.unit_cost} + {selectedPart.currency_key || 'USD'} +

{/if} -
+ {:else} +
+ +

Selecciona una parte para ver sus detalles

+
+ {/if}
+
-
+
@@ -374,8 +416,15 @@ Nueva Parte - - + +
-
\ No newline at end of file +
diff --git a/start.sh b/start.sh index c5c0b66a..f3e1a213 100755 --- a/start.sh +++ b/start.sh @@ -81,7 +81,7 @@ DEBUG=True ENVIRONMENT=development NODE_ENV=development VITE_API_URL=http://localhost:8000/api -VITE_KEYCLOAK_URL=http://localhost:8080 +VITE_KEYCLOAK_URL=http://localhost:8080/kcauth EOF echo -e "${GREEN}✓ Archivo .env creado con valores por defecto${NC}" fi From afba0332b1d8e114ceff943daa42c2a41db9646e Mon Sep 17 00:00:00 2001 From: Kevin_Ramirez Date: Tue, 3 Feb 2026 13:06:56 -0600 Subject: [PATCH 002/102] feat: implementacion inicial descargo de peps --- .../v1/modules/a76/items/line_items/models.py | 5 + .../exportacion/aviso_consolidado/__init__.py | 0 .../exportacion/aviso_consolidado/task.py | 11 + .../reports/exportacion/descargo/routes.py | 52 ++++ .../reports/exportacion/descargo/service.py | 291 ++++++++++++++++++ .../a76/reports/exportacion/descargo/task.py | 49 +++ .../descargo/templates/descarga.html | 179 +++++++++++ .../importacion/consolidados/mex/service.py | 2 +- .../importacion/facturas/mex/service.py | 2 +- backend/api/v1/modules/a76/router.py | 7 + backend/core/celery_app.py | 3 +- .../a76/reports/reports-aviso-consolidado.ts | 35 +++ .../dashboard/a76/reports/reports-descargo.ts | 36 +++ .../invoices/pdf-progress-dialog.svelte | 4 +- .../routes/dashboard/invoices/+page.svelte | 31 +- 15 files changed, 701 insertions(+), 6 deletions(-) create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/descargo/service.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/descargo/task.py create mode 100644 backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html create mode 100644 frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts create mode 100644 frontend/src/lib/api/dashboard/a76/reports/reports-descargo.ts diff --git a/backend/api/v1/modules/a76/items/line_items/models.py b/backend/api/v1/modules/a76/items/line_items/models.py index a7603d11..5192f46f 100644 --- a/backend/api/v1/modules/a76/items/line_items/models.py +++ b/backend/api/v1/modules/a76/items/line_items/models.py @@ -193,3 +193,8 @@ class LineItem(Base, TenantScopedMixin, TimestampMixin): cascade="all, delete-orphan", uselist=False, ) + part_info: Mapped[Optional["api.v1.modules.a76.parts.models.Part"]] = relationship( + "api.v1.modules.a76.parts.models.Part", + foreign_keys=[part_number], + viewonly=True, + ) diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py new file mode 100644 index 00000000..b1ff3adc --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/aviso_consolidado/task.py @@ -0,0 +1,11 @@ + +from celery import shared_task +import time + +@shared_task(bind=True, name="generate_aviso_consolidado_pdf_task") +def generate_aviso_consolidado_pdf_task(self, invoice_id: int, company_id: int): + """ + Tarea de Celery para generar el PDF del Aviso Consolidado. + Por ahora es un stub hasta que el servicio esté implementado. + """ + raise NotImplementedError("El servicio de Aviso Consolidado aún no está implementado") diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py new file mode 100644 index 00000000..5c8ee8bc --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/routes.py @@ -0,0 +1,52 @@ + +from fastapi import APIRouter, Depends, BackgroundTasks, HTTPException +from fastapi.responses import JSONResponse, Response +from sqlalchemy.orm import Session +from typing import Dict, Any + +from core.database import get_core_db as get_db +from core.security import get_current_user +from .task import generate_descarga_pdf_task +from celery.result import AsyncResult + +router = APIRouter() + +@router.post("/{invoice_id}/download-async") +async def trigger_descarga_generation( + invoice_id: int, + company_id: int, + current_user: Any = Depends(get_current_user) +): + """ + Inicia la generación del reporte de Descarga PEPS en segundo plano (Celery). + Retorna el task_id para polling. + """ + try: + # Lanza la tarea de Celery + task = generate_descarga_pdf_task.delay(invoice_id, company_id) + return {"task_id": task.id, "status": "processing"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + +@router.get("/tasks/{task_id}") +async def get_task_status(task_id: str, current_user: Any = Depends(get_current_user)): + """ + Consulta el estado de la tarea de Celery. + """ + task_result = AsyncResult(task_id) + + response = { + "task_id": task_id, + "state": task_result.state, + "result": None, + "info": None + } + + if task_result.state == 'FAILURE': + response["result"] = str(task_result.result) + elif task_result.state == 'SUCCESS': + response["result"] = task_result.result + elif task_result.state == 'PROCESSING': + response["info"] = task_result.info + + return response diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py new file mode 100644 index 00000000..9b004101 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/service.py @@ -0,0 +1,291 @@ + +import shutil +import base64 +import pdfkit +from pathlib import Path +from typing import Tuple, List, Callable, Optional, Dict, Any +from jinja2 import Environment, FileSystemLoader, select_autoescape +from fastapi import HTTPException +from sqlalchemy.orm import Session +from pydantic import BaseModel +from decimal import Decimal + +# --- MODELOS (Imported from system for Header info) --- +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.items.line_items.models import LineItem +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.pedmientos.models import Pedimentos +from sqlalchemy.orm import joinedload, load_only + +# --- SCHEMAS FOR TEMPLATE CONTEXT --- + +class DischargeItemSchema(BaseModel): + # Column 1: Pedimento Info + pedimento_numero: str + pedimento_clave: str + pedimento_fecha_pago: str + + # Column 2: Import Invoice + factura_impo: str + + # Column 3: Part Info + numero_parte: str + descripcion: str + fraccion: str + origen_pref_sector: str # e.g. "CHN-GENERAL" + + # Metrics + cantidad: str + unidad_medida: str + peso_neto: str + + # Values + valor_mn: str + valor_me: str + valor_igi: str + + # Flags + se_pago: str # "0.0" or "Yes"? Image says "0.0" in column "Se Pago"? No, "Se Pago" might be a flag, image key implies payment. + # Image: "Se Pago" column has "0.0"? No, look closer. + # "Value/Monto IGI USD/Dolares" has "0.0". + # "Se Pago" column seems empty or has '1'? + # Wait, looking at image: + # Col: "Se Pago", Row: "0.0"? No that's IGI. + # Let's assume Se Pago is a boolean/string. + # Last col: "Linea Expo". + + se_pago_val: str + linea_expo: str + + # Helper for Jinja (if methods not allowed in pydantic models in template) + def __init__(self, **data): + super().__init__(**data) + +class DischargeContext(BaseModel): + items: List[DischargeItemSchema] + invoice_number: str + company_name: str + company_address: str + company_rfc: str + company_immex: str + + # Totals + total_cantidad: str + total_peso: str + total_valor_mn: str + total_valor_me: str + total_igi: str + +class DescargaReportService: + def formatear_numero(self, valor, decimales: int = 2): + if valor is None: return "0.00" + try: + return "{:,.{}f}".format(float(valor), decimales) + except: return "0.00" + + def __init__(self): + self.template_dir = Path(__file__).parent / "templates" + self.jinja_env = Environment( + loader=FileSystemLoader(self.template_dir), + autoescape=select_autoescape(['html', 'xml']) + ) + self.template = self.jinja_env.get_template('descarga.html') + + def _get_wkhtmltopdf_config(self): + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" or "/usr/bin/wkhtmltopdf" + if not Path(path).exists(): + raise RuntimeError("wkhtmltopdf no encontrado.") + return pdfkit.configuration(wkhtmltopdf=path) + + def obtener_datos(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> DischargeContext: + try: + if progress_callback: progress_callback(10, "Buscando factura...") + + # Fetch Header for basic info + header = db.query(InvoiceHeader).filter(InvoiceHeader.id == invoice_id, InvoiceHeader.company_id == company_id).first() + if not header: + raise HTTPException(status_code=404, detail="Factura no encontrada") + + company = db.query(Company).filter(Company.id == company_id).first() + + if progress_callback: progress_callback(30, "Procesando descargas...") + + # --- REAL IMPLEMENTATION --- + # 1. Fetch Export Lines with FA Data + export_lines = db.query(LineItem).filter( + LineItem.item_id == Item.id, + Item.invoice_id == invoice_id + ).options( + joinedload(LineItem.fa_data), + joinedload(LineItem.quantity).load_only(LineQuantity.quantity, LineQuantity.net_weight), + joinedload(LineItem.customs), + joinedload(LineItem.description), + joinedload(LineItem.unit_of_measure_info), + joinedload(LineItem.part_info), # Fetch Part Relation + # joinedload(LineItem.item).joinedload(Item.invoice) # Removed due to missing relationship + ).join(Item).all() + + # 2. Collect unique Import Invoices to bulk fetch + # fa_data.search_invoice stores the "FacturaImpo" number + import_inv_nums = set() + for line in export_lines: + if line.fa_data and line.fa_data.search_invoice: + import_inv_nums.add(line.fa_data.search_invoice) + + # Map InvoiceNumber -> (InvoiceHeader, Pedimento) + import_map = {} + if import_inv_nums: + # We need to find the invoices. Warning: search_invoice is just a string number. + # potentially non-unique across companies, but we filter by current Company. + imp_invoices = db.query(InvoiceHeader).filter( + InvoiceHeader.invoice_number.in_(import_inv_nums), + InvoiceHeader.company_id == company_id, + InvoiceHeader.invoice_type == 'Ingreso' # Assuming Imports are Ingreso/Import + ).options( + joinedload(InvoiceHeader.compliance_mx) + ).all() + + # Fetch Pedimentos for these invoices + ped_ids = {inv.compliance_mx.pedimento_id for inv in imp_invoices if inv.compliance_mx and inv.compliance_mx.pedimento_id} + peds = db.query(Pedimentos).filter(Pedimentos.id.in_(ped_ids)).all() + ped_map = {p.id: p for p in peds} + + for inv in imp_invoices: + ped = None + if inv.compliance_mx and inv.compliance_mx.pedimento_id: + ped = ped_map.get(inv.compliance_mx.pedimento_id) + import_map[inv.invoice_number] = (inv, ped) + + items = [] + + for line in export_lines: + # Defaults + ped_str = "" + ped_clave = "" + ped_fecha = "" + fac_impo = "" + se_pago = "" + valor_igi = 0.0 + + # Linkage + if line.fa_data and line.fa_data.search_invoice: + fac_impo = line.fa_data.search_invoice + if fac_impo in import_map: + inv_imp, ped_imp = import_map[fac_impo] + + if ped_imp: + ped_str = f"{ped_imp.pedimento_number}" + ped_clave = f"{ped_imp.pedimento_code}" + # Format date if exists + # Simple date fallback from header if needed or Pedimento Date logic (revisit model if needed) + pass + + # Calculation logic (Prorate) + qty = float(line.quantity.quantity) if line.quantity else 0.0 + + valor_me = 0.0 + valor_mn = 0.0 + + # Create Schema + items.append(DischargeItemSchema( + pedimento_numero=ped_str, + pedimento_clave=ped_clave, + pedimento_fecha_pago=ped_fecha, + factura_impo=fac_impo, + numero_parte=line.part_info.part_number if hasattr(line, 'part_info') and line.part_info else (str(line.part_number) if line.part_number else "S/N"), + descripcion=line.description.description_spanish if line.description else "S/D", + fraccion=line.customs.fraction if line.customs else "", + origen_pref_sector=f"{line.customs.origin_country or ''} - {line.customs.sector or ''}" if line.customs else "", + cantidad=self.formatear_numero(qty, 3), + unidad_medida=line.unit_of_measure_info.code if line.unit_of_measure_info else "PZA", + peso_neto=self.formatear_numero(float(line.quantity.net_weight) if line.quantity else 0.0, 3), + valor_mn=self.formatear_numero(valor_mn), + valor_me=self.formatear_numero(valor_me), + valor_igi=self.formatear_numero(valor_igi), + se_pago=se_pago or "NO", + se_pago_val=se_pago, + linea_expo=str(line.line_number) + )) + + # Totals + + # Company Address Construction + addr_str = "DIRECCION NO REGISTRADA" + immex_val = "" + + if company: + # Address Logic + if company.addresses: + # Prefer 'main' address, otherwise take the first one + main_addr = next((a for a in company.addresses if a.address_type == 'main'), company.addresses[0]) + + parts = [] + if main_addr.street: parts.append(main_addr.street) + if main_addr.exterior_number: parts.append(f"No. {main_addr.exterior_number}") + if main_addr.neighborhood: parts.append(main_addr.neighborhood) + if main_addr.city: parts.append(main_addr.city) + if main_addr.state: parts.append(main_addr.state) + if main_addr.postal_code: parts.append(f"CP {main_addr.postal_code}") + + if parts: + addr_str = ", ".join(parts) + + # IMMEX Logic + if company.program and "IMMEX" in company.program and company.program_number: + immex_val = company.program_number + + # Calculate Totals + t_cant = sum(float(i.cantidad.replace(",","")) for i in items if i.cantidad) + t_peso = sum(float(i.peso_neto.replace(",","")) for i in items if i.peso_neto) + t_mn = sum(float(i.valor_mn.replace(",","")) for i in items if i.valor_mn) + t_me = sum(float(i.valor_me.replace(",","")) for i in items if i.valor_me) + t_igi = sum(float(i.valor_igi.replace(",","")) for i in items if i.valor_igi) + + return DischargeContext( + items=items, + invoice_number=header.invoice_number or "SIN FOLIO", + company_name=company.name if company else "EMPRESA DESCONOCIDA", + company_address=addr_str, + company_rfc=company.rfc if company else "", + company_immex=immex_val, + total_cantidad=self.formatear_numero(t_cant, 3), + total_peso=self.formatear_numero(t_peso, 3), + total_valor_mn=self.formatear_numero(t_mn), + total_valor_me=self.formatear_numero(t_me), + total_igi=self.formatear_numero(t_igi) + ) + + except Exception as e: + print(f"Error Service Discharge Report: {e}") + raise HTTPException(status_code=500, detail=f"Error: {str(e)}") + + def generar_pdf(self, db: Session, invoice_id: int, company_id: int, progress_callback: Optional[Callable] = None) -> Tuple[bytes, str, str]: + if progress_callback: progress_callback(5, "Iniciando servicio de reporte...") + + datos = self.obtener_datos(db, invoice_id, company_id, progress_callback) + + if progress_callback: progress_callback(80, "Renderizando plantilla...") + + context = datos.model_dump() + html_content = self.template.render(**context) + nombre = f"Descarga_{datos.invoice_number}.pdf" + + if progress_callback: progress_callback(90, "Generando PDF final...") + + options = { + 'page-size': 'Letter', + 'orientation': 'Landscape', # Correct argument for wkhtmltopdf + 'margin-top': '0.5in', + 'margin-right': '0.5in', + 'margin-bottom': '0.5in', + 'margin-left': '0.5in', + 'encoding': "UTF-8" + } + + pdf = pdfkit.from_string(html_content, False, options=options, configuration=self._get_wkhtmltopdf_config()) + + if progress_callback: progress_callback(100, "Completado") + return pdf, nombre, "application/pdf" diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/task.py b/backend/api/v1/modules/a76/reports/exportacion/descargo/task.py new file mode 100644 index 00000000..aa9d7b5e --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/task.py @@ -0,0 +1,49 @@ + +from celery import shared_task +from sqlalchemy.orm import Session +from core.database import CoreSessionLocal as SessionLocal +from .service import DescargaReportService +import base64 +import traceback + +@shared_task(bind=True, name="generate_descarga_pdf_task") +def generate_descarga_pdf_task(self, invoice_id: int, company_id: int): + """ + Tarea de Celery para generar el PDF del Reporte de Descarga + """ + db: Session = SessionLocal() + try: + service = DescargaReportService() + + def update_progress(percent, message): + self.update_state( + state='PROCESSING', + meta={'current': percent, 'total': 100, 'status': message} + ) + + pdf_bytes, filename, content_type = service.generar_pdf( + db=db, + invoice_id=invoice_id, + company_id=company_id, + progress_callback=update_progress + ) + + # Retornar el PDF en base64 para que el front lo descargue + pdf_b64 = base64.b64encode(pdf_bytes).decode('utf-8') + + return { + "status": "success", + "file_name": filename, + "content": pdf_b64, + "media_type": content_type, + "message": "Reporte generado correctamente" + } + + except Exception as e: + self.update_state( + state='FAILURE', + meta={'exc_type': type(e).__name__, 'exc_message': str(e)} + ) + raise e + finally: + db.close() diff --git a/backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html b/backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html new file mode 100644 index 00000000..bb5b2c64 --- /dev/null +++ b/backend/api/v1/modules/a76/reports/exportacion/descargo/templates/descarga.html @@ -0,0 +1,179 @@ + + + + + + Descarga de Factura {{ invoice_number }} + + + + + + + + + + + + +
+ DESCARGA DE LA FACTURA: {{ invoice_number }} + + {{ company_name }}
+ {{ company_address }}
+ R.F.C.: {{ company_rfc }}, IMMEX: {{ company_immex }} +
+ Page/Página: Of/de +
+ +
La factura se descargo de:
+ + + + + + + + + + + + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + + + + {% endfor %} + + + + + + + + + + + + + + + + + + + + + + + + + + +
No. Pedimento
Clave Fecha de Pago
Import Invoice/
Factura de Impo.
Part Number/No. de Parte Componente
Description/Descripción + (Origen-Prefer.-Sector)
Quantity/
Cantidad U.M.
Net Weight/
Peso Neto (KGS)
Value/Valor M.N.
MXP/Pesos
Value/Valor M.E.
USD/Dolares
Value/Monto IGI
USD/Dolares
Se
Pagó
Linea Expo
Expo Line
Comp. Temporales:
+ {{ item.pedimento_numero }}
+ {{ item.pedimento_clave }}    {{ item.pedimento_fecha_pago }} +
{{ item.factura_impo }} + {{ item.numero_parte }}
+ {{ item.descripcion }}
+ {{ item.fraccion }}
{{ item.origen_pref_sector }}
+
+ {{ item.cantidad }} {{ item.unidad_medida }} + {{ item.peso_neto }}{{ item.valor_mn }}{{ item.valor_me }}{{ item.valor_igi }}{{ item.se_pago }}{{ item.linea_expo }}
Totales de los Comp. Temporales:{{ total_cantidad }}{{ total_peso }}{{ total_valor_mn }}{{ total_valor_me }}{{ total_igi }}
TOTALES:{{ total_cantidad }}{{ total_peso }}{{ total_valor_mn }}{{ total_valor_me }}{{ total_igi }}
+ + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py index 34cbc9ef..c84043b1 100644 --- a/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/consolidados/mex/service.py @@ -61,7 +61,7 @@ class ConsolidadoImportacionMexService: self.template = self.jinja_env.get_template("cons_mex_ver.html") def _get_wkhtmltopdf_config(self): - path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" or "/usr/bin/wkhtmltopdf" if not Path(path).exists(): raise RuntimeError("wkhtmltopdf no encontrado.") return pdfkit.configuration(wkhtmltopdf=path) diff --git a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py index 619ae2f5..5ad9c5b5 100644 --- a/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py +++ b/backend/api/v1/modules/a76/reports/importacion/facturas/mex/service.py @@ -97,7 +97,7 @@ class FacturaImportacionMexService: return title def _get_wkhtmltopdf_config(self): - path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" + path = shutil.which("wkhtmltopdf") or "/usr/local/bin/wkhtmltopdf" or "/usr/bin/wkhtmltopdf" if not Path(path).exists(): raise RuntimeError("wkhtmltopdf no encontrado.") return pdfkit.configuration(wkhtmltopdf=path) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 24220d6d..4eb5e96b 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -130,4 +130,11 @@ router.include_router( consolidated_reports_router, prefix="/a76/reports/importacion/consolidados", tags=["a76 / reports"] +) + +from .reports.exportacion.descargo.routes import router as discharge_reports_router +router.include_router( + discharge_reports_router, + prefix="/a76/reports/exportacion/descargo", + tags=["a76 / reports"] ) \ No newline at end of file diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index c118db31..91207908 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -10,7 +10,8 @@ celery_app = Celery( backend=valkey_url, include=[ "api.v1.modules.a76.reports.importacion.facturas.task", - "api.v1.modules.a76.reports.importacion.consolidados.task" + "api.v1.modules.a76.reports.importacion.consolidados.task", + "api.v1.modules.a76.reports.exportacion.descargo.task" ] # Ruta al módulo donde están las tareas ) diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts new file mode 100644 index 00000000..363b1daf --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-aviso-consolidado.ts @@ -0,0 +1,35 @@ + +const BASE_URL = import.meta.env.VITE_API_URL || ''; + +export const avisoConsolidadoReportsApi = { + + triggerPdfGeneration: async (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/aviso_consolidado/${invoiceId}/download-async?${params.toString()}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) throw new Error('Error al iniciar la generación del Aviso Consolidado'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string) => { + const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/aviso_consolidado/tasks/${taskId}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Error al consultar estado del Aviso Consolidado'); + return await response.json(); + } +}; diff --git a/frontend/src/lib/api/dashboard/a76/reports/reports-descargo.ts b/frontend/src/lib/api/dashboard/a76/reports/reports-descargo.ts new file mode 100644 index 00000000..493653fe --- /dev/null +++ b/frontend/src/lib/api/dashboard/a76/reports/reports-descargo.ts @@ -0,0 +1,36 @@ + +const BASE_URL = import.meta.env.VITE_API_URL || ''; + +export const dischargeReportsApi = { + + triggerPdfGeneration: async (invoiceId: number, companyId: number) => { + const params = new URLSearchParams({ company_id: companyId.toString() }); + // Endpoint matches routes.py + const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/descargo/${invoiceId}/download-async?${params.toString()}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'POST', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) throw new Error('Error al iniciar la generación del Reporte de Descarga'); + return await response.json(); + }, + + getTaskStatus: async (taskId: string) => { + const endpoint = `${BASE_URL}/v1/a76/reports/exportacion/descargo/tasks/${taskId}`; + + const token = localStorage.getItem('access_token'); + const response = await fetch(endpoint, { + method: 'GET', + headers: { 'Authorization': `Bearer ${token}` } + }); + + if (!response.ok) throw new Error('Error al consultar estado del Reporte de Descarga'); + return await response.json(); + } +}; diff --git a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte index 36fc7e38..ee6e7d10 100644 --- a/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/pdf-progress-dialog.svelte @@ -63,9 +63,9 @@ } else if (response.state === 'FAILURE') { hasError = true; - statusMessage = "Error al generar el PDF"; + statusMessage = response.result ? `Error: ${response.result}` : "Error al generar el PDF"; stopPolling(); - toast.error("Falló la generación del PDF"); + toast.error("Falló la generación del PDF: " + (response.result || "")); } } catch (error) { console.error("Error polling task status:", error); diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 29fa17a9..63e76f31 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -6,6 +6,7 @@ import { invoicesApi, type Invoice, type OperationType } from '$lib/api/dashboard/a76/invoices'; import { invoicesReportsApi } from '$lib/api/dashboard/a76/reports/reports-invoices'; import { consolidatedReportsApi } from '$lib/api/dashboard/a76/reports/reports-consolidated'; + import { dischargeReportsApi } from '$lib/api/dashboard/a76/reports/reports-descargo'; import DataTable from '$lib/components/dashboard/invoices/data-table.svelte'; import { createColumns } from '$lib/components/dashboard/invoices/columns.js'; import * as Card from '$lib/components/ui/card'; @@ -15,7 +16,7 @@ import type { PageData } from './$types'; import { browser } from '$app/environment'; import { companyStore } from '$lib/stores/company.svelte'; - import { Plus, RefreshCw, FileText, RotateCcw, Boxes } from 'lucide-svelte'; + import { Plus, RefreshCw, FileText, RotateCcw, Boxes, ClipboardList } from 'lucide-svelte'; // IMPORTANTE: Asegúrate de tener instalada svelte-sonner para las notificaciones import { toast } from "svelte-sonner"; @@ -376,6 +377,30 @@ } } + async function handleDownloadDescargo(invoice: any) { + if (!companyStore.activeCompany) { + toast.error("No hay empresa seleccionada"); + return; + } + + try { + // 1. Trigger: Iniciar la tarea en Celery + const { task_id } = await dischargeReportsApi.triggerPdfGeneration( + invoice.id, + companyStore.activeCompany.id + ); + + // 2. Abrir diálogo de progreso + currentTaskId = task_id; + currentStatusFunction = dischargeReportsApi.getTaskStatus; + showProgressDialog = true; + + } catch (error) { + console.error(error); + toast.error("No se pudo iniciar la descarga del reporte PEPS"); + } + } + async function handleDownloadConsolidated(invoice: any) { if (!companyStore.activeCompany) { toast.error("No hay empresa seleccionada"); @@ -656,6 +681,10 @@ Consolidado +
From b7a99d739334a7838064bf04003e7293dff7ff10 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 3 Feb 2026 13:27:06 -0600 Subject: [PATCH 003/102] Feature/Integracion de navegacion free mouse a pedimentos --- .../pedimentos/edit/general-tab-form.svelte | 22 +- .../edit/other-data-tab-form.svelte | 761 ++++++++------- .../keyboard/KeyboardManager.svelte | 18 +- frontend/src/lib/config/shortcuts.ts | 11 +- frontend/src/lib/stores/shortcut-store.ts | 44 +- .../routes/dashboard/pedimentos/+page.svelte | 89 +- .../pedimentos/edit/[id]/+page.svelte | 884 ++++++++++-------- .../customs_sections/+page.svelte | 40 +- .../payment_methods/+page.svelte | 40 +- .../pedimento_codes/+page.svelte | 40 +- .../pedimento_regimens/+page.svelte | 40 +- 11 files changed, 1130 insertions(+), 859 deletions(-) diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte index 774be895..3e02dd97 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/general-tab-form.svelte @@ -22,6 +22,7 @@ getCurrentLocalDate, getCurrentLocalTime } from '$lib/date-utils'; + import { useShortcuts } from '$lib/hooks/use-shortcuts'; let { pedimento, @@ -31,7 +32,8 @@ customsSections = [], customsBrokers = [], clients = [], - codePedimentoRegimens = [] + codePedimentoRegimens = [], + isActive = false }: { pedimento: Pedimento | null; formData?: any; @@ -41,11 +43,29 @@ customsBrokers?: CustomsBroker[]; clients?: ClientProvider[]; codePedimentoRegimens?: CodePedimentoRegimen[]; + isActive?: boolean; } = $props(); // Estado para controlar la sección activa de la navegación let activeSection = $state('fechas'); + import { shortcutStore } from '$lib/stores/shortcut-store'; + $effect(() => { + if (isActive) { + shortcutStore.register('General Tab Navigation', [ + { key: 'Alt+Digit1', description: 'Ir a Fechas', action: () => activeSection = 'fechas' }, + { key: 'Alt+Digit2', description: 'Ir a Incrementables', action: () => activeSection = 'incrementables' }, + { key: 'Alt+Digit3', description: 'Ir a Identificadores', action: () => activeSection = 'identificadores' }, + { key: 'Alt+Digit4', description: 'Ir a Indices', action: () => activeSection = 'indices' }, + { key: 'Alt+Digit5', description: 'Ir a Adicional', action: () => activeSection = 'adicional' }, + { key: 'Alt+Digit6', description: 'Ir a Decrementable', action: () => activeSection = 'decrementable' }, + ]); + return () => { + shortcutStore.clear('General Tab Navigation'); + }; + } + }); + // Exchange Rate Check State let showExchangeRateDialog = $state(false); let missingExchangeRateDate = $state(""); diff --git a/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte b/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte index 471efbce..b5e0d1d9 100644 --- a/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/pedimentos/edit/other-data-tab-form.svelte @@ -42,6 +42,7 @@ Package, Search } from 'lucide-svelte'; + import { useShortcuts } from '$lib/hooks/use-shortcuts'; interface OtrosDatosFormData { // Parámetros de cálculo @@ -140,12 +141,38 @@ recargo_cc: false, factor_incrementable_manual: '0.00000000000000000000', agregar_entidad_federativa_proveedor: false - }) + }), + isActive = false }: { pedimento?: any; formData: OtrosDatosFormData; + isActive?: boolean; } = $props(); + import { shortcutStore } from '$lib/stores/shortcut-store'; + $effect(() => { + if (isActive) { + shortcutStore.register('Otros Tab Navigation', [ + { key: 'Alt+Digit1', description: 'Ir a Cálculos', action: handleCalculos }, + { key: 'Alt+Digit2', description: 'Ir a Bitácora', action: handleBitacora }, + { key: 'Alt+Digit3', description: 'Ir a Partes II', action: handlePatesII }, + { key: 'Alt+Digit4', description: 'Ir a Rectificación', action: handleRectificacion }, + { key: 'Alt+Digit5', description: 'Ir a Rectificación II', action: handleRectificacionII }, + { key: 'Alt+Digit6', description: 'Ir a Notas', action: handleNotas }, + { + key: 'Alt+Digit7', + description: 'Ir a Sel. Automatizada', + action: handleSeleccionAutomatizada + }, + { key: 'Alt+Digit8', description: 'Exp. Documentos', action: handleExpDocumentos }, + { key: 'Alt+Digit9', description: 'Ir a Multas', action: handleMultas } + ]); + return () => { + shortcutStore.clear('Otros Tab Navigation'); + }; + } + }); + // Estados para vista de Bitácora let showBitacora = $state(false); let currentBitacoraPage = $state(0); @@ -156,7 +183,7 @@ let currentPartesIIPage = $state(0); let partesIIPageSize = 10; let selectedEmbarques = $state([]); - + // Estados para diálogo de embarque let isEmbarqueDialogOpen = $state(false); let editingEmbarqueIndex = $state(null); @@ -227,7 +254,16 @@ }); // Variable para rastrear vista activa - let activeView = $state<'calculos' | 'bitacora' | 'partesII' | 'rectificacion' | 'rectificacionII' | 'notas' | 'seleccionAutomatizada' | 'multas'>('calculos'); + let activeView = $state< + | 'calculos' + | 'bitacora' + | 'partesII' + | 'rectificacion' + | 'rectificacionII' + | 'notas' + | 'seleccionAutomatizada' + | 'multas' + >('calculos'); // Estados para vista de Rectificación let showRectificacion = $state(false); @@ -242,9 +278,9 @@ utilizar_fecha_pago_original: false, calculo_manual_contribuciones: false }); - let liquidacionDiferencias = $state< - { gravamen: string; forma_pago: string; importe: string }[] - >([]); + let liquidacionDiferencias = $state<{ gravamen: string; forma_pago: string; importe: string }[]>( + [] + ); // Estados para diálogo de diferencias en contribuciones let isDiferenciasDialogOpen = $state(false); @@ -333,9 +369,7 @@ } ]); - const totalBitacoraPages = $derived( - Math.ceil(bitacoraMovimientos.length / bitacoraPageSize) - ); + const totalBitacoraPages = $derived(Math.ceil(bitacoraMovimientos.length / bitacoraPageSize)); const paginatedBitacoraMovimientos = $derived( bitacoraMovimientos.slice( @@ -363,10 +397,7 @@ const totalNotasPages = $derived(Math.ceil(notasPedimento.length / notasPageSize)); const visibleNotas = $derived( - notasPedimento.slice( - currentNotasPage * notasPageSize, - (currentNotasPage + 1) * notasPageSize - ) + notasPedimento.slice(currentNotasPage * notasPageSize, (currentNotasPage + 1) * notasPageSize) ); function handleCalculos() { @@ -703,386 +734,383 @@
{#if !showBitacora && !showPartesII && !showRectificacion && !showRectificacionII && !showNotas && !showSeleccionAutomatizada && !showMultas} - - - Opciones para el cálculo del Pedimento - - - -
- -
-

Parámetros de cálculo:

-
-
- - { - if (formData && v) formData.tipo_calculo = v; - }} - > - - - {formData?.tipo_calculo === 'ninguno' - ? 'Ninguno' - : formData?.tipo_calculo === 'Cuota fija' - ? 'Cuota fija' - : formData?.tipo_calculo === '8 al millar' - ? '8 al millar' + + + Opciones para el cálculo del Pedimento + + + +
+ +
+

Parámetros de cálculo:

+
+
+ + { + if (formData && v) formData.tipo_calculo = v; + }} + > + + + {formData?.tipo_calculo === 'ninguno' + ? 'Ninguno' + : formData?.tipo_calculo === 'Cuota fija' + ? 'Cuota fija' + : formData?.tipo_calculo === '8 al millar' + ? '8 al millar' : formData?.tipo_calculo === '1.76 al millar' - ? '1.76 al millar' - : formData?.tipo_calculo === 'Estados extranjeros' - ? 'Estados extranjeros' - : 'Seleccionar'} - - - - Ninguno - Cuota fija - 8 al millar - 1.76 al millar - Estados extranjeros - - + ? '1.76 al millar' + : formData?.tipo_calculo === 'Estados extranjeros' + ? 'Estados extranjeros' + : 'Seleccionar'} + + + + Ninguno + Cuota fija + 8 al millar + 1.76 al millar + Estados extranjeros + + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
-
- - -
+
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - +
+ + +
+ +
+ + +
-
-
- - -
+ +
+

Parámetros de Pedimento:

+
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
- -
- - +
+ + +
- -
-

Parámetros de Pedimento:

-
-
- - -
+ +
+ +
+

+ Parámetros para actualización en Pedimento Normal: +

+
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - +
+ + +
+ +
+ + +
-
-
- -
- -
-

Parámetros para actualización en Pedimento Normal:

-
-
- - -
+ +
+

Parámetros de actualización en Rectificación:

+
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - -
+
+ + +
-
- - +
+ + +
+ +
+ + +
- -
-

Parámetros de actualización en Rectificación:

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
- + +
+ +
+

Parámetro de cambio de DTA:

-
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + +
+

+ Contribuciones tomadas para el Recargo en Pedimento Normal: +

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
-
- -
- -
-

Parámetro de cambio de DTA:

+ +
+
+ + +
+
-
- -
-
- - -
- -
- - -
- -
- - -
-
- - -
-

- Contribuciones tomadas para el Recargo en Pedimento Normal: -

-
-
- - -
- -
- - -
- -
- - -
- -
- - -
- -
- - -
-
-
-
- - -
-
- - -
- -
- - -
-
- - - + + {:else if showBitacora} @@ -1185,7 +1213,6 @@
- {:else if showPartesII}
@@ -1235,7 +1262,9 @@ onclick={() => { const globalIndex = currentPartesIIPage * partesIIPageSize + index; if (selectedEmbarques.includes(globalIndex)) { - selectedEmbarques = selectedEmbarques.filter((i) => i !== globalIndex); + selectedEmbarques = selectedEmbarques.filter( + (i) => i !== globalIndex + ); } else { selectedEmbarques = [...selectedEmbarques, globalIndex]; } @@ -1454,8 +1483,7 @@
- + - +
@@ -1520,7 +1543,9 @@
-

Cuadro de liquidación para diferencias en contribuciones

+

+ Cuadro de liquidación para diferencias en contribuciones +

- @@ -1916,7 +1946,12 @@ Exp. Documentos - diff --git a/frontend/src/lib/components/keyboard/KeyboardManager.svelte b/frontend/src/lib/components/keyboard/KeyboardManager.svelte index 2d1c6eb5..40451bf1 100644 --- a/frontend/src/lib/components/keyboard/KeyboardManager.svelte +++ b/frontend/src/lib/components/keyboard/KeyboardManager.svelte @@ -1,7 +1,7 @@ @@ -116,9 +134,7 @@

Secciones Aduanales

-

- Gestiona las secciones aduanales del sistema -

+

Gestiona las secciones aduanales del sistema

diff --git a/frontend/src/routes/dashboard/reference_data/payment_methods/+page.svelte b/frontend/src/routes/dashboard/reference_data/payment_methods/+page.svelte index a4800ec3..9c36f501 100644 --- a/frontend/src/routes/dashboard/reference_data/payment_methods/+page.svelte +++ b/frontend/src/routes/dashboard/reference_data/payment_methods/+page.svelte @@ -1,6 +1,9 @@ @@ -116,9 +134,7 @@

Métodos de Pago

-

- Gestiona las formas de pago disponibles en el sistema -

+

Gestiona las formas de pago disponibles en el sistema

diff --git a/frontend/src/routes/dashboard/reference_data/pedimento_codes/+page.svelte b/frontend/src/routes/dashboard/reference_data/pedimento_codes/+page.svelte index 3761f69a..4765b083 100644 --- a/frontend/src/routes/dashboard/reference_data/pedimento_codes/+page.svelte +++ b/frontend/src/routes/dashboard/reference_data/pedimento_codes/+page.svelte @@ -1,18 +1,22 @@ @@ -116,9 +134,7 @@

Claves de Pedimento

-

- Gestiona las claves de pedimento del sistema aduanero -

+

Gestiona las claves de pedimento del sistema aduanero

diff --git a/frontend/src/routes/dashboard/reference_data/pedimento_regimens/+page.svelte b/frontend/src/routes/dashboard/reference_data/pedimento_regimens/+page.svelte index e636522f..a0341c7a 100644 --- a/frontend/src/routes/dashboard/reference_data/pedimento_regimens/+page.svelte +++ b/frontend/src/routes/dashboard/reference_data/pedimento_regimens/+page.svelte @@ -1,6 +1,9 @@ @@ -116,9 +134,7 @@

Regímenes de Pedimento

-

- Gestiona los regímenes aduaneros de pedimento -

+

Gestiona los regímenes aduaneros de pedimento

From 0dd9f96f5b8551efbb89a6a0d800d103035deff3 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 3 Feb 2026 16:16:30 -0600 Subject: [PATCH 004/102] feature/nav-tab-pediments-and-goods --- .../dashboard/goods/parts/partForm.svelte | 1814 +++++++++-------- .../edit/contributions-tab-form.svelte | 41 +- .../pedimentos/edit/general-tab-form.svelte | 28 +- .../edit/other-data-tab-form.svelte | 63 +- .../keyboard/KeyboardManager.svelte | 110 +- .../shortcuts/contributions-tab-shortcuts.ts | 11 + .../config/shortcuts/general-tab-shortcuts.ts | 36 + .../config/shortcuts/goods-edit-shortcuts.ts | 19 + .../config/shortcuts/goods-list-shortcuts.ts | 13 + .../config/shortcuts/goods-shortcuts.ts.bak | 73 + .../config/shortcuts/other-data-shortcuts.ts | 27 + .../shortcuts/pedimento-edit-shortcuts.ts | 25 + .../shortcuts/pedimento-list-shortcuts.ts | 29 + frontend/src/lib/stores/focus-store.ts | 22 + frontend/src/lib/stores/shortcut-store.ts | 16 +- .../routes/api-sveltekit/classes/+server.ts | 12 +- .../src/routes/api-sveltekit/parts/+server.ts | 12 +- .../api-sveltekit/tariff-fractions/+server.ts | 8 +- .../api-sveltekit/units-of-measure/+server.ts | 12 +- .../routes/dashboard/goods/parts/+page.svelte | 20 +- .../routes/dashboard/pedimentos/+page.svelte | 32 +- .../pedimentos/edit/[id]/+page.svelte | 45 +- frontend/vite.config.ts | 6 +- 23 files changed, 1453 insertions(+), 1021 deletions(-) create mode 100644 frontend/src/lib/config/shortcuts/contributions-tab-shortcuts.ts create mode 100644 frontend/src/lib/config/shortcuts/general-tab-shortcuts.ts create mode 100644 frontend/src/lib/config/shortcuts/goods-edit-shortcuts.ts create mode 100644 frontend/src/lib/config/shortcuts/goods-list-shortcuts.ts create mode 100644 frontend/src/lib/config/shortcuts/goods-shortcuts.ts.bak create mode 100644 frontend/src/lib/config/shortcuts/other-data-shortcuts.ts create mode 100644 frontend/src/lib/config/shortcuts/pedimento-edit-shortcuts.ts create mode 100644 frontend/src/lib/config/shortcuts/pedimento-list-shortcuts.ts create mode 100644 frontend/src/lib/stores/focus-store.ts diff --git a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte index 2555cf86..1c21e023 100644 --- a/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/parts/partForm.svelte @@ -1,904 +1,912 @@ - - -
-
-
-
- -

{title}

- - {isEdit ? "Editar" : "Nueva"} - -
-

- {formType === 'fa' ? 'Gestión de Activo Fijo' : 'Gestión de Inventario'} -

-
-
- - - - {#if error} -
- ⚠️ {error!} -
- {/if} - -
- - {#if formType === 'fa'} -
{ e.preventDefault(); handleSubmit(); }} class="space-y-6"> - - - - -
- - -
-
- -
- - -
-
-
- -
-
- - showClientModal = true} class="pl-9 cursor-pointer hover:bg-muted/50 transition-colors" placeholder="Seleccione un cliente..."/> -
- -
-
-
- -
-
- - + + +
+
+
+ + {#if line?.quantity} + + {/if} +
+
+ + {#if line?.quantity} + + {/if} +
+
+ +
+
+ + {#if line?.quantity} + + {/if} +
+
+ + {#if line?.quantity} + + {/if} +
+
+ +
+
+ + {#if line?.financial} + + {/if} +
+
+ + +
+
+ + +
+
+ + {#if line?.quantity} + + {/if} +
+
+ + {#if line?.quantity} + + {/if} +
+
+ +
+
+ + {#if line?.quantity} + + {/if} +
+
+ + {#if line?.quantity} + + {/if} +
+
-
- - + - - - - - - + + +
+
+ + {#if line?.description} + + {/if} +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ +
+ + {#if line?.description} + + {/if} +
+
+
+ +
+ + diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 785a6bb3..e94caead 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -2,15 +2,31 @@ import * as Table from '$lib/components/ui/table'; import * as Dialog from '$lib/components/ui/dialog'; import { Button } from '$lib/components/ui/button'; - import { Plus, Pencil, Trash2, Loader2 } from 'lucide-svelte'; + import { Input } from '$lib/components/ui/input'; + import { Textarea } from '$lib/components/ui/textarea'; + import { Badge } from '$lib/components/ui/badge'; + import { + Plus, + Pencil, + Trash2, + Loader2, + Save as SaveIcon, + Sparkles, + Search, + PackageOpen, + Calendar, + LayoutTemplate, + X + } from 'lucide-svelte'; import { toast } from 'svelte-sonner'; import type { Invoice } from '$lib/api/dashboard/a76/invoices'; import { itemsApi, type Item } from '$lib/api/dashboard/a76/items'; import { companyStore } from '$lib/stores/company.svelte'; import ItemSheetFa from './fa/item-sheet-fa.svelte'; import ItemSheetInv from './inv/item-sheet-inv.svelte'; - import { useShortcuts } from '$lib/hooks/use-shortcuts'; - import { obtenerAtajosListaPartidas } from '$lib/config/shortcuts/dashboard/invoices/item/list'; + import { itemPresetsApi, type ItemPreset } from '$lib/api/dashboard/a76/item-presets'; + import { Checkbox } from '$lib/components/ui/checkbox'; + import { cleanLineData } from '$lib/utils/items-logic'; let { invoice, @@ -22,40 +38,103 @@ exists?: boolean; } = $props(); - let imported = 0; - let net_weight = 0; - let gross_weight = 0; - + // 1. Core State let items = $state([]); let displayedItems = $state([]); + let imported = $state(0); + let net_weight = $state(0); + let gross_weight = $state(0); let itemsPerPage = 20; + let isLoadingItems = $state(false); + let isLoadingMore = $state(false); let currentPage = $state(1); - // Aplanar items en líneas para la tabla - const flattenedLines = $derived( - items.flatMap((item) => - (item.lines || []).map((line) => ({ - ...line, - item_id: item.id, - reference_number: item.reference_number, - order: item.order, - warehouse: item.warehouse, - location: item.location, - full_item: item - })) - ) - ); - let tableContainer: HTMLDivElement | undefined = $state(); - let isLoadingMore = $state(false); - let isLoadingItems = $state(false); + // 2. Preset State + let presets = $state([]); + let selectedPreset = $state(null); + let searchPresets = $state(''); + let isLoadingPresets = $state(false); + let isApplyingPreset = $state(false); + let showUsePresetDialog = $state(false); + // Preset Creation State + let showCreatePresetDialog = $state(false); + let createPresetName = $state(''); + let createPresetDescription = $state(''); + let builderItems: Item[] = $state([]); + let isSavingPreset = $state(false); let isSaving = $state(false); + let isTargetingPreset = $state(false); + let editingBuilderIndex = $state(null); - // Sheet states + // 3. Selection & Filtering State + let selectedLineIds = $state([]); + let tableContainer = $state(); + + // 4. Derived Values (Ordered correctly to avoid TDZ) + const flattenedLines = $derived.by(() => { + const sourceItems = items?.length ? items : formData?.items || []; + return (sourceItems || []).flatMap((item: any, itemIndex: number) => { + const lines = item?.lines || []; + return lines.map((line: any, idx: number) => ({ + ...line, + id: line?.id || `${item?.id || itemIndex}-line-${line?.line_number ?? idx + 1}`, + line_number: line?.line_number ?? idx + 1, + reference_number: line?.reference_number ?? item?.reference_number, + is_subitem: line?.is_subitem ?? false, + class_code: line?.class_code ?? line?.class_id, + class_description: + line?.class_description || + line?.description?.description_spanish || + line?.description?.description_english || + '', + unit_of_measure_code: line?.quantity?.unit_of_measure || line?.unit_of_measure, + fa_data: line?.fa_data || {}, + warehouse: line?.warehouse || item?.warehouse, + full_item: item + })); + }); + }); + + const isAllSelected = $derived( + flattenedLines.length > 0 && selectedLineIds.length === flattenedLines.length + ); + + const sourceItemsForPreset = $derived.by(() => { + if (selectedLineIds.length === 0) return []; + return (items || formData.items || []).filter((item: any) => + item.lines?.some((line: any) => + selectedLineIds.includes(line.id || `${item.id}-line-${line.line_number}`) + ) + ); + }); + + const invoiceSystem = $derived(invoice?.system || 'scaii'); + const invoiceLabel = $derived.by(() => { + if (invoice?.invoice_number) return `Factura ${invoice.invoice_number}`; + if (invoice?.id) return `Factura ${invoice.id}`; + return 'Factura nueva'; + }); + + const filteredPresets = $derived.by(() => { + const term = searchPresets.trim().toLowerCase(); + if (!term) return presets; + return presets.filter( + (p: ItemPreset) => + p.name.toLowerCase().includes(term) || p.description?.toLowerCase().includes(term) + ); + }); + + const selectedPresetItems = $derived(selectedPreset?.items || []); + const selectedPresetCount = $derived(selectedPresetItems.length); + const activeCompanyId = $derived(companyStore?.activeCompany?.id); + const formItemsCount = $derived(formData?.items?.length || 0); + + // 5. Form/Sheet State let showItemSheet = $state(false); let isEditMode = $state(false); let showDeleteDialog = $state(false); let selectedItem = $state(null); - let originalItemData = $state | null>(null); // Guardar estado original para cancelar + let originalItemData = $state | null>(null); let editingItem = $state>({ invoice_id: undefined, reference_number: '', @@ -63,20 +142,48 @@ warehouse: '', location: '' }); + let builderDraft = $state({ + description: '', + quantity: 1, + unit_cost_usd: 0, + reference_number: '' + }); - // Determinar el tipo de sistema (SCAF o SCAII) - const invoiceSystem = $derived(invoice?.system || 'scaii'); // Por defecto SCAII si no se especifica + // 6. Effects + $effect(() => { + currentPage = 1; + displayedItems = flattenedLines.slice(0, itemsPerPage); + }); - // Derived value para company ID - const activeCompanyId = $derived(companyStore.activeCompany?.id); - - // Cargar items cuando la factura tenga ID $effect(() => { if (invoice?.id && activeCompanyId) { loadItems(); } }); + $effect(() => { + if (invoice?.id && activeCompanyId && formItemsCount > 0) { + loadItems(); + } + }); + + // 7. Functions + function toggleSelectAll() { + if (isAllSelected) { + selectedLineIds = []; + } else { + selectedLineIds = flattenedLines.map((l) => l.id.toString()); + } + } + + function toggleSelectLine(id: string) { + if (selectedLineIds.includes(id)) { + selectedLineIds = selectedLineIds.filter((i) => i !== id); + } else { + selectedLineIds = [...selectedLineIds, id]; + } + } + async function loadItems() { if (!invoice?.id || !activeCompanyId) return; @@ -123,8 +230,8 @@ } function handleAdd() { - // Validar que la factura esté guardada (tiene ID) - if (!invoice?.id) { + // Validar que la factura esté guardada (tiene ID) si no estamos en modo plantilla + if (!showCreatePresetDialog && !invoice?.id) { toast.warning('Factura no guardada', { description: 'Debes guardar la factura primero antes de agregar partidas.', duration: 5000 @@ -133,6 +240,8 @@ } isEditMode = false; + isTargetingPreset = showCreatePresetDialog; + editingBuilderIndex = null; showItemSheet = true; // Auto-asignar valores desde la factura con estructura completa editingItem = { @@ -214,6 +323,235 @@ }; } + async function loadPresets(force = false) { + if (!activeCompanyId) return; + if (!force && isLoadingPresets) return; + isLoadingPresets = true; + try { + const response = await itemPresetsApi.list(activeCompanyId); + presets = response.data || []; + if (selectedPreset) { + selectedPreset = presets.find((p) => p.id === selectedPreset?.id) || null; + } + } catch (error) { + console.error('Error loading presets:', error); + toast.error('No se pudieron cargar las plantillas'); + } finally { + isLoadingPresets = false; + } + } + + function openUsePresetDialog() { + if (!invoice?.id) { + toast.warning('Primero guarda la factura para usar plantillas.'); + return; + } + showUsePresetDialog = true; + if (!presets.length) void loadPresets(); + } + + function openCreatePresetDialog() { + builderItems = []; + createPresetName = ''; + createPresetDescription = ''; + isTargetingPreset = false; + editingBuilderIndex = null; + showCreatePresetDialog = true; + } + + function handleEditInPreset(index: number) { + const itemToEdit = builderItems[index]; + isEditMode = true; + isTargetingPreset = true; + editingBuilderIndex = index; + editingItem = normalizeItemData(JSON.parse(JSON.stringify(itemToEdit))); + showItemSheet = true; + } + + function handleRemoveFromPreset(index: number) { + builderItems = builderItems.filter((_: any, i: number) => i !== index); + } + + function saveItemToPreset() { + // Sanitizar datos para la plantilla + const cleanedItem = JSON.parse(JSON.stringify(editingItem)); + + // Limpiar líneas para asegurar que son compatibles + if (cleanedItem.lines) { + cleanedItem.lines = cleanedItem.lines.map((line: any) => ({ + ...cleanLineData(line), + id: undefined // Las plantillas no deben tener IDs reales + })); + } + + if (editingBuilderIndex !== null) { + // Update existing item in builder + builderItems[editingBuilderIndex] = cleanedItem; + toast.success('Partida actualizada en la plantilla'); + } else { + // Add new item to builder + builderItems = [...builderItems, cleanedItem]; + toast.success('Partida agregada a la plantilla'); + } + + showItemSheet = false; + isTargetingPreset = false; + editingBuilderIndex = null; + } + + function sanitizeLineForPreset(line: any) { + const { id, item_id, created_at, updated_at, temp_id, ...rest } = line || {}; + return cleanLineData({ ...rest }); + } + + function cloneItemForPreset(item: Item) { + const { id, tenant_id, company_id, created_at, updated_at, temp_id, ...rest } = item as any; + return { + ...rest, + id: undefined, + invoice_id: undefined, + lines: (item.lines || []).map(sanitizeLineForPreset) + }; + } + + function buildManualItem(draft: any, index: number) { + return { + id: undefined, + temp_id: undefined, + invoice_id: undefined, + reference_number: draft.reference_number || undefined, + lines: [ + cleanLineData({ + line_number: index + 1, + description: { + description_spanish: draft.description || 'Sin descripción' + }, + quantity: { + quantity: Number(draft.quantity) || 0 + }, + financial: { + unit_cost_usd: + draft.unit_cost_usd !== null ? Number(draft.unit_cost_usd) || 0 : undefined + } + }) + ] + }; + } + + function handleAddManualItem() { + if (!builderDraft.description.trim()) { + toast.warning('Agrega una descripción para la partida'); + return; + } + builderItems = [...builderItems, { ...builderDraft }]; + builderDraft = { + description: '', + quantity: 1, + unit_cost_usd: 0, + reference_number: '' + }; + } + + async function applySelectedPreset() { + if (!selectedPreset) { + toast.warning('Selecciona una plantilla para aplicarla.'); + return; + } + if (!selectedPreset.items?.length) { + toast.warning('Esta plantilla no tiene partidas.'); + return; + } + + // Inject into active sheet if open + if (showItemSheet) { + const presetLines = selectedPreset.items.flatMap((item: any) => item.lines || []); + const cleanedNewLines = presetLines.map((line: any) => ({ + ...sanitizeLineForPreset(line), + id: undefined // Force new IDs + })); + + editingItem.lines = [...(editingItem.lines || []), ...cleanedNewLines]; + toast.success('Líneas inyectadas en la partida actual'); + showUsePresetDialog = false; + return; + } + + if (!invoice?.id || !activeCompanyId) { + toast.warning('Primero guarda la factura para usar plantillas.'); + return; + } + + isApplyingPreset = true; + try { + const payloads = selectedPreset.items.map((item) => ({ + ...cloneItemForPreset(item), + invoice_id: invoice.id + })); + + await Promise.all(payloads.map((payload) => itemsApi.create(activeCompanyId, payload))); + await loadItems(); + toast.success('Plantilla aplicada a la factura'); + showUsePresetDialog = false; + } catch (error) { + console.error('Error applying preset:', error); + toast.error('No se pudo aplicar la plantilla'); + } finally { + isApplyingPreset = false; + } + } + + async function saveCurrentItemsAsPreset() { + if (!createPresetName.trim()) { + toast.warning('Asigna un nombre a la plantilla'); + return; + } + if (!activeCompanyId) return; + + if (builderItems.length === 0) { + toast.warning('No hay partidas o líneas para guardar como plantilla'); + return; + } + + isSavingPreset = true; + try { + // We group everything as ONE Partida Template for injection + const lines = builderItems.flatMap((item: Item, idx: number) => { + return (item.lines || []).map((line: any) => ({ + ...cleanLineData(line), + line_number: line.line_number || idx + 1, // Ensure line_number is present + id: undefined // Ensure no IDs are saved in the preset + })); + }); + + const payloadItems = [ + { + reference_number: builderItems[0]?.reference_number || undefined, + lines: lines + } + ] as any; + + await itemPresetsApi.create(activeCompanyId, { + name: createPresetName.trim(), + description: createPresetDescription.trim() || undefined, + items: payloadItems + }); + + createPresetName = ''; + createPresetDescription = ''; + builderItems = []; + selectedLineIds = []; + builderDraft = { description: '', quantity: 1, unit_cost_usd: 0, reference_number: '' }; + toast.success('Plantilla guardada correctamente'); + await loadPresets(true); + showCreatePresetDialog = false; + } catch (error) { + console.error('Error saving preset:', error); + toast.error('No se pudo guardar la plantilla'); + } finally { + isSavingPreset = false; + } + } + function handleEdit(lineData: any) { isEditMode = true; selectedItem = lineData.full_item; @@ -389,65 +727,6 @@ showDeleteDialog = true; } - // Helper function to check if an object has any meaningful values - function hasValues(obj: any): boolean { - if (!obj || typeof obj !== 'object') return false; - return Object.values(obj).some( - (val) => - val !== undefined && - val !== null && - val !== '' && - !(typeof val === 'object' && !hasValues(val)) - ); - } - - // Clean nested data before sending to API - function cleanLineData(line: any) { - const cleaned: any = { ...line }; - - // Helper function to convert to number or undefined - const toNumberOrUndefined = (value: any): number | undefined => { - if (value === undefined || value === null || value === '') { - return undefined; - } - const numValue = Number(value); - return !isNaN(numValue) && isFinite(numValue) ? numValue : undefined; - }; - - // Convert integer fields - cleaned.part_number = toNumberOrUndefined(cleaned.part_number); - cleaned.component_part_number = toNumberOrUndefined(cleaned.component_part_number); - cleaned.class_id = toNumberOrUndefined(cleaned.class_id); - cleaned.unit_of_measure = toNumberOrUndefined(cleaned.unit_of_measure); - cleaned.alternate_unit = toNumberOrUndefined(cleaned.alternate_unit); - - // Remove display-only fields - delete cleaned.class_code; - delete cleaned.class_unit_of_measure; - delete cleaned.class_description; - delete cleaned.part_number; - delete cleaned.part_description_es; - delete cleaned.part_description_en; - delete cleaned.unit_code; - delete cleaned.unit_description; - - // Remove display-only fields from nested objects - if (cleaned.customs) { - delete cleaned.customs.origin_country_name; - delete cleaned.customs.fraction_description; - } - - // Remove empty nested objects - if (!hasValues(cleaned.financial)) delete cleaned.financial; - if (!hasValues(cleaned.quantity)) delete cleaned.quantity; - if (!hasValues(cleaned.customs)) delete cleaned.customs; - if (!hasValues(cleaned.description)) delete cleaned.description; - if (!hasValues(cleaned.reference)) delete cleaned.reference; - if (!hasValues(cleaned.fa_data)) delete cleaned.fa_data; - - return cleaned; - } - async function saveNewItem() { if (!invoice?.id || !activeCompanyId) return; @@ -646,7 +925,9 @@ } // Si pasa la validación, continuar con el guardado - if (isEditMode) { + if (isTargetingPreset) { + saveItemToPreset(); + } else if (isEditMode) { saveEditedItem(); } else { saveNewItem(); @@ -687,24 +968,37 @@ // Cerrar el sheet showItemSheet = false; } - - useShortcuts( - 'Invoice Items List', - obtenerAtajosListaPartidas({ - manejarAgregar: handleAdd, - manejarActualizar: loadItems - }) - );
-
-

Items de la Factura

- +
+
+

Items de la Factura

+

+ Carga partidas, crea o aplica plantillas sin salir de esta vista. +

+
+
+ + + +
+ + + Línea P/S Clase @@ -748,10 +1045,20 @@ {item.warehouse || '-'}
- -
@@ -811,33 +1118,9 @@
- - -{#if invoiceSystem === 'fixed_asset'} - -{:else} - -{/if} - - + Confirmar Eliminación @@ -845,10 +1128,15 @@ - - + +
+
+ + +
+ +
+
+
+ + +
+
+ +
+ {#if isLoadingPresets} +
+ + Cargando... +
+ {:else if filteredPresets.length === 0} +
+ +

No se encontraron plantillas

+
+ {:else} + {#each filteredPresets as preset} + + {/each} + {/if} +
+
+ + +
+ {#if !selectedPreset} +
+
+ +
+

Selecciona una plantilla para ver sus detalles

+
+ {:else} +
+ +
+
+

+ {selectedPreset.name} +

+

+ {selectedPreset.description || 'Sin descripción disponible.'} +

+
+
+
+ Creada +
+
+ {selectedPreset.created_at + ? new Date(selectedPreset.created_at).toLocaleDateString(undefined, { + dateStyle: 'long' + }) + : '-'} +
+
+
+ + +
+ + + + # + Descripción del Item + Cant. + Costo (USD) + + + + {#if selectedPresetItems.length === 0} + + +
+ + Esta plantilla no contiene items. +
+
+
+ {:else} + {#each selectedPresetItems as item, i} + + + {i + 1} + + +
+ + {item.lines?.[0]?.description?.description_spanish || + 'Sin descripción'} + + {#if item.reference_number} + + REF: {item.reference_number} + + {/if} +
+
+ + {item.lines?.[0]?.quantity?.quantity || 0} + + + ${(item.lines?.[0]?.financial?.unit_cost_usd || 0).toLocaleString( + undefined, + { minimumFractionDigits: 2 } + )} + +
+ {/each} + {/if} +
+
+
+
+ {/if} +
+
+ + +
+ + +
+ + + + { + if (!open) { + createPresetName = ''; + createPresetDescription = ''; + } + }} +> + + + Crear plantilla + Guarda los elementos actuales como una plantilla reutilizable para inyectar en otras + partidas. + + +
+
+
+ + +
+
+ + @@ -445,5 +450,5 @@
- - + + From 13254bbcfca080d24cbd9876b18814d03b292454 Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 13 Feb 2026 12:46:47 -0600 Subject: [PATCH 091/102] feat: implement server API route to set active company in cookie and update setActiveCompany method --- frontend/src/lib/stores/company.svelte.ts | 19 ++++++++++-- .../company/set-active/+server.ts | 29 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 frontend/src/routes/api-sveltekit/company/set-active/+server.ts diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index 9f04bcdb..788afe61 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -120,7 +120,7 @@ class CompanyStore { * @param company - La compañía a establecer como activa * @param silent - Si es true, no dispara el evento companyChanged (para inicialización) */ - setActiveCompany(company: Company, silent: boolean = false) { + async setActiveCompany(company: Company, silent: boolean = false) { const previousCompanyId = this._activeCompany?.id; this._activeCompany = company; @@ -130,8 +130,21 @@ class CompanyStore { } // Guardar en cookie para acceso desde el servidor (SSR) - if (typeof document !== 'undefined') { - document.cookie = `active_company_id=${company.id}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`; + // Usar el endpoint del servidor para garantizar que la cookie esté disponible en SSR + if (browser) { + try { + await fetch('/api-sveltekit/company/set-active', { + method: 'POST', + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ companyId: company.id }), + credentials: 'include' + }); + console.log('Cookie set via server:', `active_company_id=${company.id}`); + } catch (error) { + console.error('Error setting active company cookie:', error); + } } // Despachar evento personalizado solo si: diff --git a/frontend/src/routes/api-sveltekit/company/set-active/+server.ts b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts new file mode 100644 index 00000000..3820adfa --- /dev/null +++ b/frontend/src/routes/api-sveltekit/company/set-active/+server.ts @@ -0,0 +1,29 @@ +/** + * API route para establecer la compañía activa en una cookie + */ +import { json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; + +export const POST: RequestHandler = async ({ cookies, request }) => { + try { + const { companyId } = await request.json(); + + if (!companyId || typeof companyId !== 'number') { + return json({ error: 'Invalid company ID' }, { status: 400 }); + } + + // Establecer la cookie desde el servidor + cookies.set('active_company_id', companyId.toString(), { + path: '/', + maxAge: 60 * 60 * 24 * 30, // 30 días + sameSite: 'lax', + httpOnly: false, // Permitir acceso desde JavaScript + secure: process.env.NODE_ENV === 'production' + }); + + return json({ success: true, companyId }); + } catch (error) { + console.error('Error setting active company:', error); + return json({ error: 'Internal server error' }, { status: 500 }); + } +}; From 794d6d178169097f31b3ecbf16713c0b58a0712e Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 13 Feb 2026 15:49:54 -0600 Subject: [PATCH 092/102] refactor: streamline item validation functions and enhance number formatting in summary section --- .../a76/items/common/common_validators.py | 43 ++++++++----------- .../imports/temporary/validators/common.py | 14 +++--- .../imports/temporary/validators/create.py | 18 ++++---- .../a76/items/line_descriptions/models.py | 3 +- .../a76/items/line_descriptions/schemas.py | 1 + .../edit/items/fa/summary-section.svelte | 32 ++++++++------ 6 files changed, 57 insertions(+), 54 deletions(-) diff --git a/backend/api/v1/modules/a76/items/common/common_validators.py b/backend/api/v1/modules/a76/items/common/common_validators.py index 652e79fc..06e0b449 100644 --- a/backend/api/v1/modules/a76/items/common/common_validators.py +++ b/backend/api/v1/modules/a76/items/common/common_validators.py @@ -3,36 +3,31 @@ from core.exceptions import ErrorCollector from ..line_items import models from sqlalchemy.orm import Session -def item_exists( - db: Session, - item_line: int, - tenant_id: int, - company_id: int -): + +def item_exists(db: Session, item_line: int, tenant_id: int, company_id: int): item_exists = ( - db.query(models.LineItem.id) + db.query(models.LineItem) .filter( models.LineItem.line_number == item_line, models.LineItem.tenant_id == tenant_id, models.LineItem.company_id == company_id, ) + .first() + ) + + return item_exists + + +def count_items(db: Session, invoice_id: int, tenant_id: int, company_id: int): + count = ( + db.query(func.count()) + .select_from(models.Item) + .filter( + models.Item.invoice_id == invoice_id, + models.Item.tenant_id == tenant_id, + models.Item.company_id == company_id, + ) .scalar() ) - if item_exists: - return item_exists - return None - -def count_items( - db: Session, - invoice_id: int, - tenant_id: int, - company_id: int -): - count = db.query(func.count()).select_from(models.Item).filter( - models.Item.invoice_id == invoice_id, - models.Item.tenant_id == tenant_id, - models.Item.company_id == company_id, - ).scalar() - - return count \ No newline at end of file + return count diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py index 47fd73d5..d10c95fd 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py @@ -42,9 +42,7 @@ def validate_common( invoice: InvoiceHeader = invoice_exists_by_id( db, invoice_id, tenant_id, company_id, errors ) - line_item: LineItem = item_exists( - db, line.line_number, tenant_id, company_id - ) + line_item: LineItem = item_exists(db, line.line_number, tenant_id, company_id) fecha_factura = invoice.invoice_date if invoice else None fraction = None @@ -181,13 +179,15 @@ def validate_common( fraction = line.customs.fraction if line.customs.fraction else fraction country = line.customs.origin_country - if line_item: + if line_item and line_item.customs: country = ( - line_item.customs.fraction if line_item.customs.origin_country else country + line_item.customs.origin_country + if line_item.customs.origin_country + else country ) fraction_type = line.customs.fraction_type.upper() - if line_item: + if line_item and line_item.customs: fraction_type = ( line_item.customs.fraction_type if line_item.customs.fraction_type @@ -195,7 +195,7 @@ def validate_common( ) sector = line.customs.sector - if line_item: + if line_item and line_item.customs: sector = line_item.customs.sector if line_item.customs.sector else sector country_m3 = db.query(Country.m3_key).filter(Country.m3_key == country).scalar() diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py index 8f1a0206..146a4278 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/create.py @@ -206,8 +206,8 @@ def validate_create( net_weight_input = line.quantity.net_weight or Decimal("0") # Determinar si la unidad de medida es de peso - unit_is_kgs = line.unit_of_measure and line.unit_of_measure.upper() == "KGS" - unit_is_lbs = line.unit_of_measure and line.unit_of_measure.upper() == "LB" + unit_is_kgs = line.unit_of_measure and line.unit_of_measure == "24" #KGS + unit_is_lbs = line.unit_of_measure and line.unit_of_measure == "25" #LBS # Calcular peso neto en kilogramos (estándar interno) if unit_is_kgs: @@ -237,11 +237,11 @@ def validate_create( package_weight_unit = Decimal("0") # Obtener peso unitario del bulto si existe - if line.quantity.package_key: + if line.quantity.package_id: package: Package = ( db.query(Package) .filter( - Package.key == line.quantity.package_key, + Package.id == line.quantity.package_id, Package.tenant_id == tenant_id, Package.company_id == company_id, ) @@ -278,22 +278,22 @@ def validate_create( # ========================================== # ASIGNAR DESCRIPCIÓN DE BULTOS # ========================================== - if package_quantity and package_quantity > 0 and line.quantity.package_key: + if package_quantity and package_quantity > 0 and line.quantity.package_id: package: Package = ( db.query(Package) .filter( - Package.key == line.quantity.package_key, + Package.id == line.quantity.package_id, Package.tenant_id == tenant_id, Package.company_id == company_id, ) .first() ) if package: - line.quantity.package_description = package.description_es + line.description.package_description = package.description_es else: line.quantity.package_quantity = 0 - line.quantity.package_key = None - line.quantity.package_description = None + line.quantity.package_id = None + line.description.package_description = None # ========================================== # ASIGNAR FRACCIÓN AMERICANA POR DEFECTO diff --git a/backend/api/v1/modules/a76/items/line_descriptions/models.py b/backend/api/v1/modules/a76/items/line_descriptions/models.py index 39e09560..d8c7ecbf 100644 --- a/backend/api/v1/modules/a76/items/line_descriptions/models.py +++ b/backend/api/v1/modules/a76/items/line_descriptions/models.py @@ -24,7 +24,8 @@ class LineDescription(Base): description_english: Mapped[Optional[str]] = mapped_column(String(4999)) # DESCRIPCIONI extra_description: Mapped[Optional[str]] = mapped_column(Text) # DESCRIPCIONEEXTRA part_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONPARTE - class_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONCLASE + class_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONCLASE + package_description: Mapped[Optional[str]] = mapped_column(String(500)) # DESCRIPCIONBULTO # Product attributes brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA diff --git a/backend/api/v1/modules/a76/items/line_descriptions/schemas.py b/backend/api/v1/modules/a76/items/line_descriptions/schemas.py index 56965c36..8a65d68e 100644 --- a/backend/api/v1/modules/a76/items/line_descriptions/schemas.py +++ b/backend/api/v1/modules/a76/items/line_descriptions/schemas.py @@ -13,6 +13,7 @@ class LineDescriptionBase(BaseModel): extra_description: Optional[str] = Field(None, description="Extra description (DESCRIPCIONEEXTRA)") part_description: Optional[str] = Field(None, max_length=500, description="Part description (DESCRIPCIONPARTE)") class_description: Optional[str] = Field(None, max_length=500, description="Class description (DESCRIPCIONCLASE)") + package_description: Optional[str] = Field(None, max_length=500, description="Package description (DESCRIPCIONBULTO)") # Product attributes brand: Optional[str] = Field(None, max_length=50, description="Brand (MARCA)") diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte index 27b66319..0320095a 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/summary-section.svelte @@ -2,6 +2,12 @@ import type { LineFinancials, LineQuantities } from '$lib/api/dashboard/a76/items'; let { financials = $bindable(), quantities = $bindable() }: { financials: LineFinancials; quantities: LineQuantities } = $props(); + + // Helper function to safely format numbers + function formatNumber(value: any, decimals: number = 8): string { + const num = Number(value); + return isNaN(num) ? '0.00000000' : num.toFixed(decimals); + }
@@ -10,18 +16,18 @@
RETURN QUANTITY SUB-ITEMS
-
Temporary: {quantities.quantity_temp_export?.toFixed(8) || '0.00000000'}
+
Temporary: {formatNumber(quantities.quantity_temp_export)}
Replacement or Change: 0.00000000
-
Definitive: {quantities.quantity_returned?.toFixed(8) || '0.00000000'}
-
Returned Values: {financials.value_returned_usd?.toFixed(8) || '0.00000000'}
-
Returned Values: {financials.value_returned_mxn?.toFixed(8) || '0.00000000'}
+
Definitive: {formatNumber(quantities.quantity_returned)}
+
Returned Values: {formatNumber(financials.value_returned_usd)}
+
Returned Values: {formatNumber(financials.value_returned_mxn)}
WEIGHTS (KILOS)
WEIGHTS (Pounds)
-
Net: {quantities.net_weight?.toFixed(8) || '0.00000000'}
+
Net: {formatNumber(quantities.net_weight)}
0.00000000
-
Whole: {quantities.gross_weight?.toFixed(8) || '0.00000000'}
+
Whole: {formatNumber(quantities.gross_weight)}
0.00000000
@@ -33,13 +39,13 @@
(Dollars)
(Pesos)
-
Cost: {financials.unit_cost_usd?.toFixed(8) || '0.00000000'}
-
{financials.unit_cost_mxn?.toFixed(8) || '0.00000000'}
-
Value: {financials.value_usd?.toFixed(8) || '0.00000000'}
-
{financials.value_mxn?.toFixed(8) || '0.00000000'}
-
Capture Cost: {financials.unit_cost_capture?.toFixed(8) || '0.00000000'} USD
-
Capture Value: {financials.value_usd?.toFixed(8) || '0.00000000'} USD
-
Customs Value: {financials.customs_value_usd?.toFixed(8) || '0.00000000'} USD
+
Cost: {formatNumber(financials.unit_cost_usd)}
+
{formatNumber(financials.unit_cost_mxn)}
+
Value: {formatNumber(financials.value_usd)}
+
{formatNumber(financials.value_mxn)}
+
Capture Cost: {formatNumber(financials.unit_cost_capture)} USD
+
Capture Value: {formatNumber(financials.value_usd)} USD
+
Customs Value: {formatNumber(financials.customs_value_usd)} USD
From e05e50f2b88e8e98847a2cb7275ba8af75482532 Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 13 Feb 2026 16:26:06 -0600 Subject: [PATCH 093/102] refactor: enhance invoice mapping functions and integrate PedimentosResponse DTO in catalog service --- .../modules/a76/pedmientos/catalog_service.py | 6 +- .../api/v1/modules/a76/pedmientos/schemas.py | 5 +- .../edit/items/fa/item-sheet-fa.svelte | 2 + .../dashboard/invoices/edit/[id]/+page.svelte | 190 +++++++++++++++++- 4 files changed, 190 insertions(+), 13 deletions(-) diff --git a/backend/api/v1/modules/a76/pedmientos/catalog_service.py b/backend/api/v1/modules/a76/pedmientos/catalog_service.py index b78bd8b8..13b4a01e 100644 --- a/backend/api/v1/modules/a76/pedmientos/catalog_service.py +++ b/backend/api/v1/modules/a76/pedmientos/catalog_service.py @@ -17,6 +17,7 @@ from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSec from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO +from .dtos.pedimentos import PedimentosResponse from .schemas import PedimentoCatalogsResponse, PedimentoCreationResponse, PedimentoEditionResponse @@ -111,10 +112,13 @@ class PedimentoCatalogService: if not pedimento: return None + + # Convert SQLAlchemy object to Pydantic DTO + pedimento_dto = PedimentosResponse.model_validate(pedimento) return PedimentoEditionResponse( **catalogs.model_dump(), is_create=False, - pedimento=pedimento, + pedimento=pedimento_dto, pedimento_id=pedimento_id ) diff --git a/backend/api/v1/modules/a76/pedmientos/schemas.py b/backend/api/v1/modules/a76/pedmientos/schemas.py index fb673d64..aeec5ee3 100644 --- a/backend/api/v1/modules/a76/pedmientos/schemas.py +++ b/backend/api/v1/modules/a76/pedmientos/schemas.py @@ -2,7 +2,7 @@ Consolidated schemas for Pedimento catalog responses """ -from typing import List, Optional, Any +from typing import List, Optional from pydantic import BaseModel # Import DTOs for catalog items @@ -11,6 +11,7 @@ from api.v1.modules.public.reference_data.customs_sections.dto import CustomsSec from api.v1.modules.public.reference_data.code_pedimento_regimens.dto import CodePedimentoRegimenDTO from api.v1.modules.a76.customs_brokers.dto import CustomsBrokerResponseDTO from api.v1.modules.a76.clients_and_providers.dto import ClientProviderResponseDTO +from .dtos.pedimentos import PedimentosResponse class PedimentoCatalogsResponse(BaseModel): @@ -33,5 +34,5 @@ class PedimentoEditionResponse(PedimentoCatalogsResponse): """Response for editing an existing pedimento (catalogs + pedimento data)""" is_create: bool = False - pedimento: Optional[Any] = None # Will be PedimentosResponse but avoiding circular import + pedimento: Optional[PedimentosResponse] = None pedimento_id: Optional[int] = None diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index 5bf87a1c..ea64e1f0 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -24,6 +24,7 @@ invoice, onSave, onCancel, + isTargetingPreset = false, isSaving = false }: { open: boolean; @@ -32,6 +33,7 @@ invoice: Invoice | null; onSave: () => void; onCancel?: () => void; + isTargetingPreset?: boolean; isSaving?: boolean; } = $props(); diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index 1e1d88b1..c76ab8f0 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -222,29 +222,199 @@ return { ...skeleton, ...filtered }; } + // Función para mapear la factura existente a los formData + function mapInvoiceToTopFields(invoice: any) { + if (!invoice) return topFieldsSkeleton; + + let operationType: string | null = null; + if (invoice.operation_type) { + operationType = invoice.operation_type; + } else if (data.filters?.operation_type !== undefined) { + operationType = data.filters.operation_type ?? null; + } + + return { + is_pedimento_pending: invoice.compliance_mx?.is_pedimento_pending || false, + pedimento_id: invoice.compliance_mx?.pedimento_id || '', + remesa: invoice.compliance_mx?.remesa || '', + invoice_number: invoice.invoice_number || '', + invoice_date: invoice.invoice_date || new Date().toISOString().split('T')[0], + emission_date: invoice.emission_date || new Date().toISOString().split('T')[0], + operation_type: operationType, + invoice_type: invoice.invoice_type || (data.filters?.invoice_type ?? ''), + fecha_pedimento_del: '', + fecha_pedimento_al: '', + clave_pedimento: '', + regimen_pedimento: '' + }; + } + + function mapInvoiceToGeneral(invoice: any) { + if (!invoice) return generalSkeleton; + + return { + provider_header: invoice.compliance_mx?.provider_header || 'proveedor', + provider_id: invoice.compliance_mx?.provider_id || null, + sold_to_header: invoice.compliance_mx?.sold_to_header || 'consignado_a', + sold_to_id: invoice.compliance_mx?.sold_to_id || null, + shipped_to_header: invoice.compliance_mx?.shipped_to_header || 'enviado_a', + shipped_to_id: invoice.compliance_mx?.shipped_to_id || null, + customs_broker_id: invoice.compliance_mx?.customs_broker_id || null, + customs_broker_us_id: invoice.compliance_mx?.customs_broker_us_id || null, + currency_type: invoice.financials?.currency_type || '', + currency: invoice.financials?.currency || 'foreign', + exchange_rate: invoice.financials?.exchange_rate || null, + weight_type: 'kgs', + iva_factor: invoice.financials?.iva_factor || null, + carrier_id: invoice.logistics?.carrier_id || null, + transport_id: invoice.logistics?.transport_id || '', + driver_name: invoice.logistics?.driver_name || '', + transport_type: invoice.logistics?.transport_type || '', + transport_num: invoice.logistics?.vehicle_num || '', + aduana: invoice.compliance_mx?.aduana || '', + document_type: invoice.document_type || '' + }; + } + + function mapInvoiceToObservations(invoice: any) { + if (!invoice) return observationSkeleton; + + return { + observation_es: invoice.observation_es || '', + observation_en: invoice.observation_en || '', + freight: invoice.financials?.freight || null, + insurance_value: invoice.financials?.insurance_value || null, + insurance: invoice.financials?.insurance || null, + packaging: invoice.financials?.packaging || null, + other_increments: invoice.financials?.other_increments || null, + total_increments_mn: invoice.financials?.total_increments_mn || null, + total_increments_me: invoice.financials?.total_increments_me || null, + incoterm: invoice.logistics?.incoterm || null, + enclosure: invoice.compliance_mx?.enclosure || null, + num_seals: null, + movement_type: invoice.compliance_mx?.movement_type || '', + alternate_invoice: invoice.alternate_invoice || '', + valuation_method: invoice.compliance_mx?.value_method || null + }; + } + + function mapInvoiceToItems(invoice: any) { + if (!invoice) return ensureItemsFormData(null); + + return { + items: invoice.items || [] + }; + } + + function mapInvoiceToOthers(invoice: any) { + if (!invoice) return othersSkeleton; + + return { + comments_status: invoice.comments_status || '', + transport_mode: invoice.logistics?.transport_mode || 'TRUCK', + is_mixed: invoice.compliance_mx?.is_mixed || false, + print_stamp: invoice.print_stamp || false, + rule_3121_parties_ii: invoice.compliance_mx?.rule_3121_parties_ii || false, + related_doc_id: invoice.related_doc_id || null, + code_signature: invoice.compliance_mx?.code_signature || '', + electronic_signature: invoice.compliance_mx?.electronic_signature || '', + mandatory_person: invoice.compliance_mx?.mandatory_person || '', + contingency_mode: invoice.compliance_mx?.contingency_mode || false, + cove: invoice.compliance_mx?.cove || '', + operation_num: invoice.compliance_mx?.operation_num || '', + adendas: invoice.compliance_mx?.adendas || '', + observations_vu: invoice.compliance_mx?.observations_vu || '', + certified_number: invoice.compliance_mx?.certified_number || '', + bill_number: invoice.logistics?.bill_number || '', + guide_number: invoice.logistics?.guide_number || '', + shipment_number: invoice.logistics?.shipment_number || '', + option_iv18: invoice.compliance_mx?.option_iv18 || '', + delivered_status: invoice.delivered_status || false, + received_by: invoice.received_by || '', + delivery_date: invoice.delivery_date || '' + }; + } + + function mapInvoiceToContinuation(invoice: any) { + if (!invoice) return continuationSkeleton; + + return { + numero_tipo_transporte: invoice.logistics?.numero_tipo_transporte || '', + es_ferrocarril: invoice.logistics?.es_ferrocarril || 'no', + numero_bl: invoice.logistics?.numero_bl || '', + cantidad_guias_embarque: invoice.logistics?.cantidad_guias_embarque || null, + destino_origen: invoice.logistics?.destino_origen || '', + puerto_entrada: invoice.logistics?.puerto_entrada || '', + vehicle_data: invoice.logistics?.vehicle_data || '', + fue_revisado_equipo: invoice.logistics?.fue_revisado_equipo || false, + sub_division: invoice.compliance_mx?.subdivision || false, + funge_como_cd: invoice.logistics?.acts_as_cd || false, + llego_pedimento: invoice.compliance_mx?.llego_pedimento || false, + errores_facturacion: invoice.errores_facturacion || [], + semaforo_verde_aduana_mexicana: invoice.compliance_mx?.semaforo_verde_aduana_mexicana || false, + semaforo_verde_aduana_americana: invoice.compliance_mx?.semaforo_verde_aduana_americana || false, + semaforo_rojo_aduana_mexicana: invoice.compliance_mx?.semaforo_rojo_aduana_mexicana || false, + semaforo_rojo_aduana_americana: invoice.compliance_mx?.semaforo_rojo_aduana_americana || false, + is_mixed: invoice.compliance_mx?.is_mixed || false, + reason_export: invoice.compliance_mx?.reason_export || '1', + purchase_order: invoice.purchase_order || '', + payment_terms: invoice.payment_terms || '', + handling_fees: invoice.financials?.handling_fees || 0, + cfdi_uuid: invoice.cfdi_uuid || '', + path_pdf: invoice.path_pdf || '', + path_xml: invoice.path_xml || '' + }; + } + // Referencias a los componentes de formulario para obtener sus datos + // Si estamos en modo edición (!data.isCreate) y tenemos una factura, usarla + // Si estamos en modo creación, usar defaultSettings let InvoiceTopFieldsFormData = $state( - mergeDefaults(topFieldsSkeleton, data.defaultSettings?.InvoiceTopFieldsFormData) + !data.isCreate && data.invoice + ? mapInvoiceToTopFields(data.invoice) + : mergeDefaults(topFieldsSkeleton, data.defaultSettings?.InvoiceTopFieldsFormData) ); let generalFormData = $state( - mergeDefaults(generalSkeleton, data.defaultSettings?.generalFormData) + !data.isCreate && data.invoice + ? mapInvoiceToGeneral(data.invoice) + : mergeDefaults(generalSkeleton, data.defaultSettings?.generalFormData) ); let observationFormData = $state( - mergeDefaults(observationSkeleton, data.defaultSettings?.observationFormData) + !data.isCreate && data.invoice + ? mapInvoiceToObservations(data.invoice) + : mergeDefaults(observationSkeleton, data.defaultSettings?.observationFormData) + ); + let itemsFormData = $state( + !data.isCreate && data.invoice + ? mapInvoiceToItems(data.invoice) + : ensureItemsFormData(data.defaultSettings?.itemsFormData) ); - let itemsFormData = $state(ensureItemsFormData(data.defaultSettings?.itemsFormData)); let othersFormData = $state( - mergeDefaults(othersSkeleton, data.defaultSettings?.othersFormData) + !data.isCreate && data.invoice + ? mapInvoiceToOthers(data.invoice) + : mergeDefaults(othersSkeleton, data.defaultSettings?.othersFormData) ); let continuationFormData = $state( - mergeDefaults(continuationSkeleton, data.defaultSettings?.continuationFormData) + !data.isCreate && data.invoice + ? mapInvoiceToContinuation(data.invoice) + : mergeDefaults(continuationSkeleton, data.defaultSettings?.continuationFormData) ); // Estados para saber si existen datos previos - let observationExists = $state(!!data.defaultSettings?.observationFormData); - let itemsExists = $state(!!data.defaultSettings?.itemsFormData?.items?.length); - let othersExists = $state(!!data.defaultSettings?.othersFormData); - let continuationExists = $state(!!data.defaultSettings?.continuationFormData); + let observationExists = $state( + !data.isCreate ? !!data.invoice : !!data.defaultSettings?.observationFormData + ); + let itemsExists = $state( + !data.isCreate + ? !!(data.invoice?.items && data.invoice.items.length > 0) + : !!data.defaultSettings?.itemsFormData?.items?.length + ); + let othersExists = $state( + !data.isCreate ? !!data.invoice : !!data.defaultSettings?.othersFormData + ); + let continuationExists = $state( + !data.isCreate ? !!data.invoice : !!data.defaultSettings?.continuationFormData + ); let calculatedExchangeRate = $state( data.invoice?.financials?.exchange_rate ?? null From 472e42a02f2f294e42d6925b749e43a039ca461f Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 13 Feb 2026 16:30:18 -0600 Subject: [PATCH 094/102] refactor: remove console logs and clean up fetch URL assignments in various components --- .../dashboard/invoices/edit/items/fa/country-dialog.svelte | 7 ++----- .../invoices/edit/items/fa/tariff-fraction-dialog.svelte | 6 ++---- .../invoices/edit/items/fa/unit-of-measure-dialog.svelte | 3 +-- frontend/src/lib/stores/company.svelte.ts | 3 +-- frontend/src/routes/api-sveltekit/classes/+server.ts | 1 - frontend/src/routes/api-sveltekit/classes/[id]/+server.ts | 1 - frontend/src/routes/api-sveltekit/parts/+server.ts | 3 +-- frontend/src/routes/api-sveltekit/parts/[id]/+server.ts | 3 +-- .../src/routes/api-sveltekit/tariff-fractions/+server.ts | 4 ---- .../src/routes/api-sveltekit/units-of-measure/+server.ts | 4 ---- .../routes/api-sveltekit/units-of-measure/[id]/+server.ts | 3 +-- frontend/src/routes/dashboard/invoices/+page.svelte | 3 +-- .../src/routes/dashboard/invoices/edit/[id]/+page.svelte | 3 +-- frontend/test_bits.js | 7 ------- 14 files changed, 11 insertions(+), 40 deletions(-) delete mode 100644 frontend/test_bits.js diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte index ddb6b92d..a7b032be 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/country-dialog.svelte @@ -26,9 +26,7 @@ credentials: 'include' }); if (response.ok) { - const data = await response.json(); - console.log('Countries data received:', data); - console.log('First country sample:', data[0]); + const data = await response.json(); if (Array.isArray(data)) { countries = data; } else if (data.items && Array.isArray(data.items)) { @@ -37,8 +35,7 @@ console.error('Unexpected data format:', data); countries = []; } - filteredCountries = countries; - console.log('Total countries loaded:', countries.length); + filteredCountries = countries; } else { error = `Error: ${response.status} - ${response.statusText}`; console.error('Error response:', await response.text()); diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tariff-fraction-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tariff-fraction-dialog.svelte index 02721e57..a298f2c4 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tariff-fraction-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tariff-fraction-dialog.svelte @@ -47,8 +47,7 @@ }); if (response.ok) { - const data = await response.json(); - console.log('Tariff fractions data received:', data); + const data = await response.json(); if (data.items && Array.isArray(data.items)) { if (append) { @@ -58,8 +57,7 @@ } currentPage = data.page; totalPages = data.pages; - hasMore = currentPage < totalPages; - console.log(`Loaded page ${currentPage}/${totalPages}, total items: ${fractions.length}`); + hasMore = currentPage < totalPages; } else { console.error('Unexpected data format:', data); if (!append) fractions = []; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/unit-of-measure-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/unit-of-measure-dialog.svelte index 55dab050..1df38b06 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/unit-of-measure-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/unit-of-measure-dialog.svelte @@ -27,8 +27,7 @@ credentials: 'include' }); if (response.ok) { - const data = await response.json(); - console.log('Units data received:', data); + const data = await response.json(); // El backend puede devolver { items: [...] } o directamente un array if (Array.isArray(data)) { units = data; diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index 788afe61..f2572f93 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -140,8 +140,7 @@ class CompanyStore { }, body: JSON.stringify({ companyId: company.id }), credentials: 'include' - }); - console.log('Cookie set via server:', `active_company_id=${company.id}`); + }); } catch (error) { console.error('Error setting active company cookie:', error); } diff --git a/frontend/src/routes/api-sveltekit/classes/+server.ts b/frontend/src/routes/api-sveltekit/classes/+server.ts index 1d44c79d..f5ea10d4 100644 --- a/frontend/src/routes/api-sveltekit/classes/+server.ts +++ b/frontend/src/routes/api-sveltekit/classes/+server.ts @@ -36,7 +36,6 @@ export const GET: RequestHandler = async ({ cookies, url }) => { try { const fetchUrl = `${baseUrl}v1/a76/classes?${queryString}`; - console.log('Fetching classes from:', fetchUrl); const response = await fetch( fetchUrl, diff --git a/frontend/src/routes/api-sveltekit/classes/[id]/+server.ts b/frontend/src/routes/api-sveltekit/classes/[id]/+server.ts index d8de375c..43180934 100644 --- a/frontend/src/routes/api-sveltekit/classes/[id]/+server.ts +++ b/frontend/src/routes/api-sveltekit/classes/[id]/+server.ts @@ -32,7 +32,6 @@ export const GET: RequestHandler = async ({ cookies, url, params }) => { try { const fetchUrl = `${baseUrl}v1/a76/classes/${id}?company_id=${companyId}`; - console.log('Fetching class from:', fetchUrl); const response = await fetch( fetchUrl, diff --git a/frontend/src/routes/api-sveltekit/parts/+server.ts b/frontend/src/routes/api-sveltekit/parts/+server.ts index 8fcc4b47..fe1b1f23 100644 --- a/frontend/src/routes/api-sveltekit/parts/+server.ts +++ b/frontend/src/routes/api-sveltekit/parts/+server.ts @@ -35,8 +35,7 @@ export const GET: RequestHandler = async ({ cookies, url }) => { const queryString = searchParams.toString(); try { - const fetchUrl = `${baseUrl}v1/a76/parts?${queryString}`; - console.log('Fetching parts from:', fetchUrl); + const fetchUrl = `${baseUrl}v1/a76/parts?${queryString}`; const response = await fetch( fetchUrl, diff --git a/frontend/src/routes/api-sveltekit/parts/[id]/+server.ts b/frontend/src/routes/api-sveltekit/parts/[id]/+server.ts index ba82f40d..ef9662f0 100644 --- a/frontend/src/routes/api-sveltekit/parts/[id]/+server.ts +++ b/frontend/src/routes/api-sveltekit/parts/[id]/+server.ts @@ -31,8 +31,7 @@ export const GET: RequestHandler = async ({ cookies, url, params }) => { const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; try { - const fetchUrl = `${baseUrl}v1/a76/parts/${id}?company_id=${companyId}`; - console.log('Fetching part from:', fetchUrl); + const fetchUrl = `${baseUrl}v1/a76/parts/${id}?company_id=${companyId}`; const response = await fetch( fetchUrl, diff --git a/frontend/src/routes/api-sveltekit/tariff-fractions/+server.ts b/frontend/src/routes/api-sveltekit/tariff-fractions/+server.ts index 1a15e7d8..52a37f85 100644 --- a/frontend/src/routes/api-sveltekit/tariff-fractions/+server.ts +++ b/frontend/src/routes/api-sveltekit/tariff-fractions/+server.ts @@ -20,8 +20,6 @@ export const GET: RequestHandler = async ({ cookies, url }) => { try { const fetchUrl = `${baseUrl}v1/a76/tariff-fractions?${queryString}`; - console.log('Fetching tariff fractions from:', fetchUrl); - console.log('Token:', token ? 'Present' : 'Missing'); const response = await fetch( fetchUrl, @@ -35,8 +33,6 @@ export const GET: RequestHandler = async ({ cookies, url }) => { ); const data = await response.json(); - console.log('Response status:', response.status); - console.log('Response data:', JSON.stringify(data).substring(0, 200)); if (!response.ok) { return new Response(JSON.stringify(data), { diff --git a/frontend/src/routes/api-sveltekit/units-of-measure/+server.ts b/frontend/src/routes/api-sveltekit/units-of-measure/+server.ts index 35a9fc11..200120c7 100644 --- a/frontend/src/routes/api-sveltekit/units-of-measure/+server.ts +++ b/frontend/src/routes/api-sveltekit/units-of-measure/+server.ts @@ -36,8 +36,6 @@ export const GET: RequestHandler = async ({ cookies, url }) => { try { const fetchUrl = `${baseUrl}v1/a76/units-of-measure?${queryString}`; - console.log('Fetching units from:', fetchUrl); - console.log('Token:', token ? 'Present' : 'Missing'); const response = await fetch( fetchUrl, @@ -51,8 +49,6 @@ export const GET: RequestHandler = async ({ cookies, url }) => { ); const data = await response.json(); - console.log('Response status:', response.status); - console.log('Response data:', JSON.stringify(data).substring(0, 200)); if (!response.ok) { return new Response(JSON.stringify(data), { diff --git a/frontend/src/routes/api-sveltekit/units-of-measure/[id]/+server.ts b/frontend/src/routes/api-sveltekit/units-of-measure/[id]/+server.ts index 110e12c4..08813905 100644 --- a/frontend/src/routes/api-sveltekit/units-of-measure/[id]/+server.ts +++ b/frontend/src/routes/api-sveltekit/units-of-measure/[id]/+server.ts @@ -31,8 +31,7 @@ export const GET: RequestHandler = async ({ cookies, params, url }) => { const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; try { - const fetchUrl = `${baseUrl}v1/a76/units-of-measure/${id}?company_id=${companyId}`; - console.log('Fetching unit of measure from:', fetchUrl); + const fetchUrl = `${baseUrl}v1/a76/units-of-measure/${id}?company_id=${companyId}`; const response = await fetch( fetchUrl, diff --git a/frontend/src/routes/dashboard/invoices/+page.svelte b/frontend/src/routes/dashboard/invoices/+page.svelte index 0da3bdce..f3ebcc7c 100644 --- a/frontend/src/routes/dashboard/invoices/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/+page.svelte @@ -187,8 +187,7 @@ selectedInvoiceId = null; } else { selectedInvoiceId = invoice.id; - } - console.log('Selected Invoice ID:', selectedInvoiceId); + } } const selectedInvoice = $derived( diff --git a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte index c76ab8f0..506ec342 100644 --- a/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte +++ b/frontend/src/routes/dashboard/invoices/edit/[id]/+page.svelte @@ -484,8 +484,7 @@ const actualResponse = response as any; const items = actualResponse.data?.items || []; - if (items.length === 0) { - console.log('No exchange rate found for', date); + if (items.length === 0) { if (!uiStore.isExchangeRateDialogOpen) { missingExchangeRateDate = date; showExchangeRateDialog = true; diff --git a/frontend/test_bits.js b/frontend/test_bits.js deleted file mode 100644 index 6228649d..00000000 --- a/frontend/test_bits.js +++ /dev/null @@ -1,7 +0,0 @@ -import { Dialog } from "bits-ui"; -console.log("Dialog is:", Dialog); -try { - console.log("Dialog.Root is:", Dialog.Root); -} catch (e) { - console.log("Error accessing Dialog.Root:", e.message); -} From 50aa87b2aab46f74741901ea8542c671f6ba248d Mon Sep 17 00:00:00 2001 From: acazares Date: Fri, 13 Feb 2026 17:04:03 -0600 Subject: [PATCH 095/102] fix: correct logic for handling pedimento_id in invoice update validation --- .../modules/a76/invoices/imports/temporary/validators/update.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py index 97ee1843..d30069a4 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py @@ -58,7 +58,7 @@ def validate_update( # Columna A: Pedimento (si no viene en CSV, usar el existente) if invoice_data.compliance_mx.pedimento_id: - invoice_data.compliance_mx.pedimento_id = clean_str(invoice_data.compliance_mx.pedimento_id) + invoice_data.compliance_mx.pedimento_id = invoice_data.compliance_mx.pedimento_id else: invoice_data.compliance_mx.pedimento_id = existing_invoice.compliance_mx.pedimento_id if existing_invoice.compliance_mx else None From 960fca190f567d9ae03d064496111d9c5774d2d2 Mon Sep 17 00:00:00 2001 From: acazares Date: Mon, 16 Feb 2026 09:49:07 -0600 Subject: [PATCH 096/102] feat: add fractions section to sidebar with translations in English and Spanish --- frontend/messages/en.json | 10 ++++++ frontend/messages/es.json | 10 ++++++ .../src/lib/components/sidebar/modules.ts | 36 +++++++++++++++++++ 3 files changed, 56 insertions(+) diff --git a/frontend/messages/en.json b/frontend/messages/en.json index cf838b4f..fc56fcdf 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -63,6 +63,16 @@ "back_flush": "Back Flush", "crossing_notice": "Crossing Notice" }, + "fractions": { + "title": "Fractions", + "sitar": "Fraction Sitar", + "sitar_seventh_amendment": "Fraction Sitar - Seventh Amendment", + "sitar_us": "Fraction Sitar US", + "american": "Fraction American", + "canadian": "Fraction Canadian", + "historical": "Fraction Historical", + "sectors": "Sectors" + }, "goods": { "title": "Goods", "classes": "Classes", diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 1ea855b2..413726cd 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -62,6 +62,16 @@ "electronic_notices": "Avisos electrónicos", "back_flush": "Back Flush", "crossing_notice": "Aviso de cruce" + }, + "fractions": { + "title": "Fracciones", + "sitar": "Fracciones Sitar", + "sitar_seventh_amendment": "Fracciones Sitar - 7ma enmienda", + "sitar_us": "Fracciones Sitar US", + "american": "Fracciones Americana", + "canadian": "Fracciones Canadiense", + "historical": "Fracciones Historicas", + "sectors": "Sectores" }, "goods": { "title": "Mercancías", diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index ee0a8d9a..264c7683 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -8,6 +8,7 @@ import { FileText, Frame, GalleryVerticalEnd, + Hash, LayoutDashboard, Package, Settings2, @@ -296,6 +297,41 @@ export function getSidebarData(): SidebarData { }, ], }, + { + title: m["sidebar.fractions.title"](), + url: "#", + icon: Hash, + items: [ + { + title: m["sidebar.fractions.sitar"](), + url: "#", + }, + { + title: m["sidebar.fractions.sitar_seventh_amendment"](), + url: "#", + }, + { + title: m["sidebar.fractions.sitar_us"](), + url: "#", + }, + { + title: m["sidebar.fractions.american"](), + url: "#", + }, + { + title: m["sidebar.fractions.canadian"](), + url: "#", + }, + { + title: m["sidebar.fractions.historical"](), + url: "#", + }, + { + title: m["sidebar.fractions.sectors"](), + url: "#", + }, + ], + }, { title: m["sidebar.goods.title"](), url: "#", From 5ca98ad0bc1e423dbc85066afc5d72c9bb3018d1 Mon Sep 17 00:00:00 2001 From: acazares Date: Mon, 16 Feb 2026 09:49:23 -0600 Subject: [PATCH 097/102] fix: correct logic for handling remesa and package quantity validation in invoice updates --- .../modules/a76/invoices/imports/temporary/validators/update.py | 2 +- .../v1/modules/a76/items/imports/temporary/validators/common.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py index d30069a4..9c254a9b 100644 --- a/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py +++ b/backend/api/v1/modules/a76/invoices/imports/temporary/validators/update.py @@ -64,7 +64,7 @@ def validate_update( # Columna B: Remesa if invoice_data.compliance_mx.remesa: - invoice_data.compliance_mx.remesa = clean_str(invoice_data.compliance_mx.remesa) + invoice_data.compliance_mx.remesa = invoice_data.compliance_mx.remesa else: invoice_data.compliance_mx.remesa = existing_invoice.compliance_mx.remesa if existing_invoice.compliance_mx else None diff --git a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py b/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py index d10c95fd..3cbe5958 100644 --- a/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py +++ b/backend/api/v1/modules/a76/items/imports/temporary/validators/common.py @@ -164,7 +164,7 @@ def validate_common( code="PACKAGE_QUANTITY_MUST_BE_GREATER_THAN_ZERO", ) else: - if line.quantity.package_quantity and line.quantity.package_quantity > 0: + if (line.quantity.package_quantity or line.quantity.package_quantity > 0) and not line.quantity.package_id: errors.add_error( field=f"line[{line_number}].quantity.package_id", message="El paquete es obligatorio cuando se proporciona la cantidad de paquetes.", From 79a9f7274139dca9cad597aad2f4481500c0527c Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 16 Feb 2026 11:37:06 -0600 Subject: [PATCH 098/102] feat: Introduce a new `TariffFractionSelector` component, refactor tariff fraction handling across frontend forms and backend services, and add a company amendment database migration. --- .../fractions/tariff_fractions/dto.py | 3 + .../fractions/tariff_fractions/routes.py | 60 +---- .../fractions/tariff_fractions/service.py | 160 ++++++++++++- .../fractions/us_tariff_fractions/routes.py | 42 ++-- .../fractions/us_tariff_fractions/service.py | 115 +++++++++- .../v1/modules/sitar/common/base_service.py | 18 +- .../v1/modules/sitar/fracciones/schemas.py | 1 + .../v1/modules/sitar/fracciones/service.py | 6 + .../a76/general_catalogs/tariff-fractions.ts | 4 +- .../classes/forms/FixedAssetClassForm.svelte | 215 +++++------------- .../modales/TariffFractionSelector.svelte | 171 ++++++++++++++ .../modales/fraction-selector-dialog.svelte | 162 +------------ .../items/fa/tariff-fraction-dialog.svelte | 200 +--------------- frontend/src/lib/stores/company.svelte.ts | 14 +- .../tariff-fractions/+page.svelte | 72 +++--- 15 files changed, 611 insertions(+), 632 deletions(-) create mode 100644 frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/dto.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/dto.py index 1a8f15c2..498d859a 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/dto.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/dto.py @@ -48,6 +48,9 @@ class TariffFractionResponseDTO(BaseModel): umt: Optional[str] = None adv_impo: Optional[str] = None adv_expo: Optional[str] = None + dof: Optional[str] = None + aplica_ieps: Optional[str] = None + um_code: Optional[str] = None model_config = ConfigDict(from_attributes=True) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py index 4e7bd2f3..da1c2881 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/routes.py @@ -38,7 +38,11 @@ async def list_tariff_fractions( if search: filters["search"] = search - items, total = TariffFractionService.get_all( + # Updated to async call with Sitar integration + # WARNING: Using async def with blocking DB dependency (Session) run in threadpool by FastAPI. + # Service.get_all calls Sitar (async) or DB (sync). + # This should be fine. + items, total = await TariffFractionService.get_all( db, skip, page_size, filters ) @@ -55,7 +59,7 @@ async def list_tariff_fractions( "/{tariff_fraction_id}", response_model=TariffFractionResponseDTO, summary="Get Tariff Fraction by ID", - description="Get a specific tariff fraction by ID", + description="Get a specific tariff fraction by ID (Lookups in Local DB for legacy compatibility)", ) async def get_tariff_fraction( tariff_fraction_id: int, @@ -68,55 +72,3 @@ async def get_tariff_fraction( raise HTTPException(status_code=404, detail="Tariff fraction not found") return TariffFractionResponseDTO.model_validate(item) - -@router.post( - "/", - response_model=TariffFractionResponseDTO, - summary="Create Tariff Fraction", - description="Create a new tariff fraction (admin only)", - status_code=201, -) -async def create_tariff_fraction( - data: TariffFractionCreateDTO, - db: Session = Depends(get_core_db), - current_user: Dict[str, Any] = Depends(get_current_user), -): - item = TariffFractionService.create(db, data) - return TariffFractionResponseDTO.model_validate(item) - - -@router.put( - "/{tariff_fraction_id}", - response_model=TariffFractionResponseDTO, - summary="Update Tariff Fraction", - description="Update an existing tariff fraction (admin only)", -) -async def update_tariff_fraction( - tariff_fraction_id: int, - data: TariffFractionUpdateDTO, - db: Session = Depends(get_core_db), - current_user: Dict[str, Any] = Depends(get_current_user), -): - item = TariffFractionService.update(db, tariff_fraction_id, data) - if not item: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="Tariff fraction not found") - return TariffFractionResponseDTO.model_validate(item) - - -@router.delete( - "/{tariff_fraction_id}", - summary="Delete Tariff Fraction", - description="Delete a tariff fraction (admin only)", - status_code=204, -) -async def delete_tariff_fraction( - tariff_fraction_id: int, - db: Session = Depends(get_core_db), - current_user: Dict[str, Any] = Depends(get_current_user), -): - success = TariffFractionService.delete(db, tariff_fraction_id) - if not success: - from fastapi import HTTPException - raise HTTPException(status_code=404, detail="Tariff fraction not found") - diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py index 3dd13594..fd719085 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/tariff_fractions/service.py @@ -7,26 +7,165 @@ from typing import List, Optional, Tuple, Dict, Any from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from fastapi import HTTPException +import zlib import logging from .models import TariffFraction from .dto import TariffFractionCreateDTO, TariffFractionUpdateDTO +from api.v1.modules.sitar.fracciones.service import FraccionesService +from api.v1.modules.sitar.fracciones.schemas import FraccionesResponse logger = logging.getLogger(__name__) +class TariffFractionMapper: + """Helper to map Sitar responses to Local domain objects""" + + @staticmethod + def to_domain(fraccion: FraccionesResponse) -> TariffFraction: + # Generate ID: Use SYSID if available, else composite hash of code + nico + if fraccion.SYSID: + fake_id = fraccion.SYSID + else: + # Composite key for uniqueness if SYSID missing + unique_str = f"{fraccion.FRACCION}-{fraccion.NICO}" + fake_id = zlib.crc32(unique_str.encode('utf-8')) + + # UX Enhauncement: Sitar API returns empty strings for some fields. + # We fill them with fallbacks so the frontend table isn't 90% empty. + code_val = fraccion.FRACCION + + # Formatting Logic: if FRACCIONPUNTO is empty, try to format code_val + formatted_fraction = code_val + if fraccion.FRACCIONPUNTO: + formatted_fraction = fraccion.FRACCIONPUNTO + elif code_val and code_val.isdigit() and len(code_val) == 8: + # Standard 8 digit format: XX.XX.XX.XX + formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:6]}.{code_val[6:]}" + elif code_val and code_val.isdigit() and len(code_val) == 6: + # 6 digit (subheading): XX.XX.XX + formatted_fraction = f"{code_val[:2]}.{code_val[2:4]}.{code_val[4:]}" + + fraction_val = formatted_fraction + description_val = fraccion.DESCRIPCION if fraccion.DESCRIPCION else "(Sin descripción)" + + tf = TariffFraction( + id=fake_id, + code=code_val, + fraction=fraction_val, + description=description_val, + nico=fraccion.NICO, + # MAP CHANGE: UMT now maps to Abbreviation (e.g., Pza, Kg) + umt=fraccion.UMABREVIACION, + adv_impo=fraccion.ADVIMPOTXT, + adv_expo=fraccion.ADVEXPOTXT + ) + # Dynamically attach non-model attributes for DTO + tf.dof = fraccion.DOF + tf.aplica_ieps = fraccion.APLICAIEPS + # MAP CHANGE: New field for the numeric code (e.g., 01, 06) + tf.um_code = fraccion.UMCLAVE + + return tf + + class TariffFractionService: """Service para gestionar fracciones arancelarias (catálogo global)""" @staticmethod - def get_all( + async def get_all( db: Session, skip: int = 0, limit: int = 100, filters: Optional[Dict[str, Any]] = None, ) -> Tuple[List[TariffFraction], int]: - """Obtiene todas las fracciones arancelarias con filtros opcionales""" + """ + Obtiene fracciones arancelarias. + Estrategia: Sitar API -> Fallback Local DB + """ + # 1. Try Sitar API + try: + sitar_service = FraccionesService.get_instance() + + # Map filters + sitar_fraccion = None + sitar_nico = None + has_filters = False + + if filters: + if filters.get("search"): + term = filters["search"] + # Heuristic: if search starts with digit (after removing dots), treat as code/fraccion/nico + # This covers "0101", "01.01", "020691A" + clean_term = term.replace(".", "") + if clean_term and clean_term[0].isdigit(): + sitar_fraccion = clean_term + has_filters = True + else: + # Attempt description search via API first + logger.info(f"Search term '{term}' identified as text. Attempting API description search.") + pass + + if filters.get("code"): + sitar_fraccion = filters["code"] + has_filters = True + if filters.get("fraction"): + sitar_fraccion = filters["fraction"] + has_filters = True + if filters.get("nico"): + sitar_nico = filters["nico"] + has_filters = True + + # Determine description filter + sitar_description = None + # Only use description if we didn't use it as code above + if filters and filters.get("search"): + clean_term = filters["search"].replace(".", "") + if not (clean_term and clean_term[0].isdigit()): + sitar_description = filters["search"] + has_filters = True + + # Note: Sitar search might not return total count. + # We fetch page items. Pagination might be tricky if Sitar doesn't return total. + # Assuming Sitar returns a list. + sitar_items = await sitar_service.search( + fraccion=sitar_fraccion, + nico=sitar_nico, + description=sitar_description, + nivel=5, # User requested filtering by level 5 + skip=skip, + limit=limit + ) + + # STRICT API USAGE: + # We do NOT fallback to local DB on empty list, as user requested strict API consumption. + # We also do NOT attempt enrichment as codes mismatch (API uses '010191A' vs Local '01012101'). + + # Map items + items = [TariffFractionMapper.to_domain(item) for item in sitar_items] + + # Estimate total (Sitar service doesn't return total currently) + # If we got full limit, assume there are more. + total = len(items) + skip + if len(items) == limit: + total += 1 # Indicate more pages + + return items, total + + except Exception as e: + logger.error(f"Error fetching from Sitar API: {e}") + # STRICT API USAGE: Propagate error, do NOT fallback to local DB. + raise e + + @staticmethod + async def _get_all_local_async( + db: Session, + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None, + ) -> Tuple[List[TariffFraction], int]: + """Lógica original de consulta local""" query = db.query(TariffFraction) # Aplicar filtros @@ -64,21 +203,24 @@ class TariffFractionService: db: Session, tariff_fraction_id: int, ) -> Optional[TariffFraction]: - """Obtiene una fracción arancelaria por ID""" - + """ + Obtiene por ID. + Como Sitar no usa estos IDs, consultamos Local DB directamente para compatibilidad legacy. + Si se necesitara obtener detalle de Sitar, se requeriría otro identificador (Code). + """ return ( db.query(TariffFraction) .filter(TariffFraction.id == tariff_fraction_id) .first() ) + # WRITE OPERATIONS - DEPRECATED / LOCAL ONLY (Optional: Remove or Keep for Fallback Maintenance) + @staticmethod def get_by_code( db: Session, code: str, ) -> Optional[TariffFraction]: - """Obtiene una fracción arancelaria por código""" - return ( db.query(TariffFraction) .filter(TariffFraction.code == code) @@ -90,8 +232,6 @@ class TariffFractionService: db: Session, tariff_fraction_data: TariffFractionCreateDTO, ) -> TariffFraction: - """Crea una nueva fracción arancelaria""" - try: tariff_fraction = TariffFraction( **tariff_fraction_data.model_dump(), @@ -114,8 +254,6 @@ class TariffFractionService: tariff_fraction_id: int, tariff_fraction_data: TariffFractionUpdateDTO, ) -> Optional[TariffFraction]: - """Actualiza una fracción arancelaria existente""" - tariff_fraction = TariffFractionService.get_by_id( db, tariff_fraction_id ) @@ -144,8 +282,6 @@ class TariffFractionService: db: Session, tariff_fraction_id: int, ) -> bool: - """Elimina una fracción arancelaria""" - tariff_fraction = TariffFractionService.get_by_id( db, tariff_fraction_id ) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py index d3d85344..d5552c7e 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/routes.py @@ -17,21 +17,8 @@ from .dto import ( ) from .service import USTariffFractionService -# Create base router with generic CRUD routes (disabled list because we'll create a custom one) -base_router = TenantCRUDRoutes( - service=USTariffFractionService, - create_schema=USTariffFractionCreateDTO, - update_schema=USTariffFractionUpdateDTO, - response_schema=USTariffFractionResponseDTO, - prefix="/us-tariff-fractions", - tags=["a76 / general catalogs / us tariff fractions"], - resource_name="USTariffFraction", - id_name="us_tariff_fraction_id", - enable_list=False, # Disable default list, we'll add custom one - enable_filters=False, - default_page_size=50, - max_page_size=10000, -) +# Create base router with generic CRUD routes - REMOVED strictly read-only from Sitar +# Writes are disabled at API level, but Service still supports fallback writes if needed internally router = APIRouter(prefix="/us-tariff-fractions", tags=["a76 / general catalogs / us tariff fractions"]) @@ -57,7 +44,8 @@ async def list_us_tariff_fractions( if search: filters["search"] = search - items, total = USTariffFractionService.get_all( + # Updated to async call with Sitar integration + items, total = await USTariffFractionService.get_all( db, tenant_id, company_id, skip, page_size, filters ) @@ -69,5 +57,23 @@ async def list_us_tariff_fractions( "pages": (total + page_size - 1) // page_size, } -# Include other CRUD routes from base router -router.include_router(base_router.router) + +@router.get( + "/{us_tariff_fraction_id}", + response_model=USTariffFractionResponseDTO, + summary="Get US Tariff Fraction by ID", + description="Get a specific US tariff fraction by ID (Lookups in Local DB for legacy compatibility)", +) +async def get_us_tariff_fraction( + us_tariff_fraction_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + tenant_id = validate_access_to_resource(db, company_id, current_user) + + item = USTariffFractionService.get_by_id(db, tenant_id, company_id, us_tariff_fraction_id) + if not item: + from fastapi import HTTPException + raise HTTPException(status_code=404, detail="US Tariff fraction not found") + return USTariffFractionResponseDTO.model_validate(item) diff --git a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py index 41815d68..877dfcc5 100644 --- a/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py +++ b/backend/api/v1/modules/a76/general_catalogs/fractions/us_tariff_fractions/service.py @@ -6,19 +6,68 @@ from typing import List, Optional, Tuple, Dict, Any from sqlalchemy.orm import Session from sqlalchemy.exc import IntegrityError from fastapi import HTTPException +import zlib import logging +import re +from decimal import Decimal from .models import USTariffFraction from .dto import USTariffFractionCreateDTO, USTariffFractionUpdateDTO +from api.v1.modules.sitar.fracciones_usa.service import FraccionesUSAService +from api.v1.modules.sitar.fracciones_usa.schemas import FraccionesUSAResponse logger = logging.getLogger(__name__) +class USTariffFractionMapper: + """Helper to map Sitar USA responses to Local domain objects""" + + @staticmethod + def to_domain(fraccion: FraccionesUSAResponse, tenant_id: int, company_id: int) -> USTariffFraction: + # Generate a deterministic numeric ID based on the unique code + # We use CRC32 to get a consistent integer implementation-independent + fake_id = zlib.crc32((fraccion.FRACCION_SIN_PUNTO or "").encode('utf-8')) + + # Parse numeric values safely + ad_valorem = None + if fraccion.TARIFA1: + try: + # Extract numbers from string like "5.2%" or similar if present + # Assuming TARIFA1 might be clean number or percentage string + clean_val = re.sub(r'[^\d.]', '', str(fraccion.TARIFA1)) + if clean_val: + ad_valorem = Decimal(clean_val) + except: + pass + + fixed_cost = None + if fraccion.ESPECIFICO: + try: + clean_val = re.sub(r'[^\d.]', '', str(fraccion.ESPECIFICO)) + if clean_val: + fixed_cost = Decimal(clean_val) + except: + pass + + return USTariffFraction( + id=fake_id, # Updated to use fake_id instead of sitar consecutive if needed, or consistent hash + tenant_id=tenant_id, + company_id=company_id, + code=fraccion.FRACCION_SIN_PUNTO or "", + prefix=None, # Not mapped from Sitar response currently + type_code=None, + ad_valorem=ad_valorem, + fixed_cost=fixed_cost, + unit_of_measure=fraccion.UNIDADCANTIDAD, + description=fraccion.DESCRIPCION + ) + + class USTariffFractionService: """Service para gestionar fracciones arancelarias americanas""" @staticmethod - def get_all( + async def get_all( db: Session, tenant_id: int, company_id: int, @@ -26,8 +75,60 @@ class USTariffFractionService: limit: int = 100, filters: Optional[Dict[str, Any]] = None, ) -> Tuple[List[USTariffFraction], int]: - """Obtiene todas las fracciones arancelarias americanas con filtros opcionales""" + """ + Obtiene todas las fracciones arancelarias americanas con filtros opcionales. + Estrategia: Sitar API -> Fallback Local DB + """ + # 1. Try Sitar API + try: + sitar_service = FraccionesUSAService.get_instance() + + sitar_fraccion = None + has_filters = False + + if filters and filters.get("search"): + term = filters["search"] + # Sitar only filters by fraction code + if term.replace(".", "").isdigit(): + sitar_fraccion = term + has_filters = True + + sitar_items = await sitar_service.search( + fraccion=sitar_fraccion, + skip=skip, + limit=limit + ) + + # If Sitar returns empty list AND we didn't have specific filters, attempt fallback + if not sitar_items and not has_filters: + logger.warning("Sitar return empty list for USA broad query. Attempting fallback to local DB.") + return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters) + + # Map items + items = [USTariffFractionMapper.to_domain(item, tenant_id, company_id) for item in sitar_items] + + # Estimate total + total = len(items) + skip + if len(items) == limit: + total += 1 + + return items, total + + except Exception as e: + logger.error(f"Error fetching USA Fractions from Sitar API, falling back to local DB: {e}") + return USTariffFractionService._get_all_local(db, tenant_id, company_id, skip, limit, filters) + + @staticmethod + def _get_all_local( + db: Session, + tenant_id: int, + company_id: int, + skip: int = 0, + limit: int = 100, + filters: Optional[Dict[str, Any]] = None, + ) -> Tuple[List[USTariffFraction], int]: + """Lógica original de consulta local""" query = db.query(USTariffFraction).filter( USTariffFraction.tenant_id == tenant_id, USTariffFraction.company_id == company_id, @@ -53,7 +154,10 @@ class USTariffFractionService: def get_by_id( db: Session, tenant_id: int, company_id: int, fraction_id: int ) -> Optional[USTariffFraction]: - """Obtiene una fracción arancelaria americana por ID""" + """ + Obtiene por ID. + Legacy: Consulta Local DB. + """ return ( db.query(USTariffFraction) .filter( @@ -64,6 +168,8 @@ class USTariffFractionService: .first() ) + # WRITE OPERATIONS - DEPRECATED / LOCAL ONLY + @staticmethod def create( db: Session, @@ -71,7 +177,6 @@ class USTariffFractionService: company_id: int, fraction_data: USTariffFractionCreateDTO, ) -> USTariffFraction: - """Crea una nueva fracción arancelaria americana""" try: db_fraction = USTariffFraction( tenant_id=tenant_id, @@ -98,7 +203,6 @@ class USTariffFractionService: fraction_id: int, fraction_data: USTariffFractionUpdateDTO, ) -> Optional[USTariffFraction]: - """Actualiza una fracción arancelaria americana existente""" db_fraction = USTariffFractionService.get_by_id( db, tenant_id, company_id, fraction_id ) @@ -117,7 +221,6 @@ class USTariffFractionService: def delete( db: Session, tenant_id: int, company_id: int, fraction_id: int ) -> bool: - """Elimina una fracción arancelaria americana""" db_fraction = USTariffFractionService.get_by_id( db, tenant_id, company_id, fraction_id ) diff --git a/backend/api/v1/modules/sitar/common/base_service.py b/backend/api/v1/modules/sitar/common/base_service.py index b18009dd..0795a539 100644 --- a/backend/api/v1/modules/sitar/common/base_service.py +++ b/backend/api/v1/modules/sitar/common/base_service.py @@ -22,7 +22,7 @@ class SitarAPIBaseService: self.base_url = os.getenv("SITAR_API_URL") self.username = os.getenv("SITAR_API_USER") self.password = os.getenv("SITAR_API_PASSWORD") - self.timeout = 10.0 + self.timeout = 30.0 if not all([self.base_url, self.username, self.password]): raise ValueError( @@ -101,4 +101,20 @@ class SitarAPIBaseService: headers=headers, ) response.raise_for_status() + + # DEBUG LOGGING for SITAR inspection + if "fracciones" in url: + import logging + logger = logging.getLogger(__name__) + logger.info(f"SITAR API Response Headers for {url}: {dict(response.headers)}") + try: + data = response.json() + if isinstance(data, dict): + logger.info(f"SITAR API Response Body Keys: {list(data.keys())}") + elif isinstance(data, list) and len(data) > 0: + logger.info(f"SITAR API Response List Item Keys: {list(data[0].keys())}") + return data + except Exception: + pass + return response.json() diff --git a/backend/api/v1/modules/sitar/fracciones/schemas.py b/backend/api/v1/modules/sitar/fracciones/schemas.py index c4811a3d..a657c68b 100644 --- a/backend/api/v1/modules/sitar/fracciones/schemas.py +++ b/backend/api/v1/modules/sitar/fracciones/schemas.py @@ -33,6 +33,7 @@ class FraccionesResponse(BaseModel): APLICAIEPS: Optional[str] = Field(None, max_length=1) NIVEL: Optional[int] = None NICO: Optional[str] = Field(None, max_length=14) + SYSID: Optional[int] = None class Config: from_attributes = True diff --git a/backend/api/v1/modules/sitar/fracciones/service.py b/backend/api/v1/modules/sitar/fracciones/service.py index 0fbe6b1c..640cf969 100644 --- a/backend/api/v1/modules/sitar/fracciones/service.py +++ b/backend/api/v1/modules/sitar/fracciones/service.py @@ -21,6 +21,8 @@ class FraccionesService(SitarAPIBaseService): self, fraccion: Optional[str] = None, nico: Optional[str] = None, + description: Optional[str] = None, + nivel: Optional[int] = None, skip: int = 0, limit: int = 100, ) -> List[FraccionesResponse]: @@ -30,6 +32,10 @@ class FraccionesService(SitarAPIBaseService): params["fraccion"] = fraccion if nico: params["nico"] = nico + if description: + params["descripcion"] = description + if nivel is not None: + params["nivel"] = nivel data = await self._make_request("GET", "/api/v1/fracciones/", params=params) return [FraccionesResponse(**item) for item in data] diff --git a/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts b/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts index 7983e362..556dd6c7 100644 --- a/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts +++ b/frontend/src/lib/api/dashboard/a76/general_catalogs/tariff-fractions.ts @@ -11,8 +11,10 @@ export interface TariffFraction { umt: string | null; adv_impo: string | null; adv_expo: string | null; - created_at: string | null; updated_at: string | null; + dof: string | null; + aplica_ieps: string | null; + um_code: string | null; } export interface TariffFractionCreate { diff --git a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte index 3e48edfc..e0977939 100644 --- a/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte +++ b/frontend/src/lib/components/dashboard/goods/classes/forms/FixedAssetClassForm.svelte @@ -143,16 +143,30 @@ let showUnitDialog = $state(false); let searchUnit = $state(''); + import TariffFractionSelector from '$lib/components/dashboard/goods/modales/TariffFractionSelector.svelte'; + let showFractionDialog = $state(false); - let tariffFractions = $state([]); - let searchFraction = $state(''); - let currentPage = $state(1); - let totalFractions = $state(0); - let hasMoreFractions = $state(true); - let isLoadingFractions = $state(false); - let searchTimeout: ReturnType; + // Removing inline tariff fraction state + // let tariffFractions = ... + + function openFractionSearch() { + showFractionDialog = true; + } let showUSFractionDialog = $state(false); + // ... + + // loadFractions removed + + function selectFraction(fraction: TariffFraction) { + formData.fraction = fraction.fraction; + formData.fraction_umt = (fraction.umt ?? '') as string; + formData.fraction_uma_key = (fraction.nico ?? '') as string; + // Actualizar tarifa de importación + formData.import_tariff_code = fraction.fraction || ''; + formData.import_tariff_type = (fraction.umt ?? '') as string; + showFractionDialog = false; + } let usTariffFractions = $state([]); let searchUSFraction = $state(''); let currentUSPage = $state(1); @@ -262,56 +276,6 @@ searchUnit = ''; } - async function openFractionSearch() { - showFractionDialog = true; - searchFraction = ''; - tariffFractions = []; - currentPage = 1; - totalFractions = 0; - hasMoreFractions = true; - await loadFractions('', 1); - } - - async function loadFractions(search: string, page: number = currentPage) { - const companyId = companyStore.activeCompany?.id; - if (!companyId || isLoadingFractions) return; - - isLoadingFractions = true; - try { - const filters = search ? { search } : {}; - const pageSize = 100; - - const response = await getTariffFractions(page, pageSize, companyId, filters); - - if (response.data) { - if (page === 1) { - tariffFractions = [...response.data.items]; - } else { - tariffFractions = [...tariffFractions, ...response.data.items]; - } - - totalFractions = response.data.total; - currentPage = page; - hasMoreFractions = tariffFractions.length < response.data.total; - } - } catch (error) { - console.error('Error cargando fracciones arancelarias:', error); - } finally { - isLoadingFractions = false; - } - } - - function selectFraction(fraction: TariffFraction) { - formData.fraction = fraction.fraction; - formData.fraction_umt = (fraction.umt ?? '') as string; - formData.fraction_uma_key = (fraction.nico ?? '') as string; - // Actualizar tarifa de importación - formData.import_tariff_code = fraction.fraction || ''; - formData.import_tariff_type = (fraction.umt ?? '') as string; - showFractionDialog = false; - searchFraction = ''; - } - async function openUSFractionSearch() { showUSFractionDialog = true; searchUSFraction = ''; @@ -565,7 +529,7 @@
-
+
@@ -596,7 +560,7 @@ -
+
- + {formData.material_description || ''}
{#if validationErrors.material_key} -

{validationErrors.material_key}

+

{validationErrors.material_key}

{/if}
@@ -635,7 +599,7 @@ onblur={() => validateField('description_es')} /> {#if validationErrors.description_es} -

{validationErrors.description_es}

+

{validationErrors.description_es}

{/if}
@@ -651,12 +615,12 @@
-
+
-
+
- + {formData.unit_of_measure_description || ''}
{#if validationErrors.unit_of_measure} -

{validationErrors.unit_of_measure}

+

{validationErrors.unit_of_measure}

{/if}
@@ -683,7 +647,7 @@ -
+
{#if validationErrors.fraction} -

{validationErrors.fraction}

+

{validationErrors.fraction}

{/if}
-
+
-
+
-
+
-
+
-
+
- - - + - + CATALOGO DE FRACCIONES AMERICANAS @@ -950,9 +859,9 @@ class="mt-1" />
-
+
- + @@ -964,7 +873,7 @@ {#each usTariffFractions as fraction (fraction.id)} selectUSFraction(fraction)} > @@ -996,7 +905,7 @@ - + CATALOGO DE DEPRECIACION @@ -1010,9 +919,9 @@ class="mt-1" /> -
+
Código Prefijo
{fraction.code}
- + @@ -1022,7 +931,7 @@ {#each depreciationCatalog as item (item.id)} selectDepreciation(item)} > @@ -1052,7 +961,7 @@ - + CATALOGO FDA @@ -1066,9 +975,9 @@ class="mt-1" /> -
+
Fracción Descripción
{item.fraction}
- + @@ -1077,7 +986,7 @@ {#each fdaCatalog as item (item.id)} selectFDA(item)} > @@ -1106,7 +1015,7 @@ - + CATALOGO DE CARTA PORTE @@ -1120,9 +1029,9 @@ class="mt-1" /> -
+
Clave FDA Descripción
{item.fda_key}
- + @@ -1131,7 +1040,7 @@ {#each cartaPorteCatalog as item (item.id)} { formData.carta_porte_code = item.code; showCartaPorteDialog = false; diff --git a/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte new file mode 100644 index 00000000..b20dfff8 --- /dev/null +++ b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte @@ -0,0 +1,171 @@ + + + + + + CATALOGO DE FRACCIONES SITAR - SCAII + +
+
+ +
+ + { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(() => { + tariffFractions = []; + currentPage = 1; + totalFractions = 0; + hasMoreFractions = true; + loadFractions(searchFraction, 1); + }, 500); + }} + /> +
+
+
{ + const target = e.currentTarget; + if ( + target.scrollHeight - target.scrollTop <= target.clientHeight + 50 && + hasMoreFractions && + !isLoadingFractions + ) { + loadFractions(searchFraction, currentPage + 1); + } + }} + > +
Código Descripción
+ + + + + + + + + + + + + + + {#each tariffFractions as fraction (fraction.id)} + { + onSelect(fraction); + open = false; + }} + > + + + + + + + + + + + {:else} + + + + {/each} + +
ClaveFracciónNICODescripciónU.M.TAdv. ImpoAdv. ExpoDOFAplica IEPS
{fraction.um_code}{fraction.fraction}{fraction.nico}{fraction.description}{fraction.umt}{fraction.adv_impo}{fraction.adv_expo}{fraction.dof}{fraction.aplica_ieps}
+ {#if isLoadingFractions} +
+ + Cargando fracciones... +
+ {:else} + No hay fracciones disponibles + {/if} +
+
+ {#if isLoadingFractions && tariffFractions.length > 0} +
+ +
+ {/if} +
+
+ +
+ + diff --git a/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte b/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte index ea9819ed..04811fb0 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/fraction-selector-dialog.svelte @@ -1,156 +1,14 @@ - - - - Seleccionar Fracción Arancelaria - - Seleccione la fracción arancelaria del catálogo. - - - -
- - -
- -
- {#if loading} -
- -

Cargando catálogo...

-
- {:else if filteredItems.length === 0} -
-

No se encontraron fracciones.

-
- {:else} - - - - Código - Fracción - NICO - Descripción - - - - {#each filteredItems as item} - handleSelect(item)} - > - - {item.code} - - -
- - - {item.fraction} - -
-
- - {item.nico || '-'} - - - {item.description || '-'} - -
- {/each} -
-
- {/if} -
- - -
- {filteredItems.length} registros encontrados -
- -
-
-
+ diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tariff-fraction-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tariff-fraction-dialog.svelte index 89ce0247..04811fb0 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tariff-fraction-dialog.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tariff-fraction-dialog.svelte @@ -1,204 +1,14 @@ - - - - FRACCIONES ARANCELARIAS - - -
-
- - -
-

- Mostrando {fractions.length} de {currentPage * pageSize} resultados -

-
- -
- {#if loading} -
- -
- {:else if error} -
-

{error}

-
- {:else} -
- - - - - - - - - - - - {#each fractions as fraction, i} - handleSelect(fraction)} - > - - - - - - - {/each} - {#if fractions.length === 0 && !loading} - - - - {/if} - -
CódigoFracciónDescripciónNICOUMT
{fraction.code || ''}{fraction.fraction || ''}{fraction.description || ''}{fraction.nico || ''}{fraction.umt || ''}
- No se encontraron resultados -
-
- - {#if loadingMore} -
- - Cargando más... -
- {/if} - - {#if !hasMore && fractions.length > 0} -
Todos los resultados cargados
- {/if} - {/if} -
- -
- -
-
-
+ diff --git a/frontend/src/lib/stores/company.svelte.ts b/frontend/src/lib/stores/company.svelte.ts index 9f04bcdb..b45a6df3 100644 --- a/frontend/src/lib/stores/company.svelte.ts +++ b/frontend/src/lib/stores/company.svelte.ts @@ -76,11 +76,12 @@ class CompanyStore { this._loading = true; try { - const response = await fetch('/api/v1/a76/company/my-companies', { - credentials: 'include' - }); - if (response.ok) { - const newCompanies = await response.json(); + // Importamos dinámicamente para evitar dependencias circulares si las hubiera + const { api } = await import('$lib/api'); + const response = await api.get('/v1/a76/company/my-companies'); + + if (response.data) { + const newCompanies = response.data; // Detectar si el tenant ha cambiado if (newCompanies.length > 0) { @@ -88,7 +89,6 @@ class CompanyStore { // Si el tenant cambió, limpiar el store primero if (this._currentTenantId !== null && this._currentTenantId !== newTenantId) { - this.clear(); } @@ -102,7 +102,7 @@ class CompanyStore { this.setActiveCompany(this._companies[0], true); // silent=true para inicialización } } else { - console.error('Error loading companies:', response.statusText); + console.error('Error loading companies:', response.error); // Si falla la carga (ej: 401), limpiar el store if (response.status === 401) { this.clear(); diff --git a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/+page.svelte b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/+page.svelte index a5abd0d8..9ad3119a 100644 --- a/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/+page.svelte +++ b/frontend/src/routes/dashboard/general_catalogs/tariff-fractions/+page.svelte @@ -13,14 +13,15 @@ import { obtenerAtajosListaFracciones } from '$lib/config/shortcuts/dashboard/general_catalogs/tariff_fractions/list'; import { onMount } from 'svelte'; + // Estado let tariffFractions = $state([]); - let filteredFractions = $state([]); let searchQuery = $state(''); let isLoading = $state(false); let currentPage = $state(1); let totalPages = $state(1); let totalRecords = $state(0); const pageSize = 50; + let searchTimeout: NodeJS.Timeout; // Atajos useShortcuts( @@ -35,24 +36,24 @@ loadTariffFractions(); }); - // Filtrar fracciones cuando cambia la búsqueda + // Efecto para búsqueda con debounce $effect(() => { - if (searchQuery.trim() === '') { - filteredFractions = tariffFractions; - } else { - const query = searchQuery.toLowerCase(); - filteredFractions = tariffFractions.filter( - (fraction) => - fraction.code.toLowerCase().includes(query) || - fraction.fraction.toLowerCase().includes(query) || - (fraction.description ?? '').toLowerCase().includes(query) || - (fraction.nico ?? '').toLowerCase().includes(query) || - (fraction.umt ?? '').toLowerCase().includes(query) - ); - } + // Limpiar timeout anterior + clearTimeout(searchTimeout); + + // Setup nuevo timeout + searchTimeout = setTimeout(() => { + // Resetear a página 1 cuando cambia la búsqueda + if (currentPage !== 1) { + currentPage = 1; + } + loadTariffFractions(1, searchQuery); + }, 500); + + return () => clearTimeout(searchTimeout); }); - async function loadTariffFractions(page: number = 1) { + async function loadTariffFractions(page: number = 1, search: string = '') { const companyId = companyStore.activeCompany?.id; if (!companyId) { console.error('No hay compañía activa'); @@ -61,10 +62,15 @@ isLoading = true; try { - const response = await getTariffFractions(page, pageSize, companyId); + // Preparar filtros + const filters: Record = {}; + if (search.trim()) { + filters.search = search.trim(); + } + + const response = await getTariffFractions(page, pageSize, companyId, filters); if (response.data) { tariffFractions = response.data.items; - filteredFractions = response.data.items; totalPages = response.data.pages; totalRecords = response.data.total; currentPage = response.data.page; @@ -78,31 +84,31 @@ async function goToPage(page: number) { if (page >= 1 && page <= totalPages && page !== currentPage) { - await loadTariffFractions(page); + await loadTariffFractions(page, searchQuery); } }
- + Catálogo de Fracciones SITAR - SCAII -

Nomenclatura arancelaria mexicana completa

+

Nomenclatura arancelaria mexicana completa

-
-
+
+
@@ -120,13 +126,13 @@ Cargando...
{:else} - Mostrando {filteredFractions.length} de {totalRecords} fracciones arancelarias + Mostrando {tariffFractions.length} de {totalRecords} fracciones arancelarias {#if searchQuery} (filtrado) {/if} {/if}
- {#if !searchQuery && totalPages > 1} + {#if totalPages > 1}
Página {currentPage} de {totalPages}
@@ -134,9 +140,9 @@
-
+
- + Código Fracción @@ -148,9 +154,9 @@ - {#if filteredFractions.length === 0} + {#if tariffFractions.length === 0} - + {#if isLoading} Cargando fracciones arancelarias... {:else if searchQuery} @@ -161,7 +167,7 @@ {:else} - {#each filteredFractions as fraction (fraction.id)} + {#each tariffFractions as fraction (fraction.id)} {fraction.code} - {#if !searchQuery && totalPages > 1} + {#if totalPages > 1}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index ea64e1f0..be1ec0f3 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -65,7 +65,7 @@
-
+
{#if line} @@ -129,6 +129,7 @@ bind:descriptions={editingItem.lines![0].description!} bind:customs={editingItem.lines![0].customs!} bind:quantities={editingItem.lines![0].quantity!} + invoice={invoice} /> {(lineItem as any).class_description}

{/if} - {#if lineItem.class_id} -

ID: {lineItem.class_id}

- {/if}
@@ -183,9 +180,6 @@
- {#if (lineItem as any).unit_description} -

{(lineItem as any).unit_description}

- {/if}
@@ -217,9 +211,6 @@
- {#if (customs as any).fraction_description} -

{(customs as any).fraction_description}

- {/if}
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/package-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/package-dialog.svelte new file mode 100644 index 00000000..85e20201 --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/package-dialog.svelte @@ -0,0 +1,163 @@ + + + + + + CATALOGO DE BULTOS + + +
+
+ + +
+
+ +
+ {#if loading} +
+ +
+ {:else if error} +
+

{error}

+
+ {:else} +
+ + + + + + + + + + + + {#each filteredPackages as pkg, i} + handleSelect(pkg)} + > + + + + + + + {/each} + {#if filteredPackages.length === 0} + + + + {/if} + +
ClaveDescripción EspañolDescripción InglésPeso UnitarioCódigo ACE
{pkg.key || ''}{pkg.description_es || ''}{pkg.description_en || ''}{pkg.weight_unit || ''}{pkg.code_ace || ''}
+ No se encontraron resultados +
+
+ {/if} +
+ +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte index d3c6f785..d0ba2383 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/packages-section.svelte @@ -1,21 +1,95 @@
@@ -28,7 +102,24 @@
- +
+ + +
@@ -36,10 +127,10 @@
- +
- +
@@ -57,7 +148,7 @@
- KILOS + {weightUnitLabel}
@@ -99,3 +190,5 @@
+ + diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/payment-method-dialog.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/payment-method-dialog.svelte new file mode 100644 index 00000000..fe65a75a --- /dev/null +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/payment-method-dialog.svelte @@ -0,0 +1,151 @@ + + + + + + CATALOGO DE FORMAS DE PAGO + + +
+
+ + +
+
+ +
+ {#if loading} +
+ +
+ {:else if error} +
+

{error}

+
+ {:else} +
+ + + + + + + + + {#each filteredMethods as method, i} + handleSelect(method)} + > + + + + {/each} + {#if filteredMethods.length === 0} + + + + {/if} + +
ClaveDescripción
{method.key || ''}{method.description || ''}
+ No se encontraron resultados +
+
+ {/if} +
+ +
+ +
+
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte index 6381f4c9..e3111bde 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/tab-continuation.svelte @@ -3,7 +3,10 @@ import { Input } from '$lib/components/ui/input'; import { Label } from '$lib/components/ui/label'; import { Checkbox } from '$lib/components/ui/checkbox'; + import { Button } from '$lib/components/ui/button'; + import { Folder } from 'lucide-svelte'; import type { LineItem, LineDescriptions } from '$lib/api/dashboard/a76/items'; + import PaymentMethodDialog from './payment-method-dialog.svelte'; let { lineItem = $bindable(), @@ -22,6 +25,47 @@ function setHasCertificate(val: string) { lineItem.has_certificate = val === 'si'; } + + let paymentMethodDialogOpen = $state(false); + let payment_method_description = $state(''); + + // Load payment method description when payment_method exists + $effect(() => { + async function loadPaymentMethodData() { + // Si ya tiene la descripción cargada por enrichItemData, usarla + if ((lineItem as any).payment_method_description) { + payment_method_description = (lineItem as any).payment_method_description; + return; + } + + // Si tiene payment_method pero no descripción, cargarla + if (lineItem.payment_method && !payment_method_description) { + try { + const response = await fetch('/api-sveltekit/payment-methods', { + credentials: 'include' + }); + if (response.ok) { + const data = await response.json(); + const methods = data.items || data.data || data; + if (Array.isArray(methods)) { + const method = methods.find((m: any) => m.key === lineItem.payment_method); + if (method) { + payment_method_description = method.description; + } + } + } + } catch (error) { + console.error('Error loading payment method data:', error); + } + } + } + loadPaymentMethodData(); + }); + + function handlePaymentMethodSelect(method: any) { + lineItem.payment_method = method.key; + payment_method_description = method.description; + }
@@ -49,10 +93,19 @@
- + +
- +
@@ -158,3 +211,4 @@
+ \ No newline at end of file diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte index 4da902cc..82d25695 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/items-tab-form.svelte @@ -290,7 +290,7 @@ quantity_returned: undefined, net_weight: undefined, gross_weight: undefined, - package_key: undefined, + package_id: undefined, package_quantity: undefined, package_description: undefined }, @@ -664,6 +664,53 @@ console.error('Error loading fraction data:', error); } } + + // Load package data (if needed) + const packageId = line.quantity?.package_id; + if (packageId && line.quantity) { + try { + const response = await fetch('/api-sveltekit/packages', { + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + if (response.ok) { + const data = await response.json(); + const packages = data.items || data.data || data; + if (Array.isArray(packages)) { + const pkg = packages.find((p: any) => p.id === packageId); + if (pkg) { + (line.quantity as any).package_description = pkg.description_es || pkg.description_en || pkg.key; + (line.quantity as any).package_key = pkg.key; + (line.quantity as any).package_weight_unit = pkg.weight_unit || 0; + } + } + } + } catch (error) { + console.error('Error loading package data:', error); + } + } + + // Load payment method description (if needed) + if (line.payment_method) { + try { + const response = await fetch('/api-sveltekit/payment-methods', { + method: 'GET', + headers: { 'Content-Type': 'application/json' } + }); + if (response.ok) { + const data = await response.json(); + const methods = data.items || data.data || data; + if (Array.isArray(methods)) { + const method = methods.find((m: any) => m.key === line.payment_method); + if (method) { + (line as any).payment_method_description = method.description; + } + } + } + } catch (error) { + console.error('Error loading payment method data:', error); + } + } } // Normalize numeric values from strings to numbers diff --git a/frontend/src/routes/api-sveltekit/packages/+server.ts b/frontend/src/routes/api-sveltekit/packages/+server.ts new file mode 100644 index 00000000..a78d61e0 --- /dev/null +++ b/frontend/src/routes/api-sveltekit/packages/+server.ts @@ -0,0 +1,80 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies, url }) => { + const token = cookies.get('access_token'); + + // Obtener company_id de la cookie + const companyId = cookies.get('active_company_id'); + + if (!companyId) { + return new Response( + JSON.stringify({ error: 'No company selected' }), + { + status: 400, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } + + // Configurar la URL de la API usando las variables de entorno + let apiUrl = process.env.INTERNAL_API_URL; + if (!apiUrl) { + apiUrl = process.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + + // Get query parameters and add company_id + const searchParams = new URLSearchParams(url.search); + searchParams.set('company_id', companyId); + const queryString = searchParams.toString(); + + try { + const fetchUrl = `${baseUrl}v1/a76/packages?${queryString}`; + + const response = await fetch( + fetchUrl, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + + const data = await response.json(); + + if (!response.ok) { + return new Response(JSON.stringify(data), { + status: response.status, + headers: { + 'Content-Type': 'application/json' + } + }); + } + + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); + } catch (error) { + console.error('Error fetching packages:', error); + return new Response( + JSON.stringify({ error: 'Failed to fetch packages' }), + { + status: 500, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } +}; diff --git a/frontend/src/routes/api-sveltekit/payment-methods/+server.ts b/frontend/src/routes/api-sveltekit/payment-methods/+server.ts new file mode 100644 index 00000000..688259a0 --- /dev/null +++ b/frontend/src/routes/api-sveltekit/payment-methods/+server.ts @@ -0,0 +1,64 @@ +import type { RequestHandler } from './$types'; + +export const GET: RequestHandler = async ({ cookies, url }) => { + const token = cookies.get('access_token'); + + // Configurar la URL de la API usando las variables de entorno + let apiUrl = process.env.INTERNAL_API_URL; + if (!apiUrl) { + apiUrl = process.env.VITE_API_URL; + // Reemplazar 'localhost' con 'backend' para llamadas desde el servidor (SSR) + apiUrl = apiUrl?.replace('localhost', 'backend').replace('127.0.0.1', 'backend'); + } + + // Normalizar la URL + const baseUrl = apiUrl?.endsWith('/') ? apiUrl : `${apiUrl}/`; + + // Get query parameters + const searchParams = new URLSearchParams(url.search); + const queryString = searchParams.toString(); + + try { + const fetchUrl = `${baseUrl}v1/public/reference_data/payment-methods${queryString ? `?${queryString}` : ''}`; + + const response = await fetch( + fetchUrl, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` + } + } + ); + + const data = await response.json(); + + if (!response.ok) { + return new Response(JSON.stringify(data), { + status: response.status, + headers: { + 'Content-Type': 'application/json' + } + }); + } + + return new Response(JSON.stringify(data), { + status: 200, + headers: { + 'Content-Type': 'application/json' + } + }); + } catch (error) { + console.error('Error fetching payment methods:', error); + return new Response( + JSON.stringify({ error: 'Failed to fetch payment methods' }), + { + status: 500, + headers: { + 'Content-Type': 'application/json' + } + } + ); + } +}; From 498d213ad1d24f8455257f451294f13114ca4211 Mon Sep 17 00:00:00 2001 From: Galindo97 Date: Mon, 16 Feb 2026 12:53:02 -0600 Subject: [PATCH 100/102] fix: Reset tariff fraction selector state and clear fractions only when the modal is initially opened. --- .../modales/TariffFractionSelector.svelte | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte index b20dfff8..30a4b97f 100644 --- a/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte +++ b/frontend/src/lib/components/dashboard/goods/modales/TariffFractionSelector.svelte @@ -23,18 +23,21 @@ let isLoadingFractions = $state(false); let searchTimeout: ReturnType; + // Cargar datos al abrir + let wasOpen = $state(false); + // Cargar datos al abrir $effect(() => { - if (open) { - // Reiniciar estado al abrir - if (tariffFractions.length === 0) { - searchFraction = ''; - currentPage = 1; - totalFractions = 0; - hasMoreFractions = true; - loadFractions('', 1); - } + if (open && !wasOpen) { + // Reiniciar estado SOLO al abrir + searchFraction = ''; + currentPage = 1; + totalFractions = 0; + hasMoreFractions = true; + tariffFractions = []; + loadFractions('', 1); } + wasOpen = open; }); async function loadFractions(search: string, page: number = currentPage) { From 91c3f7105b39a368751452601b7e254fd1dc8cf4 Mon Sep 17 00:00:00 2001 From: hreyes Date: Mon, 16 Feb 2026 17:17:07 -0600 Subject: [PATCH 101/102] feature/csv-envoices-integration --- .../api/v1/modules/a76/imports/__init__.py | 0 backend/api/v1/modules/a76/imports/routes.py | 118 +++ backend/api/v1/modules/a76/imports/schemas.py | 20 + backend/api/v1/modules/a76/imports/tasks.py | 876 ++++++++++++++++++ .../v1/modules/a76/invoice_settings/dto.py | 8 +- .../v1/modules/a76/invoice_settings/routes.py | 3 +- .../modules/a76/invoice_settings/services.py | 4 +- backend/api/v1/modules/a76/router.py | 3 + backend/core/celery_app.py | 3 +- backend/main.py | 128 +-- .../csv-upload/ProcessingResultModal.svelte | 275 ++++++ .../invoices/edit/general-tab-form.svelte | 551 +++++------ frontend/src/lib/config/csv-upload.ts | 4 +- .../routes/dashboard/csv-upload/+page.svelte | 146 ++- 14 files changed, 1777 insertions(+), 362 deletions(-) create mode 100644 backend/api/v1/modules/a76/imports/__init__.py create mode 100644 backend/api/v1/modules/a76/imports/routes.py create mode 100644 backend/api/v1/modules/a76/imports/schemas.py create mode 100644 backend/api/v1/modules/a76/imports/tasks.py create mode 100644 frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte diff --git a/backend/api/v1/modules/a76/imports/__init__.py b/backend/api/v1/modules/a76/imports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/imports/routes.py b/backend/api/v1/modules/a76/imports/routes.py new file mode 100644 index 00000000..1ea76a74 --- /dev/null +++ b/backend/api/v1/modules/a76/imports/routes.py @@ -0,0 +1,118 @@ +from datetime import datetime +from uuid import uuid4 +import os +import json +import logging +from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query +from sqlalchemy.orm import Session +from typing import Optional, Literal, Dict, Any + +from core.celery_app import celery_app +from core.config import settings +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource + +from .tasks import scan_file, insert_valid_rows +from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest + +router = APIRouter() +logger = logging.getLogger(__name__) + +@router.post("/upload/{model_target}", response_model=ImportJobResponse) +async def upload_import_file( + model_target: Literal["invoice_header", "invoice_details"], + file: UploadFile = File(...), + footer_config: Optional[str] = Form(None), # JSON string with settings + company_id: int = Query(..., description="Company ID"), # Required for context + operation_type: Optional[str] = Query("imp"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Step 1: Upload CSV, save to temp, trigger scan task. + """ + # 1. Validate Access & Get Tenant + try: + tenant_id = validate_access_to_resource(db, company_id, current_user) + except Exception as e: + logger.error(f"Access validation failed: {e}") + raise HTTPException(status_code=403, detail="Invalid company access") + + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="Only .csv files allowed") + + job_id = str(uuid4()) + + # Ensure directory exists (Safety check) + upload_dir = os.path.join(os.getcwd(), "uploads", "temp") + os.makedirs(upload_dir, exist_ok=True) + + file_path = os.path.join(upload_dir, f"{job_id}.csv") + meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") + + try: + # Save CSV + contents = await file.read() + with open(file_path, "wb") as f: + f.write(contents) + + # Save Metadata (Context) + meta_data = { + "tenant_id": tenant_id, + "company_id": company_id, + "user_id": current_user.get("id"), + "footer_config": footer_config, + "operation_type": operation_type, + } + with open(meta_path, "w") as f: + json.dump(meta_data, f) + + except Exception as e: + logger.error(f"File save error: {e}") + raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}") + + # Trigger Celery Task (Async) + # Use our job_id as the Celery task_id for easier tracking + scan_file.apply_async(args=[job_id, file_path, model_target, footer_config], task_id=job_id) + + return ImportJobResponse( + job_id=job_id, + status="queued", + message="File uploaded. Scanning started." + ) + +@router.get("/{job_id}/status") +async def get_import_status(job_id: str): + """ + Poll this endpoint to get % progress or final report. + """ + # In a real app, query Redis or DB. + # For MVP, we might mock or use Celery AsyncResult if backend shares Redis. + task_result = celery_app.AsyncResult(job_id) + + if task_result.state == 'PENDING': + return {"status": "processing", "progress": 0} + elif task_result.state == 'PROGRESS': + return { + "status": "processing", + "progress": task_result.info.get('current', 0), + "total": task_result.info.get('total', 0) + } + elif task_result.state == 'SUCCESS': + return task_result.result # Should return the report + else: + return {"status": task_result.state, "error": str(task_result.info)} + + +@router.post("/{job_id}/commit") +async def commit_import_job(job_id: str, body: CommitRequest): + """ + Step 2: User confirms import. Trigger bulk insert. + """ + task = insert_valid_rows.delay(job_id, body.model_target) + + return { + "status": "committing", + "message": "Bulk insert started.", + "commit_job_id": task.id + } diff --git a/backend/api/v1/modules/a76/imports/schemas.py b/backend/api/v1/modules/a76/imports/schemas.py new file mode 100644 index 00000000..63c0202a --- /dev/null +++ b/backend/api/v1/modules/a76/imports/schemas.py @@ -0,0 +1,20 @@ +from pydantic import BaseModel +from typing import Optional, Literal + +class ImportJobResponse(BaseModel): + job_id: str + status: str + message: str + +class CommitRequest(BaseModel): + model_target: Literal["invoice_header", "invoice_details"] + +class ImportJobStatus(BaseModel): + status: str + job_id: str + total_rows: Optional[int] = 0 + error_count: Optional[int] = 0 + valid_rows: Optional[int] = 0 + error: Optional[str] = None + inserted: Optional[int] = 0 + error_file: Optional[str] = None diff --git a/backend/api/v1/modules/a76/imports/tasks.py b/backend/api/v1/modules/a76/imports/tasks.py new file mode 100644 index 00000000..dfb82e74 --- /dev/null +++ b/backend/api/v1/modules/a76/imports/tasks.py @@ -0,0 +1,876 @@ +import os +from datetime import datetime +from decimal import Decimal +import csv +import json +import logging +import re +import unicodedata +from celery import shared_task +from typing import Dict, Any, Optional +from core.database import CoreSessionLocal +# Models are imported inside tasks to avoid circular dependencies and mapper initialization issues in the API process + +# We'll need schemas for validation +# from api.v1.modules.a76.invoices.schemas import InvoiceHeaderCreate +# But for Phase 1 we use a lighter check + +logger = logging.getLogger(__name__) + +class ForeignKeyValidator: + def __init__(self, session, tenant_id, company_id): + self.session = session + self.tenant_id = tenant_id + self.company_id = company_id + self.cache = {} # {(model_name, value): bool} + + def check_exists(self, model, value, field_name="id", is_public=False): + if value is None: + return True # Assume optional if None, or let DB handle not-null + + key = (model.__name__, value) + if key in self.cache: + return self.cache[key] + + query = self.session.query(getattr(model, field_name)).filter(getattr(model, field_name) == value) + if not is_public: + query = query.filter(model.tenant_id == self.tenant_id, model.company_id == self.company_id) + + exists = query.first() is not None + self.cache[key] = exists + return exists + +@shared_task(bind=True) +def scan_file(self, job_id: str, file_path: str, model_target: str, config: str = None): + """ + Pass 1: Read CSV, Validate types, Write Errors to JSONL. + """ + logger.info(f"Starting scan for job {job_id} target {model_target}") + + # 1. Setup Error Log + error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl") + os.makedirs(os.path.dirname(error_path), exist_ok=True) + + total_rows = 0 + error_count = 0 + processed_rows = 0 + + # 2. Count Total (Quick Pass) or just estimate + # For better progress, we can get file line count first + try: + with open(file_path, 'r', encoding='utf-8-sig') as f: + total_rows = sum(1 for _ in f) - 1 # Minus header + except Exception as e: + return {"status": "failed", "error": f"Cannot read file: {e}"} + + footer_config = parse_footer_config(config) + date_format = footer_config.get("dateFormat") + + # Validate and set default date_format if not provided + if not date_format: + date_format = "yyyy-mm-dd" # Default to ISO format + logger.info(f"No date_format specified in config, using default: {date_format}") + + try: + with open(file_path, 'r', encoding='utf-8-sig') as f_in, \ + open(error_path, 'w', encoding='utf-8') as f_err: + + # Detect Delimiter + sample = f_in.read(2048) + f_in.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except: + dialect = 'excel' + + reader = csv.DictReader(f_in, dialect=dialect) + + for i, row in enumerate(reader, start=1): + # Check for Progress Update + if i % 1000 == 0: + self.update_state(state='PROGRESS', meta={ + 'current': i, + 'total': total_rows, + 'errors': error_count + }) + + # Validation (Phase 1: Minimal) + row_norm = normalize_row(row) + errors = validate_row_phase_1(row_norm, model_target, i, date_format) + + if errors: + error_count += 1 + # Write simple JSON error + f_err.write(json.dumps(errors) + "\n") + + processed_rows += 1 + + except Exception as e: + logger.error(f"Scan failed: {e}") + return {"status": "failed", "error": str(e)} + + # 4. Result + return { + "status": "waiting_confirmation", + "job_id": job_id, + "total_rows": processed_rows, + "error_count": error_count, + "valid_rows": processed_rows - error_count, + "error_file": error_path + } + +def validate_row_phase_1( + row: Dict[str, Any], + target: str, + line_num: int, + date_format: Optional[str], +) -> Dict[str, Any]: + """ + Minimal validation: Unique IDs and Dates. + Target: 'invoice_header' or 'invoice_details' + """ + errors = {} + + # A. Invoice Header + if target == 'invoice_header': + # 1. Unique ID + if not row.get('NUMERO FACTURA') and not row.get('NUM FACTURA') and not row.get('ID'): + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} + + # 2. Date Format + date_str = row.get('FECHA FACTURA') + if date_str: + if not is_valid_date(date_str, date_format): + expected = display_date_format(date_format) + return { + "line": line_num, + "col": "FECHA FACTURA", + "msg": f"Formato inválido ({expected})", + } + else: + return {"line": line_num, "col": "FECHA FACTURA", "msg": "Requerido"} + + # B. Invoice Details (Parts) + elif target == 'invoice_details': + # 1. Line Number + if not row.get('LINEA'): + return {"line": line_num, "col": "LINEA", "msg": "Requerido"} + + # 2. Parent Link (Invoice Number) + if not (row.get('NUMERO FACTURA') or row.get('NUM FACTURA') or row.get('FACTURA')): + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} + + # 2. Parent Link (Simplified for now, we assume parent exists or is in same batch) + # In a real scenario, we'd check if the invoice exists. + pass + + return errors if errors else None + +def parse_footer_config(config: Optional[str]) -> Dict[str, Any]: + if not config: + return {} + try: + if isinstance(config, str): + return json.loads(config) + if isinstance(config, dict): + return config + except Exception: + return {} + return {} + + +def display_date_format(date_format: Optional[str]) -> str: + if not date_format: + return "YYYY-MM-DD" + return date_format.upper() + + +def parse_date(date_text: Optional[str], date_format: Optional[str]) -> Optional[datetime.date]: + if not date_text: + return None + candidates = [] + fmt_map = { + "dd/mm/yyyy": "%d/%m/%Y", + "mm/dd/yyyy": "%m/%d/%Y", + "yyyy-mm-dd": "%Y-%m-%d", + } + if date_format and date_format in fmt_map: + candidates.append(fmt_map[date_format]) + candidates.extend(["%Y-%m-%d", "%d/%m/%Y", "%m/%d/%Y"]) + for fmt in candidates: + try: + return datetime.strptime(str(date_text).strip(), fmt).date() + except ValueError: + continue + return None + + +def is_valid_date(date_text: Optional[str], date_format: Optional[str]) -> bool: + return parse_date(date_text, date_format) is not None + + +def normalize_header(name: Optional[str]) -> str: + if not name: + return "" + name = unicodedata.normalize("NFKD", str(name)).upper() + name = "".join(ch for ch in name if not unicodedata.combining(ch)) + name = re.sub(r"[^A-Z0-9]+", " ", name) + return re.sub(r"\s+", " ", name).strip() + + +def normalize_row(row: Dict[str, Any]) -> Dict[str, Any]: + return {normalize_header(k): v for k, v in row.items()} + + +def parse_int(value: Any) -> Optional[int]: + if value is None: + return None + text = str(value).strip() + if not text: + return None + try: + return int(text) + except ValueError: + return None + + +def parse_decimal(value: Any) -> Optional[Decimal]: + if value is None: + return None + text = str(value).strip() + if not text: + return None + text = text.replace(",", "") + try: + return Decimal(text) + except Exception: + return None + + +def parse_currency(value: Optional[str], currency_type: Optional[str]): + from api.v1.modules.a76.invoices.models import Currency + if value: + normalized = normalize_header(value) + if normalized in {"MN", "M N", "NACIONAL", "LOCAL", "PESOS", "PESO"}: + return Currency.LOCAL + if normalized in {"ME", "M E", "EXTRANJERA", "EXTRANJERO", "FOREIGN", "USD", "DOLAR", "DOLARES"}: + return Currency.FOREIGN + if "MANUAL" in normalized: + return Currency.MANUAL + if currency_type and str(currency_type).strip().upper() == "MXN": + return Currency.LOCAL + if currency_type: + return Currency.FOREIGN + return Currency.MANUAL + + +def parse_weight_unit(value: Optional[str]): + from api.v1.modules.a76.invoices.models import WeightUnit + if not value: + return None + normalized = normalize_header(value) + if normalized in {"KG", "KGS", "KILOS", "KILOGRAMOS"}: + return WeightUnit.KGS + if normalized in {"LB", "LBS", "LIBRAS"}: + return WeightUnit.LBS + return None + + +def resolve_tenant_fk_id( + session: CoreSessionLocal, + model, + value: Optional[int], + tenant_id: int, + company_id: int, + cache: Dict[int, Optional[int]], +) -> Optional[int]: + if value is None: + return None + if value in cache: + return cache[value] + exists = ( + session.query(model.id) + .filter( + model.id == value, + model.tenant_id == tenant_id, + model.company_id == company_id, + ) + .scalar() + ) + cache[value] = value if exists is not None else None + return cache[value] + + +def resolve_public_code( + session: CoreSessionLocal, + model, + column, + value: Optional[str], + cache: Dict[str, Optional[str]], +) -> Optional[str]: + if not value: + return None + normalized = str(value).strip().upper() + if not normalized: + return None + if normalized in cache: + return cache[normalized] + exists = session.query(column).filter(column == normalized).scalar() + cache[normalized] = normalized if exists is not None else None + return cache[normalized] + +@shared_task(bind=True) +def insert_valid_rows(self, job_id: str, model_target: str): + """ + Pass 2: Re-read CSV, Skip Errors, Bulk Insert. + """ + logger.info(f"Starting Commit for {job_id} target {model_target}") + + try: + from api.v1.modules.a76.invoices.models import ( + InvoiceHeader, + InvoiceComplianceMx, + InvoiceFinancials, + InvoiceLogistics, + InvoiceSalesDetails, + OperationType, + WeightUnit, + ) + from api.v1.modules.a76.clients_and_providers.models import ClientProvider + from api.v1.modules.a76.customs_brokers.models import CustomsBroker + from api.v1.modules.public.reference_data.currency_types.models import CurrencyType + from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento + from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen + from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode + from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType + from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection + + from api.v1.modules.a76.items.models import Item + from api.v1.modules.a76.items.line_items.models import LineItem + from api.v1.modules.a76.items.line_financials.models import LineFinancial + from api.v1.modules.a76.items.line_quantities.models import LineQuantity + from api.v1.modules.a76.items.line_customs.models import LineCustom + from api.v1.modules.a76.items.line_descriptions.models import LineDescription + from api.v1.modules.a76.parts.models import Part + + upload_dir = os.path.join(os.getcwd(), "uploads", "temp") + file_path = os.path.join(upload_dir, f"{job_id}.csv") + error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl") + + # 1. Load Error Line Numbers + error_lines = set() + if os.path.exists(error_path): + with open(error_path, 'r', encoding='utf-8') as f: + for line in f: + try: + err = json.loads(line) + error_lines.add(err['line']) + except: pass + + # Load Metadata (Context) + meta_path = file_path.replace("temp", "temp").replace(".csv", ".meta.json") + tenant_id = None + company_id = None + footer_config = {} + + if os.path.exists(meta_path): + try: + with open(meta_path, 'r') as f: + meta = json.load(f) + tenant_id = meta.get('tenant_id') + company_id = meta.get('company_id') + operation_type_raw = meta.get('operation_type', 'imp') + footer_config = parse_footer_config(meta.get('footer_config')) + except: pass + + if not tenant_id or not company_id: + return {"status": "failed", "error": "Missing context (tenant/company)"} + + # 2. Re-read and Map + # Initialize counters outside the session block so they're accessible later + headers_to_insert = [] + details_to_insert = [] + skipped_invalid = 0 + skipped_missing_invoice = 0 + skipped_missing_fk = 0 + skipped_fk_details = [] + inserted_count = 0 + response = None # Will be set inside the session block + + date_format = footer_config.get("dateFormat") + + # Validate and set default date_format if not provided + if not date_format: + date_format = "yyyy-mm-dd" # Default to ISO format + logger.info(f"No date_format specified in config, using default: {date_format}") + else: + logger.info(f"Using date_format from config: {date_format}") + + # Default types from config or fallback + op_type_value = OperationType(meta.get('operation_type', 'imp').lower()) + inv_type_value = footer_config.get('invoice_type', 'TEM') + + logger.info(f"Processing CSV with operation_type={op_type_value}, invoice_type={inv_type_value}, date_format={date_format}") + + with CoreSessionLocal() as session: + invoice_id_cache = {} + cleared_invoices = set() # Track invoices where we've already cleared items in this job + provider_cache: Dict[int, Optional[int]] = {} + sold_to_cache: Dict[int, Optional[int]] = {} + shipped_to_cache: Dict[int, Optional[int]] = {} + broker_cache: Dict[int, Optional[int]] = {} + regimen_cache: Dict[str, Optional[str]] = {} + currency_type_cache: Dict[str, Optional[str]] = {} + customs_section_cache: Dict[str, Optional[str]] = {} + part_cache: Dict[str, Optional[int]] = {} + + validator = ForeignKeyValidator(session, tenant_id, company_id) + + with open(file_path, 'r', encoding='utf-8-sig') as f: + # Detect Delimiter + sample = f.read(2048) + f.seek(0) + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except: + dialect = 'excel' + + reader = csv.DictReader(f, dialect=dialect) + + for i, row in enumerate(reader, start=1): + if i in error_lines: + continue + + row_norm = normalize_row(row) + + # Mapping Logic + if model_target == 'invoice_header': + invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip() + invoice_date = parse_date(row_norm.get('FECHA FACTURA') or row_norm.get('FECHA'), date_format) + + if not invoice_number or not invoice_date: + skipped_invalid += 1 + logger.debug(f"Row {i}: Skipped - missing invoice_number or invalid invoice_date. " + f"Invoice: {invoice_number}, Date: {row_norm.get('FECHA FACTURA') or row_norm.get('FECHA')}") + continue + + # --- NEW: Foreign Key Validations --- + # 1. Invoice Type (Public) + if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True): + skipped_missing_fk += 1 + reason = f"Tipo de factura '{inv_type_value}' no existe" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + # 2. Client/Provider (Tenant) + provider_id = parse_int(row_norm.get('CLAVE PROVEEDOR')) + if provider_id and not validator.check_exists(ClientProvider, provider_id): + skipped_missing_fk += 1 + reason = f"Proveedor ID '{provider_id}' no existe" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + # 3. Customs Broker (Tenant) + broker_id = parse_int(row_norm.get('AGENTE ADUANAL')) + if broker_id and not validator.check_exists(CustomsBroker, broker_id): + skipped_missing_fk += 1 + reason = f"Agente Aduanal ID '{broker_id}' no existe" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + # --- 4. Check for Existing Invoice (Upsert Logic) --- + existing_header = None + if invoice_number: + existing_header = ( + session.query(InvoiceHeader) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.invoice_type == inv_type_value + ) + .first() + ) + + if existing_header: + # UPDATE existing header + header = existing_header + header.invoice_date = invoice_date + header.operation_type = op_type_value + header.is_updated = True # Mark as updated + header.updated_date = datetime.utcnow() + header.document_type = resolve_public_code( + session, + RegimenPedimento, + RegimenPedimento.code, + (row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')), + regimen_cache, + ) + header.project_number = (row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None) + header.purchase_order = (row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None) + header.alternate_invoice = (row_norm.get('FACTURA ALTERNA') or None) + header.invoice_ref = (row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None) + header.emission_date = parse_date(row_norm.get('FECHA EMISION'), date_format) + header.observation_es = (row_norm.get('OBSERVACIONES E') or None) + header.observation_en = (row_norm.get('OBSERVACIONES I') or None) + + logger.info(f"Row {i}: Updating existing invoice {invoice_number}") + + # Clean up related data that will be re-inserted/updated + # Note: compliance, financials, logistics are 1-to-1 relationships and will be updated by assignment below + # but we might want to be explicit if ORM doesn't handle replace well. + # SQLAlchemy relationship assignment usually handles 1-to-1 updates correctly. + + else: + # CREATE new header + header = InvoiceHeader( + invoice_number=invoice_number, + invoice_date=invoice_date, + operation_type=op_type_value, + is_updated=False, + system="CSV", + capture_date=datetime.utcnow(), + invoice_type=inv_type_value, + document_type=resolve_public_code( + session, + RegimenPedimento, + RegimenPedimento.code, + (row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO')), + regimen_cache, + ), + project_number=(row_norm.get('NUM PROYECTO') or row_norm.get('NUMPROYECTO') or None), + purchase_order=(row_norm.get('ORDEN COMPRA') or row_norm.get('ORDENCOMPRA') or None), + alternate_invoice=(row_norm.get('FACTURA ALTERNA') or None), + invoice_ref=(row_norm.get('FACTURA EXPO REF') or row_norm.get('FACTURAEXPOREF') or None), + emission_date=parse_date(row_norm.get('FECHA EMISION'), date_format), + observation_es=(row_norm.get('OBSERVACIONES E') or None), + observation_en=(row_norm.get('OBSERVACIONES I') or None), + tenant_id=tenant_id, + company_id=company_id, + ) + + compliance = InvoiceComplianceMx( + remesa=parse_int(row_norm.get('REMESA')), + aduana=resolve_public_code( + session, + CustomsSection, + CustomsSection.customs_code, + row_norm.get('ADUANA DE CRUCE'), + customs_section_cache, + ), + provider_id=resolve_tenant_fk_id( + session, + ClientProvider, + parse_int(row_norm.get('CLAVE PROVEEDOR')), + tenant_id, + company_id, + provider_cache, + ), + sold_to_id=resolve_tenant_fk_id( + session, + ClientProvider, + parse_int(row_norm.get('CLAVE VENDIDO A')), + tenant_id, + company_id, + sold_to_cache, + ), + shipped_to_id=resolve_tenant_fk_id( + session, + ClientProvider, + parse_int(row_norm.get('CLAVE ENVIADO A')), + tenant_id, + company_id, + shipped_to_cache, + ), + customs_broker_id=resolve_tenant_fk_id( + session, + CustomsBroker, + parse_int(row_norm.get('AGENTE ADUANAL')), + tenant_id, + company_id, + broker_cache, + ), + edocument=(row_norm.get('E DOCUMENT') or None), + vucem_operation_num=(row_norm.get('NUM OPERACION') or None), + tenant_id=tenant_id, + company_id=company_id, + ) + + financials_currency_type = resolve_public_code( + session, + CurrencyType, + CurrencyType.code, + row_norm.get('CLAVE MONEDA'), + currency_type_cache, + ) + financials = InvoiceFinancials( + currency=parse_currency(row_norm.get('TIPO MONEDA'), financials_currency_type), + currency_type=financials_currency_type, + exchange_rate=parse_decimal(row_norm.get('TIPO DE CAMBIO')), + freight=parse_decimal(row_norm.get('FLETES')), + insurance_value=parse_decimal(row_norm.get('VALOR SEGUROS')), + insurance=parse_decimal(row_norm.get('SEGUROS')), + packaging=parse_decimal(row_norm.get('EMBALAJES')), + other_increments=parse_decimal(row_norm.get('OTROS INCREMENTABLES')), + tenant_id=tenant_id, + company_id=company_id, + ) + + weight_type = parse_weight_unit(row_norm.get('TIPO PESO')) + logistics = None + if weight_type or row_norm.get('TIPO TRANSPORTE') or row_norm.get('NUMERO TRANSPORTE'): + logistics = InvoiceLogistics( + carrier_id=(row_norm.get('CLAVE TRANSPORTISTA') or None), + driver_name=(row_norm.get('NOMBRE CONDUCTOR') or None), + transport_type=str(row_norm.get('TIPO TRANSPORTE') or "none").lower(), + transport_num=(row_norm.get('NUMERO TRANSPORTE') or None), + weight_type=weight_type or WeightUnit.KGS, + seal_number=(row_norm.get('PRECINTO') or None), + incoterm=(row_norm.get('CLAVE INCOTERM') or None), + entry_exit_date=parse_date(row_norm.get('FECHA EMISION'), date_format), + tenant_id=tenant_id, + company_id=company_id, + ) + + header.compliance_mx = compliance + header.financials = financials + if logistics: + header.logistics = logistics + + headers_to_insert.append(header) + + elif model_target == 'invoice_details': + invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip() + if not invoice_number: + skipped_invalid += 1 + continue + + if invoice_number in invoice_id_cache: + invoice_id = invoice_id_cache[invoice_number] + else: + invoice_id = ( + session.query(InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == tenant_id, + InvoiceHeader.company_id == company_id, + InvoiceHeader.invoice_number == invoice_number, + ) + .scalar() + ) + invoice_id_cache[invoice_number] = invoice_id + + if not invoice_id: + logger.warning( + "Invoice not found for details row %s (invoice_number=%s)", + i, + invoice_number, + ) + skipped_missing_invoice += 1 + continue + + # --- Prevent Duplicates: Clear existing items for this invoice (Once per job) --- + if invoice_id not in cleared_invoices: + logger.info(f"Clearing existing details for Invoice {invoice_number} (ID: {invoice_id}) to prevent duplicates") + + # 1. Delete Items (Cascades to LineItem, LineFinancial, etc. if DB configured, check models) + # Checking Item model, we usually need to be careful. + # Assuming Cascade delete is set up on FKs or we rely on ORM cascade if using relationships. + # Here we use bulk delete. + session.query(Item).filter(Item.invoice_id == invoice_id).delete(synchronize_session=False) + + # 2. Delete InvoiceSalesDetails + session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False) + + cleared_invoices.add(invoice_id) + + # --- NEW LOGIC: Expanded Anexo 76 Structure --- + + # A. Find/Cache Part + part_num = (row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or '').strip() + part_id = None + if part_num: + part_id = part_cache.get(part_num) + if part_id is None: + p = session.query(Part.id).filter( + Part.part_number == part_num, + Part.tenant_id == tenant_id, + Part.company_id == company_id + ).first() + if p: + part_id = p.id + part_cache[part_num] = part_id + + line_num_val = (row_norm.get('LINEA') or row_norm.get('RENGLON') or row_norm.get('PARTIDA')) + line_num = parse_int(line_num_val) or (len(details_to_insert) + 1) + + # 1. Parent Item + item = Item( + invoice_id=invoice_id, + tenant_id=tenant_id, + company_id=company_id, + item_type="N", # Default to Normal + system_origin="CSV" + ) + session.add(item) + session.flush() # Need item.id + + # 2. Main Line + line = LineItem( + item_id=item.id, + line_number=line_num, + part_number=part_id, + tenant_id=tenant_id, + company_id=company_id + ) + session.add(line) + session.flush() # Need line.id + + # 3. Financial Data + price = parse_decimal(row_norm.get('PRECIO UNITARIO') or row_norm.get('PRECIOUNITARIO')) + val_com = parse_decimal(row_norm.get('VALOR COMERCIAL') or row_norm.get('VALORCOMERCIAL')) + qty = parse_decimal(row_norm.get('CANTIDAD')) + + session.add(LineFinancial( + item_line_id=line.id, + unit_price=price, + commercial_value=val_com or (price * qty if price and qty else None), + )) + + # 4. Quantities + if qty: + session.add(LineQuantity( + item_line_id=line.id, + quantity=qty, + )) + + # 5. Customs/Fraction + origin = row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') + fraction = row_norm.get('FRACCION') + if origin or fraction: + session.add(LineCustom( + item_line_id=line.id, + fraction=fraction, + origin_country=origin, + )) + + # 6. Description + desc = row_norm.get('DESCRIPCION') + if desc: + session.add(LineDescription( + item_line_id=line.id, + description_spanish=desc, + )) + + # 7. Legacy Sales Details (For specific audit/UI fields) + detail = InvoiceSalesDetails( + invoice_id=invoice_id, + line_number=line_num, + sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None), + line_bundles=parse_int(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), + tenant_id=tenant_id, + company_id=company_id, + ) + session.add(detail) + details_to_insert.append(item) # Use as counter/ref + + # 3. Bulk Insert (ORM Transaction) + try: + if model_target == 'invoice_header': + if headers_to_insert: + logger.info(f"Attempting to commit {len(headers_to_insert)} headers") + session.add_all(headers_to_insert) + session.commit() + inserted_count = len(headers_to_insert) + logger.info(f"Headers commit successful. Inserted: {inserted_count}") + else: + logger.warning(f"No headers to insert for job {job_id}") + else: + if details_to_insert: + logger.info(f"Attempting to commit {len(details_to_insert)} items and related data") + session.commit() # Everything was already added with session.add() + inserted_count = len(details_to_insert) + logger.info(f"Details commit successful. Inserted: {inserted_count}") + else: + logger.warning(f"No details to insert for job {job_id}") + + except Exception as db_err: + session.rollback() + logger.error(f"DB Error during {model_target} commit: {db_err}") + import traceback + logger.error(traceback.format_exc()) + return {"status": "failed", "error": str(db_err)} + + # 4. Determine final status and prepare response (inside session block to access variables) + total_skipped = skipped_invalid + skipped_missing_fk + skipped_missing_invoice + + # Log summary + logger.info(f"Job {job_id} completed. Inserted: {inserted_count}, Skipped: {total_skipped} " + f"(invalid: {skipped_invalid}, missing_fk: {skipped_missing_fk}, missing_invoice: {skipped_missing_invoice})") + + # Prepare response based on results + if inserted_count == 0: + if total_skipped > 0: + logger.warning(f"No valid records to insert for job {job_id}. All {total_skipped} records were rejected.") + response = { + "status": "warning", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details, + "message": f"No se insertaron registros. {total_skipped} fueron rechazados." + } + else: + logger.error(f"No valid records found in CSV for job {job_id}") + response = { + "status": "failed", + "error": "No hay registros válidos en el archivo CSV", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details + } + else: + # Success case - at least some records were inserted + response = { + "status": "finished", + "inserted": inserted_count, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details + } + + except Exception as e: + logger.error(f"Task failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"status": "failed", "error": str(e)} + + # 5. Cleanup + try: + if os.path.exists(file_path): + os.remove(file_path) + if os.path.exists(error_path): + os.remove(error_path) + except: + logger.warning("Failed to cleanup temp files") + + # Ensure response is defined (fallback in case of unexpected errors) + if response is None: + logger.error(f"Unexpected error: response not set for job {job_id}") + response = { + "status": "failed", + "error": "Error inesperado durante el procesamiento", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_invoice": skipped_missing_invoice, + "skipped_missing_fk": skipped_missing_fk, + "skipped_details": skipped_fk_details + } + + return response diff --git a/backend/api/v1/modules/a76/invoice_settings/dto.py b/backend/api/v1/modules/a76/invoice_settings/dto.py index 07c954a8..d330bf29 100644 --- a/backend/api/v1/modules/a76/invoice_settings/dto.py +++ b/backend/api/v1/modules/a76/invoice_settings/dto.py @@ -2,13 +2,7 @@ from typing import Any, Dict, Optional from pydantic import BaseModel, ConfigDict from enum import Enum -class OperationType(str, Enum): - IMP = "imp" # Importación - EXP = "exp" # Exportación - SM_IN = "sm_in" # Entrada SM - SM_OUT = "sm_out" # Salida SM - CTM_SEND = "ctm_send" # Envío CTM - CTM_RECEIVE = "ctm_receive" # Recibo CTM +from .models import OperationType class InvoiceSettingsBase(BaseModel): invoice_type: str diff --git a/backend/api/v1/modules/a76/invoice_settings/routes.py b/backend/api/v1/modules/a76/invoice_settings/routes.py index 07e37459..9a6f83c3 100644 --- a/backend/api/v1/modules/a76/invoice_settings/routes.py +++ b/backend/api/v1/modules/a76/invoice_settings/routes.py @@ -40,8 +40,7 @@ def get_invoice_settings( tenant_id=tenant_id, company_id=company_id ) - - return settings + return InvoiceSettingsResponse.model_validate(settings) @router.get("/", response_model=List[InvoiceSettingsResponse]) def list_invoice_settings( diff --git a/backend/api/v1/modules/a76/invoice_settings/services.py b/backend/api/v1/modules/a76/invoice_settings/services.py index 3499a700..996fcac7 100644 --- a/backend/api/v1/modules/a76/invoice_settings/services.py +++ b/backend/api/v1/modules/a76/invoice_settings/services.py @@ -17,7 +17,7 @@ def get_settings( InvoiceSettings.tenant_id == tenant_id, InvoiceSettings.company_id == company_id, InvoiceSettings.invoice_type == invoice_type, - InvoiceSettings.operation_type == operation_type + InvoiceSettings.operation_type == operation_type.value ) return db.execute(stmt).scalar_one_or_none() @@ -60,7 +60,7 @@ def upsert_settings( tenant_id=tenant_id, company_id=company_id, invoice_type=settings_data.invoice_type, - operation_type=settings_data.operation_type, + operation_type=settings_data.operation_type.value, settings=settings_data.settings ) diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index a87291f7..e1f5787f 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -11,7 +11,9 @@ from .customs_brokers.routes import router as customs_broker_router from .invoices.routes import router as invoices_router from .items.routes import router as items_router from .classes import router as classes_router +from .classes import router as classes_router from .clients_and_providers import router as client_and_provider_router +from .imports.routes import router as imports_router from .invoice_settings.routes import router as invoice_settings_router from .item_presets.routes import router as item_presets_router from .general_catalogs.company import router as company_router @@ -68,6 +70,7 @@ router = APIRouter() # Registrar módulos router.include_router(invoices_router, prefix="/a76", tags=["a76 / invoices"]) router.include_router(items_router, prefix="/a76", tags=["a76 / items"]) +router.include_router(imports_router, prefix="/a76/imports", tags=["a76 / imports"]) router.include_router(invoice_settings_router) router.include_router(item_presets_router, prefix="/a76/item-presets", tags=["a76 / item_presets"]) router.include_router(pedimentos_router, prefix="/a76") diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index e0d19372..1665bb6a 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -13,7 +13,8 @@ celery_app = Celery( "api.v1.modules.a76.reports.importacion.consolidados.task", "api.v1.modules.a76.reports.importacion.packing_list.task", "api.v1.modules.a76.reports.exportacion.aviso_consolidado.task", - "api.v1.modules.a76.reports.exportacion.descargo.task" + "api.v1.modules.a76.reports.exportacion.descargo.task", + "api.v1.modules.a76.imports.tasks" ] # Ruta al módulo donde están las tareas ) diff --git a/backend/main.py b/backend/main.py index 795e5c8a..38cf6c71 100644 --- a/backend/main.py +++ b/backend/main.py @@ -6,6 +6,71 @@ Backend API con FastAPI + Keycloak + SQLAlchemy import logging import subprocess +# Importar modelos para registrar con SQLAlchemy + +# Reference Data (Dependencies) +from api.v1.modules.public.reference_data.countries.models import Country +from api.v1.modules.public.reference_data.currency_types.models import CurrencyType +from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection +from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse +from api.v1.modules.public.reference_data.incoterms.models import Incoterm +from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType +from api.v1.modules.public.reference_data.material_types.models import MaterialType +from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod +from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode +from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento +from api.v1.modules.public.reference_data.sectors.models import Sector +from api.v1.modules.public.reference_data.states.models import State +from api.v1.modules.public.reference_data.transport_modes.models import TransportMode +from api.v1.modules.public.reference_data.transport_types.models import TransportType +from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept +from api.v1.modules.a76.general_catalogs.concepts.models import Concept +from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import CustomsBrokerConcept +from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog +from api.v1.modules.a76.general_catalogs.doda.models import Doda +from api.v1.modules.a76.general_catalogs.electronic_notices.models import ElectronicNotice +from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency +from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog +from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog +from api.v1.modules.a76.general_catalogs.inpc.models import INPC +from api.v1.modules.a76.general_catalogs.legends.models import Legend +from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType +from api.v1.modules.a76.general_catalogs.packages.models import Package +from api.v1.modules.a76.general_catalogs.ports.models import Port +from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator +from api.v1.modules.a76.general_catalogs.seal.models import Seal +from api.v1.modules.a76.general_catalogs.signatures.models import Signature +from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction +from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion +from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction + +# Core Modules & Reference Data (Dependencies) +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from api.v1.modules.a76.customs_brokers.models import CustomsBroker +from api.v1.modules.a76.general_catalogs.company.models import Company + +# Core Modules & Transactional Models +from api.v1.modules.a76.items.models import Item +from api.v1.modules.a76.items.series.models import Serie +from api.v1.modules.a76.parts.models import Part +from api.v1.modules.a24.fa.fa_parts.models import FaPart +from api.v1.modules.a24.inv.inv_parts.models import InvPart +from api.v1.modules.a76.manifests.manifest.models import Manifest +from api.v1.modules.a76.manifests.concept_manifestation.models import ConceptManifestation +from api.v1.modules.a76.manifests.value_manifestation.models import ValueManifestation + +# Transactional Primary Models +from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails +from api.v1.modules.a76.audit_log.events import register_audit_listeners + +# Core Modules (Secondary) + from api.v1.router import router as api_v1_router from core.config import settings from core.database import init_db @@ -22,16 +87,6 @@ from fastapi.responses import JSONResponse from fastapi.staticfiles import StaticFiles from pathlib import Path -# Importar modelos para registrar con SQLAlchemy -from api.v1.modules.a76.items.models import Item -from api.v1.modules.a76.items.series.models import Serie -from api.v1.modules.a76.parts.models import Part -from api.v1.modules.a24.fa.fa_parts.models import FaPart -from api.v1.modules.a24.inv.inv_parts.models import InvPart -from api.v1.modules.a76.manifests.manifest.models import Manifest -from api.v1.modules.a76.manifests.concept_manifestation.models import ConceptManifestation -from api.v1.modules.a76.manifests.value_manifestation.models import ValueManifestation - # Configurar logging logging.basicConfig( level=logging.INFO if not settings.DEBUG else logging.DEBUG, @@ -114,59 +169,6 @@ app.add_middleware(TenantMiddleware) from api.v1.modules.a76.audit_log.middleware import UserContextMiddleware app.add_middleware(UserContextMiddleware) -# Importar modelos para Audit Log -from api.v1.modules.a76.pedmientos.models.pedimentos import Pedimentos -from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceSalesDetails -from api.v1.modules.a76.audit_log.events import register_audit_listeners - -# Core Modules -from api.v1.modules.a76.clients_and_providers.models import ClientProvider -from api.v1.modules.a76.customs_brokers.models import CustomsBroker -from api.v1.modules.a76.parts.models import Part -from api.v1.modules.a76.items.models import Item -from api.v1.modules.a76.general_catalogs.company.models import Company - -# Reference Data -from api.v1.modules.public.reference_data.countries.models import Country -from api.v1.modules.public.reference_data.currency_types.models import CurrencyType -from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection -from api.v1.modules.public.reference_data.customs_warehouses.models import CustomsWarehouse -from api.v1.modules.public.reference_data.incoterms.models import Incoterm -from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType -from api.v1.modules.public.reference_data.material_types.models import MaterialType -from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod -from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode -from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento -from api.v1.modules.public.reference_data.sectors.models import Sector -from api.v1.modules.public.reference_data.states.models import State -from api.v1.modules.public.reference_data.transport_modes.models import TransportMode -from api.v1.modules.public.reference_data.transport_types.models import TransportType -from api.v1.modules.public.reference_data.valuation_methods.models import ValuationMethod -from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure -from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate -from api.v1.modules.a76.general_catalogs.identifiers.models import Identifier -from api.v1.modules.a76.classes.models import Class -from api.v1.modules.a76.general_catalogs.classification_concepts.models import ClassificationConcept -from api.v1.modules.a76.general_catalogs.concepts.models import Concept -from api.v1.modules.a76.general_catalogs.customs_broker_concepts.models import CustomsBrokerConcept -from api.v1.modules.a76.general_catalogs.depreciation_catalog.models import DepreciationCatalog -from api.v1.modules.a76.general_catalogs.doda.models import Doda -from api.v1.modules.a76.general_catalogs.electronic_notices.models import ElectronicNotice -from api.v1.modules.a76.general_catalogs.equivalencies.models import Equivalency -from api.v1.modules.a76.general_catalogs.error_catalogs.models import ErrorCatalog -from api.v1.modules.a76.general_catalogs.fda_catalog.models import FDACatalog -from api.v1.modules.a76.general_catalogs.inpc.models import INPC -from api.v1.modules.a76.general_catalogs.legends.models import Legend -from api.v1.modules.a76.general_catalogs.multi_currency_types.models import MultiCurrencyType -from api.v1.modules.a76.general_catalogs.packages.models import Package -from api.v1.modules.a76.general_catalogs.ports.models import Port -from api.v1.modules.a76.general_catalogs.prevalidators.models import Prevalidator -from api.v1.modules.a76.general_catalogs.seal.models import Seal -from api.v1.modules.a76.general_catalogs.signatures.models import Signature -from api.v1.modules.a76.general_catalogs.tariff_fractions.models import TariffFraction -from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion -from api.v1.modules.a76.general_catalogs.us_tariff_fractions.models import USTariffFraction - # Registrar Listeners de Auditoría @app.on_event("startup") def register_audit(): diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte new file mode 100644 index 00000000..7375b3ab --- /dev/null +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -0,0 +1,275 @@ + + + + + +
+
+ {#if isPending} + + {:else if isFinished && !hasErrors} + + {:else} + + {/if} +
+ +
+ + {#if isPending} + Validación de Importación + {:else if isFinished} + {hasErrors ? 'Importación con Observaciones' : 'Importación Exitosa'} + {/if} + + + {#if isPending} + Revise el análisis preliminar antes de confirmar la carga de datos. + {:else if isFinished} + El proceso de importación ha finalizado. + {/if} + +
+
+ + +
+ + {#if isPending} +
+ +
+ Total Filas + {scanResults.total_rows || 0} +
+ + +
+ Válidos + {scanResults.valid_rows || 0} +
+ + +
+ Errores + {scanResults.error_count || 0} +
+
+ + {#if scanResults.error_count > 0} +
+ +
+

Se detectaron problemas en el archivo

+

+ Las filas con errores serán omitidas automáticamente. Solo se importarán los + registros válidos. +

+
+
+ {:else} +
+ +
+

Archivo validado correctamente

+

Todos los registros parecen correctos y listos para importar.

+
+
+ {/if} + {/if} + + + {#if isFinished} +
+ +
+ +
+
+ + Insertados +
+ {commitResults.inserted || 0} +
+ + +
+
+ + Rechazados +
+ {totalSkipped} +
+
+ + + {#if commitResults.skipped_details && commitResults.skipped_details.length > 0} +
+
+
+ Detalle de Errores +
+ + {commitResults.skipped_details.length} filas + +
+
+ + + + + + + + + + {#each commitResults.skipped_details as detail} + + + + + + {/each} + +
LíneaReferenciaMotivo
{detail.line}{detail.invoice || '-'}{detail.reason}
+
+
+ {/if} +
+ {/if} +
+ + +
+ {#if isPending} + + + {:else if isFinished} + + {/if} +
+
+
diff --git a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte index 2ff9c055..3b55cdb2 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/general-tab-form.svelte @@ -34,7 +34,7 @@ defaultOperationType = undefined, exchangeRate = undefined, invoiceType = undefined - }: { + }: { invoice: Invoice | null; formData?: any; invoiceTypes?: InvoiceType[]; @@ -193,7 +193,10 @@ const soldToHeaderOptions = $derived([ { value: 'consignado_a', label: 'Consignado a' }, { value: 'vendido_a', label: 'Vendido a' }, - { value: operationType === 1 ? 'exportado_a' : 'importador', label: operationType === 1 ? 'Exportado a' : 'Importador' } + { + value: operationType === 1 ? 'exportado_a' : 'importador', + label: operationType === 1 ? 'Exportado a' : 'Importador' + } ]); const shippedToHeaderOptions = $derived( @@ -213,21 +216,21 @@ const shippedByHeaderOptions = $derived( operationType === 1 || invoiceType === 'CR' ? [ - { value: 'enviado_por', label: 'Enviado Por' }, - { value: 'destinatario', label: 'Destinatario' }, - { value: 'vendido_por', label: 'Vendido Por' }, - { value: 'consignado_a', label: 'Consignado a' }, - { value: 'vendido_a', label: 'Vendido a' }, - { value: 'exportado_a', label: 'Exportado a' }, - { value: 'enviado_a', label: 'Enviado a' }, - { value: 'transferido_a', label: 'Transferido a' }, - { value: 'donado_a', label: 'Donado a' }, - { value: 'notificar_a', label: 'Notificar a' } - ] + { value: 'enviado_por', label: 'Enviado Por' }, + { value: 'destinatario', label: 'Destinatario' }, + { value: 'vendido_por', label: 'Vendido Por' }, + { value: 'consignado_a', label: 'Consignado a' }, + { value: 'vendido_a', label: 'Vendido a' }, + { value: 'exportado_a', label: 'Exportado a' }, + { value: 'enviado_a', label: 'Enviado a' }, + { value: 'transferido_a', label: 'Transferido a' }, + { value: 'donado_a', label: 'Donado a' }, + { value: 'notificar_a', label: 'Notificar a' } + ] : [ - { value: 'enviado_a', label: 'Enviado a' }, - { value: 'transferido_a', label: 'Transferido a' } - ] + { value: 'enviado_a', label: 'Enviado a' }, + { value: 'transferido_a', label: 'Transferido a' } + ] ); // Combinar clientes y proveedores para shipped_to, evitando duplicados de tipo "both" @@ -427,80 +430,85 @@ *
-
- { - formData.shipped_to_header = v ?? ''; - }} - > - - - {shippedToHeaderOptions.find(o => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value))?.label || 'Selecciona encabezado...'} - - - - {#each shippedToHeaderOptions as option} - - {option.label} - - {/each} - - - { - formData.shipped_to_id = v ? parseInt(v) : null; - }} - > - - - {#if formData.shipped_to_id} - {allClientsProviders.find(cp => cp.id === formData.shipped_to_id)?.name || 'Selecciona...'} - {:else} - Selecciona... - {/if} - - - - {#each allClientsProviders as cp} - - {cp.name} - - {/each} - - - * -
+
+ { + formData.shipped_to_header = v ?? ''; + }} + > + + + {shippedToHeaderOptions.find( + (o) => o.value === (formData.shipped_to_header || shippedToHeaderOptions[0]?.value) + )?.label || 'Selecciona encabezado...'} + + + + {#each shippedToHeaderOptions as option} + + {option.label} + + {/each} + + + { + formData.shipped_to_id = v ? parseInt(v) : null; + }} + > + + + {#if formData.shipped_to_id} + {allClientsProviders.find((cp) => cp.id === formData.shipped_to_id)?.name || + 'Selecciona...'} + {:else} + Selecciona... + {/if} + + + + {#each allClientsProviders as cp} + + {cp.name} + + {/each} + + + * +
- -
- - { - formData.customs_broker_id = v ? parseInt(v) : null; - }} - > - - - {formData.customs_broker_id - ? customsBrokers.find(cb => cb.id === formData.customs_broker_id)?.name || 'Selecciona...' - : 'Selecciona...'} - - - - {#each customsBrokers as broker} - - {broker.name} - - {/each} - - -
+
+ + { + formData.customs_broker_id = v ? parseInt(v) : null; + }} + > + + + {formData.customs_broker_id + ? customsBrokers.find((cb) => cb.id === formData.customs_broker_id)?.name || + 'Selecciona...' + : 'Selecciona...'} + + + + {#each customsBrokers as broker} + + {broker.name} + + {/each} + + +
@@ -530,88 +538,101 @@
- -
- -
-
-

Tipo de Moneda - Pesos Netos y Brutos

-

- Tipo de cambio: - - {(exchangeRate !== undefined && exchangeRate !== null) - ? (exchangeRate === 0 ? 'N/A' : Number(exchangeRate).toFixed(4)) - : (formData.exchange_rate ? Number(formData.exchange_rate).toFixed(4) : 'N/A')} - -

-
- - -
- -
- - -
-
- - -
-
- - -
-
-
- {#if formData.currency === 'manual'} -
- - { - formData.currency_type = v ?? ''; - }} - > - - - {formData.currency_type || '...'} - - - - {#each currencyTypes as currencyType} - - {currencyType.code} - - {/each} - - -
- {/if} -
-
- - { - formData.weight_type = v ?? 'kgs'; - }} - > - - - {weightTypeOptions.find(w => w.value === formData.weight_type)?.label || 'Kilogramos (kg)'} - - - - {#each weightTypeOptions as weightType} - - {weightType.label} - - {/each} - - -
+ +
+ +
+
+

+ Tipo de Moneda - Pesos Netos y Brutos +

+

+ Tipo de cambio: + + {exchangeRate !== undefined && exchangeRate !== null + ? exchangeRate === 0 + ? 'N/A' + : Number(exchangeRate).toFixed(4) + : formData.exchange_rate + ? Number(formData.exchange_rate).toFixed(4) + : 'N/A'} + +

+
+ + +
+ +
+ + +
+
+ + +
+
+ + +
+
+
+ {#if formData.currency === 'manual'} +
+ + { + formData.currency_type = v ?? ''; + }} + > + + + {formData.currency_type || '...'} + + + + {#each currencyTypes as currencyType} + + {currencyType.code} + + {/each} + + +
+ {/if} +
+
+ + { + formData.weight_type = v ?? 'kgs'; + }} + > + + + {weightTypeOptions.find((w) => w.value === formData.weight_type)?.label || + 'Kilogramos (kg)'} + + + + {#each weightTypeOptions as weightType} + + {weightType.label} + + {/each} + + +
{#if operationType !== 1 && invoiceType !== 'MEX' && invoiceType !== 'CR' && invoiceType !== 'REP' && invoiceType !== 'REPAR'}
@@ -647,44 +668,46 @@
{/if} -
-
+
+
- -
-

Transportista

- -
+ +
+

Transportista

+ +
{#if invoiceType !== 'MEX'}
- { - formData.carrier_id = v || null; - }} - > - - - {#if formData.carrier_id} - {transporters.find(t => String(t.transporter_key) === String(formData.carrier_id))?.name || formData.carrier_id} - {:else if transporters.length > 0} - Selecciona transportista... - {:else} - Sin datos - {/if} - - - - {#each transporters as transporter} - - {transporter.transporter_key} - - {/each} - - -
+ { + formData.carrier_id = v || null; + }} + > + + + {#if formData.carrier_id} + {transporters.find( + (t) => String(t.transporter_key) === String(formData.carrier_id) + )?.name || formData.carrier_id} + {:else if transporters.length > 0} + Selecciona transportista... + {:else} + Sin datos + {/if} + + + + {#each transporters as transporter} + + {transporter.transporter_key} + + {/each} + + +
{/if}
@@ -811,33 +834,34 @@ {#if invoiceType !== 'MEX'}
- { - formData.aduana = v ?? ''; - }} - > - - - {#if formData.aduana} - {customsSections.find(cs => cs.customs_code === formData.aduana)?.section_name || formData.aduana} - {:else if customsSections.length > 0} - Selecciona aduana... - {:else} - Sin datos - {/if} - - - - {#each customsSections as section} - - {section.customs_code} - {section.section_name} - - {/each} - - -
+ { + formData.aduana = v ?? ''; + }} + > + + + {#if formData.aduana} + {customsSections.find((cs) => cs.customs_code === formData.aduana) + ?.section_name || formData.aduana} + {:else if customsSections.length > 0} + Selecciona aduana... + {:else} + Sin datos + {/if} + + + + {#each customsSections as section} + + {section.customs_code} - {section.section_name} + + {/each} + + +
{/if} {#if invoiceType !== 'MEX'} @@ -845,35 +869,36 @@ - { - formData.document_type = v ?? ''; - }} - > - - - {#if formData.document_type} - {codePedimentoRegimens.find(r => r.regimen_code === formData.document_type)?.regimen_code || formData.document_type} - {:else if filteredRegimens.length > 0} - Selecciona régimen... - {:else if operationType} - Sin regímenes para tipo {operationType} - {:else} - Selecciona tipo de operación primero - {/if} - - - - {#each filteredRegimens as regimen} - - {regimen.regimen_code} - - {/each} - - -
+ { + formData.document_type = v ?? ''; + }} + > + + + {#if formData.document_type} + {codePedimentoRegimens.find((r) => r.regimen_code === formData.document_type) + ?.regimen_code || formData.document_type} + {:else if filteredRegimens.length > 0} + Selecciona régimen... + {:else if operationType} + Sin regímenes para tipo {operationType} + {:else} + Selecciona tipo de operación primero + {/if} + + + + {#each filteredRegimens as regimen} + + {regimen.regimen_code} + + {/each} + + +
{/if} {#if invoiceType === 'MEX'} @@ -891,8 +916,8 @@
{/if} -
-
+
+
diff --git a/frontend/src/lib/config/csv-upload.ts b/frontend/src/lib/config/csv-upload.ts index 044e9d0d..656b0459 100644 --- a/frontend/src/lib/config/csv-upload.ts +++ b/frontend/src/lib/config/csv-upload.ts @@ -224,7 +224,7 @@ export const importacionConfig: CsvUploadItem[] = [ title: 'Encabezado', icon: FileText, group: 'Impo. Temp.', - modelTarget: 'InvoiceHeader', + modelTarget: 'invoice_header', templateUrl: '/csv/EstructuraEncFacImpoTemp.xls' }, { @@ -232,7 +232,7 @@ export const importacionConfig: CsvUploadItem[] = [ title: 'Partidas', icon: Package, group: 'Impo. Temp.', - modelTarget: 'InvoiceSalesDetails', + modelTarget: 'invoice_details', templateUrl: '/csv/EstructuraParFacImpoTempAF.xls' }, { diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index 40a97268..6c55bc56 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -2,6 +2,7 @@ import * as Tabs from '$lib/components/ui/tabs/index.js'; import UploadLauncherGrid from '$lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte'; import ConfigFooter from '$lib/components/dashboard/csv-upload/ConfigFooter.svelte'; + import ProcessingResultModal from '$lib/components/dashboard/csv-upload/ProcessingResultModal.svelte'; import { catalogosConfig, transportesConfig, @@ -10,42 +11,109 @@ tabSettings, type CsvUploadItem } from '$lib/config/csv-upload'; + import { api } from '$lib/api'; import { toast } from 'svelte-sonner'; + import { companyStore } from '$lib/stores/company.svelte'; // We no longer need modal state let activeTab = $state('catalogos'); - let allSettings = $state>({}); + let isUploading = $state(false); + let currentJobId = $state(null); + let activeModelTarget = $state(null); + let scanResults = $state(null); + let commitResults = $state(null); + let showResultModal = $state(false); - $effect(() => { - const fields = tabSettings[activeTab] || []; - if (!allSettings[activeTab]) { - allSettings[activeTab] = {}; - fields.forEach((f) => { - allSettings[activeTab][f.name] = f.defaultValue; + // Initialize settings for all tabs upfront to avoid reactivity loops + let allSettings = $state>(() => { + const initial: Record = {}; + for (const tab in tabSettings) { + initial[tab] = {}; + tabSettings[tab].forEach((f) => { + initial[tab][f.name] = f.defaultValue; }); } + return initial; }); - function handleUpload(file: File, config: CsvUploadItem) { + async function handleUpload(file: File, config: CsvUploadItem) { + isUploading = true; + activeModelTarget = config.modelTarget || null; + scanResults = null; const currentSettings = allSettings[activeTab] || {}; + const companyId = companyStore.activeCompany?.id || 1; + const opType = activeTab === 'exportacion' ? 'exp' : 'imp'; - console.log('🚀 Starting Direct Upload Processing', { - file: file.name, - size: file.size, - target: config.modelTarget, - config: config.title, - settings: currentSettings - }); + const res = await api.imports.upload( + file, + config.modelTarget || '', + currentSettings, + companyId, + opType + ); + if (res.data?.job_id) { + currentJobId = res.data.job_id; + pollStatus(); + } else { + toast.error('Error al subir el archivo'); + isUploading = false; + } + } - // Mock Processing Feedback - const promise = new Promise((resolve) => setTimeout(resolve, 2000)); + async function pollStatus() { + if (!currentJobId) return; - toast.promise(promise, { - loading: `Procesando ${file.name} para ${config.title}...`, - success: `Archivo cargado correctamente con configuración: ${JSON.stringify(currentSettings)}`, - error: 'Error al cargar el archivo' - }); + const res = await api.imports.status(currentJobId); + if (res.data?.status === 'waiting_confirmation') { + scanResults = res.data; + showResultModal = true; + toast.success('Escaneo completado. Revisa los resultados.'); + isUploading = false; + } else if (res.data?.status === 'failed') { + toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido')); + isUploading = false; + currentJobId = null; + scanResults = null; + commitResults = null; + showResultModal = false; + } else if (res.data?.status === 'warning') { + // Caso cuando no se insertaron registros pero hay información de rechazo + commitResults = res.data; + showResultModal = true; + const inserted = res.data?.inserted || 0; + const skippedInvalid = res.data?.skipped_invalid || 0; + const skippedFk = res.data?.skipped_missing_fk || 0; + const totalSkipped = skippedInvalid + skippedFk; + + if (inserted === 0) { + toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`); + } else { + toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`); + } + isUploading = false; + } else if (res.data?.status === 'finished') { + commitResults = res.data; + showResultModal = true; + const inserted = res.data?.inserted || 0; + const skippedInvalid = res.data?.skipped_invalid || 0; + const skippedFk = res.data?.skipped_missing_fk || 0; + const skippedDetails = res.data?.skipped_details || []; + + if (inserted > 0) { + toast.success(`Importación completada: ${inserted} registros insertados`); + if (skippedInvalid > 0 || skippedFk > 0) { + const totalSkipped = skippedInvalid + skippedFk; + toast.warning(`${totalSkipped} registros fueron rechazados`); + } + } else { + toast.error('No se insertaron registros. Revisa los errores a continuación.'); + } + isUploading = false; + } else { + // Continue polling + setTimeout(pollStatus, 2000); + } } @@ -105,3 +173,37 @@
{/if}
+ + { + if (currentJobId && activeModelTarget) { + try { + isUploading = true; + const res = await api.imports.commit(currentJobId, activeModelTarget); + if (res.data?.commit_job_id) { + currentJobId = res.data.commit_job_id; + pollStatus(); + } + } catch (err) { + toast.error('Error al iniciar la importación'); + isUploading = false; + } + } + }} + onCancel={() => { + currentJobId = null; + scanResults = null; + commitResults = null; + showResultModal = false; + }} + onClose={() => { + currentJobId = null; + scanResults = null; + commitResults = null; + showResultModal = false; + }} +/> From 8ace3735c48d5385ec8b5712369642cbe2159329 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 17 Feb 2026 08:52:25 -0600 Subject: [PATCH 102/102] add/gitignore-add-docker-compose --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1da35013..48bbc048 100644 --- a/.gitignore +++ b/.gitignore @@ -61,4 +61,5 @@ node_modules/ # Docker *.dockerignore postgres-data/ -backend/uploads/ \ No newline at end of file +backend/uploads/ +docker-compose.yml