fix/carga-csv-ergonomia

This commit is contained in:
2026-05-06 14:24:41 -06:00
parent 8c8cd0e247
commit daa3a252b9
53 changed files with 3164 additions and 391 deletions

View File

@@ -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)

View File

@@ -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",
},
)

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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]

View File

@@ -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)

View File

@@ -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

View File

@@ -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"],
}

View File

@@ -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

View File

@@ -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."""

View File

@@ -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"],
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"])

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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 13: 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"])

View File

@@ -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

View File

@@ -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"],
}

View File

@@ -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

View File

@@ -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"],
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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,
}

View File

@@ -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

View File

@@ -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()

View File

@@ -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"
}
}
}

View File

@@ -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"
}
}
}

View File

@@ -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": "",
"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()

View File

@@ -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<string, string> = {};
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);

View File

@@ -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<string, string> = {
catalogos: 'tab_catalogos',
transportes: 'tab_transportes',
importacion: 'tab_importacion',
exportacion: 'tab_exportacion'
};
let {
activeTab,
@@ -38,7 +46,9 @@
<div class="flex flex-col gap-4">
<div class="flex items-center gap-2 text-muted-foreground border-b pb-2">
<Settings2 class="h-4 w-4" />
<span class="text-xs font-semibold uppercase tracking-wider">Configuración: {activeTab}</span>
<span class="text-xs font-semibold uppercase tracking-wider"
>{csvMsg('config_prefix')}: {csvMsg(TAB_LABEL[activeTab] ?? activeTab)}</span
>
</div>
{#if currentFields.length > 0}
@@ -47,7 +57,7 @@
<div class="flex flex-col gap-2">
{#if field.type !== 'boolean'}
<Label class="text-xs font-medium text-muted-foreground uppercase"
>{field.label}</Label
>{csvMsg(field.labelKey)}</Label
>
{/if}
@@ -59,7 +69,7 @@
<Label
for={field.name}
class="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70 cursor-pointer"
>{field.label}</Label
>{csvMsg(field.labelKey)}</Label
>
</div>
{:else if field.type === 'select' && field.options}
@@ -68,7 +78,7 @@
bind:value={settings[field.name]}
>
{#each field.options as opt}
<option value={opt.value}>{opt.label}</option>
<option value={opt.value}>{csvMsg(opt.labelKey)}</option>
{/each}
</select>
{:else if field.type === 'radio' && field.options}
@@ -79,7 +89,7 @@
{#each field.options as opt}
<div class="flex items-center space-x-2">
<RadioGroup.Item value={opt.value} id={`${field.name}-${opt.value}`} />
<Label for={`${field.name}-${opt.value}`}>{opt.label}</Label>
<Label for={`${field.name}-${opt.value}`}>{csvMsg(opt.labelKey)}</Label>
</div>
{/each}
</RadioGroup.Root>
@@ -89,7 +99,7 @@
</div>
{:else}
<div class="flex items-center justify-center h-8 text-sm text-muted-foreground italic">
No hay configuraciones específicas para este módulo.
{csvMsg('config_empty')}
</div>
{/if}
</div>

View File

@@ -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<string, string> = {
catalogos: 'tab_catalogos',
transportes: 'tab_transportes',
importacion: 'tab_importacion',
exportacion: 'tab_exportacion'
};
let {
globalSettings = $bindable(),
@@ -45,13 +53,13 @@
<div class="flex items-center gap-2 text-muted-foreground border-r border-border pr-4">
<Settings2 class="h-4 w-4 shrink-0" />
<span class="text-xs font-semibold uppercase tracking-wider whitespace-nowrap"
>Parámetros globales</span
>{csvMsg('params_header')}</span
>
</div>
{#each globalCsvParams as param}
<div class="flex flex-col gap-1">
<Label for="global-{param.name}" class="text-xs font-medium text-muted-foreground"
>{param.label}</Label
>{csvMsg(param.labelKey)}</Label
>
<select
id="global-{param.name}"
@@ -62,7 +70,7 @@
}}
>
{#each param.options as opt}
<option value={opt.value}>{opt.label}</option>
<option value={opt.value}>{csvMsg(opt.labelKey)}</option>
{/each}
</select>
</div>
@@ -73,13 +81,13 @@
{#if currentTabFields.length > 0}
<div class="flex flex-wrap items-center gap-4 border-l border-border pl-4">
<span class="text-xs font-semibold uppercase tracking-wider text-muted-foreground whitespace-nowrap"
>Configuración: {activeTab}</span
>{csvMsg('config_prefix')}: {csvMsg(TAB_LABEL[activeTab] ?? activeTab)}</span
>
{#each currentTabFields as field}
<div class="flex flex-col gap-1">
{#if field.type !== 'boolean'}
<Label for="tab-{field.name}" class="text-xs font-medium text-muted-foreground"
>{field.label}</Label
>{csvMsg(field.labelKey)}</Label
>
{/if}
{#if field.type === 'select' && field.options}
@@ -92,7 +100,7 @@
}}
>
{#each field.options as opt}
<option value={opt.value}>{opt.label}</option>
<option value={opt.value}>{csvMsg(opt.labelKey)}</option>
{/each}
</select>
{:else if field.type === 'radio' && field.options}
@@ -108,7 +116,7 @@
if (tabSettingsValues) tabSettingsValues[field.name] = opt.value;
}}
/>
{opt.label}
{csvMsg(opt.labelKey)}
</label>
{/each}
</div>
@@ -121,7 +129,7 @@
if (tabSettingsValues) tabSettingsValues[field.name] = e.currentTarget.checked;
}}
/>
{field.label}
{csvMsg(field.labelKey)}
</label>
{/if}
</div>

View File

@@ -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<CsvImportPendingEntry['profile'], string> = {
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 @@
</script>
<Button variant="outline" size="sm" class="shrink-0" type="button" onclick={() => (open = true)}>
Pendientes
{csvMsg('pending.badge')}
{#if badgeCount > 0}
<span class="ml-1.5 rounded-full bg-primary/15 px-2 py-0.5 text-xs font-semibold text-primary">
{badgeCount}
@@ -145,10 +133,9 @@
<Sheet.Root bind:open>
<Sheet.Content side="right" class="flex w-full max-w-lg flex-col sm:max-w-xl">
<Sheet.Header>
<Sheet.Title>Importaciones pendientes de confirmar</Sheet.Title>
<Sheet.Title>{csvMsg('pending.title')}</Sheet.Title>
<Sheet.Description>
Escaneos listos para insertar en base de datos. Si el trabajo ya expiró en el servidor, desaparecerán al
actualizar.
{csvMsg('pending.description')}
</Sheet.Description>
</Sheet.Header>
<div class="flex items-center justify-end gap-2 border-b px-4 py-2">
@@ -158,14 +145,14 @@
{:else}
<RefreshCw class="mr-2 h-4 w-4" />
{/if}
Actualizar
{csvMsg('pending.refresh')}
</Button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-4 py-4">
{#if rows.length === 0 && !refreshing}
<p class="text-sm text-muted-foreground">No hay importaciones pendientes para esta empresa.</p>
<p class="text-sm text-muted-foreground">{csvMsg('pending.empty')}</p>
{:else if rows.length === 0 && refreshing}
<p class="text-sm text-muted-foreground">Comprobando con el servidor…</p>
<p class="text-sm text-muted-foreground">{csvMsg('pending.checking')}</p>
{:else}
<ul class="space-y-3">
{#each rows as row (row.jobId)}
@@ -177,10 +164,10 @@
{#if row.totalRows != null || row.validRows != null}
<div class="mt-1 text-xs text-muted-foreground">
{#if row.totalRows != null}
Total filas: {row.totalRows}
{csvMsg('pending.total_rows')}: {row.totalRows}
{/if}
{#if row.validRows != null}
<span class={row.totalRows != null ? ' · ' : ''}>Válidas: {row.validRows}</span>
<span class={row.totalRows != null ? ' · ' : ''}>{csvMsg('pending.valid_rows')}: {row.validRows}</span>
{/if}
</div>
{/if}
@@ -201,9 +188,9 @@
}
}}
>
Reanudar
{csvMsg('pending.resume')}
</Button>
<Button size="sm" variant="ghost" onclick={() => removeLocal(row.jobId)}>Quitar</Button>
<Button size="sm" variant="ghost" onclick={() => removeLocal(row.jobId)}>{csvMsg('pending.remove')}</Button>
</div>
</li>
{/each}

View File

@@ -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 @@
<div class="flex-1">
<Dialog.Title class="text-xl font-semibold tracking-tight text-foreground">
{#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}
</Dialog.Title>
<Dialog.Description class="mt-1 text-muted-foreground">
{#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}
</Dialog.Description>
</div>
@@ -274,7 +275,7 @@
class="bg-card p-4 rounded-lg border flex flex-col items-center justify-center text-center shadow-sm"
>
<span class="text-muted-foreground text-xs uppercase font-bold tracking-wider mb-1"
>Total Filas</span
>{csvMsg('modal.total_rows')}</span
>
<span class="text-2xl font-bold text-foreground">{scanResults.total_rows || 0}</span>
</div>
@@ -285,7 +286,7 @@
>
<span
class="text-green-600 dark:text-green-400 text-xs uppercase font-bold tracking-wider mb-1"
>Válidos</span
>{csvMsg('modal.valid_rows')}</span
>
<span class="text-2xl font-bold text-green-700 dark:text-green-300"
>{scanResults.valid_rows || 0}</span
@@ -297,7 +298,7 @@
class="bg-destructive/5 p-4 rounded-lg border border-destructive/10 flex flex-col items-center justify-center text-center shadow-sm"
>
<span class="text-destructive text-xs uppercase font-bold tracking-wider mb-1"
>Errores</span
>{csvMsg('modal.errors')}</span
>
<span class="text-2xl font-bold text-destructive">{scanResults.error_count || 0}</span>
</div>
@@ -327,10 +328,9 @@
>
<XCircle class="w-5 h-5 text-destructive mt-0.5 shrink-0" />
<div class="text-sm text-destructive-foreground/90">
<p class="font-semibold mb-1">Se detectaron problemas en el archivo</p>
<p class="font-semibold mb-1">{csvMsg('modal.scan_problems_title')}</p>
<p>
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')}
</p>
</div>
</div>
@@ -338,13 +338,13 @@
<div class="border rounded-lg overflow-hidden shadow-sm">
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
Detalle de errores (para corregir en el CSV)
{csvMsg('modal.errors_heading')}
</h5>
<div class="flex items-center gap-2">
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{scanErrorsShown} de {scanErrorsTotal} error(es)
{csvFmt('modal.errors_badge', { shown: scanErrorsShown, total: scanErrorsTotal })}
</span>
<Button
variant="ghost"
@@ -352,7 +352,7 @@
class="h-7 px-2 text-xs"
>
<FileText class="w-3.5 h-3.5 mr-1" />
Descargar CSV
{csvMsg('modal.download_csv')}
</Button>
</div>
</div>
@@ -362,10 +362,10 @@
class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm"
>
<tr>
<th class="px-4 py-2 w-16">Línea</th>
<th class="px-4 py-2 w-40">Columna</th>
<th class="px-4 py-2">Mensaje</th>
<th class="px-4 py-2 min-w-[240px]">Solución</th>
<th class="px-4 py-2 w-16">{csvMsg('modal.th_line')}</th>
<th class="px-4 py-2 w-40">{csvMsg('modal.th_column')}</th>
<th class="px-4 py-2">{csvMsg('modal.th_message')}</th>
<th class="px-4 py-2 min-w-[240px]">{csvMsg('modal.th_solution')}</th>
</tr>
</thead>
<tbody class="divide-y">
@@ -382,21 +382,21 @@
</div>
{#if scanErrorsTruncated}
<p class="text-xs text-muted-foreground px-4 py-2">
Para consultar el resto de errores, descargue el CSV.
{csvMsg('modal.errors_truncated')}
</p>
{/if}
</div>
{:else}
<p class="text-sm text-muted-foreground mt-1">
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 })}
</p>
{/if}
{:else}
<div class="rounded-md bg-primary/5 border border-primary/10 p-4 flex items-start gap-3">
<CheckCircle2 class="w-5 h-5 text-primary mt-0.5 shrink-0" />
<div class="text-sm text-primary/90">
<p class="font-semibold mb-1">Archivo validado correctamente</p>
<p>Todos los registros parecen correctos y listos para importar.</p>
<p class="font-semibold mb-1">{csvMsg('modal.scan_ok_title')}</p>
<p>{csvMsg('modal.scan_ok_body')}</p>
</div>
</div>
{/if}
@@ -413,7 +413,7 @@
<CheckCircle2 class="h-4 w-4 text-green-600 dark:text-green-400" />
<span
class="text-xs font-bold uppercase tracking-wider text-green-600 dark:text-green-400"
>Insertados</span
>{csvMsg('modal.inserted')}</span
>
</div>
<span class="text-3xl font-bold text-green-700 dark:text-green-300">{insertedCount}</span>
@@ -425,7 +425,7 @@
<CheckCircle2 class="h-4 w-4 text-green-600 dark:text-green-400" />
<span
class="text-xs font-bold uppercase tracking-wider text-green-600 dark:text-green-400"
>Actualizados</span
>{csvMsg('modal.updated')}</span
>
</div>
<span class="text-3xl font-bold text-green-700 dark:text-green-300">{updatedCount}</span>
@@ -436,13 +436,13 @@
<div class="mb-1 flex items-center gap-2">
<XCircle class="h-4 w-4 text-destructive" />
<span class="text-xs font-bold uppercase tracking-wider text-destructive"
>Rechazados</span
>{csvMsg('modal.rejected')}</span
>
</div>
<span class="text-3xl font-bold text-destructive">{totalSkipped}</span>
{#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0}
<p class="mt-2 text-xs text-muted-foreground">
Revisa el detalle por línea en la tabla inferior.
{csvMsg('modal.rejected_hint')}
</p>
{/if}
</div>
@@ -456,20 +456,17 @@
{#if refGaps > 0}
<AlertTriangle class="mt-0.5 h-5 w-5 shrink-0 text-amber-600 dark:text-amber-500" />
<div>
<p class="font-semibold text-foreground">Brechas de referencia (FK / catálogos)</p>
<p class="font-semibold text-foreground">{csvMsg('modal.ref_gaps_title')}</p>
<p class="mt-1 text-muted-foreground">
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 })}
</p>
</div>
{:else}
<CheckCircle2 class="mt-0.5 h-5 w-5 shrink-0 text-green-600 dark:text-green-500" />
<div>
<p class="font-semibold text-foreground">Estado de referencias</p>
<p class="font-semibold text-foreground">{csvMsg('modal.ref_state_title')}</p>
<p class="mt-1 text-muted-foreground">
{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')}
</p>
</div>
{/if}
@@ -483,7 +480,7 @@
{#if commitSkippedSummary.length > 0}
<div>
<p class="mb-2 text-xs font-bold uppercase tracking-wide text-muted-foreground">
Resumen de motivos de rechazo
{csvMsg('modal.skipped_reasons_heading')}
</p>
<div class="flex flex-wrap gap-2">
{#each commitSkippedSummary as item}
@@ -504,13 +501,13 @@
<div id="detalle-errores-import" class="border rounded-lg overflow-hidden mt-2 shadow-sm">
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
Detalle de Errores
{csvMsg('modal.commit_errors_heading')}
</h5>
<div class="flex items-center gap-2">
<span
class="text-[10px] bg-secondary text-secondary-foreground px-2 py-0.5 rounded-full border"
>
{commitResults.skipped_details.length} filas
{csvFmt('modal.rows_badge', { n: commitResults.skipped_details.length })}
</span>
<Button
variant="ghost"
@@ -518,7 +515,7 @@
class="h-7 px-2 text-xs"
>
<FileText class="w-3.5 h-3.5 mr-1" />
Descargar CSV
{csvMsg('modal.download_csv')}
</Button>
</div>
</div>
@@ -528,10 +525,10 @@
class="text-muted-foreground font-medium bg-muted/30 sticky top-0 z-10 shadow-sm backdrop-blur-sm"
>
<tr>
<th class="px-4 py-2 w-20">Línea</th>
<th class="px-4 py-2 w-32">Referencia</th>
<th class="px-4 py-2">Motivo</th>
<th class="px-4 py-2 min-w-[240px]">Solución</th>
<th class="px-4 py-2 w-20">{csvMsg('modal.th_line')}</th>
<th class="px-4 py-2 w-32">{csvMsg('modal.th_reference')}</th>
<th class="px-4 py-2">{csvMsg('modal.th_reason')}</th>
<th class="px-4 py-2 min-w-[240px]">{csvMsg('modal.th_solution')}</th>
</tr>
</thead>
<tbody class="divide-y">
@@ -558,7 +555,7 @@
<div class="flex flex-col gap-3 border-t bg-muted/20 px-6 py-4">
{#if isPending && isUploading}
<div class="space-y-2" role="status" aria-live="polite" aria-busy="true">
<p class="text-xs font-medium text-muted-foreground">Importando registros…</p>
<p class="text-xs font-medium text-muted-foreground">{csvMsg('modal.importing_records')}</p>
<div class="relative h-2 w-full overflow-hidden rounded-full bg-secondary">
<div
class="csv-commit-indeterminate-bar absolute top-0 h-full w-2/5 rounded-full bg-primary"
@@ -574,7 +571,7 @@
disabled={isUploading}
class="text-muted-foreground hover:bg-muted/50"
>
Cancelar Operación
{csvMsg('modal.cancel_operation')}
</Button>
<Button
onclick={onConfirm}
@@ -583,14 +580,14 @@
>
{#if isUploading}
<Loader2 class="mr-2 h-4 w-4 animate-spin" />
Procesando...
{csvMsg('modal.processing')}
{:else}
<UploadCloud class="mr-2 h-4 w-4" />
Confirmar Carga
{csvMsg('modal.confirm_load')}
{/if}
</Button>
{:else if isFinished}
<Button variant="outline" onclick={onClose} class="min-w-[100px]">Cerrar</Button>
<Button variant="outline" onclick={onClose} class="min-w-[100px]">{csvMsg('modal.close')}</Button>
{/if}
</div>
</div>

View File

@@ -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'));
}
}
</script>
@@ -128,6 +146,10 @@
class:opacity-60={gridLocked}
aria-busy={gridLocked ? true : undefined}
>
{#snippet cardTitle(itemId: string)}
<div class="font-medium text-sm text-balance">{csvMsg(`items.${itemId}`)}</div>
{/snippet}
{#if groupedItems.ungrouped.length > 0}
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each groupedItems.ungrouped as item}
@@ -171,7 +193,7 @@
<span
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
>
<Lock class="h-3 w-3" /> Próximamente
<Lock class="h-3 w-3" /> {csvMsg('soon')}
</span>
</div>
{/if}
@@ -183,7 +205,7 @@
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
<span class="text-sm font-semibold text-primary">{csvMsg('drop_here')}</span>
{:else}
<div
class={cn(
@@ -193,7 +215,7 @@
>
<item.icon class="h-6 w-6 text-primary" />
</div>
<div class="font-medium text-sm text-balance">{item.title}</div>
{@render cardTitle(item.id)}
{/if}
</Card.Content>
</Card.Root>
@@ -205,7 +227,7 @@
{#each Object.entries(groupedItems.groups) as [groupName, groupItems]}
<div class="flex flex-col gap-3">
<h3 class="text-sm font-semibold text-muted-foreground uppercase tracking-wider pl-1">
{groupName}
{csvMsg(`groups.${groupName}`)}
</h3>
<div class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each groupItems as item}
@@ -251,7 +273,7 @@
<span
class="bg-muted px-2 py-1 rounded text-xs font-semibold text-muted-foreground border flex items-center gap-1"
>
<Lock class="h-3 w-3" /> Próximamente
<Lock class="h-3 w-3" /> {csvMsg('soon')}
</span>
</div>
{/if}
@@ -263,7 +285,7 @@
<div class="animate-bounce">
<UploadCloud class="h-8 w-8 text-primary" />
</div>
<span class="text-sm font-semibold text-primary">¡Suelta el archivo!</span>
<span class="text-sm font-semibold text-primary">{csvMsg('drop_here')}</span>
{:else}
<div
class={cn(
@@ -273,7 +295,7 @@
>
<item.icon class="h-6 w-6 text-primary" />
</div>
<div class="font-medium text-sm text-balance">{item.title}</div>
{@render cardTitle(item.id)}
{/if}
</Card.Content>
</Card.Root>

View File

@@ -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"](),

View File

@@ -1,13 +1,6 @@
<script lang="ts">
import { tick } from 'svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
import { useSidebar } from '$lib/components/ui/sidebar/context.svelte.js';
import * as Sidebar from '$lib/components/ui/sidebar/index.js';
import EllipsisIcon from '@lucide/svelte/icons/ellipsis';
import FolderIcon from '@lucide/svelte/icons/folder';
import ForwardIcon from '@lucide/svelte/icons/forward';
import Trash2Icon from '@lucide/svelte/icons/trash-2';
import { helpStore } from '$lib/stores/help.svelte';
import { m } from '$lib/i18n/messages';
let {
projects
@@ -15,28 +8,14 @@
projects: {
name: string;
url: string;
// This should be `Component` after @lucide/svelte updates types
// eslint-disable-next-line @typescript-eslint/no-explicit-any
icon: any;
}[];
} = $props();
const sidebar = useSidebar();
let open = $state(false);
let position = $state({ x: 0, y: 0 });
async function handleMoreClick(e: MouseEvent) {
e.preventDefault();
open = false;
position = { x: e.clientX, y: e.clientY };
await tick();
open = true;
}
</script>
<Sidebar.Group class="group-data-[collapsible=icon]:hidden">
<Sidebar.GroupLabel>Gestion</Sidebar.GroupLabel>
<Sidebar.GroupLabel>{m['sidebar.management_label']()}</Sidebar.GroupLabel>
<Sidebar.Menu>
{#each projects as item (item.name)}
<Sidebar.MenuItem>
@@ -48,58 +27,7 @@
</a>
{/snippet}
</Sidebar.MenuButton>
<DropdownMenu.Root>
<DropdownMenu.Trigger>
{#snippet child({ props })}
<Sidebar.MenuAction showOnHover {...props}>
<EllipsisIcon />
<span class="sr-only">More</span>
</Sidebar.MenuAction>
{/snippet}
</DropdownMenu.Trigger>
<DropdownMenu.Content
class="w-48 rounded-lg"
side={sidebar.isMobile ? 'bottom' : 'right'}
align={sidebar.isMobile ? 'end' : 'start'}
>
<DropdownMenu.Item>
<FolderIcon class="text-muted-foreground" />
<span>View Project</span>
</DropdownMenu.Item>
<DropdownMenu.Item>
<ForwardIcon class="text-muted-foreground" />
<span>Share Project</span>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item>
<Trash2Icon class="text-muted-foreground" />
<span>Delete Project</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
{/each}
<Sidebar.MenuItem>
<Sidebar.MenuButton class="text-sidebar-foreground/70" onclick={handleMoreClick}>
<EllipsisIcon class="text-sidebar-foreground/70" />
<span>More</span>
</Sidebar.MenuButton>
<DropdownMenu.Root open={open} onOpenChange={(v) => (open = v)}>
<DropdownMenu.Trigger
class="fixed z-50 size-0"
style="top: {position.y}px; left: {position.x}px"
/>
<DropdownMenu.Content class="w-48 rounded-lg" side="right" align="start">
<DropdownMenu.Item>
<a href="/dashboard/csv-upload" class="flex w-full items-center gap-2">
<FolderIcon class="size-4 text-muted-foreground" />
<span>Carga CSV</span>
</a>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
</Sidebar.MenuItem>
</Sidebar.Menu>
</Sidebar.Group>

View File

@@ -22,9 +22,10 @@ import {
export interface CsvUploadItem {
id: string;
title: string;
/** Etiqueta vía i18n: csv_upload.items.<id> */
icon: any;
group?: string; // For grouping within a tab
/** Clave i18n csv_upload.groups.<group> */
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: '', 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: '', 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: '', 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<string, CsvUploadField[]> = {
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<string, CsvUploadField[]> = {
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<string, CsvUploadField[]> = {
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<string, CsvUploadField[]> = {
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<string, CsvUploadField[]> = {
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,
},

View File

@@ -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<string, unknown>;
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<string, unknown>, keys: string[]): unknown {
let cur: unknown = root;
for (const k of keys) {
if (cur === null || typeof cur !== 'object') return undefined;
cur = (cur as Record<string, unknown>)[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<string, unknown>, subPath.split('.'));
return typeof v === 'string' ? v : subPath;
}
/** Sustituye `{clave}` en el string del mensaje. */
export function csvFmt(subPath: string, vars: Record<string, string | number> = {}): string {
let s = csvMsg(subPath);
for (const [k, val] of Object.entries(vars)) {
s = s.replaceAll(`{${k}}`, String(val));
}
return s;
}

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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<string, unknown> | 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<string, unknown>);
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 @@
<!-- Single scroll: content scrolls here; padding at bottom reserves space for fixed params bar -->
<div class="flex-1 min-h-0 overflow-y-auto p-4 md:p-8 space-y-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-lg font-semibold md:text-2xl">Importación Masiva de Datos (CSV)</h1>
<h1 class="text-lg font-semibold md:text-2xl">{csvMsg('page_title')}</h1>
<CsvPendingImportsSheet companyId={companyStore.activeCompany?.id} onResume={resumePendingImport} />
</div>
<p class="text-sm text-muted-foreground">
Click izquierdo: cargar archivo CSV. Click derecho: descargar estructura (plantilla).
{csvMsg('intro_help')}
</p>
<Tabs.Root bind:value={activeTab} class="w-full">
<Tabs.List class="grid w-full grid-cols-2 md:grid-cols-4 lg:w-auto">
<Tabs.Trigger value="catalogos">Catálogos</Tabs.Trigger>
<Tabs.Trigger value="transportes">Transportes</Tabs.Trigger>
<Tabs.Trigger value="importacion">Importación</Tabs.Trigger>
<Tabs.Trigger value="exportacion">Exportación</Tabs.Trigger>
<Tabs.Trigger value="catalogos">{csvMsg('tab_catalogos')}</Tabs.Trigger>
<Tabs.Trigger value="transportes">{csvMsg('tab_transportes')}</Tabs.Trigger>
<Tabs.Trigger value="importacion">{csvMsg('tab_importacion')}</Tabs.Trigger>
<Tabs.Trigger value="exportacion">{csvMsg('tab_exportacion')}</Tabs.Trigger>
</Tabs.List>
<div class="mt-6">
<Tabs.Content value="catalogos" class="space-y-4">
<!-- Catálogos: backend layouts_csv (customs_brokers, clients_and_providers, parts, boms, etc.) -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Catálogos Generales</h2>
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_catalogs')}</h2>
</div>
<UploadLauncherGrid
items={catalogosConfig}
@@ -1167,7 +1181,7 @@
<Tabs.Content value="transportes" class="space-y-4">
<!-- Logística: backend layouts_csv (vehicles, drivers, trailers); transportistas sin layouts_csv -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Logística y Transporte</h2>
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_transport')}</h2>
</div>
<UploadLauncherGrid
items={transportesConfig}
@@ -1179,7 +1193,7 @@
<Tabs.Content value="importacion" class="space-y-4">
<!-- Operaciones de Importación: backend layouts_csv/facturas (api.imports) -->
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Operaciones de Importación</h2>
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_import')}</h2>
</div>
<UploadLauncherGrid
items={importacionConfig}
@@ -1190,7 +1204,7 @@
<Tabs.Content value="exportacion" class="space-y-4">
<div class="flex items-center justify-between">
<h2 class="text-lg font-medium tracking-tight">Operaciones de Exportación</h2>
<h2 class="text-lg font-medium tracking-tight">{csvMsg('section_export')}</h2>
</div>
<UploadLauncherGrid
items={exportacionConfig}
@@ -1224,7 +1238,7 @@
aria-busy="true"
>
{#if csvResumeOverlayHint}
<p class="text-xs text-muted-foreground">Reanudando la importación guardada en esta pestaña…</p>
<p class="text-xs text-muted-foreground">{csvMsg('progress.resume_hint')}</p>
{/if}
{#if currentImportLabel}
<p class="text-xs font-medium text-foreground">{currentImportLabel}</p>
@@ -1340,7 +1354,7 @@
pollStatus();
}
} catch (err) {
toast.error('Error al iniciar la importación');
toast.error(csvMsg('toast.commit_err'));
isUploading = false;
}
}}

View File

@@ -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',