From daa3a252b99aa9fed00808a22c83885c1d74464a Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 6 May 2026 14:24:41 -0600 Subject: [PATCH] fix/carga-csv-ergonomia --- .../v1/modules/a76/csv_templates/registry.py | 116 ++++- .../v1/modules/a76/csv_templates/routes.py | 16 +- .../boms/csv_max_chars_for_headers.py | 11 + .../a76/layouts_csv/boms/template_config.py | 17 + .../template_config.py | 6 + .../classes/csv_max_chars_for_headers.py | 16 + .../layouts_csv/classes/template_config.py | 75 +++- .../common/csv_max_chars_for_headers.py | 41 ++ .../clients_and_providers/template_config.py | 52 ++- .../a76/layouts_csv/common/csv_headers_en.py | 408 ++++++++++++++++++ .../a76/layouts_csv/common/csv_hint_inject.py | 66 +++ .../common/inject_csv_labels_en.py | 30 ++ .../a76/layouts_csv/common/template_locale.py | 160 +++++++ .../csv_max_chars_for_headers.py | 23 + .../customs_brokers/template_config.py | 15 + .../layouts_csv/drivers/common/__init__.py | 5 +- .../drivers/csv_max_chars_for_headers.py | 28 ++ .../layouts_csv/drivers/template_config.py | 16 + .../csv_max_chars_for_headers.py | 11 + .../exchange_rate/template_config.py | 12 + .../exportacion/template_config.py | 9 + .../facturas/invoice_csv_column_hints.py | 116 +++++ .../layouts_csv/facturas/template_config.py | 9 + .../parts/csv_max_chars_for_headers.py | 29 ++ .../a76/layouts_csv/parts/template_config.py | 89 +++- .../pedmientos/pedimentos_csv_hints.py | 58 +++ .../layouts_csv/pedmientos/template_config.py | 9 + .../trailers/csv_max_chars_for_headers.py | 16 + .../layouts_csv/trailers/template_config.py | 10 + .../csv_max_chars_for_headers.py | 25 ++ .../transportistas/template_config.py | 12 + .../csv_max_chars_for_headers.py | 14 + .../us_tariff_fractions/template_config.py | 15 + .../vehicles/csv_max_chars_for_headers.py | 24 ++ .../layouts_csv/vehicles/template_config.py | 10 + .../a76/layouts_csv/test_csv_header_hints.py | 143 ++++++ frontend/messages/en.json | 198 +++++++++ frontend/messages/es.json | 198 +++++++++ frontend/scripts/merge_csv_upload_i18n.py | 358 +++++++++++++++ frontend/src/lib/api.ts | 19 +- .../dashboard/csv-upload/ConfigFooter.svelte | 22 +- .../dashboard/csv-upload/CsvParamsBar.svelte | 24 +- .../csv-upload/CsvPendingImportsSheet.svelte | 41 +- .../csv-upload/ProcessingResultModal.svelte | 87 ++-- .../csv-upload/UploadLauncherGrid.svelte | 46 +- .../src/lib/components/sidebar/modules.ts | 12 + .../components/sidebar/nav-projects.svelte | 76 +--- frontend/src/lib/config/csv-upload.ts | 161 +++---- frontend/src/lib/i18n/csv-msg.ts | 46 ++ .../src/lib/i18n/csv-upload-messages.en.json | 193 +++++++++ .../src/lib/i18n/csv-upload-messages.es.json | 193 +++++++++ .../routes/dashboard/csv-upload/+page.svelte | 156 ++++--- frontend/vite.config.ts | 13 +- 53 files changed, 3164 insertions(+), 391 deletions(-) create mode 100644 backend/api/v1/modules/a76/layouts_csv/boms/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/classes/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/common/csv_headers_en.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/common/csv_hint_inject.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/common/inject_csv_labels_en.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/common/template_locale.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/customs_brokers/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/drivers/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/exchange_rate/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/facturas/invoice_csv_column_hints.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/parts/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/pedmientos/pedimentos_csv_hints.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/trailers/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/transportistas/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/csv_max_chars_for_headers.py create mode 100644 backend/api/v1/modules/a76/layouts_csv/vehicles/csv_max_chars_for_headers.py create mode 100644 backend/tests/unit/a76/layouts_csv/test_csv_header_hints.py create mode 100644 frontend/scripts/merge_csv_upload_i18n.py create mode 100644 frontend/src/lib/i18n/csv-msg.ts create mode 100644 frontend/src/lib/i18n/csv-upload-messages.en.json create mode 100644 frontend/src/lib/i18n/csv-upload-messages.es.json diff --git a/backend/api/v1/modules/a76/csv_templates/registry.py b/backend/api/v1/modules/a76/csv_templates/registry.py index b17906b3..42224cc6 100644 --- a/backend/api/v1/modules/a76/csv_templates/registry.py +++ b/backend/api/v1/modules/a76/csv_templates/registry.py @@ -4,11 +4,10 @@ Construido a partir de los TEMPLATE_COLUMNS de cada módulo de imports. """ import csv import io -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Set # Importar configs de cada módulo from api.v1.modules.a76.layouts_csv.facturas.template_config import ( - TEMPLATE_COLUMNS as IMPORTS_TEMPLATE_COLUMNS, _resolve_template_columns as resolve_imports_template, ) from api.v1.modules.a76.layouts_csv.parts.template_config import ( @@ -140,6 +139,7 @@ ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE: Dict[str, set[str]] = { "imp_temp_series": {"NUMERO FACTURA", "LINEA FACTURA"}, "imp_def_series": {"NUMERO FACTURA", "LINEA FACTURA"}, "cmex_series": {"NUMERO FACTURA", "LINEA FACTURA"}, + "exp_def_series": {"NUMERO FACTURA", "LINEA FACTURA"}, # Catálogos y transportes "customs_brokers": {"TIPO", "CLAVE", "NOMBRE"}, "clients_providers": {"PROCEDENCIA", "SHORT_NAME", "NOMBRE", "RFC"}, @@ -191,23 +191,35 @@ ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE: Dict[str, set[str]] = { } -def _apply_required_prefix(template_id: str, headers: List[str]) -> List[str]: - """Prefija '* ' a cabeceras siempre obligatorias para la plantilla.""" - required_headers = ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE.get(template_id) - if not required_headers: - return headers +def _compute_required_indices_map(base_rows: Dict[str, List[str]]) -> Dict[str, Set[int]]: + """Índices de columnas obligatorias (según plantilla ES/base), para marcar * en cualquier idioma.""" + out: Dict[str, Set[int]] = {} + for tid, headers in base_rows.items(): + req = ALWAYS_REQUIRED_HEADERS_BY_TEMPLATE.get(tid) + if not req: + continue + req_norm = {_normalize_header_for_match(h) for h in req} + idx_set: Set[int] = set() + for i, h in enumerate(headers): + if not h: + continue + if _normalize_header_for_match(h) in req_norm: + idx_set.add(i) + out[tid] = idx_set + return out - required_norm = {_normalize_header_for_match(h) for h in required_headers} + +def _apply_required_prefix_indices(template_id: str, headers: List[str]) -> List[str]: + """Prefija '* ' usando índices fijos de la plantilla base (independiente del idioma de cabecera).""" + idx_set = _REQUIRED_INDICES.get(template_id) + if not idx_set: + return headers out: List[str] = [] - for header in headers: - norm_header = _normalize_header_for_match(header) - if norm_header and norm_header in required_norm: - if header.startswith("* "): - out.append(header) - else: - out.append(f"* {header}") + for i, h in enumerate(headers): + if i in idx_set and h and not str(h).startswith("* "): + out.append(f"* {h}") else: - out.append(header) + out.append(h) return out @@ -231,6 +243,7 @@ def _build_registry() -> Dict[str, List[str]]: "imp_def_series", "exp_def_header", "exp_def_details", + "exp_def_series", "cmex_header", "cmex_details", "cmex_series", @@ -292,7 +305,9 @@ def _build_registry() -> Dict[str, List[str]]: return registry -_TEMPLATE_HEADERS: Dict[str, List[str]] = _build_registry() +_REGISTRY_BASE_ES: Dict[str, List[str]] = _build_registry() +_REQUIRED_INDICES: Dict[str, Set[int]] = _compute_required_indices_map(_REGISTRY_BASE_ES) +_TEMPLATE_HEADERS: Dict[str, List[str]] = _REGISTRY_BASE_ES # Nombre de archivo sugerido para descarga (sin path) TEMPLATE_FILENAMES: Dict[str, str] = { @@ -320,15 +335,65 @@ TEMPLATE_FILENAMES: Dict[str, str] = { "cmex_series": "EstructuraSeriesFacComprasMex.csv", "exp_def_header": "EstructuraEncFacExpoCamReg.csv", "exp_def_details": "EstructuraParExpoCamReg.csv", + "exp_def_series": "EstructuraSeriesFacExpoCamReg.csv", } +def get_download_headers(template_id: str, locale: Optional[str] = "es") -> Optional[List[str]]: + """ + Cabeceras de descarga para la plantilla (sin prefijo *). + locale: es | en (default es). + """ + from api.v1.modules.a76.layouts_csv.common.template_locale import ( + download_header_cell, + normalize_locale, + ) + + loc = normalize_locale(locale) + + cols = resolve_imports_template(template_id) + if cols is not None: + return [download_header_cell(c, loc) for c in cols] + + if template_id in ("part_numbers", "items"): + from api.v1.modules.a76.layouts_csv.parts import template_config as parts_tc + + return parts_tc.download_headers_for_locale(loc) + + if template_id == "material_classes": + from api.v1.modules.a76.layouts_csv.classes import template_config as classes_tc + + return classes_tc.download_headers_for_locale(loc) + + # template_id del registry → (module_columns, clave interna del dict de columnas) + catalog_resolvers: list[tuple[str, dict, str]] = [ + ("boms", BOMS_TEMPLATE_COLUMNS, "boms"), + ("customs_brokers", CUSTOMS_BROKERS_TEMPLATE_COLUMNS, "customs_brokers"), + ("clients_providers", CLIENTS_PROVIDERS_TEMPLATE_COLUMNS, "client_providers"), + ("exchange_rates", EXCHANGE_RATE_TEMPLATE_COLUMNS, "exchange_rates"), + ("american_fractions", US_TARIFF_FRACTIONS_TEMPLATE_COLUMNS, "us_tariff_fractions"), + ("pedimentos", PEDIMENTOS_TEMPLATE_COLUMNS, "pedimentos"), + ("transports", VEHICLES_TEMPLATE_COLUMNS, "vehicles"), + ("drivers", DRIVERS_TEMPLATE_COLUMNS, "drivers"), + ("trailers", TRAILERS_TEMPLATE_COLUMNS, "trailers"), + ("transporters", TRANSPORTERS_TEMPLATE_COLUMNS, "transporters"), + ] + for tid, mapping, inner_key in catalog_resolvers: + if tid != template_id: + continue + cols = mapping.get(inner_key) + if cols: + return [download_header_cell(col, loc) for col in cols] + + return None + + def get_template_headers(template_id: str) -> Optional[List[str]]: - """Devuelve la lista de cabeceras canónicas para el template_id, o None si no existe.""" - headers = _TEMPLATE_HEADERS.get(template_id) - if headers is None: + """Cabeceras como en descarga ES histórica, con prefijo * en obligatorias.""" + raw = get_download_headers(template_id, "es") + if raw is None: return None - return _apply_required_prefix(template_id, headers) + return _apply_required_prefix_indices(template_id, raw) def get_template_filename(template_id: str) -> str: @@ -336,14 +401,19 @@ def get_template_filename(template_id: str) -> str: return TEMPLATE_FILENAMES.get(template_id, f"plantilla_{template_id}.csv") -def generate_csv_content(template_id: str, include_bom: bool = True) -> Optional[bytes]: +def generate_csv_content( + template_id: str, + locale: str = "es", + include_bom: bool = True, +) -> Optional[bytes]: """ Genera el contenido CSV (solo fila de cabeceras) para el template_id. UTF-8, opcionalmente con BOM para Excel. """ - headers = get_template_headers(template_id) + headers = get_download_headers(template_id, locale) if not headers: return None + headers = _apply_required_prefix_indices(template_id, headers) buf = io.StringIO() writer = csv.writer(buf, lineterminator="\n") writer.writerow(headers) diff --git a/backend/api/v1/modules/a76/csv_templates/routes.py b/backend/api/v1/modules/a76/csv_templates/routes.py index 26f1e264..78e56983 100644 --- a/backend/api/v1/modules/a76/csv_templates/routes.py +++ b/backend/api/v1/modules/a76/csv_templates/routes.py @@ -1,9 +1,9 @@ """ Rutas para descargar plantillas CSV generadas desde código (sin archivos XLS/XLSX). """ -from typing import Any, Dict +from typing import Any, Dict, Optional -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import Response from core.security import get_current_user @@ -12,17 +12,23 @@ from .registry import generate_csv_content, get_template_filename router = APIRouter() +_LOCALE_ALLOWED = frozenset({"es", "en"}) + @router.get("/{template_id}", response_class=Response) async def download_csv_template( template_id: str, + locale: Optional[str] = Query("es", description="es | en: idioma de las cabeceras del CSV"), current_user: Dict[str, Any] = Depends(get_current_user), ): """ Devuelve un CSV con solo la fila de cabeceras para la plantilla indicada. - Las cabeceras son los nombres canónicos definidos en cada template_config. + Query `locale`: es (defecto) o en — cabeceras localizadas cuando estén definidas. """ - content = generate_csv_content(template_id, include_bom=True) + loc = (locale or "es").lower().strip() + if loc not in _LOCALE_ALLOWED: + raise HTTPException(status_code=400, detail="locale must be 'es' or 'en'") + content = generate_csv_content(template_id, locale=loc, include_bom=True) if content is None: raise HTTPException(status_code=404, detail=f"Plantilla desconocida: {template_id}") filename = get_template_filename(template_id) @@ -31,5 +37,7 @@ async def download_csv_template( media_type="text/csv; charset=utf-8", headers={ "Content-Disposition": f'attachment; filename="{filename}"', + "Cache-Control": "private, no-store, max-age=0, must-revalidate", + "Pragma": "no-cache", }, ) diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/boms/csv_max_chars_for_headers.py new file mode 100644 index 00000000..bc9ce2c4 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/boms/csv_max_chars_for_headers.py @@ -0,0 +1,11 @@ +"""Límites alineados a boms/validators/common.py y common/mappers.""" +from typing import Dict + +BOMS_CSV_MAX_CHARS: Dict[str, int] = { + "NUMPARTE_PADRE": 70, + "NUMPARTE_COMPONENTE": 70, + "CANTIDAD": 30, + "UNIMED": 10, + "VERSION_BOM": 20, + "VERSION_BILL": 20, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py b/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py index eb50d89f..ecf15118 100644 --- a/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/boms/template_config.py @@ -6,6 +6,10 @@ Placeholders hasta tener el XLS definitivo; ajustar canónicos y aliases según from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import BOMS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "boms": [ @@ -18,6 +22,17 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +# UNIMED también en parts/catálogo ("commercial UOM"); en BOM es unidad de cantidad genérica. +_BOM_LABEL_EN_OVERRIDES = {"UNIMED": "Unit of measure"} +for col in TEMPLATE_COLUMNS.get("boms") or []: + canon = col.get("canonical") + if canon in _BOM_LABEL_EN_OVERRIDES: + col.setdefault("labels", {})["en"] = _BOM_LABEL_EN_OVERRIDES[canon] + +inject_max_char_hints_into_columns(TEMPLATE_COLUMNS.get("boms"), BOMS_CSV_MAX_CHARS) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("boms") @@ -29,6 +44,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py index b780736c..40a833c7 100644 --- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/template_config.py @@ -6,6 +6,8 @@ Por ahora misma estructura que encabezado/partidas de exportación; luego se aju from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup # Cambio de régimen: cam_reg_header, cam_reg_details # Regularización: regulariz_header, regulariz_details @@ -108,6 +110,8 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]]: return TEMPLATE_COLUMNS.get(template_id) @@ -124,6 +128,8 @@ def build_normalized_lookup(template_id: str, normalize_header_fn) -> Dict[str, lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/classes/csv_max_chars_for_headers.py new file mode 100644 index 00000000..c5521949 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/classes/csv_max_chars_for_headers.py @@ -0,0 +1,16 @@ +"""Máximos alineados a classes/validators/common.py.""" +from typing import Dict + +MATERIAL_CLASSES_CSV_MAX_CHARS: Dict[str, int] = { + "CLASE": 30, + "DESCRIPCIONE": 500, + "DESCRIPCIONI": 500, + "CLAVEMAT": 10, + "UNIMED": 5, + "FRACCION": 20, + "FRACCIONAME": 16, + "TASADEPRECIA": 24, + "CLAVESUB": 5, + "REVFISICA": 10, + "FRACCIONEXENTAIVA": 4, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py b/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py index bd10a176..3325be7d 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/template_config.py @@ -8,10 +8,19 @@ from typing import Dict, List, Any, Optional, Tuple from ..common.cell_value import cell_to_str from ..common import csv_reader as common_csv_reader +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import ( + download_header_cell, + merge_download_header_into_lookup, + merge_labels_into_lookup, + normalize_locale, +) +from .csv_max_chars_for_headers import MATERIAL_CLASSES_CSV_MAX_CHARS # Valores que indican que la primera fila es cabecera (primera columna normalizada) -FIRST_COLUMN_HEADER_VALUES = ("CLAVE CLASE", "CLASE") +FIRST_COLUMN_HEADER_VALUES = ("CLAVE CLASE", "CLASE", "CLASS CODE") def detect_headers_or_data( file_path: str, @@ -71,6 +80,19 @@ TEMPLATE_DOWNLOAD_HEADERS: List[str] = [ "CODIGO DE PRODUCTO/SERVICIO CP", ] +TEMPLATE_DOWNLOAD_HEADERS_EN: List[str] = [ + "CLASS CODE", + "DESCRIPTION (SPANISH)", + "DESCRIPTION (ENGLISH)", + "MATERIAL TYPE", + "COMMERCIAL UOM", + "MX FRACTION", + "US FRACTION", + "DEPRECIATION RATE", + "PHYSICAL REVIEW (1/0)", + "PRODUCT/SERVICE CP CODE", +] + TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "material_classes": [ {"canonical": "CLASE", "aliases": ["CLAVE CLASE", "CLASS", "CODIGO", "CLASE CODIGO"]}, @@ -87,6 +109,48 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +# Mismos canónicos que parts (CLASE, FRACCION, …); restaurar etiquetas de catálogo de clases. +# Mismo texto que TEMPLATE_DOWNLOAD_HEADERS_EN (plantilla Clarion EN). +_MATERIAL_CLASS_LABEL_EN_OVERRIDES = { + "CLASE": "CLASS CODE", + "UNIMED": "COMMERCIAL UOM", + "FRACCION": "MX FRACTION", + "FRACCIONAME": "US FRACTION", +} +for col in TEMPLATE_COLUMNS.get("material_classes") or []: + canon = col.get("canonical") + if canon in _MATERIAL_CLASS_LABEL_EN_OVERRIDES: + col.setdefault("labels", {})["en"] = _MATERIAL_CLASS_LABEL_EN_OVERRIDES[canon] + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("material_classes"), MATERIAL_CLASSES_CSV_MAX_CHARS +) +for _mc in TEMPLATE_COLUMNS.get("material_classes") or []: + if _mc.get("canonical") == "REVFISICA": + merge_enum_hint_before_max(_mc, ["1", "0"]) + +_MC_DL_ORDER = [ + "CLASE", + "DESCRIPCIONE", + "DESCRIPCIONI", + "CLAVEMAT", + "UNIMED", + "FRACCION", + "FRACCIONAME", + "TASADEPRECIA", + "REVFISICA", + "FRACCIONEXENTAIVA", +] +_MC_BY_CANON = {c["canonical"]: c for c in (TEMPLATE_COLUMNS.get("material_classes") or [])} +_MATERIAL_CLASSES_DOWNLOAD_DEFS = [dict(_MC_BY_CANON[k]) for k in _MC_DL_ORDER] + + +def download_headers_for_locale(locale: str) -> List[str]: + loc = normalize_locale(locale) + return [download_header_cell(d, loc) for d in _MATERIAL_CLASSES_DOWNLOAD_DEFS] + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("material_classes") @@ -98,6 +162,15 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) + for es_h, en_h in zip(TEMPLATE_DOWNLOAD_HEADERS, TEMPLATE_DOWNLOAD_HEADERS_EN): + if not es_h or not en_h: + continue + es_key = normalize_header_fn(es_h) + canon = lookup.get(es_key) + if canon: + lookup[normalize_header_fn(en_h)] = canon return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py new file mode 100644 index 00000000..e04ddf65 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/csv_max_chars_for_headers.py @@ -0,0 +1,41 @@ +""" +Máximos de caracteres por columna canonical (plantilla client_providers) para sufijos en cabecera CSV. + +Debe mantenerse alineado con `common/mappers.py` → MAX_LEN y columnas String(n) en +`clients_and_providers.models`. Sin imports de SQLAlchemy ni Pydantic para poder cargar la plantilla sin arrancar la app. +""" +from typing import Dict + +CLIENT_PROVIDER_CSV_COLUMN_MAX_CHARS: Dict[str, int] = { + "SHORT_NAME": 10, + "NOMBRE": 256, + "RFC": 30, + "DIRECCION": 100, + "NUM_EXT": 20, + "CODIGO POSTAL": 15, + "COLONIA": 40, + "CIUDAD": 30, + "ESTADO": 30, + "PAIS": 3, + "TELEFONO": 30, + "FAX": 30, + "EMAIL": 100, + "CURP": 19, + "TIPO_PROGRAMA_SECON": 7, + "NUM_PROGRAMA_SECON": 40, + "NUM_AUT_PROSEC": 20, + "REGISTRO_EMPRESA_CERT": 40, + "INFORMACION_EXTRA": 399, + "CONTACTO": 50, + "MANUFACTURER_ID": 25, + "TAX_ID_PROGRAMS": 30, + "BROKER_EXPO": 6, + "BROKER_IMPO": 6, + "CLAVE_TRANSFER": 8, + "CLAVE_WEB": 40, + "RESPONSABLE": 80, + "POSICION": 30, + "INCOTERM": 19, + "VINCULACION": 1, + "TRANSFORMA_SUBMAQ": 1, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py index c06c1e2a..e70c6fea 100644 --- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/template_config.py @@ -13,13 +13,27 @@ AH=COL_EXTRA (desfase). from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import ( + merge_download_header_into_lookup, + merge_labels_into_lookup, +) +from .common.csv_max_chars_for_headers import CLIENT_PROVIDER_CSV_COLUMN_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "client_providers": [ - # Col A - Procedencia (E=Extranjero, N=Nacional) - {"canonical": "PROCEDENCIA", "aliases": ["TIPO PROCEDENCIA", "EXTranjero/Nacional", "E/N"]}, - # Col B - Tipo Cliente (C/P/A) - {"canonical": "TIPO", "aliases": ["CLIENT_OR_PROVIDER", "TIPO ENTIDAD", "CLIENTE O PROVEEDOR"]}, + # Col A - Procedencia (E=Extranjero, N=Nacional). Manual: select N/E (≤3). Ver CSV_HEADER_HINTS.md + { + "canonical": "PROCEDENCIA", + "aliases": ["TIPO PROCEDENCIA", "EXTranjero/Nacional", "E/N"], + "csv_hint": {"kind": "enum_codes", "codes": ["N", "E"]}, + }, + # Col B - Tipo Cliente (C/P/A). Manual: client/provider/both (≤3). Ver CSV_HEADER_HINTS.md + { + "canonical": "TIPO", + "aliases": ["CLIENT_OR_PROVIDER", "TIPO ENTIDAD", "CLIENTE O PROVEEDOR"], + "csv_hint": {"kind": "enum_codes", "codes": ["C", "P", "A"]}, + }, # Col C - Clave cliente/proveedor (máx 8 Clarion) {"canonical": "SHORT_NAME", "aliases": ["CLAVE", "CLAVE CORTA", "NOMBRE CORTO", "SIGLAS"]}, # Col D - Nombre @@ -92,6 +106,34 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + + +def _inject_max_char_hints_client_providers(columns: Optional[List[Dict[str, Any]]]) -> None: + """Añade csv_hint max_chars desde CLIENT_PROVIDER_CSV_COLUMN_MAX_CHARS (paridad modelos/MAX_LEN).""" + if not columns: + return + for item in columns: + if not isinstance(item, dict): + continue + canon = item.get("canonical") + if not canon: + continue + n = CLIENT_PROVIDER_CSV_COLUMN_MAX_CHARS.get(str(canon).strip()) + if n is None: + continue + mc: Dict[str, Any] = {"kind": "max_chars", "n": n} + ch = item.get("csv_hint") + if ch is None: + item["csv_hint"] = mc + elif isinstance(ch, dict): + item["csv_hint"] = [ch, mc] + elif isinstance(ch, list): + item["csv_hint"] = [*ch, mc] + + +_inject_max_char_hints_client_providers(TEMPLATE_COLUMNS.get("client_providers")) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: """normalized_header -> canonical_name para plantilla client_providers.""" @@ -104,6 +146,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/common/csv_headers_en.py b/backend/api/v1/modules/a76/layouts_csv/common/csv_headers_en.py new file mode 100644 index 00000000..38052179 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/csv_headers_en.py @@ -0,0 +1,408 @@ +""" +Traducciones EN de cabeceras CSV por nombre canónico (clave interna). +Usado para plantillas descargadas locale=en y para aliases de carga. +""" + + +def _facturas_headers() -> dict[str, str]: + return { + # Encabezados / series / partidas — alineado con messages/en.json (pedimento, exchange rate, incrementables, discharge). + "PEDIMENTO": "Pedimento", + "REMESA": "Consignment", + "NUMERO FACTURA": "Invoice number", + "FECHA FACTURA": "Invoice date", + "TIPO DE CAMBIO": "Exchange rate", + "REGIMEN": "Regime", + "CLAVE PROVEEDOR": "Supplier code", + "CLAVE VENDIDO A": "Sold-to code", + "CLAVE ENVIADO A": "Ship-to code", + "AGENTE ADUANAL": "Customs broker", + "CLAVE TRANSPORTISTA": "Carrier code", + "NOMBRE CONDUCTOR": "Driver name", + "TIPO TRANSPORTE": "Transport type", + "CLAVE TRANSPORTE": "Transport code", + "NUMERO CAJA": "Trailer/box number", + "NUMERO TRANSPORTE": "Transport number", + "TIPO MONEDA": "Currency type", + "CLAVE MONEDA": "Currency code", + "FLETES": "Freight", + "VALOR SEGUROS": "Insurance value", + "SEGUROS": "Insurance", + "EMBALAJES": "Packaging", + "OTROS INCREMENTABLES": "Incrementables (other)", + "CLAVE INCOTERM": "Incoterm code", + "PRECINTO": "Seal", + "FECHA EMISION": "Issue date", + "TIPO PESO": "Weight type", + "E DOCUMENT": "E-document", + "NUM OPERACION": "Operation number", + "ADUANA DE CRUCE": "Customs office (crossing)", + "OBSERVACIONES E": "Export remarks", + "OBSERVACIONES I": "Import remarks", + "FACTURA ALTERNA": "Alternate invoice", + "NUM PROYECTO": "Project number", + "ORDEN COMPRA": "Purchase order", + "FACTURA EXPO REF": "Export invoice reference", + "MANIFIESTO": "Manifest", + "ENVIADO POR": "Shipped by", + # Partidas impo temp / compartidas + "NUMERO FACTURA EXPO": "Export invoice number", + "LINEA": "Line", + "LINEA EXPO": "Export line", + "LINEA IMPO": "Import line", + "LINEA FACTURA": "Invoice line", + "LINEA SERIE": "Serial line", + "TIPO DE IMPO": "Import type", + "FACTURA IMPO": "Import invoice", + "GENERA DESCARGA": "Generate discharge", + "CANTIDAD EXPORTADA/DESCARGAR": "Exported qty / qty to discharge", + "CLASE": "Class", + "CANTIDAD IMPORTADA": "Imported quantity", + "UNIDAD DE MEDIDA": "Unit of measure", + "COSTO UNITARIO": "Unit cost", + "PRECIO UNITARIO": "Unit price", + "VALOR COMERCIAL": "Commercial value", + "PESO NETO": "Net weight", + "PESO BRUTO": "Gross weight", + "CANTIDAD BULTOS": "Package count", + "CLAVE BULTOS": "Package code", + "PAIS ORIGEN": "Country of origin", + "FRACCION ARANCELARIA": "Tariff fraction", + "FRACCION": "Fraction", + "PREFERENCIA ARANCELARIA": "Tariff preference", + "SECTOR": "Sector", + "FRACCION AMERICANA": "US tariff fraction", + "ORDEN DE COMPRA": "Purchase order", + "DESCRIPCION ESPAÑOL": "Description (Spanish)", + "DESCRIPCION INGLES": "Description (English)", + "DESCRIPCION": "Description", + "MARCA": "Brand", + "MODELO": "Model", + "ES PARTIDA O SUBPARTIDA": "Line or sub-line", + "ES PARTIDA/SUBPARTIDA": "Line or sub-line", + "LINEA PRINCIPAL": "Main line", + "NUM. PARTE": "Part number", + "NUMPARTE": "Part number", + "SE PAGO IMPUESTO": "Tax paid (Y/N)", + "FORMA DE PAGO": "Payment method", + "METODO DE VALORACION": "Valuation method", + "DESCRIPCION EXTRA": "Extra description", + "INFORMACION ADICIONAL": "Additional information", + "AGREGAR/SUSTITUIR": "Add/replace", + "AGREGAR(A)/SUSTITUIR(S)": "Add/replace", + "TOTAL": "Total", + "NUMERO ENTRADA": "Entry number", + "LOTE": "Lot", + "ID TYPE": "ID type", + "SERIE": "Serial", + "SUB MODELO": "Sub-model", + "NUM PARTE": "Part number", + "NUMERO ID": "ID number", + "COL_EXTRA": "Extra column", + } + + +def _series_merge() -> dict[str, str]: + """Series share columns; ensure overlap with facturas_headers.""" + return { + "NUMERO FACTURA": "Invoice number", + "LINEA FACTURA": "Invoice line", + "LINEA SERIE": "Serial line", + "SERIE": "Serial", + "MODELO": "Model", + "NUM PARTE": "Part number", + "SUB MODELO": "Sub-model", + "NUMERO ID": "ID number", + "COL_EXTRA": "Extra column", + } + + +def _customs_exchange_us_fractions_boms() -> dict[str, str]: + return { + # customs_brokers + "TIPO": "Type", + "CLAVE": "Code", + "LICENCIA": "License", + "NOMBRE": "Name", + "RFC": "Tax ID", + "DIRECCION": "Address", + "CODIGO POSTAL": "Postal code", + "CIUDAD": "City", + "ESTADO": "State", + "PAIS": "Country", + "TELEFONO": "Phone", + "FAX": "Fax", + "EMAIL": "Email", + "PERSONAL_ID": "Personal ID / CURP", + "COL_EXTRA": "Extra column", + "POSICION": "Position", + "EMPRESA": "Company", + "CONTACTO": "Contact", + # exchange_rates + "FECHA": "Date", + "VALOR": "Exchange rate", + "MONEDA_LOCAL": "Local currency", + "MONEDA_EXTRANJERA": "Foreign currency", + # us_tariff_fractions + "FRACCION_ARANCELARIA": "Tariff fraction", + "PREFIJO": "Prefix", + "UNIDAD_DE_MEDIDA": "Unit of measure", + "DESCRIPCION": "Description", + "TIPO_DE_ADVALOREM": "Ad valorem type", + "ADVALOREM_PCT": "Ad valorem %", + "ADVALOREM_DLLS": "Ad valorem (USD)", + # boms + "NUMPARTE_PADRE": "Parent part number", + "NUMPARTE_COMPONENTE": "Component part number", + "CANTIDAD": "Quantity", + "UNIMED": "Unit of measure", + "VERSION_BOM": "BOM version", + "VERSION_BILL": "Bill version", + } + + +def _clients_providers_headers() -> dict[str, str]: + return { + "PROCEDENCIA": "Origin (foreign/domestic)", + "TIPO": "Entity type", + "SHORT_NAME": "Short code", + "NOMBRE": "Name", + "RFC": "Tax ID", + "DIRECCION": "Address", + "NUM_EXT": "Exterior number", + "CODIGO POSTAL": "Postal code", + "COLONIA": "District", + "CIUDAD": "City", + "ESTADO": "State", + "PAIS": "Country", + "TELEFONO": "Phone", + "FAX": "Fax", + "EMAIL": "Email", + "CURP": "CURP", + "TIPO_PROGRAMA_SECON": "SECON program type", + "NUM_PROGRAMA_SECON": "SECON program number", + "FECHA_AUT_SECON": "SECON authorization date", + "ES_PROSEC": "Is PROSEC", + "NUM_AUT_PROSEC": "PROSEC authorization number", + "VINCULACION": "Linkage", + "ES_EMPRESA_CERTIFICADA": "Certified company", + "REGISTRO_EMPRESA_CERT": "Certified company registry", + "INFORMACION_EXTRA": "Extra information", + "CONTACTO": "Contact", + "MANUFACTURER_ID": "Manufacturer ID", + "TAX_ID_PROGRAMS": "Tax ID programs", + "BROKER_EXPO": "Export broker", + "BROKER_IMPO": "Import broker", + "CLAVE_TRANSFER": "Transfer code", + "TRANSFORMA_SUBMAQ": "Transformer/sub-maquila", + "CLAVE_WEB": "Web code", + "COL_EXTRA": "Extra column", + "RESPONSABLE": "Responsible", + "POSICION": "Position", + "INCOTERM": "Incoterm", + "ACTIVO": "Active", + } + + +def _pedimentos_headers() -> dict[str, str]: + return { + "AÑO": "Year", + "PATENTE": "Patent", + "NUMERO": "Number", + "PEDIMENTO": "Pedimento", + "TIPO_OPERACION": "Operation type", + "TIPO_PEDIMENTO": "Pediment type", + "CLAVE_PEDIMENTO": "Pediment code", + "REGIMEN": "Regime", + "FECHA_INICIO": "Start date", + "FECHA_FINAL": "End date", + "FECHA_PAGO": "Payment date", + "FECHA_ENTRADA_RECINTO": "Compound entry date", + "FECHA_EXTRACCION_RECINTO": "Compound exit date", + "FECHA_RECIBIDO": "Received date", + "FECHA_AUTORIZACION": "Authorization date", + "FECHA_CIERRE": "Closing date", + "FECHA_REVISION": "Review date", + "ADUANA_SECCION_CRUCE": "Customs office / crossing section", + "ACUSE_ELECTRONICO": "Electronic acknowledgment", + "INDIVIDUAL_CONSOLIDADO": "Individual/consolidated", + "MET_TRANSP_ENTRADA": "Inbound transport method", + "MET_TRANSP_ARRIVO": "Arrival transport method", + "MET_TRANSP_SALIDA": "Outbound transport method", + "IEPS": "IEPS", + "IEPS_2": "IEPS (2)", + "DTA": "DTA", + "DTA_2": "DTA (2)", + "CNT": "CNT", + "CNT_2": "CNT (2)", + "PREVALIDACION": "Pre-validation", + "MONTO_TIGIE": "TIGIE amount", + "PAGO_IMPUESTO": "Tax paid (Y/N)", + "ES_MIXTO": "Mixed (yes/no)", + "OBS_RECTIFICA": "Rectification remarks", + "OPCION_DESTINO": "Destination option", + "VALOR_IVA": "VAT value", + "VALOR_ME": "Foreign currency value", + "VALOR_ADUANAS": "Customs value", + "VALOR_USD": "USD value", + "VALOR_SEGUROS": "Insurance value", + "FLETE": "Freight", + "SEGUROS": "Insurance", + "EMBALAJES": "Packaging", + "OTROS_INCREMENTABLES": "Incrementables (other)", + "ESTATUS": "Status", + "PERSONA_REV": "Reviewer", + "OBSERVACIONES": "Remarks", + "TIPO_CAMBIO": "Exchange rate", + "REPRESENTANTE_AA": "Customs broker representative", + "CLIENTE_SHORT_NAME": "Client short code", + "CLAVE_DEST_ORIGEN": "Destination origin code", + "IDENTIFICADORES": "Identifiers", + "CUOTAS_COMPENSATORIAS": "Compensatory quotas", + "ERRORES": "Errors", + "MULTAS": "Fines", + "RECARGOS": "Surcharges", + "PRECIO_PAGADO": "Price paid", + "PESO_BRUTO": "Gross weight", + "IVA_DE_PREV": "VAT from pre-validation", + "IVA_2": "VAT (2)", + "IGI_2": "IGI (2)", + "FORMA_PAGO_IVA": "VAT payment method", + "FORMA_PAGO_IVA_2": "VAT payment method (2)", + "FORMA_PAGO_IGI": "IGI payment method", + "FORMA_PAGO_IGI_2": "IGI payment method (2)", + "FORMA_PAGO_IEPS_2": "IEPS payment method (2)", + "FORMA_PAGO_DTA": "DTA payment method", + "FORMA_PAGO_DTA_2": "DTA payment method (2)", + "FORMA_PAGO_CNT_2": "CNT payment method (2)", + "FORMA_PAGO_PREVAL": "Pre-validation payment method", + "FORMA_PAGO_PREVALIDACION_2": "Pre-validation payment method (2)", + } + + +def _vehicles_drivers_trailers_transporters() -> dict[str, str]: + return { + # vehicles / transports template "vehicles" + "CLAVE": "Code", + "CODIGO DE ENTIDAD": "Entity code", + "TIPO TRANSPORTE": "Transport type", + "CLAVE TRANSPORTE": "Transport code", + "CLAVE ACE": "ACE code", + "PLACAS": "Plates", + "PRECINTO": "Seal", + "NUMERO DOT": "DOT number", + "TRANSPONDEDOR": "Transponder", + "VIN": "VIN", + "EMPRESA ASEGURADORA": "Insurance company", + "NUM. ASEGURADORA": "Insurance policy number", + "FECHA DE ASEGURADORA": "Insurance date", + "MONTO ASEGURADO": "Insured amount", + "COL_EXTRA": "Extra column", + # drivers + "TRANSPORTISTA": "Carrier", + "LINEA": "Line", + "CLAVE CONDUCTOR": "Driver code", + "NOMBRE(S)": "Given name(s)", + "APELLIDO PATERNO": "Last name (paternal)", + "SEXO": "Gender", + "FECHA NACIMIENTO": "Birth date", + "PAIS NACIMIENTO": "Birth country", + "LICENCIA": "License", + "FORMA IDENTIFICACION 1": "ID type 1", + "NUM. IDENTIFICACION 1": "ID number 1", + "FORMA IDENTIFICACION 2": "ID type 2", + "NUM. IDENTIFICACION 2": "ID number 2", + "IDENTIFICACION ACE": "ACE identification", + "PAIS": "Country", + "PAIS 2": "Country 2", + "ESTADO": "State", + "ESTADO 2": "State 2", + "PERMISO LINEA EXPRESS": "Express line permit", + "PERMISO MAT. PELIGROSO": "Hazmat permit", + "TRANSPORTA MAT. PELIGROSO?": "Transports hazardous material?", + # trailers + "NUMERO TRAILER": "Trailer number", + "TIPO TRAILER": "Trailer type", + "CODIGO ENTIDAD": "Entity code", + "CLAVE CONTENEDOR": "Container code", + # transporters + "CLAVE TRANSPORTISTA": "Carrier code", + "NOMBRE CORTO": "Short name", + "RESPONSABLE": "Responsible", + "RFC": "Tax ID", + "CALLES": "Street address", + "CODIGO CAAT": "CAAT code", + "CODIGO CARGADOR": "Loader code", + "CODIGO TRANS": "Transport code", + "TIPO INTERFASE TRANS": "Carrier interface type", + "SERVIDOR FTP": "FTP server", + "USUARIO FTP": "FTP user", + "CLAVE ACCESO FTP": "FTP password", + "DIRECTORIO FTP": "FTP directory", + } + + +def _material_classes_headers() -> dict[str, str]: + # Coincide con classes/template_config.TEMPLATE_DOWNLOAD_HEADERS_EN (salvo columnas solo en template extendido). + return { + "CLASE": "CLASS CODE", + "DESCRIPCIONE": "DESCRIPTION (SPANISH)", + "DESCRIPCIONI": "DESCRIPTION (ENGLISH)", + "CLAVEMAT": "MATERIAL TYPE", + "UNIMED": "COMMERCIAL UOM", + "FRACCION": "MX FRACTION", + "FRACCIONAME": "US FRACTION", + "TASADEPRECIA": "DEPRECIATION RATE", + "CLAVESUB": "SUB KEY", + "REVFISICA": "PHYSICAL REVIEW (1/0)", + "FRACCIONEXENTAIVA": "PRODUCT/SERVICE CP CODE", + } + + +def _parts_headers() -> dict[str, str]: + # Alineado con parts/template_config.TEMPLATE_DOWNLOAD_HEADERS_EN. + return { + "NUMPARTE": "PART NUMBER", + "NUMPARTECOM": "COMMERCIAL PART NUMBER", + "DESCRIPCIONE": "DESCRIPTION (SPANISH)", + "DESCRIPCIONI": "DESCRIPTION (ENGLISH)", + "CLASE": "CLASS", + "UNIMED": "COMMERCIAL UNIT OF MEASURE", + "COSTOUNIT": "UNIT COST", + "TIPOMONEDA": "COST CURRENCY TYPE", + "CLAVEMONEDA": "CURRENCY CODE", + "PESOUNIT": "UNIT WEIGHT", + "TIPOPESO": "WEIGHT TYPE", + "FRACCION": "FRACTION", + "FRACCIONAME": "US FRACTION", + "PAIS": "COUNTRY", + "PREFERENCIA": "PREFERENCE", + "SECTOR": "SECTOR", + "RUTAIMAGEN": "IMAGE PATH", + "FDAKEY": "FDA key", + "FCCKEY": "FCC key", + "LICENCIA": "License code", + "ECCN": "ECCN", + "EXPORTCODE": "Export code", + "EXCLUSION": "Exclusion", + "ACTIVO": "Active", + } + + +def _merge_all() -> dict[str, str]: + merged: dict[str, str] = {} + for part in ( + _facturas_headers(), + _series_merge(), + _customs_exchange_us_fractions_boms(), + _clients_providers_headers(), + _pedimentos_headers(), + _vehicles_drivers_trailers_transporters(), + _material_classes_headers(), + _parts_headers(), + ): + merged.update(part) + return merged + + +HEADER_LABEL_EN: dict[str, str] = _merge_all() diff --git a/backend/api/v1/modules/a76/layouts_csv/common/csv_hint_inject.py b/backend/api/v1/modules/a76/layouts_csv/common/csv_hint_inject.py new file mode 100644 index 00000000..09b86d41 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/csv_hint_inject.py @@ -0,0 +1,66 @@ +"""Helpers to merge csv_hint max_chars into column definitions after inject_en_labels.""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + + +def inject_max_char_hints_into_columns( + columns: Optional[List[Dict[str, Any]]], + max_by_canonical: Dict[str, int], +) -> None: + """Attach or append ``{\"kind\": \"max_chars\", \"n\": N}`` per canonical (aligned with validators/models).""" + if not columns or not max_by_canonical: + return + for item in columns: + if not isinstance(item, dict): + continue + canon = item.get("canonical") + if not canon: + continue + n = max_by_canonical.get(str(canon).strip()) + if n is None: + continue + mc: Dict[str, Any] = {"kind": "max_chars", "n": n} + ch = item.get("csv_hint") + if ch is None: + item["csv_hint"] = mc + elif isinstance(ch, dict): + item["csv_hint"] = [ch, mc] + elif isinstance(ch, list): + item["csv_hint"] = [*ch, mc] + + +def append_csv_hint(item: Dict[str, Any], extra: Dict[str, Any]) -> None: + """Añade un hint (digits, enum adicional, etc.) tras los existentes.""" + ch = item.get("csv_hint") + if ch is None: + item["csv_hint"] = extra + return + if isinstance(ch, dict): + item["csv_hint"] = [ch, extra] + return + if isinstance(ch, list): + item["csv_hint"] = [*ch, extra] + + +def merge_enum_hint_before_max( + item: Dict[str, Any], + enum_codes: List[str], +) -> None: + """Prepend enum_codes hint if not already present (for columns with fixed ≤3 values).""" + if len(enum_codes) == 0 or len(enum_codes) > 3: + return + enum_hint: Dict[str, Any] = {"kind": "enum_codes", "codes": list(enum_codes)} + ch = item.get("csv_hint") + if ch is None: + item["csv_hint"] = enum_hint + return + if isinstance(ch, dict): + if ch.get("kind") == "enum_codes": + return + item["csv_hint"] = [enum_hint, ch] + return + if isinstance(ch, list): + if any(isinstance(x, dict) and x.get("kind") == "enum_codes" for x in ch): + return + item["csv_hint"] = [enum_hint, *ch] diff --git a/backend/api/v1/modules/a76/layouts_csv/common/inject_csv_labels_en.py b/backend/api/v1/modules/a76/layouts_csv/common/inject_csv_labels_en.py new file mode 100644 index 00000000..e59613da --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/inject_csv_labels_en.py @@ -0,0 +1,30 @@ +""" +Inyecta labels.en en definiciones de columnas TEMPLATE_COLUMNS tras cargar el dict. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from .csv_headers_en import HEADER_LABEL_EN + + +def inject_en_labels_into_columns(columns: Optional[List[Dict[str, Any]]]) -> None: + """Añade item['labels']['en'] cuando existe traducción para canonical.""" + if not columns: + return + for item in columns: + if not isinstance(item, dict): + continue + c = item.get("canonical") + if not c: + continue + en = HEADER_LABEL_EN.get(str(c).strip()) + if en: + lab = item.setdefault("labels", {}) + lab["en"] = en + + +def inject_en_labels_into_template_columns(template_columns: Dict[str, Optional[List[Dict[str, Any]]]]) -> None: + """Recorre todas las plantillas de un TEMPLATE_COLUMNS.""" + for cols in template_columns.values(): + inject_en_labels_into_columns(cols) diff --git a/backend/api/v1/modules/a76/layouts_csv/common/template_locale.py b/backend/api/v1/modules/a76/layouts_csv/common/template_locale.py new file mode 100644 index 00000000..4c8df10d --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/common/template_locale.py @@ -0,0 +1,160 @@ +""" +Helpers for localized CSV template header labels (ES/EN) and alias registration. + +csv_hint (opcional en cada columna TEMPLATE_COLUMNS): un dict o lista de dicts (orden importa). +- kind "enum_codes" + codes: lista corta (≤3) mostrada como "Base (a, b, c)" en descarga. +- kind "digits" + n: sufijo numérico, ES "(n dígitos)" / EN "(n digits)". +- kind "max_chars" + n: máximo caracteres (modelo / validadores CSV). +- kind "literal" + es / en: sufijo libre entre paréntesis por idioma. + +cabeceras extendidas se registran en lookup de importación vía merge_download_header_into_lookup. +""" +from __future__ import annotations + +from typing import Any, Callable, Dict, Iterator, Optional + +CsvHintDict = Dict[str, Any] + + +def normalize_locale(locale: Optional[str]) -> str: + if not locale: + return "es" + l = str(locale).lower().strip() + if l.startswith("en"): + return "en" + return "es" + + +def display_header_for_locale(column: Dict[str, Any], locale: str) -> str: + """ + Pick the CSV header cell for one column definition. + Falls back to canonical when labels are absent. + For ES: no usar labels.en antes que canonical — si solo existe labels.en (inyectado), + sin labels.es el CSV en español debe seguir usando el nombre canónico (Clarion), no el EN. + """ + loc = normalize_locale(locale) + labels = column.get("labels") + if isinstance(labels, dict): + if loc == "en": + return str(labels.get("en") or labels.get("es") or column.get("canonical") or "") + return str(labels.get("es") or column.get("canonical") or labels.get("en") or "") + return str(column.get("canonical") or "") + + +def _csv_hints_iter(column: Dict[str, Any]) -> Iterator[CsvHintDict]: + """Siempre es generador (yield from ()) para filas sin csv_hint.""" + h = column.get("csv_hint") + if h is None: + yield from () + return + if isinstance(h, list): + for item in h: + if isinstance(item, dict): + yield item + elif isinstance(h, dict): + yield h + + +def _append_suffix(text: str, suffix_body: str) -> str: + inner = suffix_body.strip() + if not inner: + return text + return f"{text} ({inner})" if text else f"({inner})" + + +def _max_chars_suffix(n: int, locale: str) -> str: + loc = normalize_locale(locale) + if loc == "en": + if n == 1: + return "max. 1 character" + return f"max. {n} characters" + if n == 1: + return "máx. 1 carácter" + return f"máx. {n} caracteres" + + +def _apply_one_csv_hint(text: str, hint: CsvHintDict, locale: str) -> str: + """Aplica un sufijo a la cabecera acumulada (display o ya con hints previos).""" + loc = normalize_locale(locale) + kind = hint.get("kind") + if kind == "enum_codes": + codes = hint.get("codes") + if not isinstance(codes, list): + return text + if len(codes) == 0 or len(codes) > 3: + return text + inner = ", ".join(str(c) for c in codes) + return _append_suffix(text, inner) + if kind == "digits": + n = hint.get("n") + if n is None: + return text + try: + ni = int(n) + except (TypeError, ValueError): + return text + suf = f"{ni} digits" if loc == "en" else f"{ni} dígitos" + return _append_suffix(text, suf) + if kind == "max_chars": + n = hint.get("n") + if n is None: + return text + try: + ni = int(n) + except (TypeError, ValueError): + return text + if ni < 0: + return text + return _append_suffix(text, _max_chars_suffix(ni, loc)) + if kind == "literal": + lit = hint.get("en") if loc == "en" else hint.get("es") + if not lit and isinstance(hint.get("es"), str): + lit = hint.get("es") + if not lit and isinstance(hint.get("en"), str): + lit = hint.get("en") + if lit: + return _append_suffix(text, str(lit)) + return text + return text + + +def download_header_cell(column: Dict[str, Any], locale: Optional[str] = None) -> str: + """ + Texto final de la celda de cabecera en plantilla descargada (incl. sufijos de ayuda). + csv_hint puede ser un dict o lista de dicts (orden: p. ej. enum_codes luego max_chars). + enum_codes solo si len(codes) ≤ 3. + """ + loc = normalize_locale(locale) + text = display_header_for_locale(column, loc) + for hint in _csv_hints_iter(column): + text = _apply_one_csv_hint(text, hint, loc) + return text + + +def merge_download_header_into_lookup( + item: Dict[str, Any], + canonical: str, + normalize_header_fn: Callable[[str], str], + lookup: Dict[str, str], +) -> None: + """Registra cabeceras de descarga ES/EN (con sufijos csv_hint) como alias del canonical.""" + for loc in ("es", "en"): + cell = download_header_cell(item, loc) + if cell: + lookup[normalize_header_fn(cell)] = canonical + + +def merge_labels_into_lookup( + item: Dict[str, Any], + canonical: str, + normalize_header_fn: Callable[[str], str], + lookup: Dict[str, str], +) -> None: + """Register labels.es / labels.en as extra normalized aliases for canonical.""" + labels = item.get("labels") + if not isinstance(labels, dict): + return + for key in ("es", "en"): + lab = labels.get(key) + if lab: + lookup[normalize_header_fn(str(lab))] = canonical diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/csv_max_chars_for_headers.py new file mode 100644 index 00000000..6e3a50fc --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/csv_max_chars_for_headers.py @@ -0,0 +1,23 @@ +"""Máximos y enums para cabeceras CSV agentes aduanales (validadores / mappers).""" +from typing import Dict + +from .common.mappers import MAX_LEN + +CUSTOMS_BROKERS_CSV_MAX_CHARS: Dict[str, int] = { + "CLAVE": MAX_LEN["broker_key"], + "LICENCIA": MAX_LEN["license"], + "NOMBRE": MAX_LEN["name"], + "RFC": MAX_LEN["tax_id"], + "DIRECCION": MAX_LEN["address"], + "CODIGO POSTAL": MAX_LEN["postal_code"], + "CIUDAD": MAX_LEN["city"], + "ESTADO": MAX_LEN["state"], + "PAIS": MAX_LEN["country"], + "TELEFONO": MAX_LEN["phone"], + "FAX": MAX_LEN["fax"], + "EMAIL": MAX_LEN["email"], + "PERSONAL_ID": MAX_LEN["personal_id"], + "POSICION": MAX_LEN["position"], + "EMPRESA": MAX_LEN["company"], + "CONTACTO": MAX_LEN["contact"], +} diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py index b1a3afa6..d91c411f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/template_config.py @@ -9,6 +9,10 @@ Solo se leen columnas definidas aquí; el resto se ignora. from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import CUSTOMS_BROKERS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "customs_brokers": [ @@ -49,6 +53,15 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("customs_brokers"), CUSTOMS_BROKERS_CSV_MAX_CHARS +) +for _col in TEMPLATE_COLUMNS.get("customs_brokers") or []: + if _col.get("canonical") == "TIPO": + merge_enum_hint_before_max(_col, ["MEX", "AME"]) + # Orden oficial de columnas (para CSV sin encabezado o detección) CUSTOMS_BROKERS_FIELDNAMES_ORDER = [ "TIPO", @@ -84,6 +97,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) for idx, name in enumerate(CUSTOMS_BROKERS_FIELDNAMES_ORDER): lookup[normalize_header_fn(f"_COL_{idx}_")] = name return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/common/__init__.py b/backend/api/v1/modules/a76/layouts_csv/drivers/common/__init__.py index 695ad37a..748b1a0f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/common/__init__.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/common/__init__.py @@ -1,4 +1 @@ -# common validators, mappers, fk_loader for drivers CSV import -from .fk_loader import load_drivers_fk_sets - -__all__ = ["load_drivers_fk_sets"] +"""Drivers CSV helpers (validators, FK loader). Import submodules explicitly to avoid heavy deps at package init.""" diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/drivers/csv_max_chars_for_headers.py new file mode 100644 index 00000000..8dda83cb --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/csv_max_chars_for_headers.py @@ -0,0 +1,28 @@ +"""Límites alineados a drivers/common/common_validators.MAX_LEN (canonical CSV).""" +from typing import Dict + +from .common.common_validators import MAX_LEN as D_MAX + +DRIVERS_CSV_MAX_CHARS: Dict[str, int] = { + "TRANSPORTISTA": D_MAX["transporter_key"], + "LINEA": 30, + "CLAVE CONDUCTOR": D_MAX["driver_name"], + "LICENCIA": D_MAX["license_number"], + "PERMISO LINEA EXPRESS": D_MAX["express_line_id"], + "IDENTIFICACION ACE": D_MAX["ace_id"], + "FECHA NACIMIENTO": 30, + "SEXO": D_MAX["gender"], + "PAIS NACIMIENTO": D_MAX["birth_country"], + "TRANSPORTA MAT. PELIGROSO?": D_MAX["hazardous_material_auth"], + "PERMISO MAT. PELIGROSO": D_MAX["hazardous_material_state"], + "NOMBRE(S)": D_MAX["first_name"], + "APELLIDO PATERNO": D_MAX["last_name"], + "FORMA IDENTIFICACION 1": D_MAX["id_key1"], + "NUM. IDENTIFICACION 1": D_MAX["id_number1"], + "ESTADO": D_MAX["id_state1"], + "PAIS": D_MAX["id_country1"], + "FORMA IDENTIFICACION 2": D_MAX["id_key2"], + "NUM. IDENTIFICACION 2": D_MAX["id_number2"], + "ESTADO 2": D_MAX["id_state2"], + "PAIS 2": D_MAX["id_country2"], +} diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py index 6c4be901..803e8c3f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/drivers/template_config.py @@ -5,6 +5,10 @@ Configuracion de plantilla CSV para Conductores (EstructuraCatConductor.xls). from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import DRIVERS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "drivers": [ @@ -33,6 +37,16 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns(TEMPLATE_COLUMNS.get("drivers"), DRIVERS_CSV_MAX_CHARS) +for _dc in TEMPLATE_COLUMNS.get("drivers") or []: + c = _dc.get("canonical") + if c == "SEXO": + merge_enum_hint_before_max(_dc, ["M", "F"]) + elif c == "TRANSPORTA MAT. PELIGROSO?": + merge_enum_hint_before_max(_dc, ["SI", "NO"]) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("drivers") @@ -44,6 +58,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/csv_max_chars_for_headers.py new file mode 100644 index 00000000..e456f8d1 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/csv_max_chars_for_headers.py @@ -0,0 +1,11 @@ +"""Límites cabecera plantilla tipos de cambio.""" +from typing import Dict + +from .common.common_validators import CURRENCY_MAX + +EXCHANGE_RATE_CSV_MAX_CHARS: Dict[str, int] = { + "FECHA": 30, + "VALOR": 24, + "MONEDA_LOCAL": CURRENCY_MAX, + "MONEDA_EXTRANJERA": CURRENCY_MAX, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py index 9f32c526..c8d5471f 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/template_config.py @@ -6,6 +6,10 @@ Solo se leen columnas definidas aquí; el resto se ignora. from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import EXCHANGE_RATE_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "exchange_rates": [ @@ -16,6 +20,12 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("exchange_rates"), EXCHANGE_RATE_CSV_MAX_CHARS +) + def build_normalized_lookup(normalize_header_fn, template_id: str = "exchange_rates") -> Dict[str, str]: """normalized_header -> canonical_name para plantilla exchange_rates.""" @@ -28,6 +38,8 @@ def build_normalized_lookup(normalize_header_fn, template_id: str = "exchange_ra lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py index e7b6124e..a8599d19 100644 --- a/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/template_config.py @@ -6,6 +6,9 @@ Misma estructura que facturas exp_def_header / exp_def_details; módulo autocont from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from ..facturas.invoice_csv_column_hints import apply_invoice_csv_hints # Columnas para encabezado y partidas de exportación (EstructuraEncFacExpoCamReg / EstructuraParExpoCamReg) TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { @@ -101,6 +104,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +apply_invoice_csv_hints(TEMPLATE_COLUMNS) + def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]]: return TEMPLATE_COLUMNS.get(template_id) @@ -117,6 +124,8 @@ def build_normalized_lookup(template_id: str, normalize_header_fn) -> Dict[str, lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/invoice_csv_column_hints.py b/backend/api/v1/modules/a76/layouts_csv/facturas/invoice_csv_column_hints.py new file mode 100644 index 00000000..e22b5574 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/invoice_csv_column_hints.py @@ -0,0 +1,116 @@ +""" +Hints de cabecera para plantillas de factura (longitudes conservadoras + enums ≤3). + +Valores orientativos respecto a validación en tasks.py y modelos de factura; afinar si cambian ORM. +""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max + +# Unión de canónicos frecuentes en encabezados y partidas (multiplantilla) +INVOICE_MAX_CHARS_BY_CANONICAL: Dict[str, int] = { + "NUMERO FACTURA": 100, + "FECHA FACTURA": 30, + "FECHA EMISION": 30, + "TIPO DE CAMBIO": 24, + "REGIMEN": 10, + "PEDIMENTO": 30, + "REMESA": 12, + "CLAVE PROVEEDOR": 40, + "CLAVE VENDIDO A": 40, + "CLAVE ENVIADO A": 40, + "AGENTE ADUANAL": 20, + "CLAVE TRANSPORTISTA": 30, + "NOMBRE CONDUCTOR": 80, + "TIPO TRANSPORTE": 10, + "CLAVE TRANSPORTE": 30, + "NUMERO CAJA": 20, + "NUMERO TRANSPORTE": 30, + "TIPO MONEDA": 10, + "CLAVE MONEDA": 10, + "FLETES": 24, + "VALOR SEGUROS": 24, + "SEGUROS": 24, + "EMBALAJES": 24, + "OTROS INCREMENTABLES": 24, + "CLAVE INCOTERM": 10, + "PRECINTO": 49, + "TIPO PESO": 10, + "E DOCUMENT": 5, + "NUM OPERACION": 30, + "ADUANA DE CRUCE": 80, + "OBSERVACIONES E": 500, + "OBSERVACIONES I": 500, + "FACTURA ALTERNA": 99, + "NUM PROYECTO": 14, + "ORDEN COMPRA": 50, + "FACTURA EXPO REF": 19, + "MANIFIESTO": 50, + "ENVIADO POR": 80, + "LINEA": 10, + "CLASE": 20, + "CANTIDAD IMPORTADA": 24, + "UNIDAD DE MEDIDA": 10, + "COSTO UNITARIO": 24, + "PESO NETO": 24, + "PESO BRUTO": 24, + "CANTIDAD BULTOS": 24, + "CLAVE BULTOS": 20, + "PAIS ORIGEN": 3, + "FRACCION ARANCELARIA": 20, + "PREFERENCIA ARANCELARIA": 10, + "SECTOR": 20, + "FRACCION AMERICANA": 20, + "ORDEN DE COMPRA": 50, + "DESCRIPCION ESPAÑOL": 256, + "DESCRIPCION INGLES": 256, + "MARCA": 40, + "MODELO": 40, + "NUM. PARTE": 70, + "SE PAGO IMPUESTO": 10, + "FORMA DE PAGO": 30, + "METODO DE VALORACION": 30, + "DESCRIPCION EXTRA": 256, + "INFORMACION ADICIONAL": 500, + "AGREGAR/SUSTITUIR": 10, + "TOTAL": 24, + "NUMERO ENTRADA": 30, + "LOTE": 40, + "ID TYPE": 20, + "NUMERO FACTURA EXPO": 100, + "LINEA EXPO": 10, + "TIPO DE IMPO": 10, + "FACTURA IMPO": 100, + "LINEA IMPO": 10, + "GENERA DESCARGA": 5, + "CANTIDAD EXPORTADA/DESCARGAR": 24, + "AGREGAR(A)/SUSTITUIR(S)": 12, + "ES PARTIDA O SUBPARTIDA": 10, + "ES PARTIDA/SUBPARTIDA": 10, + "LINEA FACTURA": 10, + "LINEA SERIE": 10, + "SERIE": 40, + "NUM PARTE": 70, + "SUB MODELO": 40, + "NUMERO ID": 40, +} + + +def apply_invoice_csv_hints(template_columns: Dict[str, Optional[List[Dict[str, Any]]]]) -> None: + """Inyecta max_chars y enums en todas las listas de columnas no nulas.""" + for _tid, cols in template_columns.items(): + if not cols: + continue + inject_max_char_hints_into_columns(cols, INVOICE_MAX_CHARS_BY_CANONICAL) + for item in cols: + c = item.get("canonical") + if c == "E DOCUMENT": + merge_enum_hint_before_max(item, ["S", "N"]) + elif c in ("SE PAGO IMPUESTO",): + merge_enum_hint_before_max(item, ["SI", "NO"]) + elif c == "GENERA DESCARGA": + merge_enum_hint_before_max(item, ["SI", "NO"]) + elif c == "TIPO DE IMPO": + merge_enum_hint_before_max(item, ["I", "E"]) diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py index e972528c..d878f58a 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py @@ -7,6 +7,9 @@ Solo se escribe en BD lo que los modelos de facturas aceptan (respetando models) from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .invoice_csv_column_hints import apply_invoice_csv_hints # Cada plantilla define sus columnas canónicas y alias (otros nombres que aceptamos en el CSV). # canonical = nombre estándar con el que trabajamos internamente; debe coincidir con lo que @@ -266,6 +269,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +apply_invoice_csv_hints(TEMPLATE_COLUMNS) + def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]]: cols = TEMPLATE_COLUMNS.get(template_id) @@ -300,6 +307,8 @@ def build_normalized_lookup(template_id: str, normalize_header_fn) -> Dict[str, lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/parts/csv_max_chars_for_headers.py new file mode 100644 index 00000000..e60fb6a7 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/parts/csv_max_chars_for_headers.py @@ -0,0 +1,29 @@ +"""Máximos alineados a parts/validators/common.py (canonical interno).""" +from typing import Dict + +PART_NUMBERS_CSV_MAX_CHARS: Dict[str, int] = { + "NUMPARTE": 70, + "NUMPARTECOM": 70, + "DESCRIPCIONE": 256, + "DESCRIPCIONI": 256, + "CLASE": 20, + "UNIMED": 5, + "COSTOUNIT": 24, + "TIPOMONEDA": 3, + "CLAVEMONEDA": 3, + "PESOUNIT": 24, + "TIPOPESO": 6, + "FRACCION": 10, + "FRACCIONAME": 10, + "PAIS": 3, + "PREFERENCIA": 7, + "SECTOR": 5, + "RUTAIMAGEN": 255, + "FDAKEY": 30, + "FCCKEY": 30, + "LICENCIA": 30, + "ECCN": 20, + "EXPORTCODE": 20, + "EXCLUSION": 20, + "ACTIVO": 5, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py b/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py index 55910691..d59c3694 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py @@ -8,10 +8,19 @@ from typing import Dict, List, Any, Optional, Tuple from ..common.cell_value import cell_to_str from ..common import csv_reader as common_csv_reader +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import ( + download_header_cell, + merge_download_header_into_lookup, + merge_labels_into_lookup, + normalize_locale, +) +from .csv_max_chars_for_headers import PART_NUMBERS_CSV_MAX_CHARS # Valores que indican que la primera fila es cabecera (primera columna normalizada) -FIRST_COLUMN_HEADER_VALUES = ("NUMERO DE PARTE", "NUMPARTE") +FIRST_COLUMN_HEADER_VALUES = ("NUMERO DE PARTE", "NUMPARTE", "PART NUMBER") def detect_headers_or_data( @@ -76,6 +85,27 @@ TEMPLATE_DOWNLOAD_HEADERS: List[str] = [ "RUTA DE LA IMAGEN", ] +# Misma longitud que TEMPLATE_DOWNLOAD_HEADERS; columnas I/J vacías. +TEMPLATE_DOWNLOAD_HEADERS_EN: List[str] = [ + "PART NUMBER", + "DESCRIPTION (SPANISH)", + "DESCRIPTION (ENGLISH)", + "CLASS", + "COMMERCIAL UNIT OF MEASURE", + "UNIT COST", + "COST CURRENCY TYPE", + "CURRENCY CODE", + "", + "", + "UNIT WEIGHT", + "WEIGHT TYPE", + "FRACTION", + "COUNTRY", + "PREFERENCE", + "SECTOR", + "IMAGE PATH", +] + # Para lectura cuando la primera fila es dato (sin cabecera): nombres únicos para columnas I y J TEMPLATE_FIELDNAMES_FOR_READING: List[str] = [ "NUMERO DE PARTE", @@ -126,6 +156,53 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("part_numbers"), PART_NUMBERS_CSV_MAX_CHARS +) + +_PART_ORDER_DOWNLOAD = [ + "NUMPARTE", + "DESCRIPCIONE", + "DESCRIPCIONI", + "CLASE", + "UNIMED", + "COSTOUNIT", + "TIPOMONEDA", + "CLAVEMONEDA", + None, + None, + "PESOUNIT", + "TIPOPESO", + "FRACCION", + "PAIS", + "PREFERENCIA", + "SECTOR", + "RUTAIMAGEN", +] + +_PN_BY_CANON = {c["canonical"]: c for c in (TEMPLATE_COLUMNS.get("part_numbers") or [])} + + +def _parts_download_column_defs() -> List[Dict[str, Any]]: + out: List[Dict[str, Any]] = [] + for key in _PART_ORDER_DOWNLOAD: + if key is None: + out.append({"canonical": ""}) + else: + base = _PN_BY_CANON.get(key) + out.append(dict(base) if base else {"canonical": key}) + return out + + +_PARTS_DOWNLOAD_DEFS = _parts_download_column_defs() + + +def download_headers_for_locale(locale: str) -> List[str]: + loc = normalize_locale(locale) + return [download_header_cell(d, loc) for d in _PARTS_DOWNLOAD_DEFS] + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("part_numbers") @@ -137,6 +214,16 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) + # Plantilla EN: mismas posiciones que TEMPLATE_DOWNLOAD_HEADERS + for es_h, en_h in zip(TEMPLATE_DOWNLOAD_HEADERS, TEMPLATE_DOWNLOAD_HEADERS_EN): + if not es_h or not en_h: + continue + es_key = normalize_header_fn(es_h) + canon = lookup.get(es_key) + if canon: + lookup[normalize_header_fn(en_h)] = canon return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/pedimentos_csv_hints.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/pedimentos_csv_hints.py new file mode 100644 index 00000000..01df9256 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/pedimentos_csv_hints.py @@ -0,0 +1,58 @@ +"""Hints cabecera CSV pedimentos (dígitos col Clarion, enums acotados, longitudes típicas).""" +from __future__ import annotations + +from typing import Any, Dict, List, Optional + +from ..common.csv_hint_inject import ( + append_csv_hint, + inject_max_char_hints_into_columns, + merge_enum_hint_before_max, +) + +# Cols 1–3: solo dígitos con longitud fija +_PEDIMENTOS_DIGITS: Dict[str, int] = { + "AÑO": 2, + "PATENTE": 4, + "NUMERO": 7, +} + +# Resto de campos con tope razonable (validadores/modelos variados) +_PEDIMENTOS_MAX_CHARS: Dict[str, int] = { + "TIPO_OPERACION": 8, + "CLAVE_PEDIMENTO": 30, + "REGIMEN": 10, + "FECHA_INICIO": 30, + "FECHA_FINAL": 30, + "FECHA_PAGO": 30, + "ADUANA_SECCION_CRUCE": 120, + "ACUSE_ELECTRONICO": 80, + "INDIVIDUAL_CONSOLIDADO": 10, + "MET_TRANSP_ENTRADA": 30, + "MET_TRANSP_ARRIVO": 30, + "MET_TRANSP_SALIDA": 30, + "PEDIMENTO": 25, + "CLIENTE_SHORT_NAME": 30, + "TIPO_PEDIMENTO": 20, + "OBSERVACIONES": 500, +} + + +def apply_pedimentos_csv_hints(columns: Optional[List[Dict[str, Any]]]) -> None: + if not columns: + return + inject_max_char_hints_into_columns(columns, _PEDIMENTOS_MAX_CHARS) + for item in columns: + canon = item.get("canonical") + if not canon: + continue + nd = _PEDIMENTOS_DIGITS.get(str(canon).strip()) + if nd is not None: + append_csv_hint(item, {"kind": "digits", "n": nd}) + if canon == "TIPO_OPERACION": + merge_enum_hint_before_max(item, ["I", "E"]) + elif canon == "PAGO_IMPUESTO": + merge_enum_hint_before_max(item, ["S", "N"]) + elif canon == "ES_MIXTO": + merge_enum_hint_before_max(item, ["SI", "NO"]) + elif canon == "INDIVIDUAL_CONSOLIDADO": + merge_enum_hint_before_max(item, ["IND", "CON"]) diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py index e18541ef..7c654b00 100644 --- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/template_config.py @@ -10,6 +10,9 @@ from typing import Dict, List, Any, Optional, Tuple # Convierte valor de celda a str; si es lista (p. ej. CSV con columnas duplicadas), toma el primer elemento. # Re-exportado desde common para uso en validators; ver layouts_csv.common.cell_value. from ..common.cell_value import cell_to_str as _cell_to_str +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .pedimentos_csv_hints import apply_pedimentos_csv_hints from ..common import csv_reader as common_csv_reader @@ -116,6 +119,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +apply_pedimentos_csv_hints(TEMPLATE_COLUMNS.get("pedimentos")) + # Orden de columnas para CSV sin cabecera (primera fila = datos). Usado por detect_headers_or_data. PEDIMENTOS_TEMPLATE_ORDER: List[str] = [ item["canonical"] for item in TEMPLATE_COLUMNS["pedimentos"] @@ -190,6 +197,8 @@ def build_normalized_lookup(normalize_header_fn, template_id: str = "pedimentos" lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/trailers/csv_max_chars_for_headers.py new file mode 100644 index 00000000..717e8996 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/csv_max_chars_for_headers.py @@ -0,0 +1,16 @@ +"""Límites alineados a trailers/common/common_validators.MAX_LEN.""" +from typing import Dict + +from .common.common_validators import MAX_LEN as T_MAX + +TRAILERS_CSV_MAX_CHARS: Dict[str, int] = { + "NUMERO TRAILER": T_MAX["trailer_number"], + "CLAVE ACE": T_MAX["ace_trailer_number"], + "TIPO TRAILER": T_MAX["trailer_type_key"], + "PRECINTO": T_MAX["seal"], + "CODIGO ENTIDAD": T_MAX["entity_code"], + "PLACAS": T_MAX["plate_number"], + "ESTADO": T_MAX["state"], + "PAIS": T_MAX["country"], + "CLAVE CONTENEDOR": T_MAX["container_key"], +} diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py b/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py index ee2ea29e..91bd4cd9 100644 --- a/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/trailers/template_config.py @@ -6,6 +6,10 @@ Mapeo: NUMERO TRAILER → trailer_number, CLAVE ACE → ace_trailer_number, etc. from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import TRAILERS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "trailers": [ @@ -22,6 +26,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns(TEMPLATE_COLUMNS.get("trailers"), TRAILERS_CSV_MAX_CHARS) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("trailers") @@ -33,6 +41,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/csv_max_chars_for_headers.py new file mode 100644 index 00000000..9c51441d --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/csv_max_chars_for_headers.py @@ -0,0 +1,25 @@ +"""Límites alineados a transportistas/common/common_validators.MAX_LEN.""" +from typing import Dict + +from .common.common_validators import MAX_LEN as TR_MAX + +TRANSPORTISTAS_CSV_MAX_CHARS: Dict[str, int] = { + "CLAVE TRANSPORTISTA": TR_MAX["transporter_key"], + "NOMBRE": TR_MAX["name"], + "NOMBRE CORTO": TR_MAX["short_name"], + "RESPONSABLE": TR_MAX["responsible"], + "RFC": TR_MAX["rfc"], + "CALLES": TR_MAX["streets"], + "CODIGO POSTAL": TR_MAX["postal_code"], + "CIUDAD": TR_MAX["city"], + "ESTADO": TR_MAX["state"], + "PAIS": TR_MAX["country"], + "CODIGO CARGADOR": TR_MAX["loader_code"], + "CODIGO CAAT": TR_MAX["caat_code"], + "CODIGO TRANS": TR_MAX["transport_code"], + "TIPO INTERFASE TRANS": TR_MAX["transport_interface_type"], + "SERVIDOR FTP": TR_MAX["ftp_server"], + "USUARIO FTP": TR_MAX["ftp_user"], + "CLAVE ACCESO FTP": TR_MAX["ftp_password"], + "DIRECTORIO FTP": TR_MAX["ftp_directory"], +} diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py index 41d80821..7ba75abe 100644 --- a/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/template_config.py @@ -6,6 +6,10 @@ Mapeo Clarion: Col A = CLAVE TRANSPORTISTA, B = NOMBRE, ... R = DIRECTORIO FTP, from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import TRANSPORTISTAS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "transporters": [ @@ -31,6 +35,12 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("transporters"), TRANSPORTISTAS_CSV_MAX_CHARS +) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("transporters") @@ -42,6 +52,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/csv_max_chars_for_headers.py new file mode 100644 index 00000000..a68c2243 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/csv_max_chars_for_headers.py @@ -0,0 +1,14 @@ +"""Fracción americana: common_validators CODE_MAX, etc.""" +from typing import Dict + +from .common.common_validators import CODE_MAX, PREFIX_MAX, UNIT_MAX, TYPE_MAX + +US_TARIFF_FRACTIONS_CSV_MAX_CHARS: Dict[str, int] = { + "FRACCION_ARANCELARIA": CODE_MAX, + "PREFIJO": PREFIX_MAX, + "UNIDAD_DE_MEDIDA": UNIT_MAX, + "DESCRIPCION": 500, + "TIPO_DE_ADVALOREM": TYPE_MAX, + "ADVALOREM_PCT": 24, + "ADVALOREM_DLLS": 24, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py index 76106678..4af8022d 100644 --- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/template_config.py @@ -6,6 +6,10 @@ Solo se leen columnas definidas aquí; el resto se ignora. from typing import Dict, List, Any, Optional from ..common.cell_value import cell_to_str as _cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns, merge_enum_hint_before_max +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import US_TARIFF_FRACTIONS_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "us_tariff_fractions": [ @@ -20,6 +24,15 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns( + TEMPLATE_COLUMNS.get("us_tariff_fractions"), US_TARIFF_FRACTIONS_CSV_MAX_CHARS +) +for _uc in TEMPLATE_COLUMNS.get("us_tariff_fractions") or []: + if _uc.get("canonical") == "TIPO_DE_ADVALOREM": + merge_enum_hint_before_max(_uc, ["PO", "ME"]) + # Orden A..H para detectar desfase en 8ª columna (raw_row.values()[7]) TEMPLATE_ORDER = [item["canonical"] for item in TEMPLATE_COLUMNS["us_tariff_fractions"]] DESFASE_COLUMN_INDEX = 7 @@ -36,6 +49,8 @@ def build_normalized_lookup(normalize_header_fn, template_id: str = "us_tariff_f lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/csv_max_chars_for_headers.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/csv_max_chars_for_headers.py new file mode 100644 index 00000000..36f9b06f --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/csv_max_chars_for_headers.py @@ -0,0 +1,24 @@ +"""Límites de cabecera alineados a vehicles/common/common_validators.MAX_LEN.""" +from typing import Dict + +from .common.common_validators import MAX_LEN as V_MAX + +VEHICLES_CSV_MAX_CHARS: Dict[str, int] = { + "CLAVE": V_MAX["vehicle_key"], + "CLAVE ACE": V_MAX["ace_vehicle_key"], + "CLAVE TRANSPORTE": V_MAX["transporter_key"], + "VIN": V_MAX["transport_identifier"], + "TIPO TRANSPORTE": V_MAX["transport_type"], + "CODIGO DE ENTIDAD": V_MAX["entity_code"], + "TRANSPONDEDOR": V_MAX["transponder_number"], + "NUMERO DOT": V_MAX["dot_number"], + "PLACAS": V_MAX["plate_number"], + "CIUDAD": V_MAX["city"], + "ESTADO": V_MAX["state"], + "PAIS": V_MAX["country"], + "PRECINTO": V_MAX["seal"], + "EMPRESA ASEGURADORA": V_MAX["insurance_company_name"], + "NUM. ASEGURADORA": V_MAX["insurance_number"], + "MONTO ASEGURADO": 24, + "FECHA DE ASEGURADORA": 30, +} diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py index 248d35e8..459d7617 100644 --- a/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/template_config.py @@ -6,6 +6,10 @@ Mapeo: CLAVE → vehicle_key, CLAVE ACE → ace_vehicle_key, etc. from typing import Dict, List, Any from ..common.cell_value import cell_to_str +from ..common.csv_hint_inject import inject_max_char_hints_into_columns +from ..common.inject_csv_labels_en import inject_en_labels_into_template_columns +from ..common.template_locale import merge_download_header_into_lookup, merge_labels_into_lookup +from .csv_max_chars_for_headers import VEHICLES_CSV_MAX_CHARS TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "vehicles": [ @@ -30,6 +34,10 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], } +inject_en_labels_into_template_columns(TEMPLATE_COLUMNS) + +inject_max_char_hints_into_columns(TEMPLATE_COLUMNS.get("vehicles"), VEHICLES_CSV_MAX_CHARS) + def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: cols = TEMPLATE_COLUMNS.get("vehicles") @@ -41,6 +49,8 @@ def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: lookup[normalize_header_fn(canonical)] = canonical for alias in item.get("aliases") or []: lookup[normalize_header_fn(alias)] = canonical + merge_labels_into_lookup(item, canonical, normalize_header_fn, lookup) + merge_download_header_into_lookup(item, canonical, normalize_header_fn, lookup) return lookup diff --git a/backend/tests/unit/a76/layouts_csv/test_csv_header_hints.py b/backend/tests/unit/a76/layouts_csv/test_csv_header_hints.py new file mode 100644 index 00000000..14a5b007 --- /dev/null +++ b/backend/tests/unit/a76/layouts_csv/test_csv_header_hints.py @@ -0,0 +1,143 @@ +"""Tests for CSV template header hints (inline) and import lookup aliases (stdlib unittest).""" + +import unittest + +from api.v1.modules.a76.csv_templates.registry import ( + _normalize_header_for_match, + _TEMPLATE_HEADERS, + get_download_headers, +) +from api.v1.modules.a76.layouts_csv.common.template_locale import ( + download_header_cell, + display_header_for_locale, +) +from api.v1.modules.a76.layouts_csv.clients_and_providers import template_config as cp_tc + + +class TestRegistryCsvDownloadSmoke(unittest.TestCase): + """Smoke: cada template_id del registry tiene fila de descarga misma longitud que columnas base.""" + + def test_download_headers_length_matches_registry_for_all_templates(self): + for tid in sorted(_TEMPLATE_HEADERS.keys()): + row = get_download_headers(tid, "es") + self.assertIsNotNone(row, tid) + self.assertEqual(len(row), len(_TEMPLATE_HEADERS[tid]), tid) + + def test_customs_brokers_lookup_resolves_decorated_tip_header(self): + from api.v1.modules.a76.layouts_csv.customs_brokers import template_config as cb_tc + + lookup = cb_tc.build_normalized_lookup(_normalize_header_for_match) + hdr0 = get_download_headers("customs_brokers", "es")[0] + self.assertEqual(lookup[_normalize_header_for_match(hdr0)], "TIPO") + + def test_imp_temp_header_lookup_resolves_decorated_e_document(self): + from api.v1.modules.a76.layouts_csv.facturas import template_config as ft_tc + + lookup = ft_tc.build_normalized_lookup("imp_temp_header", _normalize_header_for_match) + for cell in get_download_headers("imp_temp_header", "es"): + if lookup.get(_normalize_header_for_match(cell)) == "E DOCUMENT": + self.assertIn("(S, N)", cell) + return + self.fail("E DOCUMENT column missing from imp_temp_header download row") + + def test_pedimentos_lookup_resolves_tipo_operacion_decorated(self): + from api.v1.modules.a76.layouts_csv.pedmientos import template_config as ped_tc + + lookup = ped_tc.build_normalized_lookup(_normalize_header_for_match, "pedimentos") + for cell in get_download_headers("pedimentos", "es"): + if lookup.get(_normalize_header_for_match(cell)) == "TIPO_OPERACION": + self.assertIn("(I, E)", cell) + return + self.fail("TIPO_OPERACION column missing from pedimentos download row") + + def test_material_classes_lookup_resolves_revfisica_enum_header(self): + from api.v1.modules.a76.layouts_csv.classes import template_config as cl_tc + + lookup = cl_tc.build_normalized_lookup(_normalize_header_for_match) + for cell in get_download_headers("material_classes", "es"): + if lookup.get(_normalize_header_for_match(cell)) == "REVFISICA": + self.assertIn("(1, 0)", cell) + return + self.fail("REVFISICA column missing from material_classes download row") + + +class TestCsvHeaderHints(unittest.TestCase): + def test_download_header_cell_enum_within_limit(self): + col = { + "canonical": "TIPO", + "csv_hint": {"kind": "enum_codes", "codes": ["C", "P", "A"]}, + } + self.assertEqual(download_header_cell(col, "es"), "TIPO (C, P, A)") + col_en = {**col, "labels": {"en": "Entity type"}} + self.assertEqual(download_header_cell(col_en, "en"), "Entity type (C, P, A)") + + def test_download_header_cell_enum_over_limit_ignored(self): + col = { + "canonical": "X", + "csv_hint": {"kind": "enum_codes", "codes": ["a", "b", "c", "d"]}, + } + self.assertEqual(download_header_cell(col, "es"), "X") + + def test_download_header_cell_digits_es_en(self): + col = {"canonical": "NUMERO", "csv_hint": {"kind": "digits", "n": 7}} + self.assertEqual(download_header_cell(col, "es"), "NUMERO (7 dígitos)") + self.assertEqual(download_header_cell(col, "en"), "NUMERO (7 digits)") + + def test_clients_providers_lookup_accepts_extended_headers(self): + lookup = cp_tc.build_normalized_lookup(_normalize_header_for_match) + self.assertEqual(lookup[_normalize_header_for_match("PROCEDENCIA")], "PROCEDENCIA") + self.assertEqual( + lookup[_normalize_header_for_match("PROCEDENCIA (N, E)")], + "PROCEDENCIA", + ) + self.assertEqual(lookup[_normalize_header_for_match("TIPO")], "TIPO") + self.assertEqual(lookup[_normalize_header_for_match("TIPO (C, P, A)")], "TIPO") + self.assertEqual( + lookup[_normalize_header_for_match("Origin (foreign/domestic) (N, E)")], + "PROCEDENCIA", + ) + self.assertEqual( + lookup[_normalize_header_for_match("Entity type (C, P, A)")], + "TIPO", + ) + self.assertEqual( + lookup[_normalize_header_for_match("RFC (máx. 30 caracteres)")], + "RFC", + ) + self.assertEqual( + lookup[_normalize_header_for_match("SHORT_NAME (máx. 10 caracteres)")], + "SHORT_NAME", + ) + self.assertEqual( + lookup[_normalize_header_for_match("Short code (max. 10 characters)")], + "SHORT_NAME", + ) + + def test_download_header_cell_max_chars(self): + col = {"canonical": "RFC", "csv_hint": {"kind": "max_chars", "n": 30}} + self.assertEqual(download_header_cell(col, "es"), "RFC (máx. 30 caracteres)") + self.assertEqual(download_header_cell(col, "en"), "RFC (max. 30 characters)") + col_one = {"canonical": "X", "csv_hint": {"kind": "max_chars", "n": 1}} + self.assertEqual(download_header_cell(col_one, "es"), "X (máx. 1 carácter)") + self.assertEqual(download_header_cell(col_one, "en"), "X (max. 1 character)") + + def test_download_header_cell_enum_then_max_chars(self): + col = { + "canonical": "TIPO", + "csv_hint": [ + {"kind": "enum_codes", "codes": ["C", "P", "A"]}, + {"kind": "max_chars", "n": 1}, + ], + } + self.assertEqual(download_header_cell(col, "es"), "TIPO (C, P, A) (máx. 1 carácter)") + + def test_display_header_unchanged_without_hint(self): + col = {"canonical": "ZZZ_NO_SCHEMA_LEN"} + self.assertEqual( + download_header_cell(col, "es"), + display_header_for_locale(col, "es"), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/frontend/messages/en.json b/frontend/messages/en.json index b440a47e..72f1428b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -103,6 +103,11 @@ }, "sidebar": { "dashboard": "Dashboard", + "management_label": "Management", + "bulk_upload": { + "title": "Bulk uploads", + "entry": "CSV import" + }, "reference_data": { "title": "Fixed Catalogs", "codes_pedimento_regimen": "Pedimento and Regime Codes", @@ -1601,5 +1606,198 @@ "cancel": "Cancel", "accept": "Accept" } + }, + "csv_upload": { + "page_title": "CSV import", + "intro_help": "Left-click: upload CSV file. Right-click: download template.", + "tab_catalogos": "Catalogs", + "tab_transportes": "Transportation", + "tab_importacion": "Import", + "tab_exportacion": "Export", + "section_catalogs": "General Catalogs", + "section_transport": "Transportation", + "section_import": "Import operations", + "section_export": "Export operations", + "params_header": "Global parameters", + "config_prefix": "Settings", + "soon": "Coming soon", + "drop_here": "Drop the file!", + "groups": { + "permisos": "Permissions", + "impo_temp": "Temporary import", + "impo_def": "Definitive import", + "cmex": "Mexican purchases", + "expo_def": "Definitive export / regime change", + "expo_rep": "Export replenishment", + "manifest": "Manifest" + }, + "items": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "material_classes": "Classes", + "part_numbers": "Parts", + "boms": "BOMs", + "items": "Lines (permissions)", + "headers": "Headers (permissions)", + "historical_fractions": "Historical tariff fractions", + "pedimentos": "Pedimentos", + "transporters": "Carriers", + "transports": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "imp_temp_header": "Header", + "imp_temp_details": "Lines", + "imp_temp_series": "Serial numbers", + "imp_def_header": "Header", + "imp_def_details": "Lines", + "imp_def_series": "Serial numbers", + "comp_mex_header": "Header", + "comp_mex_details": "Lines", + "comp_mex_series": "Serial numbers", + "exp_def_header": "Header", + "exp_def_details": "Lines", + "exp_def_series": "Serial numbers", + "exp_def_nodes": "NODES", + "exp_rep_header": "Header", + "exp_rep_details": "Lines", + "exp_rep_series": "Serial numbers", + "manifest_header": "Header" + }, + "params": { + "load_mode": "Load mode", + "date_format": "Date format", + "weight_unit": "Weight unit", + "autonumber_series": "Autonumber lines/series", + "load_subpartidas": "Load sub-lines", + "recalculate_pedimento_date": "Recalculate pedimento date", + "autonumber_remesas": "Autonumber consignments", + "recalculate_dates": "Recalculate dates", + "invoice_type": "Invoice type", + "is_regime_change": "Regime change" + }, + "options": { + "update": "Update", + "replace": "Replace", + "yes": "Yes", + "no": "No", + "kgs": "Kilograms (kg)", + "lbs": "Pounds (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Uploading CSV file", + "scan": "Validating records on the server", + "commit": "Saving records to the database", + "upload_known": "Uploading file…", + "upload_unknown": "Uploading file (unknown size in browser)…", + "in_progress": "In progress…", + "resume_hint": "Resuming import saved in this tab…", + "rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…", + "rows_scan": "Records processed: {current} / {total}", + "rows_commit": "Records saved: {current} / {total}", + "rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)" + }, + "toast": { + "invalid_csv": "Invalid format. Only .csv files are allowed.", + "download_loading": "Downloading template…", + "download_ok": "Template downloaded.", + "download_err": "Could not download the template.", + "upload_err": "Could not upload the file.", + "upload_err_generic": "Unexpected error uploading the file.", + "scan_done": "Scan complete. Review the results.", + "import_done": "Import completed. Review the record list.", + "import_maybe_done": "Import may have completed. Review the record list.", + "stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.", + "poll_err": "Could not fetch status", + "commit_err": "Could not start import", + "scan_alt": "Scan finished. If you do not see the modal, check the record list.", + "finished_none": "No records inserted. Review the errors below.", + "commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.", + "commit_warning_none": "No records inserted or updated. {skipped} rejected.", + "success_counts": "Import completed: {msg}", + "warn_skipped": "{n} records rejected or skipped", + "error_processing": "Processing error: {msg}", + "n_inserted": "{n} inserted", + "n_updated": "{n} updated", + "err_fetch_scan_result": "Could not fetch the scan result. Check the results modal.", + "err_unknown": "Unknown error", + "err_processing_fallback": "Processing error. Check the modal or details." + }, + "pending": { + "badge": "Pending", + "title": "Imports pending confirmation", + "description": "Scans ready to save to the database. Expired jobs disappear when you refresh.", + "refresh": "Refresh", + "empty": "No pending imports for this company.", + "checking": "Checking with the server…", + "total_rows": "Total rows", + "valid_rows": "Valid", + "resume": "Resume", + "remove": "Remove", + "profiles": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "pedimentos": "Pedimentos", + "material_classes": "Classes", + "vehicles": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "transporters": "Carriers", + "part_numbers": "Parts", + "boms": "BOMs", + "exportacion": "Export operations", + "imports": "Import operations" + } + }, + "config_empty": "No module-specific settings.", + "modal": { + "title_pending": "Import validation", + "title_success": "Import successful", + "title_warning": "Import with remarks", + "desc_pending": "Review the preliminary analysis before confirming.", + "desc_done": "The import process has finished.", + "total_rows": "Total rows", + "valid_rows": "Valid", + "invalid_rows": "Invalid", + "errors": "Errors", + "errors_heading": "Scan errors (fix in your CSV)", + "errors_badge": "{shown} of {total} error(s)", + "errors_truncated": "Download the CSV to see all errors.", + "errors_missing_detail": "{count} row(s) had errors but details are not available. Ensure the server is up to date and upload again.", + "scan_ok_title": "File validated successfully", + "scan_ok_body": "All rows look correct and ready to import.", + "scan_problems_title": "Problems found in the file", + "scan_problems_body": "Fix the issues listed below in your CSV and upload again, or confirm to import only valid rows (invalid rows will be skipped).", + "inserted": "Inserted", + "updated": "Updated", + "rejected": "Rejected", + "rejected_hint": "See line-by-line detail in the table below.", + "ref_gaps_title": "Reference gaps (FK / catalogs)", + "ref_gaps_body": "There are {n} critical reference gap(s). Review catalogs and rejected rows before retrying.", + "ref_state_title": "Reference state", + "ref_state_ok": "References ready to operate (no critical gaps reported).", + "ref_state_other": "No numeric gaps; review the server message if applicable.", + "skipped_reasons_heading": "Rejection reasons summary", + "commit_errors_heading": "Error detail", + "rows_badge": "{n} rows", + "importing_records": "Importing records…", + "cancel_operation": "Cancel", + "processing": "Processing…", + "confirm_load": "Confirm import", + "close": "Close", + "th_line": "Line", + "th_column": "Column", + "th_message": "Message", + "th_solution": "Solution", + "th_reference": "Reference", + "th_reason": "Reason", + "download_csv": "Download CSV" + } } } diff --git a/frontend/messages/es.json b/frontend/messages/es.json index 615b18d2..c45f076d 100644 --- a/frontend/messages/es.json +++ b/frontend/messages/es.json @@ -103,6 +103,11 @@ }, "sidebar": { "dashboard": "Dashboard", + "management_label": "Gestión", + "bulk_upload": { + "title": "Cargas masivas", + "entry": "Importación CSV" + }, "reference_data": { "title": "Catálogos Fijos", "codes_pedimento_regimen": "Códigos de Pedimento y Régimen", @@ -1601,5 +1606,198 @@ "cancel": "Cancelar", "accept": "Aceptar" } + }, + "csv_upload": { + "page_title": "Importación CSV", + "intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.", + "tab_catalogos": "Catálogos", + "tab_transportes": "Transportes", + "tab_importacion": "Importación", + "tab_exportacion": "Exportación", + "section_catalogs": "Catalogos Generales", + "section_transport": "Transportes", + "section_import": "Operaciones de importación", + "section_export": "Operaciones de exportación", + "params_header": "Parámetros globales", + "config_prefix": "Configuración", + "soon": "Próximamente", + "drop_here": "¡Suelta el archivo!", + "groups": { + "permisos": "Permisos", + "impo_temp": "Impo. temp.", + "impo_def": "Impo. def.", + "cmex": "Compras mex.", + "expo_def": "Expo. def./Cam. reg.", + "expo_rep": "Expo. rep.", + "manifest": "Manifiesto" + }, + "items": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "material_classes": "Clases", + "part_numbers": "Partes", + "boms": "BOMs", + "items": "Partidas (permisos)", + "headers": "Encabezados (permisos)", + "historical_fractions": "Fracciones históricas", + "pedimentos": "Pedimentos", + "transporters": "Transportistas", + "transports": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "imp_temp_header": "Encabezado", + "imp_temp_details": "Partidas", + "imp_temp_series": "Series", + "imp_def_header": "Encabezado", + "imp_def_details": "Partidas", + "imp_def_series": "Series", + "comp_mex_header": "Encabezado", + "comp_mex_details": "Partidas", + "comp_mex_series": "Series", + "exp_def_header": "Encabezado", + "exp_def_details": "Partidas", + "exp_def_series": "Series", + "exp_def_nodes": "NODES", + "exp_rep_header": "Encabezado", + "exp_rep_details": "Partidas", + "exp_rep_series": "Series", + "manifest_header": "Encabezado" + }, + "params": { + "load_mode": "Modo de carga", + "date_format": "Formato de fecha", + "weight_unit": "Unidad de peso", + "autonumber_series": "Autonumerar partidas/series", + "load_subpartidas": "Levantar subpartidas", + "recalculate_pedimento_date": "Recalcular fecha pedimento", + "autonumber_remesas": "Autonumerar remesas", + "recalculate_dates": "Recalcular fechas", + "invoice_type": "Tipo de factura", + "is_regime_change": "Es cambio de régimen" + }, + "options": { + "update": "Actualizar", + "replace": "Reemplazar", + "yes": "Sí", + "no": "No", + "kgs": "Kilos (kg)", + "lbs": "Libras (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Subiendo archivo CSV", + "scan": "Validando registros en el servidor", + "commit": "Grabando registros en base de datos", + "upload_known": "Subiendo archivo…", + "upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…", + "in_progress": "En proceso…", + "resume_hint": "Reanudando la importación guardada en esta pestaña…", + "rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…", + "rows_scan": "Registros procesados: {current} / {total}", + "rows_commit": "Registros grabados: {current} / {total}", + "rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)" + }, + "toast": { + "invalid_csv": "Formato inválido. Solo se permiten archivos .csv", + "download_loading": "Descargando plantilla…", + "download_ok": "Plantilla descargada.", + "download_err": "Error al descargar la plantilla", + "upload_err": "Error al subir el archivo", + "upload_err_generic": "Error inesperado al subir el archivo", + "scan_done": "Escaneo completado. Revisa los resultados.", + "import_done": "Importación completada. Revisa el listado de registros.", + "import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.", + "stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.", + "poll_err": "Error al consultar el estado", + "commit_err": "Error al iniciar la importación", + "scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.", + "finished_none": "No se insertaron registros. Revisa los errores a continuación.", + "commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.", + "commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.", + "success_counts": "Importación completada: {msg}", + "warn_skipped": "{n} registros fueron rechazados u omitidos", + "error_processing": "Error en el procesamiento: {msg}", + "n_inserted": "{n} insertados", + "n_updated": "{n} actualizados", + "err_fetch_scan_result": "Error al obtener el resultado. Revisa el modal de resultados.", + "err_unknown": "Error desconocido", + "err_processing_fallback": "Error en el procesamiento. Revisa el modal o los detalles." + }, + "pending": { + "badge": "Pendientes", + "title": "Importaciones pendientes de confirmar", + "description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.", + "refresh": "Actualizar", + "empty": "No hay importaciones pendientes para esta empresa.", + "checking": "Comprobando con el servidor…", + "total_rows": "Total filas", + "valid_rows": "Válidas", + "resume": "Reanudar", + "remove": "Quitar", + "profiles": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "pedimentos": "Pedimentos", + "material_classes": "Clases", + "vehicles": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "transporters": "Transportistas", + "part_numbers": "Partes", + "boms": "BOMs", + "exportacion": "Exportación (operaciones)", + "imports": "Importación (operaciones)" + } + }, + "config_empty": "No hay configuraciones específicas para este módulo.", + "modal": { + "title_pending": "Validación de importación", + "title_success": "Importación exitosa", + "title_warning": "Importación con observaciones", + "desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.", + "desc_done": "El proceso de importación ha finalizado.", + "total_rows": "Total filas", + "valid_rows": "Válidos", + "invalid_rows": "Inválidos", + "errors": "Errores", + "errors_heading": "Detalle de errores (para corregir en el CSV)", + "errors_badge": "{shown} de {total} error(es)", + "errors_truncated": "Para consultar el resto de errores, descargue el CSV.", + "errors_missing_detail": "Se detectaron {count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo.", + "scan_ok_title": "Archivo validado correctamente", + "scan_ok_body": "Todos los registros parecen correctos y listos para importar.", + "scan_problems_title": "Se detectaron problemas en el archivo", + "scan_problems_body": "Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para importar solo las filas válidas (las erróneas se omitirán).", + "inserted": "Insertados", + "updated": "Actualizados", + "rejected": "Rechazados", + "rejected_hint": "Revisa el detalle por línea en la tabla inferior.", + "ref_gaps_title": "Brechas de referencia (FK / catálogos)", + "ref_gaps_body": "Hay {n} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de filas rechazadas antes de reintentar.", + "ref_state_title": "Estado de referencias", + "ref_state_ok": "Referencias listas para operar (sin brechas críticas reportadas).", + "ref_state_other": "Sin brechas numéricas; revisa el mensaje del servidor si aplica.", + "skipped_reasons_heading": "Resumen de motivos de rechazo", + "commit_errors_heading": "Detalle de errores", + "rows_badge": "{n} filas", + "importing_records": "Importando registros…", + "cancel_operation": "Cancelar", + "processing": "Procesando…", + "confirm_load": "Confirmar carga", + "close": "Cerrar", + "th_line": "Línea", + "th_column": "Columna", + "th_message": "Mensaje", + "th_solution": "Solución", + "th_reference": "Referencia", + "th_reason": "Motivo", + "download_csv": "Descargar CSV" + } } } diff --git a/frontend/scripts/merge_csv_upload_i18n.py b/frontend/scripts/merge_csv_upload_i18n.py new file mode 100644 index 00000000..be3d33d0 --- /dev/null +++ b/frontend/scripts/merge_csv_upload_i18n.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Fusiona bloques csv_upload en messages/en.json y messages/es.json y vuelca a src/lib/i18n/csv-upload-messages.*.json. + +La fuente de verdad del copy CSV es `messages/{en,es}.json` (alineado con sidebar, dashboard, facturas). +Si editas solo esos JSON, sincroniza con: + node -e "const fs=require('fs'),p=require('path'),r='.../frontend';for(const l of['en','es']){const j=JSON.parse(fs.readFileSync(p.join(r,'messages',l+'.json'),'utf8'));fs.writeFileSync(p.join(r,'src/lib/i18n','csv-upload-messages.'+l+'.json'),JSON.stringify(j.csv_upload,null,'\\t')+'\\n')}" + +Ejecutar desde frontend/: python scripts/merge_csv_upload_i18n.py +""" +from __future__ import annotations + +import json +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +MESSAGES = ROOT / "messages" + +EN_CSV = { + "page_title": "CSV import", + "intro_help": "Left-click: upload CSV file. Right-click: download template.", + "tab_catalogos": "Catalogs", + "tab_transportes": "Transportation", + "tab_importacion": "Import", + "tab_exportacion": "Export", + "section_catalogs": "General Catalogs", + "section_transport": "Transportation", + "section_import": "Import operations", + "section_export": "Export operations", + "params_header": "Global parameters", + "config_prefix": "Settings", + "soon": "Coming soon", + "drop_here": "Drop the file!", + "groups": { + "permisos": "Permissions", + "impo_temp": "Temporary import", + "impo_def": "Definitive import", + "cmex": "Mexican purchases", + "expo_def": "Definitive export / regime change", + "expo_rep": "Export replenishment", + "manifest": "Manifest", + }, + "items": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "material_classes": "Classes", + "part_numbers": "Parts", + "boms": "BOMs", + "items": "Lines (permissions)", + "headers": "Headers (permissions)", + "historical_fractions": "Historical tariff fractions", + "pedimentos": "Pedimentos", + "transporters": "Carriers", + "transports": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "imp_temp_header": "Header", + "imp_temp_details": "Lines", + "imp_temp_series": "Serial numbers", + "imp_def_header": "Header", + "imp_def_details": "Lines", + "imp_def_series": "Serial numbers", + "comp_mex_header": "Header", + "comp_mex_details": "Lines", + "comp_mex_series": "Serial numbers", + "exp_def_header": "Header", + "exp_def_details": "Lines", + "exp_def_series": "Serial numbers", + "exp_def_nodes": "NODES", + "exp_rep_header": "Header", + "exp_rep_details": "Lines", + "exp_rep_series": "Serial numbers", + "manifest_header": "Header", + }, + "params": { + "load_mode": "Load mode", + "date_format": "Date format", + "weight_unit": "Weight unit", + "autonumber_series": "Autonumber lines/series", + "load_subpartidas": "Load sub-lines", + "recalculate_pedimento_date": "Recalculate pedimento date", + "autonumber_remesas": "Autonumber consignments", + "recalculate_dates": "Recalculate dates", + "invoice_type": "Invoice type", + "is_regime_change": "Regime change", + }, + "options": { + "update": "Update", + "replace": "Replace", + "yes": "Yes", + "no": "No", + "kgs": "Kilograms (kg)", + "lbs": "Pounds (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL", + }, + "progress": { + "upload": "Uploading CSV file", + "scan": "Validating records on the server", + "commit": "Saving records to the database", + "upload_known": "Uploading file…", + "upload_unknown": "Uploading file (unknown size in browser)…", + "in_progress": "In progress…", + "resume_hint": "Resuming import saved in this tab…", + "rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…", + "rows_scan": "Records processed: {current} / {total}", + "rows_commit": "Records saved: {current} / {total}", + "rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)", + }, + "toast": { + "invalid_csv": "Invalid format. Only .csv files are allowed.", + "download_loading": "Downloading template…", + "download_ok": "Template downloaded.", + "download_err": "Could not download the template.", + "upload_err": "Could not upload the file.", + "upload_err_generic": "Unexpected error uploading the file.", + "scan_done": "Scan complete. Review the results.", + "import_done": "Import completed. Review the record list.", + "import_maybe_done": "Import may have completed. Review the record list.", + "stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.", + "poll_err": "Could not fetch status", + "commit_err": "Could not start import", + "scan_alt": "Scan finished. If you do not see the modal, check the record list.", + "finished_none": "No records inserted. Review the errors below.", + "commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.", + "commit_warning_none": "No records inserted or updated. {skipped} rejected.", + "success_counts": "Import completed: {msg}", + "warn_skipped": "{n} records rejected or skipped", + "error_processing": "Processing error: {msg}", + }, + "pending": { + "badge": "Pending", + "title": "Imports pending confirmation", + "description": "Scans ready to save to the database. Expired jobs disappear when you refresh.", + "refresh": "Refresh", + "empty": "No pending imports for this company.", + "checking": "Checking with the server…", + "total_rows": "Total rows", + "valid_rows": "Valid", + "resume": "Resume", + "remove": "Remove", + "profiles": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "pedimentos": "Pedimentos", + "material_classes": "Classes", + "vehicles": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "transporters": "Carriers", + "part_numbers": "Parts", + "boms": "BOMs", + "exportacion": "Export operations", + "imports": "Import operations", + }, + }, + "modal": { + "title_pending": "Import validation", + "title_success": "Import successful", + "title_warning": "Import with remarks", + "desc_pending": "Review the preliminary analysis before confirming.", + "desc_done": "The import process has finished.", + "total_rows": "Total rows", + "valid_rows": "Valid", + "invalid_rows": "Invalid", + }, +} + +ES_CSV = { + "page_title": "Importación CSV", + "intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.", + "tab_catalogos": "Catálogos", + "tab_transportes": "Transportes", + "tab_importacion": "Importación", + "tab_exportacion": "Exportación", + "section_catalogs": "Catalogos Generales", + "section_transport": "Transportes", + "section_import": "Operaciones de importación", + "section_export": "Operaciones de exportación", + "params_header": "Parámetros globales", + "config_prefix": "Configuración", + "soon": "Próximamente", + "drop_here": "¡Suelta el archivo!", + "groups": { + "permisos": "Permisos", + "impo_temp": "Impo. temp.", + "impo_def": "Impo. def.", + "cmex": "Compras mex.", + "expo_def": "Expo. def./Cam. reg.", + "expo_rep": "Expo. rep.", + "manifest": "Manifiesto", + }, + "items": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "material_classes": "Clases", + "part_numbers": "Partes", + "boms": "BOMs", + "items": "Partidas (permisos)", + "headers": "Encabezados (permisos)", + "historical_fractions": "Fracciones históricas", + "pedimentos": "Pedimentos", + "transporters": "Transportistas", + "transports": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "imp_temp_header": "Encabezado", + "imp_temp_details": "Partidas", + "imp_temp_series": "Series", + "imp_def_header": "Encabezado", + "imp_def_details": "Partidas", + "imp_def_series": "Series", + "comp_mex_header": "Encabezado", + "comp_mex_details": "Partidas", + "comp_mex_series": "Series", + "exp_def_header": "Encabezado", + "exp_def_details": "Partidas", + "exp_def_series": "Series", + "exp_def_nodes": "NODES", + "exp_rep_header": "Encabezado", + "exp_rep_details": "Partidas", + "exp_rep_series": "Series", + "manifest_header": "Encabezado", + }, + "params": { + "load_mode": "Modo de carga", + "date_format": "Formato de fecha", + "weight_unit": "Unidad de peso", + "autonumber_series": "Autonumerar partidas/series", + "load_subpartidas": "Levantar subpartidas", + "recalculate_pedimento_date": "Recalcular fecha pedimento", + "autonumber_remesas": "Autonumerar remesas", + "recalculate_dates": "Recalcular fechas", + "invoice_type": "Tipo de factura", + "is_regime_change": "Es cambio de régimen", + }, + "options": { + "update": "Actualizar", + "replace": "Reemplazar", + "yes": "Sí", + "no": "No", + "kgs": "Kilos (kg)", + "lbs": "Libras (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL", + }, + "progress": { + "upload": "Subiendo archivo CSV", + "scan": "Validando registros en el servidor", + "commit": "Grabando registros en base de datos", + "upload_known": "Subiendo archivo…", + "upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…", + "in_progress": "En proceso…", + "resume_hint": "Reanudando la importación guardada en esta pestaña…", + "rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…", + "rows_scan": "Registros procesados: {current} / {total}", + "rows_commit": "Registros grabados: {current} / {total}", + "rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)", + }, + "toast": { + "invalid_csv": "Formato inválido. Solo se permiten archivos .csv", + "download_loading": "Descargando plantilla…", + "download_ok": "Plantilla descargada.", + "download_err": "Error al descargar la plantilla", + "upload_err": "Error al subir el archivo", + "upload_err_generic": "Error inesperado al subir el archivo", + "scan_done": "Escaneo completado. Revisa los resultados.", + "import_done": "Importación completada. Revisa el listado de registros.", + "import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.", + "stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.", + "poll_err": "Error al consultar el estado", + "commit_err": "Error al iniciar la importación", + "scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.", + "finished_none": "No se insertaron registros. Revisa los errores a continuación.", + "commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.", + "commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.", + "success_counts": "Importación completada: {msg}", + "warn_skipped": "{n} registros fueron rechazados u omitidos", + "error_processing": "Error en el procesamiento: {msg}", + }, + "pending": { + "badge": "Pendientes", + "title": "Importaciones pendientes de confirmar", + "description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.", + "refresh": "Actualizar", + "empty": "No hay importaciones pendientes para esta empresa.", + "checking": "Comprobando con el servidor…", + "total_rows": "Total filas", + "valid_rows": "Válidas", + "resume": "Reanudar", + "remove": "Quitar", + "profiles": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "pedimentos": "Pedimentos", + "material_classes": "Clases", + "vehicles": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "transporters": "Transportistas", + "part_numbers": "Partes", + "boms": "BOMs", + "exportacion": "Exportación (operaciones)", + "imports": "Importación (operaciones)", + }, + }, + "modal": { + "title_pending": "Validación de importación", + "title_success": "Importación exitosa", + "title_warning": "Importación con observaciones", + "desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.", + "desc_done": "El proceso de importación ha finalizado.", + "total_rows": "Total filas", + "valid_rows": "Válidos", + "invalid_rows": "Inválidos", + }, +} + + +def merge_locale(filename: str, csv_obj: dict) -> None: + path = MESSAGES / filename + data = json.loads(path.read_text(encoding="utf-8")) + data["csv_upload"] = csv_obj + path.write_text(json.dumps(data, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8") + + +def extract_csv_upload_to_lib() -> None: + """Copia `csv_upload` a src/lib/i18n/csv-upload-messages.*.json (lo que importa csv-msg.ts).""" + dest_dir = ROOT / "src/lib/i18n" + for filename, suffix in (("en.json", "en"), ("es.json", "es")): + path = MESSAGES / filename + data = json.loads(path.read_text(encoding="utf-8")) + cu = data.get("csv_upload") + if cu is None: + raise SystemExit(f"merge_csv_upload_i18n: falta csv_upload en {filename}") + out = dest_dir / f"csv-upload-messages.{suffix}.json" + out.write_text(json.dumps(cu, ensure_ascii=False, indent="\t") + "\n", encoding="utf-8") + print(f"Wrote {out.relative_to(ROOT)}") + + +def main() -> None: + merge_locale("en.json", EN_CSV) + merge_locale("es.json", ES_CSV) + print("Merged csv_upload into en.json and es.json") + extract_csv_upload_to_lib() + + +if __name__ == "__main__": + main() diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index cd036ea2..dc118038 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -681,16 +681,23 @@ export const api = { * Returns blob and suggested filename for the browser download. */ async getCsvTemplateDownload( - templateId: string + templateId: string, + locale?: string ): Promise<{ blob: Blob; filename: string }> { const token = getToken(); const headers: Record = {}; if (token) headers['Authorization'] = `Bearer ${token}`; - const response = await fetch(`${API_BASE_URL}/v1/a76/csv-templates/${templateId}`, { - method: 'GET', - headers, - credentials: 'include' - }); + const loc = locale === 'en' ? 'en' : 'es'; + const qs = new URLSearchParams({ locale: loc }); + const response = await fetch( + `${API_BASE_URL}/v1/a76/csv-templates/${templateId}?${qs.toString()}`, + { + method: 'GET', + headers, + credentials: 'include', + cache: 'no-store' + } + ); if (!response.ok) { const msg = response.status === 404 ? 'Plantilla no encontrada' : `Error ${response.status}`; throw new Error(msg); diff --git a/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte b/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte index 1adb87aa..bf1c468b 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ConfigFooter.svelte @@ -5,6 +5,14 @@ import * as RadioGroup from '$lib/components/ui/radio-group/index.js'; import { Settings2 } from 'lucide-svelte'; import { tabSettings } from '$lib/config/csv-upload'; + import { csvMsg } from '$lib/i18n/csv-msg'; + + const TAB_LABEL: Record = { + catalogos: 'tab_catalogos', + transportes: 'tab_transportes', + importacion: 'tab_importacion', + exportacion: 'tab_exportacion' + }; let { activeTab, @@ -38,7 +46,9 @@
- Configuración: {activeTab} + {csvMsg('config_prefix')}: {csvMsg(TAB_LABEL[activeTab] ?? activeTab)}
{#if currentFields.length > 0} @@ -47,7 +57,7 @@
{#if field.type !== 'boolean'} {csvMsg(field.labelKey)} {/if} @@ -59,7 +69,7 @@ {csvMsg(field.labelKey)}
{:else if field.type === 'select' && field.options} @@ -68,7 +78,7 @@ bind:value={settings[field.name]} > {#each field.options as opt} - + {/each} {:else if field.type === 'radio' && field.options} @@ -79,7 +89,7 @@ {#each field.options as opt}
- +
{/each} @@ -89,7 +99,7 @@
{:else}
- No hay configuraciones específicas para este módulo. + {csvMsg('config_empty')}
{/if} diff --git a/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte b/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte index 38278510..8629bc97 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/CsvParamsBar.svelte @@ -3,6 +3,14 @@ import { Settings2 } from 'lucide-svelte'; import { globalCsvParams, tabSettings, type CsvUploadField } from '$lib/config/csv-upload'; import { cn } from '$lib/utils'; + import { csvMsg } from '$lib/i18n/csv-msg'; + + const TAB_LABEL: Record = { + catalogos: 'tab_catalogos', + transportes: 'tab_transportes', + importacion: 'tab_importacion', + exportacion: 'tab_exportacion' + }; let { globalSettings = $bindable(), @@ -45,13 +53,13 @@
Parámetros globales{csvMsg('params_header')}
{#each globalCsvParams as param}
{csvMsg(param.labelKey)}
@@ -73,13 +81,13 @@ {#if currentTabFields.length > 0}
Configuración: {activeTab}{csvMsg('config_prefix')}: {csvMsg(TAB_LABEL[activeTab] ?? activeTab)} {#each currentTabFields as field}
{#if field.type !== 'boolean'} {csvMsg(field.labelKey)} {/if} {#if field.type === 'select' && field.options} @@ -92,7 +100,7 @@ }} > {#each field.options as opt} - + {/each} {:else if field.type === 'radio' && field.options} @@ -108,7 +116,7 @@ if (tabSettingsValues) tabSettingsValues[field.name] = opt.value; }} /> - {opt.label} + {csvMsg(opt.labelKey)} {/each}
@@ -121,7 +129,7 @@ if (tabSettingsValues) tabSettingsValues[field.name] = e.currentTarget.checked; }} /> - {field.label} + {csvMsg(field.labelKey)} {/if}
diff --git a/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte b/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte index c9d03b91..1344f2cd 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte @@ -14,6 +14,7 @@ } from '$lib/csv-import-pending'; import { fetchCsvImportStatus, isWaitingConfirmationPayload } from '$lib/csv-import-status-api'; import { Loader2, RefreshCw } from 'lucide-svelte'; + import { csvMsg } from '$lib/i18n/csv-msg'; type ValidatedRow = CsvImportPendingEntry & { checking?: boolean }; @@ -36,22 +37,9 @@ } function profileLabel(p: CsvImportPendingEntry['profile']): string { - const map: Record = { - customs_brokers: 'Agentes aduanales', - clients_providers: 'Clientes / proveedores', - exchange_rates: 'Tipos de cambio', - pedimentos: 'Pedimentos', - material_classes: 'Clases de material', - vehicles: 'Vehículos', - drivers: 'Conductores', - trailers: 'Remolques', - transporters: 'Transportistas', - part_numbers: 'Números de parte', - boms: 'BOMs', - exportacion: 'Exportación (operaciones)', - imports: 'Importación (operaciones)' - }; - return map[p] ?? p; + const key = `pending.profiles.${p}` as const; + const t = csvMsg(key); + return t === key ? p : t; } function isStaleJob(status: number, err: string | undefined): boolean { @@ -134,7 +122,7 @@
{#if rows.length === 0 && !refreshing} -

No hay importaciones pendientes para esta empresa.

+

{csvMsg('pending.empty')}

{:else if rows.length === 0 && refreshing} -

Comprobando con el servidor…

+

{csvMsg('pending.checking')}

{:else}
    {#each rows as row (row.jobId)} @@ -177,10 +164,10 @@ {#if row.totalRows != null || row.validRows != null}
    {#if row.totalRows != null} - Total filas: {row.totalRows} + {csvMsg('pending.total_rows')}: {row.totalRows} {/if} {#if row.validRows != null} - Válidas: {row.validRows} + {csvMsg('pending.valid_rows')}: {row.validRows} {/if}
    {/if} @@ -201,9 +188,9 @@ } }} > - Reanudar + {csvMsg('pending.resume')} - +
{/each} diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index b15acaeb..85a364f4 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -16,6 +16,7 @@ criticalReferenceGaps, referenceStateReady } from '$lib/csv-import-commit-metrics'; + import { csvFmt, csvMsg } from '$lib/i18n/csv-msg'; let { open = $bindable(false), @@ -249,16 +250,16 @@
{#if isPending} - Validación de Importación + {csvMsg('modal.title_pending')} {:else if isFinished} - {hasErrors ? 'Importación con Observaciones' : 'Importación Exitosa'} + {hasErrors ? csvMsg('modal.title_warning') : csvMsg('modal.title_success')} {/if} {#if isPending} - Revise el análisis preliminar antes de confirmar la carga de datos. + {csvMsg('modal.desc_pending')} {:else if isFinished} - El proceso de importación ha finalizado. + {csvMsg('modal.desc_done')} {/if}
@@ -274,7 +275,7 @@ class="bg-card p-4 rounded-lg border flex flex-col items-center justify-center text-center shadow-sm" > Total Filas{csvMsg('modal.total_rows')} {scanResults.total_rows || 0} @@ -285,7 +286,7 @@ > Válidos{csvMsg('modal.valid_rows')} {scanResults.valid_rows || 0} Errores{csvMsg('modal.errors')} {scanResults.error_count || 0} @@ -327,10 +328,9 @@ >
-

Se detectaron problemas en el archivo

+

{csvMsg('modal.scan_problems_title')}

- Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para - importar solo las filas válidas (las erróneas se omitirán). + {csvMsg('modal.scan_problems_body')}

@@ -338,13 +338,13 @@
- Detalle de errores (para corregir en el CSV) + {csvMsg('modal.errors_heading')}
- {scanErrorsShown} de {scanErrorsTotal} error(es) + {csvFmt('modal.errors_badge', { shown: scanErrorsShown, total: scanErrorsTotal })}
@@ -362,10 +362,10 @@ class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm" > - Línea - Columna - Mensaje - Solución + {csvMsg('modal.th_line')} + {csvMsg('modal.th_column')} + {csvMsg('modal.th_message')} + {csvMsg('modal.th_solution')} @@ -382,21 +382,21 @@
{#if scanErrorsTruncated}

- Para consultar el resto de errores, descargue el CSV. + {csvMsg('modal.errors_truncated')}

{/if} {:else}

- Se detectaron {scanResults.error_count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo. + {csvFmt('modal.errors_missing_detail', { count: scanResults.error_count })}

{/if} {:else}
-

Archivo validado correctamente

-

Todos los registros parecen correctos y listos para importar.

+

{csvMsg('modal.scan_ok_title')}

+

{csvMsg('modal.scan_ok_body')}

{/if} @@ -413,7 +413,7 @@ Insertados{csvMsg('modal.inserted')} {insertedCount} @@ -425,7 +425,7 @@ Actualizados{csvMsg('modal.updated')} {updatedCount} @@ -436,13 +436,13 @@
Rechazados{csvMsg('modal.rejected')}
{totalSkipped} {#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0}

- Revisa el detalle por línea en la tabla inferior. + {csvMsg('modal.rejected_hint')}

{/if} @@ -456,20 +456,17 @@ {#if refGaps > 0}
-

Brechas de referencia (FK / catálogos)

+

{csvMsg('modal.ref_gaps_title')}

- Hay {refGaps} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de - filas rechazadas antes de reintentar. + {csvFmt('modal.ref_gaps_body', { n: refGaps })}

{:else}
-

Estado de referencias

+

{csvMsg('modal.ref_state_title')}

- {refReady - ? 'Referencias listas para operar (sin brechas críticas reportadas).' - : 'Sin brechas numéricas; revisa el mensaje del servidor si aplica.'} + {refReady ? csvMsg('modal.ref_state_ok') : csvMsg('modal.ref_state_other')}

{/if} @@ -483,7 +480,7 @@ {#if commitSkippedSummary.length > 0}

- Resumen de motivos de rechazo + {csvMsg('modal.skipped_reasons_heading')}

{#each commitSkippedSummary as item} @@ -504,13 +501,13 @@
- Detalle de Errores + {csvMsg('modal.commit_errors_heading')}
- {commitResults.skipped_details.length} filas + {csvFmt('modal.rows_badge', { n: commitResults.skipped_details.length })}
@@ -528,10 +525,10 @@ class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm" > - Línea - Referencia - Motivo - Solución + {csvMsg('modal.th_line')} + {csvMsg('modal.th_reference')} + {csvMsg('modal.th_reason')} + {csvMsg('modal.th_solution')} @@ -558,7 +555,7 @@
{#if isPending && isUploading}
-

Importando registros…

+

{csvMsg('modal.importing_records')}

- Cancelar Operación + {csvMsg('modal.cancel_operation')} {:else if isFinished} - + {/if}
diff --git a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte index 0c9bdaaa..8896a5cb 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte @@ -4,7 +4,21 @@ import { UploadCloud, Lock } from 'lucide-svelte'; import { cn } from '$lib/utils'; import { toast } from 'svelte-sonner'; + import { browser } from '$app/environment'; import { api } from '$lib/api'; + import { cookieName, getLocale } from '$lib/paraglide/runtime'; + import { csvMsg } from '$lib/i18n/csv-msg'; + + /** Misma fuente que el switch de idioma (cookie Paraglide); `getLocale()` puede quedar desincronizado. */ + function csvTemplateLocale(): 'es' | 'en' { + if (browser) { + const cookies = document.cookie.split(';').map((c) => c.trim()); + const localeCookie = cookies.find((c) => c.startsWith(`${cookieName}=`)); + const current = localeCookie ? localeCookie.split('=')[1] : ''; + if (current) return current.toLowerCase().startsWith('en') ? 'en' : 'es'; + } + return String(getLocale()).toLowerCase().startsWith('en') ? 'en' : 'es'; + } let { items, @@ -62,7 +76,7 @@ const isValidExtension = file.name.toLowerCase().endsWith('.csv'); if (!isValidExtension) { - toast.error('Formato inválido. Solo se permiten archivos .csv'); + toast.error(csvMsg('toast.invalid_csv')); return; } @@ -106,8 +120,12 @@ if (!item.templateId) return; try { - toast.info(`Descargando plantilla para ${item.title}...`); - const { blob, filename } = await api.getCsvTemplateDownload(item.templateId); + const itemLabel = csvMsg(`items.${item.id}`); + toast.info(`${csvMsg('toast.download_loading')} ${itemLabel}`); + const { blob, filename } = await api.getCsvTemplateDownload( + item.templateId, + csvTemplateLocale() + ); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; @@ -116,9 +134,9 @@ link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); - toast.success(`Plantilla descargada: ${filename}`); + toast.success(`${csvMsg('toast.download_ok')} ${filename}`); } catch (err) { - toast.error(err instanceof Error ? err.message : 'Error al descargar la plantilla'); + toast.error(err instanceof Error ? err.message : csvMsg('toast.download_err')); } } @@ -128,6 +146,10 @@ class:opacity-60={gridLocked} aria-busy={gridLocked ? true : undefined} > + {#snippet cardTitle(itemId: string)} +
{csvMsg(`items.${itemId}`)}
+ {/snippet} + {#if groupedItems.ungrouped.length > 0}
{#each groupedItems.ungrouped as item} @@ -171,7 +193,7 @@ - Próximamente + {csvMsg('soon')}
{/if} @@ -183,7 +205,7 @@
- ¡Suelta el archivo! + {csvMsg('drop_here')} {:else}
-
{item.title}
+ {@render cardTitle(item.id)} {/if} @@ -205,7 +227,7 @@ {#each Object.entries(groupedItems.groups) as [groupName, groupItems]}

- {groupName} + {csvMsg(`groups.${groupName}`)}

{#each groupItems as item} @@ -251,7 +273,7 @@ - Próximamente + {csvMsg('soon')}
{/if} @@ -263,7 +285,7 @@
- ¡Suelta el archivo! + {csvMsg('drop_here')} {:else}
-
{item.title}
+ {@render cardTitle(item.id)} {/if} diff --git a/frontend/src/lib/components/sidebar/modules.ts b/frontend/src/lib/components/sidebar/modules.ts index 411ba348..41c7427e 100644 --- a/frontend/src/lib/components/sidebar/modules.ts +++ b/frontend/src/lib/components/sidebar/modules.ts @@ -91,6 +91,18 @@ export function getSidebarData(): SidebarData { items: [], permission: 'audit_logs.view', }, + { + title: m["sidebar.bulk_upload.title"](), + url: "#", + icon: ArrowUpFromLine, + items: [ + { + title: m["sidebar.bulk_upload.entry"](), + url: "/dashboard/csv-upload", + permission: "csv_upload.process", + }, + ], + }, { title: m["sidebar.reference_data.title"](), diff --git a/frontend/src/lib/components/sidebar/nav-projects.svelte b/frontend/src/lib/components/sidebar/nav-projects.svelte index ced6fb82..05910373 100644 --- a/frontend/src/lib/components/sidebar/nav-projects.svelte +++ b/frontend/src/lib/components/sidebar/nav-projects.svelte @@ -1,13 +1,6 @@ - Gestion + {m['sidebar.management_label']()} {#each projects as item (item.name)} @@ -48,58 +27,7 @@ {/snippet} - - - {#snippet child({ props })} - - - More - - {/snippet} - - - - - View Project - - - - Share Project - - - - - Delete Project - - - {/each} - - - - - More - - - (open = v)}> - - - - - - Carga CSV - - - - - diff --git a/frontend/src/lib/config/csv-upload.ts b/frontend/src/lib/config/csv-upload.ts index 426880c2..426eb969 100644 --- a/frontend/src/lib/config/csv-upload.ts +++ b/frontend/src/lib/config/csv-upload.ts @@ -22,9 +22,10 @@ import { export interface CsvUploadItem { id: string; - title: string; + /** Etiqueta vía i18n: csv_upload.items. */ icon: any; - group?: string; // For grouping within a tab + /** Clave i18n csv_upload.groups. */ + group?: string; modelTarget?: string; // The backend model this maps to description?: string; /** Backend template id for CSV download (e.g. customs_brokers, part_numbers). No physical file. */ @@ -36,9 +37,10 @@ export interface CsvUploadItem { export interface CsvUploadField { name: string; - label: string; + /** Ruta bajo csv_upload.* (p. ej. params.load_mode) */ + labelKey: string; type: 'text' | 'select' | 'boolean' | 'date' | 'radio'; - options?: { label: string; value: string | boolean | number }[]; + options?: { labelKey: string; value: string | boolean | number }[]; required?: boolean; defaultValue?: any; } @@ -46,9 +48,9 @@ export interface CsvUploadField { /** Global parameters shown in the CSV upload footer bar (one option or the other via select). */ export interface GlobalCsvParam { name: string; - label: string; + labelKey: string; type: 'select'; - options: { label: string; value: string }[]; + options: { labelKey: string; value: string }[]; defaultValue: string; } @@ -56,62 +58,62 @@ export interface GlobalCsvParam { export const globalCsvParams: GlobalCsvParam[] = [ { name: 'mode', - label: 'Modo de Carga', + labelKey: 'params.load_mode', type: 'select', options: [ - { label: 'Actualizar', value: 'update' }, - { label: 'Reemplazar', value: 'replace' } + { labelKey: 'options.update', value: 'update' }, + { labelKey: 'options.replace', value: 'replace' } ], defaultValue: 'update' }, { name: 'dateFormat', - label: 'Formato de Fecha', + labelKey: 'params.date_format', type: 'select', options: [ - { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, - { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, - { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } + { labelKey: 'options.date_dd_mm', value: 'dd/mm/yyyy' }, + { labelKey: 'options.date_mm_dd', value: 'mm/dd/yyyy' }, + { labelKey: 'options.date_iso', value: 'yyyy-mm-dd' } ], defaultValue: 'dd/mm/yyyy' }, { name: 'weight_unit', - label: 'Unidad de Peso', + labelKey: 'params.weight_unit', type: 'select', options: [ - { label: 'Kilos (Kgs)', value: 'kgs' }, - { label: 'Libras (Lbs)', value: 'lbs' } + { labelKey: 'options.kgs', value: 'kgs' }, + { labelKey: 'options.lbs', value: 'lbs' } ], defaultValue: 'kgs' }, { name: 'autonumber_series', - label: 'Autonumerar Partidas/Series', + labelKey: 'params.autonumber_series', type: 'select', options: [ - { label: 'Sí', value: 'true' }, - { label: 'No', value: 'false' } + { labelKey: 'options.yes', value: 'true' }, + { labelKey: 'options.no', value: 'false' } ], defaultValue: 'false' }, { name: 'load_subpartidas', - label: 'Levantar Subpartidas', + labelKey: 'params.load_subpartidas', type: 'select', options: [ - { label: 'Sí', value: 'true' }, - { label: 'No', value: 'false' } + { labelKey: 'options.yes', value: 'true' }, + { labelKey: 'options.no', value: 'false' } ], defaultValue: 'false' }, { name: 'recalculate_pedimento_date', - label: 'Recalcular Fecha Pedimento', + labelKey: 'params.recalculate_pedimento_date', type: 'select', options: [ - { label: 'Sí', value: 'true' }, - { label: 'No', value: 'false' } + { labelKey: 'options.yes', value: 'true' }, + { labelKey: 'options.no', value: 'false' } ], defaultValue: 'false' } @@ -122,11 +124,11 @@ export const tabSettings: Record = { catalogos: [ { name: 'mode', - label: 'Modo de Carga', + labelKey: 'params.load_mode', type: 'radio', options: [ - { label: 'Actualizar', value: 'update' }, - { label: 'Reemplazar', value: 'replace' } + { labelKey: 'options.update', value: 'update' }, + { labelKey: 'options.replace', value: 'replace' } ], defaultValue: 'update' } @@ -134,11 +136,11 @@ export const tabSettings: Record = { transportes: [ { name: 'mode', - label: 'Modo de Carga', + labelKey: 'params.load_mode', type: 'radio', options: [ - { label: 'Actualizar', value: 'update' }, - { label: 'Reemplazar', value: 'replace' } + { labelKey: 'options.update', value: 'update' }, + { labelKey: 'options.replace', value: 'replace' } ], defaultValue: 'update' } @@ -146,24 +148,24 @@ export const tabSettings: Record = { importacion: [ { name: 'autonumber_remesas', - label: 'Autonumerar Remesas', + labelKey: 'params.autonumber_remesas', type: 'boolean', defaultValue: false }, { name: 'recalculate_dates', - label: 'Recalcular Fechas', + labelKey: 'params.recalculate_dates', type: 'boolean', defaultValue: false }, { name: 'dateFormat', - label: 'Formato de Fecha', + labelKey: 'params.date_format', type: 'select', options: [ - { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, - { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, - { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } + { labelKey: 'options.date_dd_mm', value: 'dd/mm/yyyy' }, + { labelKey: 'options.date_mm_dd', value: 'mm/dd/yyyy' }, + { labelKey: 'options.date_iso', value: 'yyyy-mm-dd' } ], defaultValue: 'dd/mm/yyyy' } @@ -171,28 +173,28 @@ export const tabSettings: Record = { exportacion: [ { name: 'invoice_type', - label: 'Tipo de Factura', + labelKey: 'params.invoice_type', type: 'select', options: [ - { label: 'AFIJO', value: 'AFIJO' }, - { label: 'NORMAL', value: 'NORMAL' }, + { labelKey: 'options.afi', value: 'AFIJO' }, + { labelKey: 'options.normal', value: 'NORMAL' }, ], defaultValue: 'AFIJO', }, { name: 'is_regime_change', - label: 'Es Cambio de Régimen', + labelKey: 'params.is_regime_change', type: 'boolean', defaultValue: false, }, { name: 'dateFormat', - label: 'Formato de Fecha', + labelKey: 'params.date_format', type: 'select', options: [ - { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, - { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, - { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } + { labelKey: 'options.date_dd_mm', value: 'dd/mm/yyyy' }, + { labelKey: 'options.date_mm_dd', value: 'mm/dd/yyyy' }, + { labelKey: 'options.date_iso', value: 'yyyy-mm-dd' } ], defaultValue: 'dd/mm/yyyy' } @@ -204,7 +206,6 @@ export const tabSettings: Record = { export const catalogosConfig: CsvUploadItem[] = [ { id: 'customs_brokers', - title: 'Agentes Aduanales', icon: User, modelTarget: 'CustomsBroker', templateId: 'customs_brokers', @@ -212,7 +213,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'clients_providers', - title: 'Clientes y Proveedores', icon: Users, modelTarget: 'ClientProvider', templateId: 'clients_providers', @@ -220,7 +220,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'exchange_rates', - title: 'Tipo de Cambios', icon: DollarSign, modelTarget: 'ExchangeRate', templateId: 'exchange_rates', @@ -228,7 +227,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'material_classes', - title: 'Clases de Materiales', icon: Package, modelTarget: 'MaterialClass', templateId: 'material_classes', @@ -236,7 +234,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'part_numbers', - title: 'Números de parte', icon: Hash, modelTarget: 'Part', templateId: 'part_numbers', @@ -244,7 +241,6 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'boms', - title: 'BOMs', icon: Briefcase, modelTarget: 'Bom', templateId: 'boms', @@ -253,30 +249,26 @@ export const catalogosConfig: CsvUploadItem[] = [ }, { id: 'items', - title: 'Partidas (Permisos)', icon: FileText, - group: 'Permisos', + group: 'permisos', modelTarget: 'ItemPermission', templateId: 'part_numbers' }, { id: 'headers', - title: 'Encabezados (Permisos)', icon: FileText, - group: 'Permisos', + group: 'permisos', modelTarget: 'HeaderPermission', disabled: true, }, { id: 'historical_fractions', - title: 'Fracciones Históricas', icon: Calendar, modelTarget: 'HistoricalFraction', disabled: true, }, { id: 'pedimentos', - title: 'Pedimentos', icon: FileDigit, modelTarget: 'Pedimento', templateId: 'pedimentos', @@ -288,7 +280,6 @@ export const catalogosConfig: CsvUploadItem[] = [ export const transportesConfig: CsvUploadItem[] = [ { id: 'transporters', - title: 'Transportistas', icon: Ship, modelTarget: 'Transporter', templateId: 'transporters', @@ -296,7 +287,6 @@ export const transportesConfig: CsvUploadItem[] = [ }, { id: 'transports', - title: 'Transportes', icon: Truck, modelTarget: 'Transport', templateId: 'transports', @@ -304,7 +294,6 @@ export const transportesConfig: CsvUploadItem[] = [ }, { id: 'drivers', - title: 'Conductores', icon: User, modelTarget: 'Driver', templateId: 'drivers', @@ -312,7 +301,6 @@ export const transportesConfig: CsvUploadItem[] = [ }, { id: 'trailers', - title: 'Trailers y Cajas', icon: Container, modelTarget: 'Trailer', templateId: 'trailers', @@ -326,27 +314,24 @@ export const importacionConfig: CsvUploadItem[] = [ // Impo Temp { id: 'imp_temp_header', - title: 'Encabezado', icon: FileText, - group: 'Impo. Temp.', + group: 'impo_temp', modelTarget: 'invoice_header', templateId: 'imp_temp_header', layoutModule: 'layouts_csv/facturas' }, { id: 'imp_temp_details', - title: 'Partidas', icon: Package, - group: 'Impo. Temp.', + group: 'impo_temp', modelTarget: 'invoice_details', templateId: 'imp_temp_details', layoutModule: 'layouts_csv/facturas' }, { id: 'imp_temp_series', - title: 'Series', icon: Hash, - group: 'Impo. Temp.', + group: 'impo_temp', modelTarget: 'invoice_series', templateId: 'imp_temp_series', layoutModule: 'layouts_csv/facturas' @@ -354,27 +339,24 @@ export const importacionConfig: CsvUploadItem[] = [ // Impo Def { id: 'imp_def_header', - title: 'Encabezado', icon: FileText, - group: 'Impo. Def.', + group: 'impo_def', modelTarget: 'invoice_header', templateId: 'imp_def_header', layoutModule: 'layouts_csv/facturas' }, { id: 'imp_def_details', - title: 'Partidas', icon: Package, - group: 'Impo. Def.', + group: 'impo_def', modelTarget: 'invoice_details', templateId: 'imp_def_details', layoutModule: 'layouts_csv/facturas' }, { id: 'imp_def_series', - title: 'Series', icon: Hash, - group: 'Impo. Def.', + group: 'impo_def', modelTarget: 'invoice_series', templateId: 'imp_def_series', layoutModule: 'layouts_csv/facturas' @@ -382,27 +364,24 @@ export const importacionConfig: CsvUploadItem[] = [ // Compras Mex { id: 'comp_mex_header', - title: 'Encabezado', icon: FileText, - group: 'Compras Mex.', + group: 'cmex', modelTarget: 'invoice_header', templateId: 'cmex_header', layoutModule: 'layouts_csv/facturas' }, { id: 'comp_mex_details', - title: 'Partidas', icon: Package, - group: 'Compras Mex.', + group: 'cmex', modelTarget: 'invoice_details', templateId: 'cmex_details', layoutModule: 'layouts_csv/facturas' }, { id: 'comp_mex_series', - title: 'Series', icon: Hash, - group: 'Compras Mex.', + group: 'cmex', modelTarget: 'invoice_series', templateId: 'cmex_series', layoutModule: 'layouts_csv/facturas' @@ -415,70 +394,62 @@ export const exportacionConfig: CsvUploadItem[] = [ // Expo Def / Cam. Reg. { id: 'exp_def_header', - title: 'Encabezado', icon: FileText, - group: 'Expo. Def./Cam. Reg.', + group: 'expo_def', modelTarget: 'invoice_header', templateId: 'exp_def_header', layoutModule: 'layouts_csv/exportacion' }, { id: 'exp_def_details', - title: 'Partidas', icon: Package, - group: 'Expo. Def./Cam. Reg.', + group: 'expo_def', modelTarget: 'invoice_details', templateId: 'exp_def_details', layoutModule: 'layouts_csv/exportacion' }, { id: 'exp_def_series', - title: 'Series', icon: Hash, - group: 'Expo. Def./Cam. Reg.', + group: 'expo_def', modelTarget: 'invoice_series', templateId: 'exp_def_series', layoutModule: 'layouts_csv/exportacion' }, { id: 'exp_def_nodes', - title: 'NODES', icon: Briefcase, - group: 'Expo. Def./Cam. Reg.', + group: 'expo_def', modelTarget: 'Nodes', disabled: true, }, // Expo Rep { id: 'exp_rep_header', - title: 'Encabezado', icon: FileText, - group: 'Expo. Rep.', + group: 'expo_rep', modelTarget: 'InvoiceHeader', disabled: true, }, { id: 'exp_rep_details', - title: 'Partidas', icon: Package, - group: 'Expo. Rep.', + group: 'expo_rep', modelTarget: 'InvoiceSalesDetails', disabled: true, }, { id: 'exp_rep_series', - title: 'Series', icon: Hash, - group: 'Expo. Rep.', + group: 'expo_rep', modelTarget: 'InvoiceSeries', disabled: true, }, // Manifiesto { id: 'manifest_header', - title: 'Encabezado', icon: FileText, - group: 'Manifiesto', + group: 'manifest', modelTarget: 'Manifest', disabled: true, }, diff --git a/frontend/src/lib/i18n/csv-msg.ts b/frontend/src/lib/i18n/csv-msg.ts new file mode 100644 index 00000000..fbf56fa6 --- /dev/null +++ b/frontend/src/lib/i18n/csv-msg.ts @@ -0,0 +1,46 @@ +/** + * Textos CSV desde `csv-upload-messages.{en,es}.json` (rama `csv_upload` extraída de messages/). + * Mantener en sync al editar `messages/*.json` (p. ej. volver a exportar la clave csv_upload). + */ +import enPack from './csv-upload-messages.en.json'; +import esPack from './csv-upload-messages.es.json'; +import { baseLocale, getLocale } from '$lib/paraglide/runtime'; + +type CsvUploadPack = Record; + +function pickPack(): CsvUploadPack { + let raw: string; + try { + raw = String(getLocale()).toLowerCase(); + } catch { + raw = String(baseLocale).toLowerCase(); + } + const root = raw.startsWith('en') ? enPack : esPack; + return root as CsvUploadPack; +} + +function walk(root: Record, keys: string[]): unknown { + let cur: unknown = root; + for (const k of keys) { + if (cur === null || typeof cur !== 'object') return undefined; + cur = (cur as Record)[k]; + } + return cur; +} + +/** Navega `csv_upload.a.b.c` a partir de `subPath` = `a.b.c`. */ +export function csvMsg(subPath: string): string { + const pack = pickPack(); + if (!pack) return subPath; + const v = walk(pack as Record, subPath.split('.')); + return typeof v === 'string' ? v : subPath; +} + +/** Sustituye `{clave}` en el string del mensaje. */ +export function csvFmt(subPath: string, vars: Record = {}): string { + let s = csvMsg(subPath); + for (const [k, val] of Object.entries(vars)) { + s = s.replaceAll(`{${k}}`, String(val)); + } + return s; +} diff --git a/frontend/src/lib/i18n/csv-upload-messages.en.json b/frontend/src/lib/i18n/csv-upload-messages.en.json new file mode 100644 index 00000000..2ebba1a5 --- /dev/null +++ b/frontend/src/lib/i18n/csv-upload-messages.en.json @@ -0,0 +1,193 @@ +{ + "page_title": "CSV import", + "intro_help": "Left-click: upload CSV file. Right-click: download template.", + "tab_catalogos": "Catalogs", + "tab_transportes": "Transportation", + "tab_importacion": "Import", + "tab_exportacion": "Export", + "section_catalogs": "General Catalogs", + "section_transport": "Transportation", + "section_import": "Import operations", + "section_export": "Export operations", + "params_header": "Global parameters", + "config_prefix": "Settings", + "soon": "Coming soon", + "drop_here": "Drop the file!", + "groups": { + "permisos": "Permissions", + "impo_temp": "Temporary import", + "impo_def": "Definitive import", + "cmex": "Mexican purchases", + "expo_def": "Definitive export / regime change", + "expo_rep": "Export replenishment", + "manifest": "Manifest" + }, + "items": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "material_classes": "Classes", + "part_numbers": "Parts", + "boms": "BOMs", + "items": "Lines (permissions)", + "headers": "Headers (permissions)", + "historical_fractions": "Historical tariff fractions", + "pedimentos": "Pedimentos", + "transporters": "Carriers", + "transports": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "imp_temp_header": "Header", + "imp_temp_details": "Lines", + "imp_temp_series": "Serial numbers", + "imp_def_header": "Header", + "imp_def_details": "Lines", + "imp_def_series": "Serial numbers", + "comp_mex_header": "Header", + "comp_mex_details": "Lines", + "comp_mex_series": "Serial numbers", + "exp_def_header": "Header", + "exp_def_details": "Lines", + "exp_def_series": "Serial numbers", + "exp_def_nodes": "NODES", + "exp_rep_header": "Header", + "exp_rep_details": "Lines", + "exp_rep_series": "Serial numbers", + "manifest_header": "Header" + }, + "params": { + "load_mode": "Load mode", + "date_format": "Date format", + "weight_unit": "Weight unit", + "autonumber_series": "Autonumber lines/series", + "load_subpartidas": "Load sub-lines", + "recalculate_pedimento_date": "Recalculate pedimento date", + "autonumber_remesas": "Autonumber consignments", + "recalculate_dates": "Recalculate dates", + "invoice_type": "Invoice type", + "is_regime_change": "Regime change" + }, + "options": { + "update": "Update", + "replace": "Replace", + "yes": "Yes", + "no": "No", + "kgs": "Kilograms (kg)", + "lbs": "Pounds (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Uploading CSV file", + "scan": "Validating records on the server", + "commit": "Saving records to the database", + "upload_known": "Uploading file…", + "upload_unknown": "Uploading file (unknown size in browser)…", + "in_progress": "In progress…", + "resume_hint": "Resuming import saved in this tab…", + "rows_file": "File: ~{n} data row(s) — uploading (not yet validated on server)…", + "rows_scan": "Records processed: {current} / {total}", + "rows_commit": "Records saved: {current} / {total}", + "rows_commit_fallback": "Saving to database… ({current} / {total} using last known total)" + }, + "toast": { + "invalid_csv": "Invalid format. Only .csv files are allowed.", + "download_loading": "Downloading template…", + "download_ok": "Template downloaded.", + "download_err": "Could not download the template.", + "upload_err": "Could not upload the file.", + "upload_err_generic": "Unexpected error uploading the file.", + "scan_done": "Scan complete. Review the results.", + "import_done": "Import completed. Review the record list.", + "import_maybe_done": "Import may have completed. Review the record list.", + "stale_job": "This import is no longer available (session expired or job removed). You can start a new upload.", + "poll_err": "Could not fetch status", + "commit_err": "Could not start import", + "scan_alt": "Scan finished. If you do not see the modal, check the record list.", + "finished_none": "No records inserted. Review the errors below.", + "commit_warning_ok": "{inserted} inserted, {updated} updated. {skipped} rejected.", + "commit_warning_none": "No records inserted or updated. {skipped} rejected.", + "success_counts": "Import completed: {msg}", + "warn_skipped": "{n} records rejected or skipped", + "error_processing": "Processing error: {msg}", + "n_inserted": "{n} inserted", + "n_updated": "{n} updated", + "err_fetch_scan_result": "Could not fetch the scan result. Check the results modal.", + "err_unknown": "Unknown error", + "err_processing_fallback": "Processing error. Check the modal or details." + }, + "pending": { + "badge": "Pending", + "title": "Imports pending confirmation", + "description": "Scans ready to save to the database. Expired jobs disappear when you refresh.", + "refresh": "Refresh", + "empty": "No pending imports for this company.", + "checking": "Checking with the server…", + "total_rows": "Total rows", + "valid_rows": "Valid", + "resume": "Resume", + "remove": "Remove", + "profiles": { + "customs_brokers": "Customs Brokers", + "clients_providers": "Clients and Providers", + "exchange_rates": "Exchange Rates", + "pedimentos": "Pedimentos", + "material_classes": "Classes", + "vehicles": "Vehicles", + "drivers": "Drivers", + "trailers": "Trailers", + "transporters": "Carriers", + "part_numbers": "Parts", + "boms": "BOMs", + "exportacion": "Export operations", + "imports": "Import operations" + } + }, + "config_empty": "No module-specific settings.", + "modal": { + "title_pending": "Import validation", + "title_success": "Import successful", + "title_warning": "Import with remarks", + "desc_pending": "Review the preliminary analysis before confirming.", + "desc_done": "The import process has finished.", + "total_rows": "Total rows", + "valid_rows": "Valid", + "invalid_rows": "Invalid", + "errors": "Errors", + "errors_heading": "Scan errors (fix in your CSV)", + "errors_badge": "{shown} of {total} error(s)", + "errors_truncated": "Download the CSV to see all errors.", + "errors_missing_detail": "{count} row(s) had errors but details are not available. Ensure the server is up to date and upload again.", + "scan_ok_title": "File validated successfully", + "scan_ok_body": "All rows look correct and ready to import.", + "scan_problems_title": "Problems found in the file", + "scan_problems_body": "Fix the issues listed below in your CSV and upload again, or confirm to import only valid rows (invalid rows will be skipped).", + "inserted": "Inserted", + "updated": "Updated", + "rejected": "Rejected", + "rejected_hint": "See line-by-line detail in the table below.", + "ref_gaps_title": "Reference gaps (FK / catalogs)", + "ref_gaps_body": "There are {n} critical reference gap(s). Review catalogs and rejected rows before retrying.", + "ref_state_title": "Reference state", + "ref_state_ok": "References ready to operate (no critical gaps reported).", + "ref_state_other": "No numeric gaps; review the server message if applicable.", + "skipped_reasons_heading": "Rejection reasons summary", + "commit_errors_heading": "Error detail", + "rows_badge": "{n} rows", + "importing_records": "Importing records…", + "cancel_operation": "Cancel", + "processing": "Processing…", + "confirm_load": "Confirm import", + "close": "Close", + "th_line": "Line", + "th_column": "Column", + "th_message": "Message", + "th_solution": "Solution", + "th_reference": "Reference", + "th_reason": "Reason", + "download_csv": "Download CSV" + } +} diff --git a/frontend/src/lib/i18n/csv-upload-messages.es.json b/frontend/src/lib/i18n/csv-upload-messages.es.json new file mode 100644 index 00000000..326d2842 --- /dev/null +++ b/frontend/src/lib/i18n/csv-upload-messages.es.json @@ -0,0 +1,193 @@ +{ + "page_title": "Importación CSV", + "intro_help": "Clic izquierdo: cargar archivo CSV. Clic derecho: descargar plantilla.", + "tab_catalogos": "Catálogos", + "tab_transportes": "Transportes", + "tab_importacion": "Importación", + "tab_exportacion": "Exportación", + "section_catalogs": "Catalogos Generales", + "section_transport": "Transportes", + "section_import": "Operaciones de importación", + "section_export": "Operaciones de exportación", + "params_header": "Parámetros globales", + "config_prefix": "Configuración", + "soon": "Próximamente", + "drop_here": "¡Suelta el archivo!", + "groups": { + "permisos": "Permisos", + "impo_temp": "Impo. temp.", + "impo_def": "Impo. def.", + "cmex": "Compras mex.", + "expo_def": "Expo. def./Cam. reg.", + "expo_rep": "Expo. rep.", + "manifest": "Manifiesto" + }, + "items": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "material_classes": "Clases", + "part_numbers": "Partes", + "boms": "BOMs", + "items": "Partidas (permisos)", + "headers": "Encabezados (permisos)", + "historical_fractions": "Fracciones históricas", + "pedimentos": "Pedimentos", + "transporters": "Transportistas", + "transports": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "imp_temp_header": "Encabezado", + "imp_temp_details": "Partidas", + "imp_temp_series": "Series", + "imp_def_header": "Encabezado", + "imp_def_details": "Partidas", + "imp_def_series": "Series", + "comp_mex_header": "Encabezado", + "comp_mex_details": "Partidas", + "comp_mex_series": "Series", + "exp_def_header": "Encabezado", + "exp_def_details": "Partidas", + "exp_def_series": "Series", + "exp_def_nodes": "NODES", + "exp_rep_header": "Encabezado", + "exp_rep_details": "Partidas", + "exp_rep_series": "Series", + "manifest_header": "Encabezado" + }, + "params": { + "load_mode": "Modo de carga", + "date_format": "Formato de fecha", + "weight_unit": "Unidad de peso", + "autonumber_series": "Autonumerar partidas/series", + "load_subpartidas": "Levantar subpartidas", + "recalculate_pedimento_date": "Recalcular fecha pedimento", + "autonumber_remesas": "Autonumerar remesas", + "recalculate_dates": "Recalcular fechas", + "invoice_type": "Tipo de factura", + "is_regime_change": "Es cambio de régimen" + }, + "options": { + "update": "Actualizar", + "replace": "Reemplazar", + "yes": "Sí", + "no": "No", + "kgs": "Kilos (kg)", + "lbs": "Libras (lb)", + "date_dd_mm": "DD/MM/YYYY", + "date_mm_dd": "MM/DD/YYYY", + "date_iso": "YYYY-MM-DD", + "afi": "AFIJO", + "normal": "NORMAL" + }, + "progress": { + "upload": "Subiendo archivo CSV", + "scan": "Validando registros en el servidor", + "commit": "Grabando registros en base de datos", + "upload_known": "Subiendo archivo…", + "upload_unknown": "Subiendo archivo (tamaño desconocido en el navegador)…", + "in_progress": "En proceso…", + "resume_hint": "Reanudando la importación guardada en esta pestaña…", + "rows_file": "Archivo: ~{n} fila(s) de datos — subiendo (aún no se validan registros en servidor)…", + "rows_scan": "Registros procesados: {current} / {total}", + "rows_commit": "Registros grabados: {current} / {total}", + "rows_commit_fallback": "Grabando en base de datos… ({current} / {total} según último total conocido)" + }, + "toast": { + "invalid_csv": "Formato inválido. Solo se permiten archivos .csv", + "download_loading": "Descargando plantilla…", + "download_ok": "Plantilla descargada.", + "download_err": "Error al descargar la plantilla", + "upload_err": "Error al subir el archivo", + "upload_err_generic": "Error inesperado al subir el archivo", + "scan_done": "Escaneo completado. Revisa los resultados.", + "import_done": "Importación completada. Revisa el listado de registros.", + "import_maybe_done": "La importación pudo completarse. Revisa el listado de registros.", + "stale_job": "Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.", + "poll_err": "Error al consultar el estado", + "commit_err": "Error al iniciar la importación", + "scan_alt": "El escaneo terminó. Si no ves el modal, revisa el listado de registros.", + "finished_none": "No se insertaron registros. Revisa los errores a continuación.", + "commit_warning_ok": "{inserted} insertados, {updated} actualizados. {skipped} rechazados.", + "commit_warning_none": "No se insertaron ni actualizaron registros. {skipped} rechazados.", + "success_counts": "Importación completada: {msg}", + "warn_skipped": "{n} registros fueron rechazados u omitidos", + "error_processing": "Error en el procesamiento: {msg}", + "n_inserted": "{n} insertados", + "n_updated": "{n} actualizados", + "err_fetch_scan_result": "Error al obtener el resultado. Revisa el modal de resultados.", + "err_unknown": "Error desconocido", + "err_processing_fallback": "Error en el procesamiento. Revisa el modal o los detalles." + }, + "pending": { + "badge": "Pendientes", + "title": "Importaciones pendientes de confirmar", + "description": "Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al actualizar.", + "refresh": "Actualizar", + "empty": "No hay importaciones pendientes para esta empresa.", + "checking": "Comprobando con el servidor…", + "total_rows": "Total filas", + "valid_rows": "Válidas", + "resume": "Reanudar", + "remove": "Quitar", + "profiles": { + "customs_brokers": "Agentes Aduanales", + "clients_providers": "Clientes y Proveedores", + "exchange_rates": "Tipos de cambio", + "pedimentos": "Pedimentos", + "material_classes": "Clases", + "vehicles": "Vehículos", + "drivers": "Conductores", + "trailers": "Trailers", + "transporters": "Transportistas", + "part_numbers": "Partes", + "boms": "BOMs", + "exportacion": "Exportación (operaciones)", + "imports": "Importación (operaciones)" + } + }, + "config_empty": "No hay configuraciones específicas para este módulo.", + "modal": { + "title_pending": "Validación de importación", + "title_success": "Importación exitosa", + "title_warning": "Importación con observaciones", + "desc_pending": "Revise el análisis preliminar antes de confirmar la carga de datos.", + "desc_done": "El proceso de importación ha finalizado.", + "total_rows": "Total filas", + "valid_rows": "Válidos", + "invalid_rows": "Inválidos", + "errors": "Errores", + "errors_heading": "Detalle de errores (para corregir en el CSV)", + "errors_badge": "{shown} de {total} error(es)", + "errors_truncated": "Para consultar el resto de errores, descargue el CSV.", + "errors_missing_detail": "Se detectaron {count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo.", + "scan_ok_title": "Archivo validado correctamente", + "scan_ok_body": "Todos los registros parecen correctos y listos para importar.", + "scan_problems_title": "Se detectaron problemas en el archivo", + "scan_problems_body": "Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para importar solo las filas válidas (las erróneas se omitirán).", + "inserted": "Insertados", + "updated": "Actualizados", + "rejected": "Rechazados", + "rejected_hint": "Revisa el detalle por línea en la tabla inferior.", + "ref_gaps_title": "Brechas de referencia (FK / catálogos)", + "ref_gaps_body": "Hay {n} brecha(s) crítica(s) de referencia. Revisa catálogos y el detalle de filas rechazadas antes de reintentar.", + "ref_state_title": "Estado de referencias", + "ref_state_ok": "Referencias listas para operar (sin brechas críticas reportadas).", + "ref_state_other": "Sin brechas numéricas; revisa el mensaje del servidor si aplica.", + "skipped_reasons_heading": "Resumen de motivos de rechazo", + "commit_errors_heading": "Detalle de errores", + "rows_badge": "{n} filas", + "importing_records": "Importando registros…", + "cancel_operation": "Cancelar", + "processing": "Procesando…", + "confirm_load": "Confirmar carga", + "close": "Cerrar", + "th_line": "Línea", + "th_column": "Columna", + "th_message": "Mensaje", + "th_solution": "Solución", + "th_reference": "Referencia", + "th_reason": "Motivo", + "download_csv": "Descargar CSV" + } +} diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index 876a166b..7c05bf78 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -39,6 +39,7 @@ import { totalSkippedFromCommit } from '$lib/csv-import-commit-metrics'; import { countCsvDataRows } from '$lib/csv-upload-row-count'; import CsvPendingImportsSheet from '$lib/components/dashboard/csv-upload/CsvPendingImportsSheet.svelte'; + import { csvFmt, csvMsg } from '$lib/i18n/csv-msg'; /** Intenta extraer un objeto tipo scan desde string tipo repr de Python. */ function parsePythonReprScan(s: string): Record | null { @@ -240,11 +241,11 @@ const csvProgressStepTitle = $derived.by(() => { switch (csvProgressPhase) { case 'upload': - return 'Subiendo archivo CSV'; + return csvMsg('progress.upload'); case 'scan': - return 'Validando registros en el servidor'; + return csvMsg('progress.scan'); case 'commit': - return 'Grabando registros en base de datos'; + return csvMsg('progress.commit'); default: return ''; } @@ -261,17 +262,26 @@ switch (csvProgressPhase) { case 'upload': if (csvImportRowTotal > 0) { - return `Archivo: ~${csvImportRowTotal} fila(s) de datos — subiendo (aún no se validan registros en servidor)…`; + return csvFmt('progress.rows_file', { n: csvImportRowTotal }); } return uploadLengthComputable - ? 'Subiendo archivo…' - : 'Subiendo archivo (tamaño desconocido en el navegador)…'; + ? csvMsg('progress.upload_known') + : csvMsg('progress.upload_unknown'); case 'scan': - return `Registros procesados: ${Math.min(scanProgressCurrent, denom)} / ${denom}`; + return csvFmt('progress.rows_scan', { + current: Math.min(scanProgressCurrent, denom), + total: denom + }); case 'commit': return scanProgressTotal > 0 - ? `Registros grabados: ${Math.min(scanProgressCurrent, denom)} / ${denom}` - : `Grabando en base de datos… (${Math.min(scanProgressCurrent, denom)} / ${denom} según último total conocido)`; + ? csvFmt('progress.rows_commit', { + current: Math.min(scanProgressCurrent, denom), + total: denom + }) + : csvFmt('progress.rows_commit_fallback', { + current: Math.min(scanProgressCurrent, denom), + total: denom + }); default: return ''; } @@ -306,7 +316,7 @@ const csvFooterAriaValueText = $derived( csvFooterProgressIndeterminate - ? `${csvProgressStepTitle}. ${csvProgressDetailLine || 'En proceso…'}` + ? `${csvProgressStepTitle}. ${csvProgressDetailLine || csvMsg('progress.in_progress')}` : csvProgressDetailLine ? `${csvProgressStepTitle}, ${csvFooterPercentLabel}. ${csvProgressDetailLine}` : `${csvProgressStepTitle}, ${csvFooterPercentLabel}` @@ -591,7 +601,7 @@ csvResumeOverlayHint = false; skipScanCompleteToastOnce = false; scanPhaseJobId = null; - currentImportLabel = (config.title && String(config.title).trim()) || null; + currentImportLabel = csvMsg(`items.${config.id}`); const onCsvFileUploadProgress = (e: { loaded: number; total: number }) => { if (e.total > 0) { @@ -626,12 +636,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -646,12 +656,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -673,12 +683,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -698,12 +708,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -721,12 +731,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -744,12 +754,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -764,12 +774,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -787,12 +797,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -810,12 +820,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -834,12 +844,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -854,12 +864,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -888,12 +898,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } return; @@ -915,12 +925,12 @@ currentJobId = res.data.job_id; beginCsvScanAfterUpload(); } else { - toast.error(res.error || 'Error al subir el archivo'); + toast.error(res.error || csvMsg('toast.upload_err')); isUploading = false; } } catch (e) { console.error('Upload exception', e); - toast.error('Error inesperado al subir el archivo'); + toast.error(csvMsg('toast.upload_err_generic')); isUploading = false; } } @@ -943,11 +953,9 @@ const errMsg = res.error || ''; if (isStaleImportJobError(res.status, errMsg)) { removePendingLinkedToCurrentFlow(); - toast.info( - 'Esta importación ya no está disponible (sesión expirada o trabajo eliminado). Puedes iniciar una nueva carga.' - ); + toast.info(csvMsg('toast.stale_job')); } else { - toast.error(errMsg || 'Error al consultar el estado'); + toast.error(errMsg || csvMsg('toast.poll_err')); } isUploading = false; clearCsvImportSession(); @@ -978,7 +986,7 @@ if (skipScanCompleteToastOnce) { skipScanCompleteToastOnce = false; } else { - toast.success('Escaneo completado. Revisa los resultados.'); + toast.success(csvMsg('toast.scan_done')); } isUploading = false; } else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') { @@ -996,7 +1004,7 @@ commitResults = parsedCommit; showResultModal = true; finalizeCommitAndClearPending(); - toast.success('Importación completada. Revisa el listado de registros.'); + toast.success(csvMsg('toast.import_done')); isUploading = false; clearCsvImportSession(); currentJobId = null; @@ -1026,7 +1034,7 @@ if (skipScanCompleteToastOnce) { skipScanCompleteToastOnce = false; } else { - toast.success('Escaneo completado. Revisa los resultados.'); + toast.success(csvMsg('toast.scan_done')); } isUploading = false; } else { @@ -1036,30 +1044,32 @@ (errRaw.includes('waiting_confirmation') || (errRaw.includes('total_rows') && errRaw.includes('job_id'))); if (looksLikeScanInError) { removePendingLinkedToCurrentFlow(); - toast.info('El escaneo terminó. Si no ves el modal, revisa el listado de registros.'); + toast.info(csvMsg('toast.scan_alt')); isUploading = false; clearCsvImportSession(); currentJobId = null; return; } let errText: string; + const maybeDoneMsg = csvMsg('toast.import_maybe_done'); if (typeof errRaw === 'string') { errText = errRaw.includes('finished') && errRaw.includes('inserted') - ? 'La importación pudo completarse. Revisa el listado de registros.' + ? maybeDoneMsg : errRaw.includes("'status'") && errRaw.includes('waiting_confirmation') - ? 'Error al obtener el resultado. Revisa el modal de resultados.' + ? csvMsg('toast.err_fetch_scan_result') : errRaw; } else if (typeof errRaw === 'object' && errRaw !== null) { - errText = (errRaw as { message?: string })?.message || 'Error en el procesamiento. Revisa el modal o los detalles.'; + errText = + (errRaw as { message?: string })?.message || csvMsg('toast.err_processing_fallback'); } else { - errText = 'Error desconocido'; + errText = csvMsg('toast.err_unknown'); } // No mostrar como error si el mensaje indica éxito - if (errText.includes('pudo completarse') || errText.includes('Revisa el listado')) { + if (errText === maybeDoneMsg || errText.includes(maybeDoneMsg)) { toast.success(errText); } else { - toast.error('Error en el procesamiento: ' + errText); + toast.error(csvFmt('toast.error_processing', { msg: errText })); } isUploading = false; removePendingLinkedToCurrentFlow(); @@ -1082,12 +1092,16 @@ if (totalOk === 0) { toast.error( backendMessage || - `No se insertaron ni actualizaron registros. ${totalSkipped} fueron rechazados.` + csvFmt('toast.commit_warning_none', { skipped: totalSkipped }) ); } else { toast.warning( backendMessage || - `Se aplicaron ${totalOk} registros (${inserted} insertados, ${updated} actualizados). ${totalSkipped} rechazados.` + csvFmt('toast.commit_warning_ok', { + inserted, + updated, + skipped: totalSkipped + }) ); } finalizeCommitAndClearPending(); @@ -1100,15 +1114,15 @@ const totalSkipped = totalSkippedFromCommit(res.data as Record); if (inserted > 0 || updated > 0) { - const parts = []; - if (inserted > 0) parts.push(`${inserted} insertados`); - if (updated > 0) parts.push(`${updated} actualizados`); - toast.success(`Importación completada: ${parts.join(', ')}`); + const parts: string[] = []; + if (inserted > 0) parts.push(csvFmt('toast.n_inserted', { n: inserted })); + if (updated > 0) parts.push(csvFmt('toast.n_updated', { n: updated })); + toast.success(csvFmt('toast.success_counts', { msg: parts.join(', ') })); if (totalSkipped > 0) { - toast.warning(`${totalSkipped} registros fueron rechazados u omitidos`); + toast.warning(csvFmt('toast.warn_skipped', { n: totalSkipped })); } } else { - toast.error('No se insertaron registros. Revisa los errores a continuación.'); + toast.error(csvMsg('toast.finished_none')); } finalizeCommitAndClearPending(); isUploading = false; @@ -1136,26 +1150,26 @@
-

Importación Masiva de Datos (CSV)

+

{csvMsg('page_title')}

- Click izquierdo: cargar archivo CSV. Click derecho: descargar estructura (plantilla). + {csvMsg('intro_help')}

- Catálogos - Transportes - Importación - Exportación + {csvMsg('tab_catalogos')} + {csvMsg('tab_transportes')} + {csvMsg('tab_importacion')} + {csvMsg('tab_exportacion')}
-

Catálogos Generales

+

{csvMsg('section_catalogs')}

-

Logística y Transporte

+

{csvMsg('section_transport')}

-

Operaciones de Importación

+

{csvMsg('section_import')}

-

Operaciones de Exportación

+

{csvMsg('section_export')}

{#if csvResumeOverlayHint} -

Reanudando la importación guardada en esta pestaña…

+

{csvMsg('progress.resume_hint')}

{/if} {#if currentImportLabel}

{currentImportLabel}

@@ -1340,7 +1354,7 @@ pollStatus(); } } catch (err) { - toast.error('Error al iniciar la importación'); + toast.error(csvMsg('toast.commit_err')); isUploading = false; } }} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 5346ce8c..6ecf1384 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -40,16 +40,9 @@ export default defineConfig({ server: { port: 5173, // fija el puerto host: true, // escucha en 0.0.0.0 - allowedHosts: [ - 'anexo76-dev.aduanasoft.com', - // Requeridos para dev local, healthcheck del contenedor y E2E (Playwright - // con --network host apunta a 127.0.0.1:5173). Al definir allowedHosts, - // Vite 5.1+ reemplaza el default ['.localhost'] y bloquea todo lo que no - // esté aquí, devolviendo 403 "Blocked request". - '127.0.0.1', - 'localhost', - // 'otro-host.com' si necesitas más - ], + // Lista explícita + peticiones internas (p. ej. chunks JSON `?import`) pueden usar + // Hosts distintos y recibir 403. `true` permite cualquier Host en dev. + allowedHosts: true, proxy: { '/api/uploads': { target: 'http://backend:8000',