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