From 9e30dede1c153027fa434fa8025d7e8d5af1f72b Mon Sep 17 00:00:00 2001
From: hreyes
Date: Fri, 20 Mar 2026 09:49:46 -0600
Subject: [PATCH] checkpoint
---
.../7937209f9718_seed_initial_data.py | 3 +
.../v1/modules/a76/layouts_csv/boms/routes.py | 6 +
.../v1/modules/a76/layouts_csv/boms/tasks.py | 38 +-
.../a76/layouts_csv/boms/validators/create.py | 37 +-
.../cambio_regimen_regularizacion/routes.py | 6 +
.../modules/a76/layouts_csv/classes/routes.py | 6 +
.../modules/a76/layouts_csv/classes/tasks.py | 46 +-
.../layouts_csv/classes/validators/common.py | 49 +-
.../layouts_csv/classes/validators/create.py | 25 +-
.../common/common_validators.py | 46 +-
.../clients_and_providers/common/mappers.py | 3 +-
.../clients_and_providers/routes.py | 6 +
.../clients_and_providers/tasks.py | 76 +-
.../validators/common.py | 117 ++-
.../a76/layouts_csv/common/error_csv.py | 81 ++
.../a76/layouts_csv/common/responses.py | 7 +-
.../common/common_validators.py | 84 +++
.../customs_brokers/common/mappers.py | 4 +-
.../a76/layouts_csv/customs_brokers/routes.py | 6 +
.../a76/layouts_csv/customs_brokers/tasks.py | 45 +-
.../customs_brokers/validators/common.py | 83 ++-
.../modules/a76/layouts_csv/drivers/routes.py | 6 +
.../modules/a76/layouts_csv/drivers/tasks.py | 68 +-
.../layouts_csv/drivers/validators/common.py | 100 ++-
.../layouts_csv/drivers/validators/create.py | 17 +-
.../a76/layouts_csv/exchange_rate/routes.py | 6 +
.../a76/layouts_csv/exchange_rate/tasks.py | 62 +-
.../exchange_rate/validators/common.py | 27 +-
.../a76/layouts_csv/exportacion/routes.py | 59 ++
.../a76/layouts_csv/facturas/routes.py | 58 ++
.../modules/a76/layouts_csv/facturas/tasks.py | 701 ++++++++++++++----
.../facturas/validators/encabezados_cmex.py | 93 ++-
.../facturas/validators/encabezados_expo.py | 152 ++--
.../validators/encabezados_impo_def.py | 131 ++--
.../validators/encabezados_impo_temp.py | 147 ++--
.../facturas/validators/partidas_expo.py | 109 ++-
.../facturas/validators/partidas_impo_def.py | 142 ++--
.../facturas/validators/partidas_impo_temp.py | 143 ++--
.../facturas/validators/series_expo.py | 69 +-
.../facturas/validators/series_impo_def.py | 67 +-
.../facturas/validators/series_impo_temp.py | 56 +-
.../modules/a76/layouts_csv/parts/routes.py | 6 +
.../v1/modules/a76/layouts_csv/parts/tasks.py | 59 +-
.../layouts_csv/parts/validators/common.py | 61 +-
.../layouts_csv/parts/validators/create.py | 29 +-
.../pedmientos/common/fk_loader.py | 11 +
.../a76/layouts_csv/pedmientos/routes.py | 6 +
.../a76/layouts_csv/pedmientos/tasks.py | 76 +-
.../pedmientos/validators/common.py | 75 +-
.../pedmientos/validators/create.py | 58 +-
.../a76/layouts_csv/trailers/routes.py | 6 +
.../modules/a76/layouts_csv/trailers/tasks.py | 68 +-
.../layouts_csv/trailers/validators/common.py | 33 +-
.../layouts_csv/trailers/validators/create.py | 17 +-
.../a76/layouts_csv/transportistas/routes.py | 6 +
.../a76/layouts_csv/transportistas/tasks.py | 59 +-
.../transportistas/validators/common.py | 45 +-
.../transportistas/validators/create.py | 17 +-
.../layouts_csv/us_tariff_fractions/routes.py | 6 +
.../layouts_csv/us_tariff_fractions/tasks.py | 51 +-
.../us_tariff_fractions/validators/common.py | 59 +-
.../a76/layouts_csv/vehicles/routes.py | 6 +
.../modules/a76/layouts_csv/vehicles/tasks.py | 66 +-
.../layouts_csv/vehicles/validators/common.py | 58 +-
.../layouts_csv/vehicles/validators/create.py | 17 +-
.../code_pedimento_regimens/seed.py | 20 +-
frontend/src/lib/api.ts | 78 +-
.../csv-upload/ProcessingResultModal.svelte | 255 ++++++-
.../routes/dashboard/csv-upload/+page.svelte | 137 +++-
69 files changed, 2827 insertions(+), 1545 deletions(-)
create mode 100644 backend/api/v1/modules/a76/layouts_csv/common/error_csv.py
diff --git a/backend/alembic/versions/7937209f9718_seed_initial_data.py b/backend/alembic/versions/7937209f9718_seed_initial_data.py
index 6d8cc4b0..0b11ab6c 100644
--- a/backend/alembic/versions/7937209f9718_seed_initial_data.py
+++ b/backend/alembic/versions/7937209f9718_seed_initial_data.py
@@ -159,10 +159,13 @@ def upgrade() -> None:
"""
)
+ valid_pedimento_codes = {code for code, _ in pedimento_codes_seed}
+ valid_regimen_codes = {code for code, _ in pedimento_regimens_seed}
values_cpr = ", ".join(
[
f"('{ped_code}', '{reg_code}', '{type_code}')"
for ped_code, reg_code, type_code in code_pedimento_regimens_seed
+ if ped_code in valid_pedimento_codes and reg_code in valid_regimen_codes
]
)
op.execute(
diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py
index 730878bb..38fb20ed 100644
--- a/backend/api/v1/modules/a76/layouts_csv/boms/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/boms/routes.py
@@ -25,6 +25,7 @@ from .tasks import (
BOM_IMPORT_META_PREFIX,
BOM_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -150,3 +151,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("bom", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py b/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py
index 5e585be5..99e9c6d4 100644
--- a/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py
@@ -68,17 +68,23 @@ def scan_file(self, job_id: str, config: str = None):
meta={"current": i, "total": total_rows, "errors": error_count},
)
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_bom(row_norm, i, valid_part_numbers=valid_part_numbers)
- if err:
+ row_errors = validate_row_bom(row_norm, i, valid_part_numbers=valid_part_numbers)
+ if row_errors:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ error_lines_list.append(i)
+ for e in row_errors:
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e.get("line", i),
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -128,13 +134,15 @@ def insert_valid_rows(self, job_id: str):
continue
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_bom(row_norm, i, valid_part_numbers=valid_part_numbers)
- if err:
+ row_errors = validate_row_bom(row_norm, i, valid_part_numbers=valid_part_numbers)
+ if row_errors:
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ for err in row_errors:
+ skipped_details.append({
+ "line": i,
+ "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
+ "solution": err.get("solution", ""),
+ })
continue
valid_count += 1
diff --git a/backend/api/v1/modules/a76/layouts_csv/boms/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/boms/validators/create.py
index cad75aa0..7d8f96bd 100644
--- a/backend/api/v1/modules/a76/layouts_csv/boms/validators/create.py
+++ b/backend/api/v1/modules/a76/layouts_csv/boms/validators/create.py
@@ -1,7 +1,7 @@
"""
Punto de entrada de validación para import de una fila BOM.
"""
-from typing import Dict, Any, Optional, Set
+from typing import Dict, Any, Optional, Set, List
from .common import (
validate_row_required_parent,
@@ -17,27 +17,22 @@ def validate_row_bom(
row: Dict[str, Any],
line_num: int,
valid_part_numbers: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de BOMs.
Encadena: requeridos (padre, componente, cantidad) → longitudes → opcionales numéricos → FKs.
"""
- err = validate_row_required_parent(row, line_num)
- if err:
- return err
- err = validate_row_required_component(row, line_num)
- if err:
- return err
- err = validate_row_quantity(row, line_num)
- if err:
- return err
- err = validate_row_lengths(row, line_num)
- if err:
- return err
- err = validate_row_optional_numbers(row, line_num)
- if err:
- return err
- err = validate_row_fks(row, line_num, valid_part_numbers)
- if err:
- return err
- return None
+ errors: List[Dict[str, Any]] = []
+
+ for err in [
+ validate_row_required_parent(row, line_num),
+ validate_row_required_component(row, line_num),
+ validate_row_quantity(row, line_num),
+ validate_row_lengths(row, line_num),
+ validate_row_optional_numbers(row, line_num),
+ validate_row_fks(row, line_num, valid_part_numbers),
+ ]:
+ if err:
+ errors.append(err)
+
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py
index 0bba9dce..d84e32c9 100644
--- a/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/cambio_regimen_regularizacion/routes.py
@@ -20,6 +20,7 @@ from core.security import get_current_user, validate_access_to_resource
from .schemas import ImportJobResponse, CommitRequest
from .tasks import scan_file, insert_valid_rows, JOB_TYPE, CRREG_IMPORT_REDIS_TTL
from ..common import storage as common_storage
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -155,3 +156,8 @@ async def commit_import_job(job_id: str, body: CommitRequest):
"message": "Proceso de commit iniciado.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream(JOB_TYPE, job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py
index 338c97ea..ff123ccf 100644
--- a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py
@@ -25,6 +25,7 @@ from .tasks import (
CLS_IMPORT_META_PREFIX,
CLS_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -175,3 +176,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("cls", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py b/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py
index 153e661f..a455ce8b 100644
--- a/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/classes/tasks.py
@@ -90,7 +90,7 @@ def scan_file(self, job_id: str, config: str = None):
meta={"current": i, "total": total_rows, "errors": error_count},
)
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_class(
+ row_errors = validate_row_class(
row_norm,
i,
valid_material_keys=valid_material_keys,
@@ -102,16 +102,25 @@ def scan_file(self, job_id: str, config: str = None):
valid_fraction_ame=valid_fraction_ame,
valid_product_codes_cp=valid_product_codes_cp,
)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e.get("line", i),
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -184,7 +193,7 @@ def insert_valid_rows(self, job_id: str):
use_partial = actualizar and class_code_raw and class_code_raw in existing_by_code and not siempre_toda
if use_partial:
- err = validate_row_class_partial(
+ row_errors = validate_row_class_partial(
row_norm, i,
valid_material_keys=valid_material_keys,
valid_uom_codes=valid_uom_codes,
@@ -193,7 +202,7 @@ def insert_valid_rows(self, job_id: str):
valid_product_codes_cp=valid_product_codes_cp,
)
else:
- err = validate_row_class(
+ row_errors = validate_row_class(
row_norm,
i,
valid_material_keys=valid_material_keys,
@@ -205,12 +214,15 @@ def insert_valid_rows(self, job_id: str):
valid_fraction_ame=valid_fraction_ame,
valid_product_codes_cp=valid_product_codes_cp,
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
if use_partial:
diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/classes/validators/common.py
index 5f4bd02b..fad8ded8 100644
--- a/backend/api/v1/modules/a76/layouts_csv/classes/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/classes/validators/common.py
@@ -2,7 +2,7 @@
Validaciones comunes de fila para import CSV de clases de materiales.
Paridad con Clarion: VALIDACIONES_CLASE (Col A len, Col D/E/F/G en catálogo, Col F len≥8, Col H ≤100, Col J en catálogo CP).
"""
-from typing import Dict, Any, Optional, Set
+from typing import Dict, Any, Optional, Set, List
from ..common.common_validators import (
check_max_length,
@@ -207,33 +207,24 @@ def validaciones_clase(
valid_fraction_mex_8: Optional[Set[str]] = None,
valid_fraction_ame: Optional[Set[str]] = None,
valid_product_codes_cp: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""Reglas compartidas Clarion (VALIDACIONES_CLASE): longitudes, tipos, FKs, fracciones, tasa, código CP."""
- err = validate_row_lengths(row, line_num)
- if err:
- return err
- err = validate_row_fraction_min(row, line_num)
- if err:
- return err
- err = validate_row_types(row, line_num)
- if err:
- return err
- err = validate_row_fks(row, line_num, valid_material_keys, valid_uom_codes)
- if err:
- return err
- err = validate_row_fraction_mex_catalog(row, line_num, valid_fraction_mex_8)
- if err:
- return err
- err = validate_row_fraction_ame_catalog(row, line_num, valid_fraction_ame)
- if err:
- return err
- err = validate_row_tasa_depreciacion(row, line_num)
- if err:
- return err
+ errors: List[Dict[str, Any]] = []
+
class_code = (row.get("CLASE") or "").strip()
- err = validate_row_codigo_producto_cp(
- row, line_num, valid_product_codes_cp, class_code
- )
- if err:
- return err
- return None
+ for err in [
+ validate_row_lengths(row, line_num),
+ validate_row_fraction_min(row, line_num),
+ validate_row_types(row, line_num),
+ validate_row_fks(row, line_num, valid_material_keys, valid_uom_codes),
+ validate_row_fraction_mex_catalog(row, line_num, valid_fraction_mex_8),
+ validate_row_fraction_ame_catalog(row, line_num, valid_fraction_ame),
+ validate_row_tasa_depreciacion(row, line_num),
+ validate_row_codigo_producto_cp(
+ row, line_num, valid_product_codes_cp, class_code
+ ),
+ ]:
+ if err:
+ errors.append(err)
+
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/classes/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/classes/validators/create.py
index 10ea806d..61c8456b 100644
--- a/backend/api/v1/modules/a76/layouts_csv/classes/validators/create.py
+++ b/backend/api/v1/modules/a76/layouts_csv/classes/validators/create.py
@@ -3,7 +3,7 @@ Punto de entrada de validación para import de una fila de clase de material.
Flujo Clarion: no ACT → siempre VALIDA_TODA_CLASE; ACT y clase existe → VALIDA_PARCIAL_CLASE;
ACT y clase no existe → VALIDA_TODA_CLASE (validación completa) y si pasa se crea en el insert (ADD).
"""
-from typing import Dict, Any, Optional, Set
+from typing import Dict, Any, Optional, Set, List
from .common import (
validate_row_required,
@@ -23,7 +23,7 @@ def validate_row_class(
valid_fraction_mex_8: Optional[Set[str]] = None,
valid_fraction_ame: Optional[Set[str]] = None,
valid_product_codes_cp: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Igual que Clarion:
- No ACT (actualizar=False): siempre TODA (B,D,E,F obligatorios + validaciones_clase).
@@ -31,9 +31,10 @@ def validate_row_class(
- ACT y clase no existe: TODA (validación completa); si pasa, en insert se crea (ADD).
siempre_toda fuerza TODA en todos los casos.
"""
+ errors: List[Dict[str, Any]] = []
err = validate_row_required(row, line_num)
if err:
- return err
+ errors.append(err)
class_code = (row.get("CLASE") or "").strip().upper()[:8]
use_full = siempre_toda or not actualizar
@@ -43,10 +44,11 @@ def validate_row_class(
if use_full:
err = validate_row_required_full(row, line_num)
if err:
- return err
+ errors.append(err)
# En Actualizar, si la clase no existe se valida completa y si pasa se crea en insert (como Clarion ADD).
- return validaciones_clase(
+ errors.extend(
+ validaciones_clase(
row,
line_num,
valid_material_keys,
@@ -54,7 +56,9 @@ def validate_row_class(
valid_fraction_mex_8=valid_fraction_mex_8,
valid_fraction_ame=valid_fraction_ame,
valid_product_codes_cp=valid_product_codes_cp,
+ )
)
+ return errors
def validate_row_class_partial(
@@ -65,12 +69,15 @@ def validate_row_class_partial(
valid_fraction_mex_8: Optional[Set[str]] = None,
valid_fraction_ame: Optional[Set[str]] = None,
valid_product_codes_cp: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""Validación parcial (modo Act, clase existente): solo CLASE + validaciones_clase."""
+ errors: List[Dict[str, Any]] = []
err = validate_row_required(row, line_num)
if err:
- return err
- return validaciones_clase(
+ errors.append(err)
+
+ errors.extend(
+ validaciones_clase(
row,
line_num,
valid_material_keys,
@@ -78,4 +85,6 @@ def validate_row_class_partial(
valid_fraction_mex_8=valid_fraction_mex_8,
valid_fraction_ame=valid_fraction_ame,
valid_product_codes_cp=valid_product_codes_cp,
+ )
)
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py
index 06f50b76..38a1415d 100644
--- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py
+++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py
@@ -163,15 +163,25 @@ def check_short_name_max_clarion(
def check_tipo_programa_secon(
row: Dict[str, Any], line_num: int
) -> Optional[Dict[str, Any]]:
- """Col Q: IMMEX, Maquila, Pitex, Ecex, RECIME, Pronex, Ninguno. Si no Ninguno → R y S obligatorios; si Ninguno → R y S vacíos."""
- q = (row.get("TIPO_PROGRAMA_SECON") or "").strip()
+ """Col Q: IMMEX, Maquila, Pitex, Ecex, RECIME, Pronex, Ninguno.
+
+ Reglas Clarion para el import CSV:
+ - Si NO es Ninguno: obligatorios `NUM_PROGRAMA_SECON` (Col. R) y `FECHA_AUT_SECON` (Col. S).
+ - Si es Ninguno o viene vacío: `NUM_PROGRAMA_SECON` (Col. R) debe venir vacío;
+ `FECHA_AUT_SECON` (Col. S) se permite (no debe bloquear la carga).
+ """
+ q_raw = (row.get("TIPO_PROGRAMA_SECON") or "").strip()
+ # Normalización robusta: CSV puede traer "Ninguno" o "ninguno", etc.
+ valid_map = {v.lower(): v for v in TIPO_PROGRAMA_SECON_VALIDOS}
+ q_lower = q_raw.lower()
+ q = valid_map.get(q_lower, q_raw)
r = (row.get("NUM_PROGRAMA_SECON") or "").strip()
s = (row.get("FECHA_AUT_SECON") or "").strip()
- if q and q not in TIPO_PROGRAMA_SECON_VALIDOS:
+ if q_raw and q not in TIPO_PROGRAMA_SECON_VALIDOS:
return {
"line": line_num,
"col": "TIPO_PROGRAMA_SECON",
- "msg": f"Error: (Col. Q) El Tipo de Programa SECON: {q} es incorrecto.",
+ "msg": f"Error: (Col. Q) El Tipo de Programa SECON: {q_raw} es incorrecto.",
"solution": "Capturar los Tipos de Programa correctos: IMMEX, Maquila, Pitex, Ecex, RECIME, Pronex o Ninguno.",
}
if not q or q == "Ninguno":
@@ -182,13 +192,6 @@ def check_tipo_programa_secon(
"msg": "Error: (Col. R) El Tipo de Programa es Ninguno y está capturado el número de programa.",
"solution": "Borrar la información en la columna R del archivo o asignar un Programa en la columna Q.",
}
- if s:
- return {
- "line": line_num,
- "col": "FECHA_AUT_SECON",
- "msg": "Error: (Col. S) El Tipo de Programa es Ninguno y está capturada la Fecha de autorización.",
- "solution": "Borrar la información en la columna S del archivo o asignar un Programa en la columna Q.",
- }
return None
# No es Ninguno: R y S obligatorios
if not r:
@@ -209,7 +212,11 @@ def check_tipo_programa_secon(
def check_es_prosec_num_aut(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
- """Col T (SI/NO): si SI → Col U obligatoria; si no SI → Col U vacía."""
+ """Col T (SI/NO): valida solo el formato/validez de `ES_PROSEC`.
+
+ Nota de negocio (Clarion): `NUM_AUT_PROSEC` ya no debe ser forzado por la opción
+ de `ES_PROSEC` (la carga no debe depender entre ambas columnas).
+ """
t = (row.get("ES_PROSEC") or "").strip().upper()
u = (row.get("NUM_AUT_PROSEC") or "").strip()
if t and t not in ("SI", "NO"):
@@ -219,20 +226,6 @@ def check_es_prosec_num_aut(row: Dict[str, Any], line_num: int) -> Optional[Dict
"msg": f"Error: (Col. T) La opción de si Es Prosec? {t} no es valida.",
"solution": "Capturar una opción valida: SI, NO, o dejar el campo vacio (se asigna NO).",
}
- if t == "SI" and not u:
- return {
- "line": line_num,
- "col": "NUM_AUT_PROSEC",
- "msg": "Error: (Col. U) Es Prosec? es SI y no está capturado el número de permiso.",
- "solution": "Capturar en la columna U el número de Permiso PROSEC o cambiar la opcion a NO en la columna T.",
- }
- if t != "SI" and u:
- return {
- "line": line_num,
- "col": "NUM_AUT_PROSEC",
- "msg": "Error: (Col. U) Es Prosec? no es SI y está capturado el número de permiso.",
- "solution": "Borrar la información de la columna U o cambiar la opcion a SI en la columna T.",
- }
return None
@@ -305,6 +298,7 @@ def check_desfase(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]
"col": "COL_EXTRA",
"msg": "Advertencia: Podría existir un desfase en esta línea.",
"solution": "Revisar esta línea del archivo CSV y verificar cada campo esté en la posición correcta.",
+ "warning": True,
}
diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py
index be64f05b..bd13b242 100644
--- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py
+++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/mappers.py
@@ -156,6 +156,7 @@ def row_to_client_provider_data(
if tipo_prog and tipo_prog.lower() == "ninguno":
tipo_prog = None
num_prog = _str_or_none(row_norm.get("NUM_PROGRAMA_SECON"), MAX_LEN["program_number"])
+ secon_auth = _str_or_none(row_norm.get("NUM_PROGRAMA_SECON"), MAX_LEN["secon_authorization"])
fecha_secon = _parse_date_to_yyyymmdd(row_norm.get("FECHA_AUT_SECON"))
es_prosec = (row_norm.get("ES_PROSEC") or "").strip().upper()
prosec_val = "1" if es_prosec == "SI" else ("0" if es_prosec else None)
@@ -185,7 +186,7 @@ def row_to_client_provider_data(
programs_data = {
"program": tipo_prog[:7] if tipo_prog else None,
"program_number": num_prog,
- "secon_authorization": num_prog,
+ "secon_authorization": secon_auth,
"secon_auth_date": fecha_secon,
"prosec": prosec_val,
"prosec_authorization": num_aut_prosec,
diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py
index 6795787f..f1d082d8 100644
--- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py
@@ -25,6 +25,7 @@ from .tasks import (
CP_IMPORT_META_PREFIX,
CP_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -160,3 +161,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("cp", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py
index 603924e7..21cb2e46 100644
--- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py
@@ -81,21 +81,30 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
progress_callback(i, total_rows, error_count)
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_client_provider(
+ row_errors = validate_row_client_provider(
row_norm, i,
actualizar=actualizar,
existing_short_names=existing_short_names if actualizar else None,
)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -191,34 +200,55 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_client_provider(
+ row_errors = validate_row_client_provider(
row_norm, i,
actualizar=actualizar,
existing_short_names=existing_short_names if actualizar else None,
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
cp_data, address_data, programs_data = row_to_client_provider_data(row_norm, tenant_id, company_id)
if not cp_data:
skipped_invalid += 1
- skipped_details.append({"line": i, "reason": "Fila sin RFC ni Clave"})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": "Fila sin RFC ni Clave",
+ "solution": "Capturar en el CSV el RFC (columna RFC) o la clave del cliente/proveedor (columna SHORT_NAME).",
+ }
+ )
continue
if actualizar:
short_name_key = (cp_data.get("short_name") or "").strip()
if not short_name_key:
skipped_invalid += 1
- skipped_details.append({"line": i, "reason": "Clave (SHORT_NAME) requerida en modo Actualizar"})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": "Clave (SHORT_NAME) requerida en modo Actualizar",
+ "solution": "Capturar en la columna SHORT_NAME la clave requerida para modo Actualizar.",
+ }
+ )
continue
existing = existing_by_short_name.get(short_name_key)
if not existing:
skipped_invalid += 1
- skipped_details.append({"line": i, "reason": "Clave no existe en catálogo"})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": "Clave no existe en catálogo",
+ "solution": "Capturar en la columna SHORT_NAME una clave que exista en el catálogo.",
+ }
+ )
continue
# Merge: fill from existing when csv value is empty
for k, v in cp_data.items():
@@ -280,7 +310,13 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
# Alta / Reemplazar: key por RFC
if not cp_data.get("rfc"):
skipped_invalid += 1
- skipped_details.append({"line": i, "reason": "RFC requerido"})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": "RFC requerido",
+ "solution": "Capturar en la columna RFC el RFC requerido.",
+ }
+ )
continue
rfc = cp_data["rfc"]
existing = existing_by_rfc.get(rfc)
diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py
index 60e03c2b..dacf458a 100644
--- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py
@@ -4,7 +4,7 @@ Paridad Clarion: VALIDA_TODA_CLIENTE_O_PROV, VALIDA_PARCIAL_CLIENTE_O_PROV, VALI
Origen de reglas: código legacy Clarion (EstructuraCatClienteProv).
Mapa columnas: A=PROCEDENCIA, B=TIPO, C=SHORT_NAME, D=NOMBRE, E=RFC, F–AG (ver template_config).
"""
-from typing import Dict, Any, Optional, Set
+from typing import Dict, Any, Optional, Set, List
from ..common.common_validators import (
RFC_MAX,
@@ -58,43 +58,37 @@ def validate_row_clave_vacia(row: Dict[str, Any], line_num: int) -> Optional[Dic
return None
-def validaciones_cliente_o_prov(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
+def validaciones_cliente_o_prov(row: Dict[str, Any], line_num: int) -> List[Dict[str, Any]]:
"""
VALIDACIONES_CLIENTE_O_PROV: reglas de dominio compartidas.
Col A (E/N + coherencia con L), B (C/P/A), C (máx 8), Q/R/S, T/U, V, W/X, AF.
"""
- err = check_procedencia(row, line_num)
- if err:
- return err
- err = check_tipo_client_provider(row, line_num)
- if err:
- return err
- err = check_short_name_max_clarion(row, line_num)
- if err:
- return err
- err = check_tipo_programa_secon(row, line_num)
- if err:
- return err
- err = check_es_prosec_num_aut(row, line_num)
- if err:
- return err
- err = check_vinculacion(row, line_num)
- if err:
- return err
- err = check_es_empresa_certificada_registro(row, line_num)
- if err:
- return err
- err = check_transformador_submaq(row, line_num)
- if err:
- return err
- return None
+ errors: List[Dict[str, Any]] = []
+
+ checks = [
+ check_procedencia,
+ check_tipo_client_provider,
+ check_short_name_max_clarion,
+ check_tipo_programa_secon,
+ check_es_prosec_num_aut,
+ check_vinculacion,
+ check_es_empresa_certificada_registro,
+ check_transformador_submaq,
+ ]
+
+ for fn in checks:
+ err = fn(row, line_num)
+ if err:
+ errors.append(err)
+
+ return errors
def valida_toda_cliente_o_prov(
row: Dict[str, Any],
line_num: int,
actualizar: bool = False,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
VALIDA_TODA: obligatorios Col A (Procedencia), Col D (Nombre) cuando no es ACT.
Si modo ACT y clave no existe → error se devuelve antes (en validate_row_client_provider).
@@ -107,17 +101,20 @@ def valida_toda_cliente_o_prov(
# Col D - Nombre obligatorio solo cuando no es actualizar
if not actualizar and not (row.get("NOMBRE") or "").strip():
campos_oblig.append("(Col.D) Nombre")
+ errors: List[Dict[str, Any]] = []
if campos_oblig:
- return {
+ errors.append({
"line": line_num,
"col": "PROCEDENCIA" if not (row.get("PROCEDENCIA") or "").strip() else "NOMBRE",
"msg": MSG_CAMPOS_OBLIGATORIOS.format(campos=", ".join(campos_oblig)),
"solution": MSG_CAMPOS_OBLIGATORIOS_SOLUCION,
- }
- return validaciones_cliente_o_prov(row, line_num)
+ })
+
+ errors.extend(validaciones_cliente_o_prov(row, line_num))
+ return errors
-def valida_parcial_cliente_o_prov(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
+def valida_parcial_cliente_o_prov(row: Dict[str, Any], line_num: int) -> List[Dict[str, Any]]:
"""VALIDA_PARCIAL: solo VALIDACIONES_CLIENTE_O_PROV (no exige A ni D)."""
return validaciones_cliente_o_prov(row, line_num)
@@ -127,7 +124,7 @@ def validate_row_client_provider(
line_num: int,
actualizar: bool = False,
existing_short_names: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de clientes y proveedores.
- Desfase (COL_EXTRA) primero.
@@ -137,34 +134,35 @@ def validate_row_client_provider(
- Si no actualizar o clave no existe → VALIDA_TODA (A y D obligatorios cuando aplique, luego comunes).
Además se validan RFC (requerido max 30), longitudes NOMBRE/SHORT_NAME/CURP para compatibilidad.
"""
+ errors: List[Dict[str, Any]] = []
err = validate_row_desfase(row, line_num)
if err:
- return err
+ errors.append(err)
+
err = validate_row_clave_vacia(row, line_num)
if err:
- return err
+ errors.append(err)
short_name = (row.get("SHORT_NAME") or "").strip()
existing = existing_short_names or set()
use_partial = actualizar and short_name in existing
if actualizar and short_name and short_name not in existing:
- return {
+ errors.append({
"line": line_num,
"col": "SHORT_NAME",
"msg": MSG_CLAVE_NO_EXISTE,
"solution": MSG_CLAVE_NO_EXISTE_SOLUCION,
- }
+ })
if use_partial:
- err = valida_parcial_cliente_o_prov(row, line_num)
+ errors.extend(valida_parcial_cliente_o_prov(row, line_num))
else:
- err = valida_toda_cliente_o_prov(row, line_num, actualizar=actualizar)
- if err:
- return err
+ errors.extend(valida_toda_cliente_o_prov(row, line_num, actualizar=actualizar))
# Identificador fiscal requerido: Nacional = RFC, Extranjero = TAX-ID (mismo campo "RFC" en layout).
procedencia = (row.get("PROCEDENCIA") or "").strip().upper()[:1]
+
err = check_required_max(row, "RFC", RFC_MAX, line_num)
if err:
if procedencia == "E":
@@ -174,21 +172,22 @@ def validate_row_client_provider(
"msg": "Requerido (TAX-ID)",
"solution": "Capturar el TAX-ID del cliente/proveedor extranjero en la columna E (RFC/TAX-ID).",
}
- return err
- # Validar formato según procedencia: RFC (N) o TAX-ID (E).
- if procedencia == "N":
- err = check_rfc_format(row, line_num)
- elif procedencia == "E":
- err = check_tax_id_format(row, line_num)
- if err:
- return err
- err = check_max_length(row, "NOMBRE", NAME_MAX, line_num)
- if err:
- return err
- err = check_max_length(row, "SHORT_NAME", 10, line_num) # modelo permite 10
- if err:
- return err
- err = check_max_length(row, "CURP", CURP_MAX, line_num)
- if err:
- return err
- return None
+ errors.append(err)
+ else:
+ # Validar formato según procedencia: RFC (N) o TAX-ID (E).
+ if procedencia == "N":
+ err = check_rfc_format(row, line_num)
+ elif procedencia == "E":
+ err = check_tax_id_format(row, line_num)
+ if err:
+ errors.append(err)
+
+ for err in [
+ check_max_length(row, "NOMBRE", NAME_MAX, line_num),
+ check_max_length(row, "SHORT_NAME", 10, line_num), # modelo permite 10
+ check_max_length(row, "CURP", CURP_MAX, line_num),
+ ]:
+ if err:
+ errors.append(err)
+
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/common/error_csv.py b/backend/api/v1/modules/a76/layouts_csv/common/error_csv.py
new file mode 100644
index 00000000..1782536a
--- /dev/null
+++ b/backend/api/v1/modules/a76/layouts_csv/common/error_csv.py
@@ -0,0 +1,81 @@
+"""
+Generación de CSV de errores a partir del JSONL persistido por el worker.
+"""
+
+import csv
+import io
+import json
+import os
+from typing import Any, Dict, Iterable, List
+
+from fastapi import HTTPException
+from fastapi.responses import StreamingResponse
+
+from . import storage as common_storage
+
+
+def _iter_jsonl_rows(error_path: str) -> Iterable[Dict[str, Any]]:
+ """
+ Itera sobre errores normalizados desde un .jsonl.
+
+ El worker suele escribir:
+ - dict por línea, o
+ - lista de dicts por línea.
+ """
+ if not os.path.exists(error_path):
+ return []
+
+ with open(error_path, "r", encoding="utf-8") as f:
+ for line in f:
+ if not line.strip():
+ continue
+ try:
+ payload = json.loads(line)
+ except Exception:
+ continue
+
+ items: List[Any] = payload if isinstance(payload, list) else [payload]
+ for err in items:
+ if isinstance(err, dict):
+ yield err
+
+
+def download_scan_errors_csv_stream(job_type: str, job_id: str, *, filename_prefix: str = "errores") -> StreamingResponse:
+ """
+ Devuelve un StreamingResponse con cabecera:
+ `linea, columna, mensaje, solucion`.
+ """
+ error_path = common_storage.error_path_for_job(job_type, job_id)
+ if not os.path.exists(error_path):
+ raise HTTPException(
+ status_code=404,
+ detail="Archivo de errores no encontrado. Vuelve a escanear o intenta más tarde.",
+ )
+
+ headers = {
+ "Content-Disposition": f'attachment; filename="{filename_prefix}_{job_id}.csv"',
+ }
+
+ def row_iter():
+ buffer = io.StringIO()
+ writer = csv.writer(buffer)
+ writer.writerow(["linea", "columna", "mensaje", "solucion"])
+ yield buffer.getvalue()
+ buffer.seek(0)
+ buffer.truncate(0)
+
+ for err in _iter_jsonl_rows(error_path):
+ buffer.seek(0)
+ buffer.truncate(0)
+ writer.writerow(
+ [
+ err.get("line", ""),
+ err.get("col", ""),
+ err.get("msg", ""),
+ err.get("solution", ""),
+ ]
+ )
+ yield buffer.getvalue()
+
+ return StreamingResponse(row_iter(), media_type="text/csv; charset=utf-8", headers=headers)
+
diff --git a/backend/api/v1/modules/a76/layouts_csv/common/responses.py b/backend/api/v1/modules/a76/layouts_csv/common/responses.py
index af33f9fb..4d1f6d6d 100644
--- a/backend/api/v1/modules/a76/layouts_csv/common/responses.py
+++ b/backend/api/v1/modules/a76/layouts_csv/common/responses.py
@@ -4,6 +4,9 @@ Helpers para construir respuestas de scan y commit (formato unificado).
from typing import Dict, Any, List, Optional
+ERRORS_PREVIEW_LIMIT = 100
+
+
def scan_result(
job_id: str,
processed_rows: int,
@@ -22,7 +25,9 @@ def scan_result(
"total_rows": total,
"error_count": error_count,
"valid_rows": processed_rows - error_count,
- "errors": errors_detail,
+ # Para no saturar el front: devolvemos solo un preview.
+ # El detalle completo se descarga desde CSV usando el job_id.
+ "errors": errors_detail[:ERRORS_PREVIEW_LIMIT],
}
if message:
out["message"] = message
diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/common_validators.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/common_validators.py
index 24b2c573..044246f9 100644
--- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/common_validators.py
+++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/common_validators.py
@@ -10,9 +10,15 @@ BROKER_KEY_MAX = 5
LICENSE_MAX = 4
RFC_MAX = 30
CURP_MAX = 19
+PHONE_MIN_DIGITS = 10
+PHONE_MAX_DIGITS = 15
# Formato RFC/CURP (paridad con flujo manual dashboard/customs_brokers)
RFC_PATTERN = re.compile(r"^[A-Z&Ñ]{3,4}\d{6}[A-Z0-9]{3}$", re.IGNORECASE)
+# Email (paridad con flujo manual via DTO)
+EMAIL_PATTERN = re.compile(
+ r"^$|^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$"
+)
# CURP: 18 caracteres (inicial, vocal, 2 letras, fecha, sexo, estado, 3 consonantes, homoclave)
CURP_PATTERN = re.compile(
r"^[A-Z][AEIOUX][A-Z]{2}\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])[HM]"
@@ -74,6 +80,7 @@ def check_desfase(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]
"col": "COL_EXTRA",
"msg": "Advertencia: Podría existir un desfase en esta línea.",
"solution": "Revisar esta línea del archivo CSV y verificar cada campo esté en la posición correcta.",
+ "warning": True,
}
@@ -234,3 +241,80 @@ def check_pais_catalogo(
"msg": f"Error: (Col. J) El Pais del Agente Aduanal: {clave} no está en el Catálogo de Países.",
"solution": "Capturar en la columna J un País válido.",
}
+
+
+def normalize_phone_digits(value: Optional[str]) -> Optional[str]:
+ """
+ Normaliza teléfono a solo dígitos.
+ - Permite separadores: espacio, guion, paréntesis y punto.
+ - No permite signos ni letras (por ejemplo '+', 'ext', '#').
+ - Si queda vacío tras normalizar, devuelve None.
+ """
+ if value is None:
+ return None
+ raw = str(value).strip()
+ if not raw:
+ return None
+ # Caracteres aceptados en entrada para normalizar.
+ if re.search(r"[^0-9\s\-\(\)\.]", raw):
+ return None
+ normalized = re.sub(r"[\s\-\(\)\.]", "", raw)
+ if not normalized:
+ return None
+ return normalized
+
+
+def check_phone_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
+ """
+ Col K: TELEFONO opcional.
+ Si existe, debe poder normalizarse a solo dígitos y quedar entre 10 y 15.
+ """
+ raw_phone = (row.get("TELEFONO") or "").strip()
+ if not raw_phone:
+ return None
+
+ normalized_phone = normalize_phone_digits(raw_phone)
+ if not normalized_phone:
+ return {
+ "line": line_num,
+ "col": "TELEFONO",
+ "msg": "Error: (Col. K) El teléfono contiene texto o símbolos no permitidos.",
+ "solution": "Capturar en la columna K solo números o separadores válidos (espacio, guion, paréntesis o punto).",
+ }
+
+ digits = len(normalized_phone)
+ if digits < PHONE_MIN_DIGITS or digits > PHONE_MAX_DIGITS:
+ return {
+ "line": line_num,
+ "col": "TELEFONO",
+ "msg": f"Error: (Col. K) El teléfono debe contener entre {PHONE_MIN_DIGITS} y {PHONE_MAX_DIGITS} dígitos.",
+ "solution": f"Capturar en la columna K un teléfono válido de {PHONE_MIN_DIGITS} a {PHONE_MAX_DIGITS} dígitos.",
+ }
+
+ # Se deja la fila ya normalizada para mapear/persistir consistentemente.
+ row["TELEFONO"] = normalized_phone
+ return None
+
+
+def check_optional_email_format(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
+ """
+ Col M: EMAIL opcional.
+ Si viene informado (no vacío), debe cumplir formato email.
+
+ Nota: Se rechaza explícitamente texto como 'NULL' (string literal),
+ para evitar que el endpoint de listado falle por validación Pydantic.
+ """
+ raw_email = (row.get("EMAIL") or "").strip()
+ if not raw_email:
+ return None
+
+ # EMAIL_PATTERN incluye el caso '' via ^$; aun así aquí raw_email no está vacío.
+ if not EMAIL_PATTERN.match(raw_email):
+ clave = (row.get("CLAVE") or "").strip()
+ return {
+ "line": line_num,
+ "col": "EMAIL",
+ "msg": f"Error: (Col. M) El correo electrónico del Agente Aduanal con Clave: {clave} es inválido.",
+ "solution": "Capturar en la columna M un correo con formato válido (ej. usuario@dominio.com) o dejar la celda vacía.",
+ }
+ return None
diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/mappers.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/mappers.py
index 4fbd6eae..1ddfb4d1 100644
--- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/mappers.py
+++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/common/mappers.py
@@ -4,7 +4,7 @@ Clarion: TIPO normalizado a M/A con parse_tipo_agente_aduanal; CURP/personal_id
"""
from typing import Dict, Any, Optional
-from .common_validators import parse_tipo_agente_aduanal, CURP_MAX
+from .common_validators import parse_tipo_agente_aduanal, CURP_MAX, normalize_phone_digits
MAX_LEN = {
"broker_key": 5,
@@ -64,7 +64,7 @@ def row_to_customs_broker_data(
"postal_code": _str_or_none(row_norm.get("CODIGO POSTAL"), MAX_LEN["postal_code"]),
"city": _str_or_none(row_norm.get("CIUDAD"), MAX_LEN["city"]),
"state": _str_or_none(row_norm.get("ESTADO"), MAX_LEN["state"]),
- "phone": _str_or_none(row_norm.get("TELEFONO"), MAX_LEN["phone"]),
+ "phone": _str_or_none(normalize_phone_digits(row_norm.get("TELEFONO")), MAX_LEN["phone"]),
"fax": _str_or_none(row_norm.get("FAX"), MAX_LEN["fax"]),
"email": _str_or_none(row_norm.get("EMAIL"), MAX_LEN["email"]),
"country": _str_or_none(row_norm.get("PAIS"), MAX_LEN["country"]),
diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py
index 369208f0..5c9c32b8 100644
--- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/routes.py
@@ -25,6 +25,7 @@ from .tasks import (
CB_IMPORT_META_PREFIX,
CB_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -160,3 +161,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("cb", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py
index 2f58f124..1ddb3e51 100644
--- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/tasks.py
@@ -92,23 +92,33 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
progress_callback(i, total_rows, error_count)
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_customs_broker(
+ row_errors = validate_row_customs_broker(
row_norm,
i,
actualizar=actualizar,
existing_broker_keys=existing_broker_keys,
valid_country_m3=valid_country_m3,
)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ # Guardar una sola vez por renglón (aunque haya múltiples errores en la misma línea).
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -183,19 +193,22 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_customs_broker(
+ row_errors = validate_row_customs_broker(
row_norm,
i,
actualizar=actualizar,
existing_broker_keys=existing_broker_keys,
valid_country_m3=valid_country_m3,
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
data = row_to_customs_broker_data(row_norm, tenant_id, company_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/validators/common.py
index 8edfdefd..e386451d 100644
--- a/backend/api/v1/modules/a76/layouts_csv/customs_brokers/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/customs_brokers/validators/common.py
@@ -2,7 +2,7 @@
Validaciones comunes de fila para import CSV de agentes aduanales.
Paridad Clarion: VALIDA_TODA_AGENTE_ADUANAL, VALIDA_PARCIAL_AGENTE_ADUANAL, VALIDACIONES_AGENTE_ADUANAL.
"""
-from typing import Dict, Any, Optional, Set
+from typing import Dict, Any, Optional, Set, List
from ..common.common_validators import (
check_required_broker_key,
@@ -15,6 +15,8 @@ from ..common.common_validators import (
check_rfc_format,
check_curp_format,
check_pais_catalogo,
+ check_phone_format,
+ check_optional_email_format,
)
MSG_CLAVE_NO_EXISTE = "Error: (Col. B) Clave de A. Aduanal No Existe en el Catalogo."
@@ -27,43 +29,39 @@ def validaciones_agente_aduanal(
row: Dict[str, Any],
line_num: int,
valid_country_m3: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
VALIDACIONES_AGENTE_ADUANAL: reglas de dominio compartidas.
Tipo MEX/AME + coherencia País, patente si MEX, RFC/CURP máx, país en catálogo, licencia formato.
"""
- err = check_tipo_mex_ame(row, line_num)
- if err:
- return err
- err = check_patente_obligatoria_si_mex(row, line_num)
- if err:
- return err
- err = check_optional_license(row, line_num)
- if err:
- return err
- err = check_rfc_max(row, line_num)
- if err:
- return err
- err = check_rfc_format(row, line_num)
- if err:
- return err
- err = check_curp_max(row, line_num)
- if err:
- return err
- err = check_curp_format(row, line_num)
- if err:
- return err
- err = check_pais_catalogo(row, line_num, valid_country_m3)
- if err:
- return err
- return None
+ errors: List[Dict[str, Any]] = []
+
+ checks = [
+ check_tipo_mex_ame,
+ check_patente_obligatoria_si_mex,
+ check_optional_license,
+ check_rfc_max,
+ check_rfc_format,
+ check_curp_max,
+ check_curp_format,
+ lambda r, ln: check_pais_catalogo(r, ln, valid_country_m3),
+ check_optional_email_format,
+ check_phone_format,
+ ]
+
+ for fn in checks:
+ err = fn(row, line_num)
+ if err:
+ errors.append(err)
+
+ return errors
def valida_toda_agente_aduanal(
row: Dict[str, Any],
line_num: int,
valid_country_m3: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
VALIDA_TODA: TIPO (Col A) y NOMBRE (Col D) obligatorios; luego VALIDACIONES_AGENTE_ADUANAL.
"""
@@ -73,12 +71,15 @@ def valida_toda_agente_aduanal(
if not (row.get("NOMBRE") or "").strip():
campos_oblig.append("(Col.D) Nombre")
if campos_oblig:
- return {
+ err = {
"line": line_num,
"col": "TIPO" if not (row.get("TIPO") or "").strip() else "NOMBRE",
"msg": MSG_CAMPOS_OBLIGATORIOS.format(campos=", ".join(campos_oblig)),
"solution": MSG_CAMPOS_OBLIGATORIOS_SOLUCION,
}
+ # No cortamos: también queremos ver errores de columnas dependientes/no-dependientes.
+ return [err] + validaciones_agente_aduanal(row, line_num, valid_country_m3)
+
return validaciones_agente_aduanal(row, line_num, valid_country_m3)
@@ -86,7 +87,7 @@ def valida_parcial_agente_aduanal(
row: Dict[str, Any],
line_num: int,
valid_country_m3: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDA_PARCIAL: solo VALIDACIONES_AGENTE_ADUANAL (no exige TIPO ni NOMBRE)."""
return validaciones_agente_aduanal(row, line_num, valid_country_m3)
@@ -97,7 +98,7 @@ def validate_row_customs_broker(
actualizar: bool = False,
existing_broker_keys: Optional[Set[str]] = None,
valid_country_m3: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de agentes aduanales.
1. Desfase (COL_EXTRA) primero.
@@ -106,29 +107,31 @@ def validate_row_customs_broker(
4. Si actualizar y clave existe → valida_parcial_agente_aduanal.
5. Si no actualizar o clave no existe → valida_toda_agente_aduanal.
"""
+ errors: List[Dict[str, Any]] = []
+
err = check_desfase(row, line_num)
if err:
- return err
+ errors.append(err)
+
err = check_required_broker_key(row, line_num)
if err:
- return err
+ errors.append(err)
clave = (row.get("CLAVE") or "").strip()
existing = existing_broker_keys or set()
use_partial = actualizar and clave in existing
if actualizar and clave and clave not in existing:
- return {
+ errors.append({
"line": line_num,
"col": "CLAVE",
"msg": MSG_CLAVE_NO_EXISTE,
"solution": MSG_CLAVE_NO_EXISTE_SOLUCION,
- }
+ })
if use_partial:
- err = valida_parcial_agente_aduanal(row, line_num, valid_country_m3)
+ errors.extend(valida_parcial_agente_aduanal(row, line_num, valid_country_m3))
else:
- err = valida_toda_agente_aduanal(row, line_num, valid_country_m3)
- if err:
- return err
- return None
+ errors.extend(valida_toda_agente_aduanal(row, line_num, valid_country_m3))
+
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py b/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py
index c4b1972b..4a155a6c 100644
--- a/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/drivers/routes.py
@@ -27,6 +27,7 @@ from .tasks import (
DRV_IMPORT_STATUS_PREFIX,
DRV_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -184,3 +185,8 @@ async def commit_import_job(job_id: str):
"message": "Insercion iniciada.",
"commit_job_id": job_id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("drv", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py
index e7c3f777..f81a341b 100644
--- a/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/drivers/tasks.py
@@ -139,8 +139,17 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
progress_callback(i, total_rows, error_count)
row_norm = row_from_template(row, common_normalize.normalize_header)
- _ = validate_row_driver_desfase(row_norm, i)
- err = validate_row_driver(
+ warn = validate_row_driver_desfase(row_norm, i)
+ if warn and len(errors_detail) < 500:
+ errors_detail.append({
+ "line": warn.get("line", i),
+ "col": warn.get("col", ""),
+ "msg": warn.get("msg", ""),
+ "solution": warn.get("solution", ""),
+ "warning": bool(warn.get("warning", False)),
+ })
+
+ row_errors = validate_row_driver(
row_norm,
i,
actualizar=actualizar,
@@ -148,16 +157,25 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
valid_transporter_keys=valid_transporter_keys,
valid_country_ame=valid_country_ame,
)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e.get("line", i),
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -305,7 +323,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_driver(
+ row_errors = validate_row_driver(
row_norm,
i,
actualizar=actualizar,
@@ -313,15 +331,18 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
valid_transporter_keys=valid_transporter_keys,
valid_country_ame=valid_country_ame,
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
driver_key = (row_norm.get("CLAVE CONDUCTOR") or "").strip()[:80] or "-"
- skipped_details.append({
- "line": i,
- "driver_key": driver_key,
- "invoice": driver_key,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "driver_key": driver_key,
+ "invoice": driver_key,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
data = row_to_driver_data(row_norm, tenant_id, company_id)
@@ -340,6 +361,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
"driver_key": f"{tk}:{data.get('line')}",
"invoice": f"{tk}:{data.get('line')}",
"reason": f"El transportista {tk} no existe en el catálogo.",
+ "solution": "Capturar en el CSV un TRANSPORTISTA existente (columna TRANSPORTISTA CLAVE / CLAVE TRANSPORTISTA).",
})
continue
data["transporter_key"] = transporter_map_from_redis[tk_upper]
@@ -361,6 +383,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
"driver_key": f"{tk}:{data.get('line')}",
"invoice": f"{tk}:{data.get('line')}",
"reason": f"El transportista {tk} no existe en el catálogo.",
+ "solution": "Capturar en el CSV un TRANSPORTISTA existente (columna TRANSPORTISTA CLAVE / CLAVE TRANSPORTISTA).",
})
continue
data["transporter_key"] = (transporter_row[0] or "").strip()
@@ -373,6 +396,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
"driver_key": key,
"invoice": key,
"reason": "Clave duplicada en el archivo (se usa la primera)",
+ "solution": "Revisa TRANSPORTISTA y LINEA para no duplicar la combinación en el archivo (se conserva la primera).",
})
continue
seen_keys_in_file[key] = i
@@ -405,7 +429,11 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
i, data.get("transporter_key"), len(valid_transporter_keys),
)
skipped_details.append({
- "line": i, "driver_key": key, "invoice": key, "reason": err_msg,
+ "line": i,
+ "driver_key": key,
+ "invoice": key,
+ "reason": err_msg,
+ "solution": "Revisar la línea del CSV y los catálogos relacionados para corregir el error e intentar de nuevo.",
})
continue
diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/drivers/validators/common.py
index ab2cc764..43478bbc 100644
--- a/backend/api/v1/modules/a76/layouts_csv/drivers/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/drivers/validators/common.py
@@ -2,7 +2,7 @@
Validaciones comunes de fila para import CSV de conductores.
Paridad Clarion: VALIDACIONES_CONDUCTOR, VALIDA_TODA_CONDUCTOR, VALIDA_PARCIAL_CONDUCTOR.
"""
-from typing import Dict, Any, Optional, Set
+from typing import Dict, Any, Optional, Set, List
from ..common.common_validators import (
MAX_LEN,
@@ -77,60 +77,46 @@ def validaciones_conductores(
line_num: int,
valid_transporter_keys: Optional[Set[str]] = None,
valid_country_ame: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
VALIDACIONES_CONDUCTOR: reglas compartidas (longitudes, fecha, transportista en catálogo,
sexo M/F, país nacimiento, material peligroso Si/No, tipo ID 1/2, país ID1/ID2).
"""
- err = validate_row_driver_lengths(row, line_num)
- if err:
- return err
- err = validate_row_driver_date(row, line_num)
- if err:
- return err
- err = check_transportista_catalog(row, line_num, valid_transporter_keys)
- if err:
- return err
- err = check_sexo_m_f(row, line_num)
- if err:
- return err
- err = check_pais_catalog_drivers(
- row, "PAIS NACIMIENTO", line_num, valid_country_ame, col_letter="Col. I"
- )
- if err:
- return err
- err = check_material_peligroso_si_no(row, line_num)
- if err:
- return err
- err = check_tipo_identificacion(
- row,
- "FORMA IDENTIFICACION 1",
- line_num,
- col_letter="Col. N",
- primera_o_segunda="Primera",
- )
- if err:
- return err
- err = check_pais_catalog_drivers(
- row, "PAIS", line_num, valid_country_ame, col_letter="Col. Q"
- )
- if err:
- return err
- err = check_tipo_identificacion(
- row,
- "FORMA IDENTIFICACION 2",
- line_num,
- col_letter="Col. R",
- primera_o_segunda="Segunda",
- )
- if err:
- return err
- err = check_pais_catalog_drivers(
- row, "PAIS 2", line_num, valid_country_ame, col_letter="Col. U"
- )
- if err:
- return err
- return None
+ errors: List[Dict[str, Any]] = []
+ for err in [
+ validate_row_driver_lengths(row, line_num),
+ validate_row_driver_date(row, line_num),
+ check_transportista_catalog(row, line_num, valid_transporter_keys),
+ check_sexo_m_f(row, line_num),
+ check_pais_catalog_drivers(
+ row, "PAIS NACIMIENTO", line_num, valid_country_ame, col_letter="Col. I"
+ ),
+ check_material_peligroso_si_no(row, line_num),
+ check_tipo_identificacion(
+ row,
+ "FORMA IDENTIFICACION 1",
+ line_num,
+ col_letter="Col. N",
+ primera_o_segunda="Primera",
+ ),
+ check_pais_catalog_drivers(
+ row, "PAIS", line_num, valid_country_ame, col_letter="Col. Q"
+ ),
+ check_tipo_identificacion(
+ row,
+ "FORMA IDENTIFICACION 2",
+ line_num,
+ col_letter="Col. R",
+ primera_o_segunda="Segunda",
+ ),
+ check_pais_catalog_drivers(
+ row, "PAIS 2", line_num, valid_country_ame, col_letter="Col. U"
+ ),
+ ]:
+ if err:
+ errors.append(err)
+
+ return errors
def valida_toda_conductor(
@@ -138,16 +124,20 @@ def valida_toda_conductor(
line_num: int,
valid_transporter_keys: Optional[Set[str]] = None,
valid_country_ame: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDA_TODA_CONDUCTOR: obligatorios A, C y LINEA (B) + validaciones_conductores (registro nuevo)."""
+ errors: List[Dict[str, Any]] = []
err = validate_row_driver_required_full(row, line_num)
if err:
- return err
- return validaciones_conductores(
+ errors.append(err)
+ errors.extend(
+ validaciones_conductores(
row, line_num,
valid_transporter_keys=valid_transporter_keys,
valid_country_ame=valid_country_ame,
+ )
)
+ return errors
def valida_parcial_conductor(
@@ -155,7 +145,7 @@ def valida_parcial_conductor(
line_num: int,
valid_transporter_keys: Optional[Set[str]] = None,
valid_country_ame: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDA_PARCIAL_CONDUCTOR: solo validaciones_conductores (actualizar registro existente)."""
return validaciones_conductores(
row, line_num,
diff --git a/backend/api/v1/modules/a76/layouts_csv/drivers/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/drivers/validators/create.py
index d04d14b2..e628e319 100644
--- a/backend/api/v1/modules/a76/layouts_csv/drivers/validators/create.py
+++ b/backend/api/v1/modules/a76/layouts_csv/drivers/validators/create.py
@@ -2,7 +2,7 @@
Punto de entrada de validación para import de una fila conductor.
Paridad Clarion: desfase (advertencia), obligatorios A y C, VALIDA_TODA vs VALIDA_PARCIAL según actualizar y clave existente.
"""
-from typing import Dict, Any, Optional, Set, Tuple
+from typing import Dict, Any, Optional, Set, Tuple, List
from ..common.common_validators import parse_int, check_desfase_drivers
from .common import (
@@ -19,7 +19,7 @@ def validate_row_driver(
existing_driver_keys: Optional[Set[Tuple[str, int]]] = None,
valid_transporter_keys: Optional[Set[str]] = None,
valid_country_ame: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de conductores.
1. Obligatorios A (TRANSPORTISTA) y C (CLAVE CONDUCTOR) vacíos → error.
@@ -27,9 +27,10 @@ def validate_row_driver(
3. Si no actualizar o conductor no existe → valida_toda_conductor (obligatorios A, C y LINEA + validaciones).
Desfase (COL_EXTRA) no se valida aquí; el caller puede llamar validate_row_driver_desfase para advertencias no bloqueantes.
"""
+ errors: List[Dict[str, Any]] = []
err = validate_row_driver_required(row, line_num)
if err:
- return err
+ errors.append(err)
existing = existing_driver_keys or set()
transporter_key = (row.get("TRANSPORTISTA") or "").strip().upper()
@@ -41,20 +42,24 @@ def validate_row_driver(
)
if use_partial:
- err = valida_parcial_conductor(
+ errors.extend(
+ valida_parcial_conductor(
row,
line_num,
valid_transporter_keys=valid_transporter_keys,
valid_country_ame=valid_country_ame,
+ )
)
else:
- err = valida_toda_conductor(
+ errors.extend(
+ valida_toda_conductor(
row,
line_num,
valid_transporter_keys=valid_transporter_keys,
valid_country_ame=valid_country_ame,
+ )
)
- return err
+ return errors
def validate_row_driver_desfase(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py
index cb8ca60a..838887ae 100644
--- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/routes.py
@@ -25,6 +25,7 @@ from .tasks import (
ER_IMPORT_META_PREFIX,
ER_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -170,3 +171,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("er", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py
index 68610c11..c8f5521e 100644
--- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/tasks.py
@@ -70,17 +70,31 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
progress_callback(i, total_rows, error_count)
row_norm = _norm_row(row)
- err = validate_row_exchange_rate(row_norm, i, raw_row=row, date_format_preference=date_format_preference)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ row_errors = validate_row_exchange_rate(
+ row_norm,
+ i,
+ raw_row=row,
+ date_format_preference=date_format_preference,
+ )
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -152,19 +166,33 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue
row_norm = _norm_row(row)
- err = validate_row_exchange_rate(row_norm, i, raw_row=row, date_format_preference=date_format_preference)
- if err:
+ row_errors = validate_row_exchange_rate(
+ row_norm,
+ i,
+ raw_row=row,
+ date_format_preference=date_format_preference,
+ )
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
data = row_to_exchange_rate_data(row_norm, tenant_id, company_id, date_format_preference)
if not data or not data.get("date"):
skipped_invalid += 1
- skipped_details.append({"line": i, "reason": "FECHA o VALOR no válidos"})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": "FECHA o VALOR no válidos",
+ "solution": "Capturar en la columna FECHA una fecha válida y en la columna VALOR un número válido.",
+ }
+ )
continue
key_date = data["date"].date() if hasattr(data["date"], "date") else data["date"]
diff --git a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py
index 314af8c9..89acf620 100644
--- a/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/exchange_rate/validators/common.py
@@ -3,7 +3,7 @@ Validaciones comunes de fila para import CSV de tipos de cambio.
Paridad Clarion: desfase (Col C vacía), obligatorios (Col A Fecha, Col B Tipo de Cambio),
validación fecha (longitud, día acorde al mes, mes ≤ 12; sin límite de año).
"""
-from typing import Dict, Any, Optional
+from typing import Dict, Any, Optional, List
from ...common.cell_value import cell_to_str
from ..common.common_validators import (
@@ -33,6 +33,7 @@ def validate_row_desfase(raw_row: Dict[str, Any], line_num: int) -> Optional[Dic
"line": line_num,
"col": "",
"msg": f"{MSG_DESFASE} {MSG_DESFASE_SOLUCION}",
+ "warning": True,
}
return None
@@ -65,29 +66,37 @@ def validate_row_exchange_rate(
line_num: int,
raw_row: Optional[Dict[str, Any]] = None,
date_format_preference: Optional[str] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de tipos de cambio.
Orden: desfase (si raw_row) → obligatorios → fecha Clarion → VALOR > 0 → MONEDA opc (max 7).
date_format_preference: valor del parámetro global (ej. dd/mm/yyyy, mm/dd/yyyy, yyyy-mm-dd).
"""
+ errors: List[Dict[str, Any]] = []
+
if raw_row is not None:
err = validate_row_desfase(raw_row, line_num)
if err:
- return err
+ errors.append(err)
+
err = validate_row_required_exchange_rate(row, line_num)
if err:
- return err
+ errors.append(err)
+
err = validate_row_fecha_clarion(row, line_num, date_format_preference)
if err:
- return err
+ errors.append(err)
+
err = check_required_value_positive(row, line_num)
if err:
- return err
+ errors.append(err)
+
err = check_optional_max_length(row, "MONEDA_LOCAL", CURRENCY_MAX, line_num)
if err:
- return err
+ errors.append(err)
+
err = check_optional_max_length(row, "MONEDA_EXTRANJERA", CURRENCY_MAX, line_num)
if err:
- return err
- return None
+ errors.append(err)
+
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py
index 0af83ad1..953d2860 100644
--- a/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/exportacion/routes.py
@@ -7,8 +7,11 @@ import json
import logging
import os
from uuid import uuid4
+import csv
+import io
from fastapi import APIRouter, File, HTTPException, UploadFile, Depends, Form, Query
+from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import Literal, Optional, Dict, Any
@@ -160,3 +163,59 @@ async def commit_import_job(job_id: str, body: CommitRequest):
"message": "Proceso de commit iniciado.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ """
+ Descarga CSV con TODO el detalle de errores del scan (sin límite),
+ leyendo el archivo JSONL generado por el worker.
+ """
+
+ # Exportación usa job_type "exp" (storage: exp_{job_id}.jsonl)
+ error_path = common_storage.error_path_for_job(JOB_TYPE, job_id)
+ if not os.path.exists(error_path):
+ raise HTTPException(
+ status_code=404,
+ detail="Archivo de errores no encontrado. Vuelve a escanear o intenta más tarde.",
+ )
+
+ def row_iter():
+ buffer = io.StringIO()
+ writer = csv.writer(buffer)
+ writer.writerow(["linea", "columna", "mensaje", "solucion"])
+ yield buffer.getvalue()
+ buffer.seek(0)
+ buffer.truncate(0)
+
+ with open(error_path, "r", encoding="utf-8") as f:
+ for line in f:
+ if not line.strip():
+ continue
+ try:
+ payload = json.loads(line)
+ except Exception:
+ continue
+
+ items = payload if isinstance(payload, list) else [payload]
+ for err in items:
+ if not isinstance(err, dict):
+ continue
+ buffer.seek(0)
+ buffer.truncate(0)
+ writer.writerow(
+ [
+ err.get("line", ""),
+ err.get("col", ""),
+ err.get("msg", ""),
+ err.get("solution", ""),
+ ]
+ )
+ yield buffer.getvalue()
+ buffer.seek(0)
+ buffer.truncate(0)
+
+ headers = {
+ "Content-Disposition": f'attachment; filename="errores_{job_id}.csv"'
+ }
+ return StreamingResponse(row_iter(), media_type="text/csv; charset=utf-8", headers=headers)
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py
index a80af18f..488c5871 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py
@@ -4,7 +4,10 @@ import base64
import os
import json
import logging
+import csv
+import io
from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query
+from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from typing import Optional, Literal, Dict, Any
@@ -22,6 +25,7 @@ from .tasks import (
IMPORT_REDIS_TTL,
)
from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest
+from ..common import storage as common_storage
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -170,6 +174,60 @@ async def get_import_status(job_id: str):
return {"status": "failed", "error": err_msg}
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ """
+ Descarga CSV con TODO el detalle de errores del scan (sin límite),
+ leyendo el archivo JSONL generado por el worker.
+ """
+
+ # Facturas (impo/exp delega a facturas) usan job_type vacío ("").
+ error_path = common_storage.error_path_for_job("", job_id)
+ if not os.path.exists(error_path):
+ raise HTTPException(status_code=404, detail="Archivo de errores no encontrado. Vuelve a escanear o intenta más tarde.")
+
+ def row_iter():
+ buffer = io.StringIO()
+ writer = csv.writer(buffer)
+ writer.writerow(["linea", "columna", "mensaje", "solucion"])
+ yield buffer.getvalue()
+ buffer.seek(0)
+ buffer.truncate(0)
+
+ with open(error_path, "r", encoding="utf-8") as f:
+ for line in f:
+ if not line.strip():
+ continue
+ try:
+ payload = json.loads(line)
+ except Exception:
+ continue
+
+ # Algunos flujos pueden escribir un dict por línea o una lista de dicts.
+ items = payload if isinstance(payload, list) else [payload]
+ for err in items:
+ if not isinstance(err, dict):
+ continue
+ buffer.seek(0)
+ buffer.truncate(0)
+ writer.writerow(
+ [
+ err.get("line", ""),
+ err.get("col", ""),
+ err.get("msg", ""),
+ err.get("solution", ""),
+ ]
+ )
+ yield buffer.getvalue()
+ buffer.seek(0)
+ buffer.truncate(0)
+
+ headers = {
+ "Content-Disposition": f'attachment; filename="errores_{job_id}.csv"'
+ }
+ return StreamingResponse(row_iter(), media_type="text/csv; charset=utf-8", headers=headers)
+
+
@router.post("/{job_id}/commit")
async def commit_import_job(job_id: str, body: CommitRequest):
"""
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py
index 03a1751c..eebf2ea3 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py
@@ -115,10 +115,20 @@ def validate_public_code(
code = normalize_public_code(value)
if not code:
if required:
- return {"line": line_num, "col": col_name, "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "Requerido",
+ "solution": f"Capturar el dato requerido en la columna {col_name}.",
+ }
return None
if not validator.check_exists(model, code, field_name=field_name, is_public=True):
- return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "No existe en el catalogo",
+ "solution": f"Capturar en la columna {col_name} un valor que exista en el catálogo.",
+ }
return None
@@ -132,10 +142,20 @@ def validate_tenant_fk_id(
) -> Optional[Dict[str, Any]]:
if value is None:
if required:
- return {"line": line_num, "col": col_name, "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "Requerido",
+ "solution": f"Capturar el dato requerido en la columna {col_name}.",
+ }
return None
if not validator.check_exists(model, value):
- return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "No existe en el catalogo",
+ "solution": f"Capturar en la columna {col_name} un valor que exista en el catálogo.",
+ }
return None
@@ -150,14 +170,24 @@ def _validate_client_provider_ref(
"""Valida CLAVE PROVEEDOR / VENDIDO A / ENVIADO A: acepta ID (entero) o short_name (texto)."""
if raw_value is None or not str(raw_value).strip():
if required:
- return {"line": line_num, "col": col_name, "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "Requerido",
+ "solution": f"Capturar el dato requerido en la columna {col_name}.",
+ }
return None
pid = parse_int(raw_value)
if pid is not None:
return validate_tenant_fk_id(validator, model, pid, line_num, col_name, required=False)
short_norm = str(raw_value).strip().upper()
if not validator.check_exists(model, short_norm, field_name="short_name"):
- return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "No existe en el catalogo",
+ "solution": f"Capturar en la columna {col_name} un valor que exista en el catálogo.",
+ }
return None
@@ -172,14 +202,24 @@ def _validate_customs_broker_ref(
"""Valida AGENTE ADUANAL: acepta ID (entero) o clave broker_key (texto)."""
if raw_value is None or not str(raw_value).strip():
if required:
- return {"line": line_num, "col": col_name, "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "Requerido",
+ "solution": f"Capturar el dato requerido en la columna {col_name}.",
+ }
return None
pid = parse_int(raw_value)
if pid is not None:
return validate_tenant_fk_id(validator, model, pid, line_num, col_name, required=False)
clave = str(raw_value).strip()
if not validator.check_exists(model, clave, field_name="broker_key"):
- return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "No existe en el catalogo",
+ "solution": f"Capturar en la columna {col_name} una clave que exista en el catálogo.",
+ }
return None
@celery_app.task(bind=True)
@@ -206,6 +246,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
total_rows = 0
error_count = 0
processed_rows = 0
+ errors_detail: List[Dict[str, Any]] = []
# 3. Count Total (Quick Pass) or just estimate
try:
@@ -382,7 +423,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)})
row_norm = row_from_template(row, "imp_def_series", normalize_header)
warnings_list: List[Dict[str, Any]] = []
- err = validate_row_series_impo_def(
+ row_errors = validate_row_series_impo_def(
row_norm,
i,
actualizar=actualizar,
@@ -396,21 +437,39 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
existing_series_data=existing_series_data,
warnings=warnings_list,
)
- if err and not err.get("warning"):
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
else:
inv_num = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip()
line_fac = (row_norm.get("LINEA FACTURA") or row_norm.get("LINEA") or row_norm.get("PARTIDA") or "").strip()
if inv_num and line_fac:
key_csv = (inv_num, line_fac)
csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1
- for w in warnings_list:
- if len(errors_detail) < 500:
- errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True})
+ for e in row_errors + warnings_list:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -558,7 +617,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)})
row_norm = row_from_template(row, "exp_def_series", normalize_header)
warnings_list: List[Dict[str, Any]] = []
- err = validate_row_series_expo(
+ row_errors = validate_row_series_expo(
row_norm,
i,
actualizar=actualizar,
@@ -573,21 +632,39 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
existing_series_data=existing_series_data,
warnings=warnings_list,
)
- if err and not err.get("warning"):
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
else:
inv_num = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or row_norm.get("FACTURA EXPO") or "").strip()
line_fac = (row_norm.get("LINEA FACTURA") or row_norm.get("LINEA") or row_norm.get("PARTIDA") or "").strip()
if inv_num and line_fac:
key_csv = (inv_num, line_fac)
csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1
- for w in warnings_list:
- if len(errors_detail) < 500:
- errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True})
+ for e in row_errors + warnings_list:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -718,7 +795,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)})
row_norm = row_from_template(row, "cmex_series", normalize_header)
warnings_list: List[Dict[str, Any]] = []
- err = validate_row_series_impo_def(
+ row_errors = validate_row_series_impo_def(
row_norm,
i,
actualizar=actualizar,
@@ -733,21 +810,39 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
warnings=warnings_list,
catalog_label="Compras Mexicanas",
)
- if err and not err.get("warning"):
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
else:
inv_num = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip()
line_fac = (row_norm.get("LINEA FACTURA") or row_norm.get("LINEA") or row_norm.get("PARTIDA") or "").strip()
if inv_num and line_fac:
key_csv = (inv_num, line_fac)
csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1
- for w in warnings_list:
- if len(errors_detail) < 500:
- errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True})
+ for e in row_errors + warnings_list:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -855,7 +950,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": len(error_lines_list)})
row_norm = row_from_template(row, "imp_temp_series", normalize_header)
warnings_list: List[Dict[str, Any]] = []
- err = validate_row_series_impo_temp(
+ row_errors = validate_row_series_impo_temp(
row_norm,
i,
actualizar=actualizar,
@@ -867,15 +962,33 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
existing_series_data=existing_series_data,
warnings=warnings_list,
)
- if err and not err.get("warning"):
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
- for w in warnings_list:
- if len(errors_detail) < 500:
- errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
+ for e in row_errors + warnings_list:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -1099,7 +1212,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
if i % 1000 == 0:
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count})
row_norm = row_from_template(row, "imp_temp_details", normalize_header)
- err = validate_row_partidas_impo_temp(
+ row_errors = validate_row_partidas_impo_temp(
row_norm,
i,
autonumerar=autonumerar,
@@ -1131,12 +1244,33 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
valid_part_numbers=valid_part_numbers,
warnings=None,
)
- if err:
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
+ for e in row_errors:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -1362,7 +1496,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
if i % 1000 == 0:
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count})
row_norm = row_from_template(row, "imp_def_details", normalize_header)
- err = validate_row_partidas_impo_def(
+ row_errors = validate_row_partidas_impo_def(
row_norm,
i,
autonumerar=autonumerar,
@@ -1394,12 +1528,33 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
valid_part_numbers=valid_part_numbers,
warnings=None,
)
- if err:
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
+ for e in row_errors:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -1631,7 +1786,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
with open(error_path, "w", encoding="utf-8") as f_err:
for i, row in enumerate(rows_list, start=1):
row_norm = row_from_template(row, "exp_def_partidas", normalize_header)
- err = validate_row_partidas_expo(
+ row_errors = validate_row_partidas_expo(
row_norm,
i,
autonumerar=autonumerar,
@@ -1656,18 +1811,33 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
rfc_exception_egm=rfc_exception_egm,
validar_decimales_pza=validar_decimales_pza,
)
- if err:
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
- logger.info(
- "Partidas expo scan rechazo línea %s (col %s): %s",
- err["line"],
- err.get("col", ""),
- err.get("msg", ""),
- )
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
+ for e in row_errors:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -1893,7 +2063,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
if i % 1000 == 0:
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count})
row_norm = row_from_template(row, "cmex_details", normalize_header)
- err = validate_row_partidas_impo_def(
+ row_errors = validate_row_partidas_impo_def(
row_norm,
i,
autonumerar=autonumerar,
@@ -1926,12 +2096,33 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
warnings=None,
catalog_label="Compras Mexicanas",
)
- if err:
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
+ for e in row_errors:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -2218,7 +2409,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count})
row_norm = row_from_template(row, "imp_temp_header", normalize_header)
warnings_row: List[Dict[str, Any]] = []
- err = validate_row_encabezados_impo_temp(
+ row_errors = validate_row_encabezados_impo_temp(
row_norm,
i,
actualizar=actualizar,
@@ -2250,15 +2441,33 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
parse_date_fn=parse_date,
warnings=warnings_row,
)
- if err:
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
- for w in warnings_row:
- if len(errors_detail) < 500:
- errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
+ for e in row_errors + warnings_row:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -2552,7 +2761,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count})
row_norm = row_from_template(row, "imp_def_header", normalize_header)
warnings_row = []
- err = validate_row_encabezados_impo_def(
+ row_errors = validate_row_encabezados_impo_def(
row_norm,
i,
actualizar=actualizar,
@@ -2584,15 +2793,33 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
parse_date_fn=parse_date,
warnings=warnings_row,
)
- if err:
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
- for w in warnings_row:
- if len(errors_detail) < 500:
- errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
+ for e in row_errors + warnings_row:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -2907,7 +3134,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count})
row_norm = row_from_template(row, "exp_def_header", normalize_header)
warnings_row = []
- err = validate_row_encabezados_expo(
+ row_errors = validate_row_encabezados_expo(
row_norm,
i,
actualizar=actualizar,
@@ -2943,15 +3170,33 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
parse_date_fn=parse_date,
warnings=warnings_row,
)
- if err:
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
- for w in warnings_row:
- if len(errors_detail) < 500:
- errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
+ for e in row_errors + warnings_row:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -3100,7 +3345,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count})
row_norm = row_from_template(row, "cmex_header", normalize_header)
warnings_row = []
- err = validate_row_encabezados_cmex(
+ row_errors = validate_row_encabezados_cmex(
row_norm,
i,
actualizar=actualizar,
@@ -3122,15 +3367,33 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
parse_date_fn=parse_date,
warnings=warnings_row,
)
- if err:
+ blocking = [e for e in row_errors if not e.get("warning")]
+ if blocking:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
- if len(errors_detail) < 500:
- errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
- for w in warnings_row:
- if len(errors_detail) < 500:
- errors_detail.append({"line": w["line"], "col": w.get("col", ""), "msg": w.get("msg", ""), "warning": True})
+ error_lines_list.append(i)
+ for e in blocking:
+ f_err.write(
+ json.dumps(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ }
+ )
+ + "\n"
+ )
+ for e in row_errors + warnings_row:
+ if len(errors_detail) < 5000:
+ errors_detail.append(
+ {
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ }
+ )
processed_rows += 1
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -3210,8 +3473,15 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
if errors:
error_count += 1
- # Write simple JSON error
f_err.write(json.dumps(errors) + "\n")
+ if len(errors_detail) < 5000:
+ errors_detail.append({
+ "line": errors.get("line", i),
+ "col": errors.get("col", ""),
+ "msg": errors.get("msg", ""),
+ "solution": errors.get("solution", ""),
+ "warning": bool(errors.get("warning", False)),
+ })
processed_rows += 1
@@ -3221,7 +3491,6 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
# 4. Store error line numbers in Redis so insert_valid_rows can skip them (any worker)
error_lines_list = []
- errors_detail: List[Dict[str, Any]] = []
try:
if os.path.exists(error_path):
with open(error_path, "r", encoding="utf-8") as f:
@@ -3230,14 +3499,6 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] =
err = json.loads(line)
if "line" in err:
error_lines_list.append(err["line"])
- if len(errors_detail) < 500:
- errors_detail.append(
- {
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- }
- )
except Exception:
pass
common_storage.store_error_lines(effective_job_type, job_id, error_lines_list)
@@ -3260,14 +3521,24 @@ def validate_row_phase_1(
val = row.get(col_name)
if val and str(val).strip():
if parse_decimal(val) is None:
- return {"line": line_num, "col": col_name, "msg": "Debe ser un número decimal válido"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "Debe ser un número decimal válido",
+ "solution": f"Capturar en la columna {col_name} un número decimal válido (ej. 12.34).",
+ }
return None
def check_int(col_name):
val = row.get(col_name)
if val and str(val).strip():
if parse_int(val) is None:
- return {"line": line_num, "col": col_name, "msg": "Debe ser un número entero válido"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "Debe ser un número entero válido",
+ "solution": f"Capturar en la columna {col_name} un número entero válido (solo números, sin letras).",
+ }
return None
def check_date(col_name):
@@ -3279,6 +3550,7 @@ def validate_row_phase_1(
"line": line_num,
"col": col_name,
"msg": f"Formato de fecha inválido ({expected})",
+ "solution": f"Capturar una fecha con el formato esperado ({expected}).",
}
return None
@@ -3286,7 +3558,12 @@ def validate_row_phase_1(
val = row.get(col_name)
if val and str(val).strip():
if parse_weight_unit(val) is None:
- return {"line": line_num, "col": col_name, "msg": "Unidad de peso inválida (ej. KGS, LBS)"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "Unidad de peso inválida (ej. KGS, LBS)",
+ "solution": f"Capturar en la columna {col_name} una unidad de peso válida (ej. KGS o LBS).",
+ }
return None
def check_currency(col_name):
@@ -3296,26 +3573,46 @@ def validate_row_phase_1(
val_norm = normalize_header(val)
# parse_currency returns MANUAL if unknown, so if it wasn't explicitly MANUAL, it's invalid
if parsed_currency.value == "manual" and "MANUAL" not in val_norm:
- return {"line": line_num, "col": col_name, "msg": "Moneda inválida (ej. MN, ME, USD, PESOS)"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "Moneda inválida (ej. MN, ME, USD, PESOS)",
+ "solution": f"Capturar en la columna {col_name} una moneda válida (ej. MN, ME, USD, PESOS) o usar 'MANUAL' si aplica.",
+ }
return None
def check_transport_type(col_name):
val = row.get(col_name)
if val and str(val).strip():
if str(val).strip().lower() not in TRANSPORT_TYPE_VALUES:
- return {"line": line_num, "col": col_name, "msg": "Tipo de transporte inválido (ej. box, truck, container)"}
+ return {
+ "line": line_num,
+ "col": col_name,
+ "msg": "Tipo de transporte inválido (ej. box, truck, container)",
+ "solution": f"Capturar en la columna {col_name} un tipo de transporte válido (ej. box, truck, container).",
+ }
return None
# A. Invoice Header
if target == 'invoice_header':
# 1. Unique ID
if not row.get('NUMERO FACTURA') and not row.get('NUM FACTURA') and not row.get('ID'):
- return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": "NUMERO FACTURA",
+ "msg": "Requerido",
+ "solution": "Capturar el número de Factura en la columna NUMERO FACTURA.",
+ }
# 2. Date Format
date_str = row.get('FECHA FACTURA') or row.get('FECHA')
if not date_str or not str(date_str).strip():
- return {"line": line_num, "col": "FECHA FACTURA", "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": "FECHA FACTURA",
+ "msg": "Requerido",
+ "solution": "Capturar la FECHA FACTURA en la columna FECHA FACTURA.",
+ }
err = check_date('FECHA FACTURA') or check_date('FECHA')
if err: return err
@@ -3353,11 +3650,21 @@ def validate_row_phase_1(
elif target == 'invoice_details':
# 1. Line Number
if not row.get('LINEA') and not row.get('RENGLON') and not row.get('PARTIDA'):
- return {"line": line_num, "col": "LINEA", "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": "LINEA",
+ "msg": "Requerido",
+ "solution": "Capturar el número de línea en la columna LINEA.",
+ }
# 2. Parent Link (Invoice Number)
if not (row.get('NUMERO FACTURA') or row.get('NUM FACTURA') or row.get('FACTURA')):
- return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": "NUMERO FACTURA",
+ "msg": "Requerido",
+ "solution": "Capturar el número de Factura en la columna NUMERO FACTURA.",
+ }
# 3. Numeric Fields
for col in ['PRECIO UNITARIO', 'PRECIOUNITARIO', 'VALOR COMERCIAL', 'VALORCOMERCIAL', 'CANTIDAD']:
@@ -3397,7 +3704,12 @@ def validate_row_strict(
if target == "invoice_header":
if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True):
- return {"line": line_num, "col": "TIPO FACTURA", "msg": "No existe en el catalogo"}
+ return {
+ "line": line_num,
+ "col": "TIPO FACTURA",
+ "msg": "No existe en el catalogo",
+ "solution": "Capturar un TIPO FACTURA válido que exista en el catálogo.",
+ }
err = _validate_client_provider_ref(
validator, ClientProvider, row.get("CLAVE PROVEEDOR"), line_num, "CLAVE PROVEEDOR", required=True
@@ -3467,7 +3779,12 @@ def validate_row_strict(
elif target == "invoice_details":
invoice_number = (row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or "").strip()
if not invoice_number:
- return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": "NUMERO FACTURA",
+ "msg": "Requerido",
+ "solution": "Capturar el número de Factura en la columna NUMERO FACTURA.",
+ }
cache_key = f"{invoice_number}|{inv_type_value}"
if cache_key in invoice_id_cache:
@@ -3485,13 +3802,28 @@ def validate_row_strict(
)
invoice_id_cache[cache_key] = invoice_id
if not invoice_id:
- return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Factura no existe"}
+ return {
+ "line": line_num,
+ "col": "NUMERO FACTURA",
+ "msg": "Factura no existe",
+ "solution": "Capturar un número de Factura que exista en el sistema (verificar NUMERO FACTURA).",
+ }
part_num = (row.get("NUMPARTE") or row.get("NUMERO PARTE") or "").strip()
if not part_num:
- return {"line": line_num, "col": "NUMPARTE", "msg": "Requerido"}
+ return {
+ "line": line_num,
+ "col": "NUMPARTE",
+ "msg": "Requerido",
+ "solution": "Capturar el número de parte en la columna NUMPARTE.",
+ }
if not validator.check_exists(Part, part_num, field_name="part_number"):
- return {"line": line_num, "col": "NUMPARTE", "msg": "No existe en el catalogo"}
+ return {
+ "line": line_num,
+ "col": "NUMPARTE",
+ "msg": "No existe en el catalogo",
+ "solution": "Capturar un número de parte que exista en el catálogo (verificar NUMPARTE).",
+ }
return None
@@ -3870,13 +4202,19 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
"line": i,
"invoice": invoice_number or "(vacío)",
"reason": "Factura de exportación no encontrada.",
+ "solution": "Capturar en el CSV un número de Factura de exportación que exista en el sistema (columna NUMERO FACTURA).",
})
continue
invoice_id = invoice_id_by_number[invoice_number]
line_number_val = parse_int(linea_factura)
if line_number_val is None:
skipped_invalid += 1
- skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA FACTURA debe ser numérico."})
+ skipped_details.append({
+ "line": i,
+ "invoice": invoice_number,
+ "reason": "LINEA FACTURA debe ser numérico.",
+ "solution": "Capturar en el CSV la LINEA FACTURA solo con números enteros.",
+ })
continue
line_item = (
session.query(LineItem)
@@ -3906,6 +4244,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
"line": i,
"invoice": invoice_number,
"reason": f"Partida línea {linea_factura} no existe en la factura. Partidas existentes: {existing_str}.",
+ "solution": "Revisar que la partida (LINEA FACTURA) exista en la factura indicada en el CSV.",
})
continue
@@ -3922,7 +4261,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
row_num = parse_int(linea_serie)
if row_num is None:
skipped_invalid += 1
- skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA SERIE debe ser numérico."})
+ skipped_details.append({
+ "line": i,
+ "invoice": invoice_number,
+ "reason": "LINEA SERIE debe ser numérico.",
+ "solution": "Capturar en el CSV la LINEA SERIE solo con números enteros.",
+ })
continue
existing_serie = (
@@ -3944,7 +4288,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
updated_count += 1
else:
skipped_invalid += 1
- skipped_details.append({"line": i, "invoice": invoice_number, "reason": "Serie ya existe (use actualizar)."})
+ skipped_details.append({
+ "line": i,
+ "invoice": invoice_number,
+ "reason": "Serie ya existe (use actualizar).",
+ "solution": "La serie ya existe: activa la opción de Actualizar o ajusta tu carga para que actualice en lugar de crear.",
+ })
else:
new_serie = Serie(
tenant_id=tenant_id,
@@ -4109,7 +4458,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
if i in error_lines:
continue
row_norm = row_from_template(row, series_row_template_id, normalize_header)
- err = validate_row_series_impo_def(
+ row_errors = validate_row_series_impo_def(
row_norm,
i,
actualizar=actualizar,
@@ -4124,13 +4473,16 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
warnings=None,
catalog_label="Compras Mexicanas" if is_cmex_series else "Importación Definitiva",
)
- if err:
+ _blocking = [e for e in row_errors if not e.get("warning")]
+ if _blocking:
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "invoice": (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip(),
- "reason": err.get("msg", ""),
- })
+ for blocking in _blocking:
+ skipped_details.append({
+ "line": i,
+ "invoice": (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip(),
+ "reason": blocking.get("msg", ""),
+ "solution": blocking.get("solution", ""),
+ })
continue
data = row_to_series_normalized_def(row_norm)
@@ -4145,7 +4497,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
line_number_val = parse_int(linea_factura)
if line_number_val is None:
skipped_invalid += 1
- skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA FACTURA debe ser numérico."})
+ skipped_details.append({
+ "line": i,
+ "invoice": invoice_number,
+ "reason": "LINEA FACTURA debe ser numérico.",
+ "solution": "Capturar en el CSV la LINEA FACTURA solo con números enteros.",
+ })
continue
line_item = (
session.query(LineItem)
@@ -4175,6 +4532,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
"line": i,
"invoice": invoice_number,
"reason": f"Partida línea {linea_factura} no existe en la factura. Partidas existentes en la factura: {existing_str}.",
+ "solution": "Revisar que la partida (línea) exista en la factura indicada y corregir el valor en el CSV.",
})
continue
@@ -4191,7 +4549,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
row_num = parse_int(linea_serie)
if row_num is None:
skipped_invalid += 1
- skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA SERIE debe ser numérico."})
+ skipped_details.append({
+ "line": i,
+ "invoice": invoice_number,
+ "reason": "LINEA SERIE debe ser numérico.",
+ "solution": "Capturar en el CSV la LINEA SERIE solo con números enteros.",
+ })
continue
existing_serie = (
@@ -4213,7 +4576,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
updated_count += 1
else:
skipped_invalid += 1
- skipped_details.append({"line": i, "invoice": invoice_number, "reason": "Serie ya existe (use actualizar)."})
+ skipped_details.append({
+ "line": i,
+ "invoice": invoice_number,
+ "reason": "Serie ya existe (use actualizar).",
+ "solution": "Si la serie ya existe, usa el modo Actualizar para que el archivo haga update.",
+ })
else:
new_serie = Serie(
tenant_id=tenant_id,
@@ -4354,7 +4722,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
if i in error_lines:
continue
row_norm = row_from_template(row, "imp_temp_series", normalize_header)
- err = validate_row_series_impo_temp(
+ row_errors = validate_row_series_impo_temp(
row_norm,
i,
actualizar=actualizar,
@@ -4366,13 +4734,16 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
existing_series_data=existing_series_data,
warnings=None,
)
- if err:
+ _blocking = [e for e in row_errors if not e.get("warning")]
+ if _blocking:
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "invoice": (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip(),
- "reason": err.get("msg", ""),
- })
+ for blocking in _blocking:
+ skipped_details.append({
+ "line": i,
+ "invoice": (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip(),
+ "reason": blocking.get("msg", ""),
+ "solution": blocking.get("solution", ""),
+ })
continue
data = row_to_series_normalized(row_norm)
@@ -4387,7 +4758,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
line_number_val = parse_int(linea_factura)
if line_number_val is None:
skipped_invalid += 1
- skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA FACTURA debe ser numérico."})
+ skipped_details.append({
+ "line": i,
+ "invoice": invoice_number,
+ "reason": "LINEA FACTURA debe ser numérico.",
+ "solution": "Capturar en el CSV la LINEA FACTURA solo con números enteros.",
+ })
continue
line_item = (
session.query(LineItem)
@@ -4405,6 +4781,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
"line": i,
"invoice": invoice_number,
"reason": f"Partida línea {linea_factura} no existe en la factura.",
+ "solution": "Revisar que la partida (línea) exista en la factura indicada.",
})
continue
@@ -4421,7 +4798,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
row_num = parse_int(linea_serie)
if row_num is None:
skipped_invalid += 1
- skipped_details.append({"line": i, "invoice": invoice_number, "reason": "LINEA SERIE debe ser numérico."})
+ skipped_details.append({
+ "line": i,
+ "invoice": invoice_number,
+ "reason": "LINEA SERIE debe ser numérico.",
+ "solution": "Capturar en el CSV la LINEA SERIE solo con números enteros.",
+ })
continue
existing_serie = (
@@ -4443,7 +4825,12 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
updated_count += 1
else:
skipped_invalid += 1
- skipped_details.append({"line": i, "invoice": invoice_number, "reason": "Serie ya existe (use actualizar)."})
+ skipped_details.append({
+ "line": i,
+ "invoice": invoice_number,
+ "reason": "Serie ya existe (use actualizar).",
+ "solution": "Si la serie ya existe, usa el modo Actualizar para que el sistema haga update.",
+ })
else:
new_serie = Serie(
tenant_id=tenant_id,
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py
index 5f6ba6b1..320924d3 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_cmex.py
@@ -189,81 +189,50 @@ def validate_row_encabezados_cmex(
date_format: Optional[str] = None,
parse_date_fn=None,
warnings: Optional[List[Dict[str, Any]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de Encabezados de Compras Mexicanas.
Clarion: VALIDA_TODA_FAC_COM_MEX (factura nueva o no actualizar) vs VALIDA_PARCIAL_FAC_COM_MEX (actualizar existente).
Siempre ejecuta VALIDACIONES_FAC_COM_MEX (longitud C, tipo cambio, catálogos G/H/I/K, transporte M/N, moneda O/P, incoterm V, tipo peso Y).
"""
+ errors: List[Dict[str, Any]] = []
+
factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not factura:
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones.",
- )
+ ))
+ return errors
if invoice_processed_by_number.get(factura.strip(), False):
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
"Error: (Celda C{}) El Número de Factura: {} ya existe y está Actualizada, no se puede hacer cambios.".format(
line_num, factura
),
- )
+ ))
+ return errors
if actualizar and factura.strip() not in invoice_exists_by_number:
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) Factura de importación {} no existe (modo Actualizar).".format(factura),
- )
+ ))
+ return errors
use_partial = actualizar and invoice_exists_by_number.get(factura.strip(), False)
if not use_partial:
err = _validaciones_obligatorios_toda_cmex(row, line_num, actualizar)
if err:
- return err
-
- err = _validaciones_factura_longitud(row, line_num)
- if err:
- return err
-
- err = _validaciones_transporte_cmex(row, line_num)
- if err:
- return err
+ errors.append(err)
has_partidas = invoice_has_partidas_by_number.get(factura.strip(), False) if invoice_has_partidas_by_number else False
existing_moneda = existing_tipo_moneda_by_number.get(factura.strip()) if existing_tipo_moneda_by_number else None
- err = _validaciones_moneda(
- row,
- line_num,
- valid_currency_codes or set(),
- has_partidas if use_partial else None,
- existing_moneda if use_partial else None,
- )
- if err:
- return err
-
- err = _validaciones_tipo_peso(row, line_num)
- if err:
- return err
-
- err = _validaciones_catalogos_cmex(
- row,
- line_num,
- valid_provider_ids or set(),
- valid_sold_to_ids or set(),
- valid_shipped_to_ids or set(),
- valid_provider_short_names or set(),
- valid_sold_to_short_names or set(),
- valid_shipped_to_short_names or set(),
- valid_transporter_keys or set(),
- valid_incoterms or set(),
- )
- if err:
- return err
invoice_date_parsed = None
if parse_date_fn:
@@ -271,10 +240,34 @@ def validate_row_encabezados_cmex(
if date_str:
invoice_date_parsed = parse_date_fn(date_str, date_format)
- err = _validaciones_tipo_cambio(
- row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
- )
- if err:
- return err
+ for err in [
+ _validaciones_factura_longitud(row, line_num),
+ _validaciones_transporte_cmex(row, line_num),
+ _validaciones_moneda(
+ row,
+ line_num,
+ valid_currency_codes or set(),
+ has_partidas if use_partial else None,
+ existing_moneda if use_partial else None,
+ ),
+ _validaciones_tipo_peso(row, line_num),
+ _validaciones_catalogos_cmex(
+ row,
+ line_num,
+ valid_provider_ids or set(),
+ valid_sold_to_ids or set(),
+ valid_shipped_to_ids or set(),
+ valid_provider_short_names or set(),
+ valid_sold_to_short_names or set(),
+ valid_shipped_to_short_names or set(),
+ valid_transporter_keys or set(),
+ valid_incoterms or set(),
+ ),
+ _validaciones_tipo_cambio(
+ row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
+ ),
+ ]:
+ if err:
+ errors.append(err)
- return None
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py
index d72ccdf7..3e83be18 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py
@@ -335,39 +335,45 @@ def validate_row_encabezados_expo(
date_format: Optional[str] = None,
parse_date_fn=None,
warnings: Optional[List[Dict[str, Any]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de Encabezados de Exportación (Expo Def) o Cambio de Régimen.
Clarion: VALIDA_TODA_FAC_EXPO vs VALIDA_PARCIAL_FAC_EXPO.
"""
+ errors: List[Dict[str, Any]] = []
+
factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not factura:
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones.",
- )
+ ))
+ return errors
if invoice_processed_by_number.get(factura.strip(), False):
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
f"Error: (Celda C{line_num}) El Número de Factura: {factura} ya existe y está Actualizada, no se puede hacer cambios.",
- )
+ ))
+ return errors
if actualizar and factura.strip() not in invoice_exists_by_number:
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) Factura de Exportación no existe (modo Actualizar).",
- )
+ ))
+ return errors
if actualizar and (invoice_in_report_by_number or {}).get(factura.strip(), False):
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) Factura de Exportación Rep. La factura está en reporte y no se puede actualizar.",
- )
+ ))
+ return errors
use_partial = actualizar and invoice_exists_by_number.get(factura.strip(), False)
tiene_pedimento = bool(_get(row, "PEDIMENTO"))
@@ -375,7 +381,7 @@ def validate_row_encabezados_expo(
if not use_partial:
err = _validaciones_obligatorios_toda_expo(row, line_num, tiene_pedimento)
if err:
- return err
+ errors.append(err)
invoice_date_parsed = None
if parse_date_fn:
@@ -383,82 +389,60 @@ def validate_row_encabezados_expo(
if date_str:
invoice_date_parsed = parse_date_fn(date_str, date_format)
- err = _validaciones_pedimento_remesa_expo(
- row,
- line_num,
- cambio_regimen,
- pedimento_data_by_key or {},
- remesa_por_pedimento_bd,
- remesa_por_pedimento_csv,
- autonumerar_remesas,
- invoice_date_parsed,
- recalcular_fecha_pedimentos,
- )
- if err:
- return err
-
- err = _validaciones_factura_longitud(row, line_num)
- if err:
- return err
- err = _validaciones_regimen_expo(row, line_num, cambio_regimen)
- if err:
- return err
-
row_transport = _normalize_tipo_transporte_ferro(row)
- err = _validaciones_transporte(row_transport, line_num)
- if err:
- return err
-
has_partidas = invoice_has_partidas_by_number.get(factura.strip(), False) if invoice_has_partidas_by_number else False
existing_moneda = existing_tipo_moneda_by_number.get(factura.strip()) if existing_tipo_moneda_by_number else None
- err = _validaciones_moneda(
- row,
- line_num,
- valid_currency_codes or set(),
- has_partidas if use_partial else None,
- existing_moneda if use_partial else None,
- )
- if err:
- return err
- err = _validaciones_tipo_peso(row, line_num)
- if err:
- return err
- err = _validaciones_catalogos(
- row,
- line_num,
- valid_provider_ids or set(),
- valid_sold_to_ids or set(),
- valid_shipped_to_ids or set(),
- valid_provider_short_names or set(),
- valid_sold_to_short_names or set(),
- valid_shipped_to_short_names or set(),
- valid_broker_ids or set(),
- valid_broker_claves or set(),
- valid_transporter_keys or set(),
- valid_incoterms or set(),
- valid_aduana_codes or set(),
- )
- if err:
- return err
+ for err in [
+ _validaciones_pedimento_remesa_expo(
+ row,
+ line_num,
+ cambio_regimen,
+ pedimento_data_by_key or {},
+ remesa_por_pedimento_bd,
+ remesa_por_pedimento_csv,
+ autonumerar_remesas,
+ invoice_date_parsed,
+ recalcular_fecha_pedimentos,
+ ),
+ _validaciones_factura_longitud(row, line_num),
+ _validaciones_regimen_expo(row, line_num, cambio_regimen),
+ _validaciones_transporte(row_transport, line_num),
+ _validaciones_moneda(
+ row,
+ line_num,
+ valid_currency_codes or set(),
+ has_partidas if use_partial else None,
+ existing_moneda if use_partial else None,
+ ),
+ _validaciones_tipo_peso(row, line_num),
+ _validaciones_catalogos(
+ row,
+ line_num,
+ valid_provider_ids or set(),
+ valid_sold_to_ids or set(),
+ valid_shipped_to_ids or set(),
+ valid_provider_short_names or set(),
+ valid_sold_to_short_names or set(),
+ valid_shipped_to_short_names or set(),
+ valid_broker_ids or set(),
+ valid_broker_claves or set(),
+ valid_transporter_keys or set(),
+ valid_incoterms or set(),
+ valid_aduana_codes or set(),
+ ),
+ _validaciones_manifiesto(row, line_num, valid_manifiesto_codes or set()),
+ _validaciones_enviado_por(
+ row,
+ line_num,
+ valid_enviado_por_ids or set(),
+ valid_enviado_por_short_names or set(),
+ ),
+ _validaciones_tipo_cambio(
+ row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
+ ),
+ ]:
+ if err:
+ errors.append(err)
- err = _validaciones_manifiesto(row, line_num, valid_manifiesto_codes or set())
- if err:
- return err
-
- err = _validaciones_enviado_por(
- row,
- line_num,
- valid_enviado_por_ids or set(),
- valid_enviado_por_short_names or set(),
- )
- if err:
- return err
-
- err = _validaciones_tipo_cambio(
- row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
- )
- if err:
- return err
-
- return None
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py
index 7c374313..4c3ef868 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py
@@ -251,34 +251,39 @@ def validate_row_encabezados_impo_def(
date_format: Optional[str] = None,
parse_date_fn=None,
warnings: Optional[List[Dict[str, Any]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de Encabezados de Importación Definitiva.
Clarion: VALIDA_TODA_FACIMPO_DEF vs VALIDA_PARCIAL_FACIMPO_DEF.
Reutiliza de encabezados_impo_temp las validaciones de catálogos (claves y short names),
transporte, moneda, tipo peso y tipo cambio.
"""
+ errors: List[Dict[str, Any]] = []
+
factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not factura:
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
"Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones.",
- )
+ ))
+ return errors
if invoice_processed_by_number.get(factura.strip(), False):
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
f"Error: (Celda C{line_num}) El Número de Factura: {factura} ya existe y está Actualizada, no se puede hacer cambios a las facturas actualizadas.",
- )
+ ))
+ return errors
if actualizar and factura.strip() not in invoice_exists_by_number:
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
f"Error: (Col.C) Factura de importación Definitiva no existe (modo Actualizar).",
- )
+ ))
+ return errors
use_partial = actualizar and invoice_exists_by_number.get(factura.strip(), False)
tiene_pedimento = bool(_get(row, "PEDIMENTO"))
@@ -286,7 +291,7 @@ def validate_row_encabezados_impo_def(
if not use_partial:
err = _validaciones_obligatorios_toda_def(row, line_num, tiene_pedimento)
if err:
- return err
+ errors.append(err)
invoice_date_parsed = None
if parse_date_fn:
@@ -294,70 +299,54 @@ def validate_row_encabezados_impo_def(
if date_str:
invoice_date_parsed = parse_date_fn(date_str, date_format)
- err = _validaciones_pedimento_remesa_def(
- row,
- line_num,
- pedimento_data_by_key,
- remesa_por_pedimento_bd,
- remesa_por_pedimento_csv,
- autonumerar_remesas,
- control_remesa,
- remesa_inicio,
- remesa_fin,
- invoice_date_parsed,
- )
- if err:
- return err
-
- err = _validaciones_factura_longitud(row, line_num)
- if err:
- return err
- err = _validaciones_regimen_imd(row, line_num)
- if err:
- return err
-
- # Normalizar FERRO BARCAZA -> FERROBARCAZA para reutilizar validación TEM
row_transport = _normalize_tipo_transporte_ferro(row)
- err = _validaciones_transporte(row_transport, line_num)
- if err:
- return err
-
has_partidas = invoice_has_partidas_by_number.get(factura.strip(), False) if invoice_has_partidas_by_number else False
existing_moneda = existing_tipo_moneda_by_number.get(factura.strip()) if existing_tipo_moneda_by_number else None
- err = _validaciones_moneda(
- row,
- line_num,
- valid_currency_codes or set(),
- has_partidas if use_partial else None,
- existing_moneda if use_partial else None,
- )
- if err:
- return err
- err = _validaciones_tipo_peso(row, line_num)
- if err:
- return err
- # Mismas claves y short names que TEM (Proveedor, Vendido A, Enviado A, Agente, Transportista, Incoterm, Aduana)
- err = _validaciones_catalogos(
- row,
- line_num,
- valid_provider_ids or set(),
- valid_sold_to_ids or set(),
- valid_shipped_to_ids or set(),
- valid_provider_short_names or set(),
- valid_sold_to_short_names or set(),
- valid_shipped_to_short_names or set(),
- valid_broker_ids or set(),
- valid_broker_claves or set(),
- valid_transporter_keys or set(),
- valid_incoterms or set(),
- valid_aduana_codes or set(),
- )
- if err:
- return err
- err = _validaciones_tipo_cambio(
- row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
- )
- if err:
- return err
- return None
+ for err in [
+ _validaciones_pedimento_remesa_def(
+ row,
+ line_num,
+ pedimento_data_by_key,
+ remesa_por_pedimento_bd,
+ remesa_por_pedimento_csv,
+ autonumerar_remesas,
+ control_remesa,
+ remesa_inicio,
+ remesa_fin,
+ invoice_date_parsed,
+ ),
+ _validaciones_factura_longitud(row, line_num),
+ _validaciones_regimen_imd(row, line_num),
+ _validaciones_transporte(row_transport, line_num),
+ _validaciones_moneda(
+ row,
+ line_num,
+ valid_currency_codes or set(),
+ has_partidas if use_partial else None,
+ existing_moneda if use_partial else None,
+ ),
+ _validaciones_tipo_peso(row, line_num),
+ _validaciones_catalogos(
+ row,
+ line_num,
+ valid_provider_ids or set(),
+ valid_sold_to_ids or set(),
+ valid_shipped_to_ids or set(),
+ valid_provider_short_names or set(),
+ valid_sold_to_short_names or set(),
+ valid_shipped_to_short_names or set(),
+ valid_broker_ids or set(),
+ valid_broker_claves or set(),
+ valid_transporter_keys or set(),
+ valid_incoterms or set(),
+ valid_aduana_codes or set(),
+ ),
+ _validaciones_tipo_cambio(
+ row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
+ ),
+ ]:
+ if err:
+ errors.append(err)
+
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py
index 5248c059..f912caa3 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py
@@ -97,7 +97,22 @@ def parse_pedimento_col_a(pedimento_str: str) -> Optional[Tuple[str, str, str]]:
def _pedimento_key_from_parsed(customs_office: str, license_val: str, pedimento_number: str) -> str:
"""Clave para lookup: CC-LLLL-NNNNNNN (solo primeros 2 dígitos de aduana)."""
- co = (customs_office or "").strip()[:2]
+ co = (customs_office or "").strip()
+
+ # La aduana en el catálogo puede venir con 3 dígitos (String(3)).
+ # El CSV de encabezados normalmente usa 2 dígitos, por lo que normalizamos:
+ # - 2 dígitos: se usan tal cual
+ # - 3 dígitos:
+ # - si inicia con 0, usar los últimos 2 para no "perder" el 0 relevante (ej. 007 -> 07)
+ # - si no inicia con 0, usar los primeros 2
+ # - 1 dígito: left-pad a 2
+ if len(co) == 3:
+ co = co[1:3] if co.startswith("0") else co[:2]
+ elif len(co) == 1:
+ co = co.zfill(2)
+ else:
+ co = co[:2]
+
return f"{co}-{license_val}-{pedimento_number}"
@@ -540,28 +555,33 @@ def validate_row_encabezados_impo_temp(
date_format: Optional[str] = None,
parse_date_fn=None,
warnings: Optional[List[Dict[str, Any]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Encabezados de Importación Temporal.
Clarion: VALIDA_TODA_FACIMPO_TEM vs VALIDA_PARCIAL_FACIMPO_TEM según actualizar y si la factura existe.
"""
+ errors: List[Dict[str, Any]] = []
+
factura = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not factura:
- return _err(line_num, "NUMERO FACTURA", "Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones.")
+ errors.append(_err(line_num, "NUMERO FACTURA", "Error: (Col.C) La columna de Número de Factura está vacía y no se pueden hacer las validaciones."))
+ return errors
if invoice_processed_by_number.get(factura.strip(), False):
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
f"Error: (Celda C{line_num}) El Número de Factura: {factura} ya existe y está Actualizada, no se puede hacer cambios.",
- )
+ ))
+ return errors
if actualizar and factura.strip() not in invoice_exists_by_number:
- return _err(
+ errors.append(_err(
line_num,
"NUMERO FACTURA",
f"Error: (Col.C) Factura de importación {factura} no existe (modo Actualizar).",
- )
+ ))
+ return errors
use_partial = actualizar and invoice_exists_by_number.get(factura.strip(), False)
tiene_pedimento = bool(_get(row, "PEDIMENTO"))
@@ -569,7 +589,7 @@ def validate_row_encabezados_impo_temp(
if not use_partial:
err = _validaciones_obligatorios_toda(row, line_num, tiene_pedimento)
if err:
- return err
+ errors.append(err)
invoice_date_parsed = None
if parse_date_fn:
@@ -577,70 +597,61 @@ def validate_row_encabezados_impo_temp(
if date_str:
invoice_date_parsed = parse_date_fn(date_str, date_format)
- err = _validaciones_pedimento_remesa(
- row,
- line_num,
- pedimento_data_by_key,
- remesa_por_pedimento_bd,
- remesa_por_pedimento_csv,
- autonumerar_remesas,
- control_remesa,
- remesa_inicio,
- remesa_fin,
- date_format,
- invoice_date_parsed,
- )
- if err:
- return err
-
- err = _validaciones_factura_longitud(row, line_num)
- if err:
- return err
- err = _validaciones_regimen(row, line_num)
- if err:
- return err
- err = _validaciones_transporte(row, line_num)
- if err:
- return err
+ for err in [
+ _validaciones_pedimento_remesa(
+ row,
+ line_num,
+ pedimento_data_by_key,
+ remesa_por_pedimento_bd,
+ remesa_por_pedimento_csv,
+ autonumerar_remesas,
+ control_remesa,
+ remesa_inicio,
+ remesa_fin,
+ date_format,
+ invoice_date_parsed,
+ ),
+ _validaciones_factura_longitud(row, line_num),
+ _validaciones_regimen(row, line_num),
+ _validaciones_transporte(row, line_num),
+ ]:
+ if err:
+ errors.append(err)
has_partidas = invoice_has_partidas_by_number.get(factura.strip(), False) if invoice_has_partidas_by_number else False
existing_moneda = existing_tipo_moneda_by_number.get(factura.strip()) if existing_tipo_moneda_by_number else None
- err = _validaciones_moneda(
- row,
- line_num,
- valid_currency_codes or set(),
- has_partidas if use_partial else None,
- existing_moneda if use_partial else None,
- )
- if err:
- return err
- err = _validaciones_tipo_peso(row, line_num)
- if err:
- return err
- err = _validaciones_catalogos(
- row,
- line_num,
- valid_provider_ids or set(),
- valid_sold_to_ids or set(),
- valid_shipped_to_ids or set(),
- valid_provider_short_names or set(),
- valid_sold_to_short_names or set(),
- valid_shipped_to_short_names or set(),
- valid_broker_ids or set(),
- valid_broker_claves or set(),
- valid_transporter_keys or set(),
- valid_incoterms or set(),
- valid_aduana_codes or set(),
- )
- if err:
- return err
- err = _validaciones_tipo_cambio(
- row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
- )
- if err:
- return err
+ for err in [
+ _validaciones_moneda(
+ row,
+ line_num,
+ valid_currency_codes or set(),
+ has_partidas if use_partial else None,
+ existing_moneda if use_partial else None,
+ ),
+ _validaciones_tipo_peso(row, line_num),
+ _validaciones_catalogos(
+ row,
+ line_num,
+ valid_provider_ids or set(),
+ valid_sold_to_ids or set(),
+ valid_shipped_to_ids or set(),
+ valid_provider_short_names or set(),
+ valid_sold_to_short_names or set(),
+ valid_shipped_to_short_names or set(),
+ valid_broker_ids or set(),
+ valid_broker_claves or set(),
+ valid_transporter_keys or set(),
+ valid_incoterms or set(),
+ valid_aduana_codes or set(),
+ ),
+ _validaciones_tipo_cambio(
+ row, line_num, invoice_date_parsed, exchange_rate_by_date or {}, warnings
+ ),
+ ]:
+ if err:
+ errors.append(err)
- return None
+ return errors
def row_to_transport_type_clarion(val: Optional[str]) -> Optional[str]:
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py
index b4b62b3d..c3427978 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py
@@ -470,89 +470,70 @@ def validate_row_partidas_expo(
line_exists_def: Set[Tuple[int, str]],
rfc_exception_egm: bool = False,
validar_decimales_pza: bool = False,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de Partidas de Exportación Definitiva (o Cambio de Régimen).
Clarion: VALIDA_TODA_PAR_EXPO vs VALIDA_PARCIAL_PAR_EXPO según autonumerar, actualizar y si la partida existe.
"""
+ errors: List[Dict[str, Any]] = []
+
err = _check_factura_expo_vacia(row, line_num)
if err:
- return err
+ errors.append(err)
+ return errors
factura_expo = _get(row, "NUMERO FACTURA EXPO", "NUMERO FACTURA EXPO.", "NUM FACTURA EXPO", "FACTURA EXPO")
if not factura_expo:
- return _err(line_num, "NUMERO FACTURA EXPO", "Requerido")
+ errors.append(_err(line_num, "NUMERO FACTURA EXPO", "Requerido"))
+ return errors
- err = _check_factura_expo_existe(factura_expo, line_num, invoice_id_by_number)
- if err:
- return err
+ for err in [
+ _check_factura_expo_existe(factura_expo, line_num, invoice_id_by_number),
+ _check_factura_expo_no_actualizada(factura_expo, line_num, invoice_processed_by_number, rfc_exception_updated),
+ _check_linea_expo_si_no_autonumerar(row, line_num, autonumerar),
+ _check_levantar_subpartidas_expo(row, line_num, levantar_subpartidas, rfc_exception_egm),
+ ]:
+ if err:
+ errors.append(err)
- err = _check_factura_expo_no_actualizada(
- factura_expo, line_num, invoice_processed_by_number, rfc_exception_updated
- )
- if err:
- return err
-
- err = _check_linea_expo_si_no_autonumerar(row, line_num, autonumerar)
- if err:
- return err
-
- err = _check_levantar_subpartidas_expo(row, line_num, levantar_subpartidas, rfc_exception_egm)
- if err:
- return err
+ if errors:
+ return errors
linea_expo = _get(row, "LINEA EXPO", "LINEA EXPO.", "RENGLON EXPO")
existing_lines = existing_line_keys_by_invoice.get(factura_expo.strip(), set())
partida_existe = bool(linea_expo and linea_expo in existing_lines)
use_partial = actualizar and not autonumerar and partida_existe
+ _expo_kwargs = dict(
+ valid_uom_codes=valid_uom_codes,
+ valid_bulks_codes=valid_bulks_codes,
+ valid_payment_methods=valid_payment_methods,
+ valid_fraction_ame=valid_fraction_ame,
+ valid_part_numbers=valid_part_numbers,
+ factura_impo_tem_by_number=factura_impo_tem_by_number,
+ factura_impo_def_by_number=factura_impo_def_by_number,
+ line_exists_tem=line_exists_tem,
+ line_exists_def=line_exists_def,
+ validar_decimales_pza=validar_decimales_pza,
+ )
+
if use_partial:
- return _validaciones_par_expo(
- row,
- line_num,
- factura_expo,
- valid_uom_codes=valid_uom_codes,
- valid_bulks_codes=valid_bulks_codes,
- valid_payment_methods=valid_payment_methods,
- valid_fraction_ame=valid_fraction_ame,
- valid_part_numbers=valid_part_numbers,
- factura_impo_tem_by_number=factura_impo_tem_by_number,
- factura_impo_def_by_number=factura_impo_def_by_number,
- line_exists_tem=line_exists_tem,
- line_exists_def=line_exists_def,
- validar_decimales_pza=validar_decimales_pza,
- )
+ err = _validaciones_par_expo(row, line_num, factura_expo, **_expo_kwargs)
+ if err:
+ errors.append(err)
else:
err = _valida_toda_obligatorios_expo(row, line_num, levantar_subpartidas, rfc_exception_egm)
if err:
- return err
+ errors.append(err)
if levantar_subpartidas:
- err = _valida_subpartidas_duplicados_expo(
- factura_expo, linea_expo or "", line_num, line_counts_csv
- )
- if err:
- return err
- err = _valida_subpartida_tiene_principal_expo(
- row, line_num, factura_expo,
- partidas_principales_csv, partidas_principales_bd, rfc_exception_egm
- )
- if err:
- return err
- err = _valida_subpartida_linea_principal_no_cero_expo(row, line_num, rfc_exception_egm)
- if err:
- return err
- return _validaciones_par_expo(
- row,
- line_num,
- factura_expo,
- valid_uom_codes=valid_uom_codes,
- valid_bulks_codes=valid_bulks_codes,
- valid_payment_methods=valid_payment_methods,
- valid_fraction_ame=valid_fraction_ame,
- valid_part_numbers=valid_part_numbers,
- factura_impo_tem_by_number=factura_impo_tem_by_number,
- factura_impo_def_by_number=factura_impo_def_by_number,
- line_exists_tem=line_exists_tem,
- line_exists_def=line_exists_def,
- validar_decimales_pza=validar_decimales_pza,
- )
+ for err in [
+ _valida_subpartidas_duplicados_expo(factura_expo, linea_expo or "", line_num, line_counts_csv),
+ _valida_subpartida_tiene_principal_expo(row, line_num, factura_expo, partidas_principales_csv, partidas_principales_bd, rfc_exception_egm),
+ _valida_subpartida_linea_principal_no_cero_expo(row, line_num, rfc_exception_egm),
+ ]:
+ if err:
+ errors.append(err)
+ err = _validaciones_par_expo(row, line_num, factura_expo, **_expo_kwargs)
+ if err:
+ errors.append(err)
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_def.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_def.py
index 07be021f..24ffe293 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_def.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_def.py
@@ -75,37 +75,35 @@ def validate_row_partidas_impo_def(
valid_part_numbers: Optional[Set[str]],
warnings: Optional[List[Dict[str, Any]]] = None,
catalog_label: str = "Importación Definitiva",
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de Partidas de Importación Definitiva.
Clarion: VALIDA_TODA_PARIMPO_DEF vs VALIDA_PARCIAL_PARIMPO_DEF según autonumerar, actualizar y si la partida existe.
Reutiliza todo de partidas_impo_temp salvo el check de factura existente (mensaje DEF).
"""
+ errors: List[Dict[str, Any]] = []
+
err = _check_factura_vacia(row, line_num)
if err:
- return err
+ errors.append(err)
+ return errors
invoice_number = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not invoice_number:
- return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
+ errors.append({"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"})
+ return errors
- err = _check_factura_existe_def(invoice_number, line_num, invoice_id_by_number, catalog_label)
- if err:
- return err
+ for err in [
+ _check_factura_existe_def(invoice_number, line_num, invoice_id_by_number, catalog_label),
+ _check_factura_no_actualizada(invoice_number, line_num, invoice_processed_by_number, rfc_exception_updated),
+ _check_linea_si_no_autonumerar(row, line_num, autonumerar),
+ _check_levantar_subpartidas_uv(row, line_num, levantar_subpartidas),
+ ]:
+ if err:
+ errors.append(err)
- err = _check_factura_no_actualizada(
- invoice_number, line_num, invoice_processed_by_number, rfc_exception_updated
- )
- if err:
- return err
-
- err = _check_linea_si_no_autonumerar(row, line_num, autonumerar)
- if err:
- return err
-
- err = _check_levantar_subpartidas_uv(row, line_num, levantar_subpartidas)
- if err:
- return err
+ if errors:
+ return errors
_warn_apostrofes_num_parte(row, line_num, warnings)
@@ -114,86 +112,60 @@ def validate_row_partidas_impo_def(
partida_existe = bool(linea and linea in existing_lines)
use_partial = actualizar and not autonumerar and partida_existe
+ _validaciones_kwargs = dict(
+ valid_class_codes=valid_class_codes,
+ class_um_by_code=class_um_by_code,
+ class_fraction_by_code=class_fraction_by_code,
+ class_desc_es_by_code=class_desc_es_by_code,
+ class_desc_en_by_code=class_desc_en_by_code,
+ valid_uom_codes=valid_uom_codes,
+ valid_bulks_codes=valid_bulks_codes,
+ valid_country_keys=valid_country_keys,
+ valid_fraction_ame=valid_fraction_ame,
+ valid_payment_methods=valid_payment_methods,
+ valid_valuation_methods=valid_valuation_methods,
+ authorized_sectors=authorized_sectors,
+ company_has_prosec=company_has_prosec,
+ validar_decimales_pza=validar_decimales_pza,
+ rfc_exception_num_parte=rfc_exception_num_parte,
+ invoice_number=invoice_number,
+ )
+
if use_partial:
- return _validaciones_parimpo_tem(
- row,
- line_num,
- valid_class_codes=valid_class_codes,
- class_um_by_code=class_um_by_code,
- class_fraction_by_code=class_fraction_by_code,
- class_desc_es_by_code=class_desc_es_by_code,
- class_desc_en_by_code=class_desc_en_by_code,
- valid_uom_codes=valid_uom_codes,
- valid_bulks_codes=valid_bulks_codes,
- valid_country_keys=valid_country_keys,
- valid_fraction_ame=valid_fraction_ame,
- valid_payment_methods=valid_payment_methods,
- valid_valuation_methods=valid_valuation_methods,
- authorized_sectors=authorized_sectors,
- company_has_prosec=company_has_prosec,
- validar_decimales_pza=validar_decimales_pza,
- rfc_exception_num_parte=rfc_exception_num_parte,
- invoice_number=invoice_number,
- )
- else:
- err = _valida_toda_obligatorios(
- row, line_num, levantar_subpartidas, calcular_costo_en_base_a_total
- )
+ err = _validaciones_parimpo_tem(row, line_num, **_validaciones_kwargs)
if err:
- return err
- # Importación Definitiva / Compras Mexicanas: NUM. PARTE es obligatorio en todas las partidas (el insert lo exige).
+ errors.append(err)
+ else:
+ err = _valida_toda_obligatorios(row, line_num, levantar_subpartidas, calcular_costo_en_base_a_total)
+ if err:
+ errors.append(err)
num_parte = _get(row, "NUM. PARTE", "NUMPARTE", "NUMERO PARTE", "NUM PARTE")
if not (num_parte and str(num_parte).strip()):
- return {
+ errors.append({
"line": line_num,
"col": "NUM. PARTE",
"msg": f"NUM. PARTE: Requerido (obligatorio para partidas de {catalog_label}).",
- }
+ })
err = _valida_toda_numericos(row, line_num, calcular_costo_en_base_a_total)
if err:
- return err
+ errors.append(err)
if levantar_subpartidas:
- err = _valida_subpartidas_duplicados(
- invoice_number, linea, line_num, line_counts_csv
- )
- if err:
- return err
- err = _valida_subpartida_tiene_principal(
- row, line_num, partidas_principales_csv, partidas_principales_bd
- )
- if err:
- return err
- err = _valida_subpartida_v_no_cero(row, line_num)
- if err:
- return err
- err = _validaciones_parimpo_tem(
- row,
- line_num,
- valid_class_codes=valid_class_codes,
- class_um_by_code=class_um_by_code,
- class_fraction_by_code=class_fraction_by_code,
- class_desc_es_by_code=class_desc_es_by_code,
- class_desc_en_by_code=class_desc_en_by_code,
- valid_uom_codes=valid_uom_codes,
- valid_bulks_codes=valid_bulks_codes,
- valid_country_keys=valid_country_keys,
- valid_fraction_ame=valid_fraction_ame,
- valid_payment_methods=valid_payment_methods,
- valid_valuation_methods=valid_valuation_methods,
- authorized_sectors=authorized_sectors,
- company_has_prosec=company_has_prosec,
- validar_decimales_pza=validar_decimales_pza,
- rfc_exception_num_parte=rfc_exception_num_parte,
- invoice_number=invoice_number,
- )
+ for err in [
+ _valida_subpartidas_duplicados(invoice_number, linea, line_num, line_counts_csv),
+ _valida_subpartida_tiene_principal(row, line_num, partidas_principales_csv, partidas_principales_bd),
+ _valida_subpartida_v_no_cero(row, line_num),
+ ]:
+ if err:
+ errors.append(err)
+ err = _validaciones_parimpo_tem(row, line_num, **_validaciones_kwargs)
if err:
- return err
+ errors.append(err)
if rfc_exception_num_parte and invoice_number in rfc_exception_num_parte and valid_part_numbers is not None:
num_parte = _get(row, "NUM. PARTE", "NUMPARTE", "NUMERO PARTE", "NUM PARTE")
if num_parte and num_parte.upper() not in valid_part_numbers:
- return {
+ errors.append({
"line": line_num,
"col": "NUM. PARTE",
"msg": f"Error: (Celda W{line_num}) El número de parte Capturado: {num_parte} no existe.",
- }
- return None
+ })
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_temp.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_temp.py
index 6b1bac93..1180bf79 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_temp.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_impo_temp.py
@@ -420,36 +420,36 @@ def validate_row_partidas_impo_temp(
rfc_exception_num_parte: Optional[Set[str]],
valid_part_numbers: Optional[Set[str]],
warnings: Optional[List[Dict[str, Any]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Partidas de Importación Temporal.
Clarion: VALIDA_TODA vs VALIDA_PARCIAL según autonumerar, actualizar y si la partida existe.
"""
+ errors: List[Dict[str, Any]] = []
+
err = _check_factura_vacia(row, line_num)
if err:
- return err
+ errors.append(err)
+ return errors
invoice_number = _get(row, "NUMERO FACTURA", "NUM FACTURA", "FACTURA")
if not invoice_number:
- return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
+ errors.append({"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"})
+ return errors
- err = _check_factura_existe(invoice_number, line_num, invoice_id_by_number)
- if err:
- return err
+ for err in [
+ _check_factura_existe(invoice_number, line_num, invoice_id_by_number),
+ _check_factura_no_actualizada(
+ invoice_number, line_num, invoice_processed_by_number, rfc_exception_updated
+ ),
+ _check_linea_si_no_autonumerar(row, line_num, autonumerar),
+ _check_levantar_subpartidas_uv(row, line_num, levantar_subpartidas),
+ ]:
+ if err:
+ errors.append(err)
- err = _check_factura_no_actualizada(
- invoice_number, line_num, invoice_processed_by_number, rfc_exception_updated
- )
- if err:
- return err
-
- err = _check_linea_si_no_autonumerar(row, line_num, autonumerar)
- if err:
- return err
-
- err = _check_levantar_subpartidas_uv(row, line_num, levantar_subpartidas)
- if err:
- return err
+ if errors:
+ return errors
_warn_apostrofes_num_parte(row, line_num, warnings)
@@ -458,78 +458,53 @@ def validate_row_partidas_impo_temp(
partida_existe = bool(linea and linea in existing_lines)
use_partial = actualizar and not autonumerar and partida_existe
+ _validaciones_kwargs = dict(
+ valid_class_codes=valid_class_codes,
+ class_um_by_code=class_um_by_code,
+ class_fraction_by_code=class_fraction_by_code,
+ class_desc_es_by_code=class_desc_es_by_code,
+ class_desc_en_by_code=class_desc_en_by_code,
+ valid_uom_codes=valid_uom_codes,
+ valid_bulks_codes=valid_bulks_codes,
+ valid_country_keys=valid_country_keys,
+ valid_fraction_ame=valid_fraction_ame,
+ valid_payment_methods=valid_payment_methods,
+ valid_valuation_methods=valid_valuation_methods,
+ authorized_sectors=authorized_sectors,
+ company_has_prosec=company_has_prosec,
+ validar_decimales_pza=validar_decimales_pza,
+ rfc_exception_num_parte=rfc_exception_num_parte,
+ invoice_number=invoice_number,
+ )
+
if use_partial:
- return _validaciones_parimpo_tem(
- row,
- line_num,
- valid_class_codes=valid_class_codes,
- class_um_by_code=class_um_by_code,
- class_fraction_by_code=class_fraction_by_code,
- class_desc_es_by_code=class_desc_es_by_code,
- class_desc_en_by_code=class_desc_en_by_code,
- valid_uom_codes=valid_uom_codes,
- valid_bulks_codes=valid_bulks_codes,
- valid_country_keys=valid_country_keys,
- valid_fraction_ame=valid_fraction_ame,
- valid_payment_methods=valid_payment_methods,
- valid_valuation_methods=valid_valuation_methods,
- authorized_sectors=authorized_sectors,
- company_has_prosec=company_has_prosec,
- validar_decimales_pza=validar_decimales_pza,
- rfc_exception_num_parte=rfc_exception_num_parte,
- invoice_number=invoice_number,
- )
+ err = _validaciones_parimpo_tem(row, line_num, **_validaciones_kwargs)
+ if err:
+ errors.append(err)
else:
- err = _valida_toda_obligatorios(
- row, line_num, levantar_subpartidas, calcular_costo_en_base_a_total
- )
- if err:
- return err
- err = _valida_toda_numericos(row, line_num, calcular_costo_en_base_a_total)
- if err:
- return err
+ for err in [
+ _valida_toda_obligatorios(row, line_num, levantar_subpartidas, calcular_costo_en_base_a_total),
+ _valida_toda_numericos(row, line_num, calcular_costo_en_base_a_total),
+ ]:
+ if err:
+ errors.append(err)
if levantar_subpartidas:
- err = _valida_subpartidas_duplicados(
- invoice_number, linea, line_num, line_counts_csv
- )
- if err:
- return err
- err = _valida_subpartida_tiene_principal(
- row, line_num, partidas_principales_csv, partidas_principales_bd
- )
- if err:
- return err
- err = _valida_subpartida_v_no_cero(row, line_num)
- if err:
- return err
- err = _validaciones_parimpo_tem(
- row,
- line_num,
- valid_class_codes=valid_class_codes,
- class_um_by_code=class_um_by_code,
- class_fraction_by_code=class_fraction_by_code,
- class_desc_es_by_code=class_desc_es_by_code,
- class_desc_en_by_code=class_desc_en_by_code,
- valid_uom_codes=valid_uom_codes,
- valid_bulks_codes=valid_bulks_codes,
- valid_country_keys=valid_country_keys,
- valid_fraction_ame=valid_fraction_ame,
- valid_payment_methods=valid_payment_methods,
- valid_valuation_methods=valid_valuation_methods,
- authorized_sectors=authorized_sectors,
- company_has_prosec=company_has_prosec,
- validar_decimales_pza=validar_decimales_pza,
- rfc_exception_num_parte=rfc_exception_num_parte,
- invoice_number=invoice_number,
- )
+ for err in [
+ _valida_subpartidas_duplicados(invoice_number, linea, line_num, line_counts_csv),
+ _valida_subpartida_tiene_principal(row, line_num, partidas_principales_csv, partidas_principales_bd),
+ _valida_subpartida_v_no_cero(row, line_num),
+ ]:
+ if err:
+ errors.append(err)
+ err = _validaciones_parimpo_tem(row, line_num, **_validaciones_kwargs)
if err:
- return err
+ errors.append(err)
if rfc_exception_num_parte and invoice_number in rfc_exception_num_parte and valid_part_numbers is not None:
num_parte = _get(row, "NUM. PARTE", "NUMPARTE", "NUMERO PARTE", "NUM PARTE")
if num_parte and num_parte.upper() not in valid_part_numbers:
- return {
+ errors.append({
"line": line_num,
"col": "NUM. PARTE",
"msg": f"Error: (Celda W{line_num}) El número de parte Capturado: {num_parte} no existe.",
- }
- return None
+ })
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_expo.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_expo.py
index 06ee40c9..7b3ed842 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_expo.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_expo.py
@@ -182,63 +182,45 @@ def validate_row_series_expo(
existing_series_keys: Set[Tuple[str, str, str]],
existing_series_data: Optional[Dict[Tuple[str, str, str], Dict[str, Any]]],
warnings: Optional[List[Dict[str, Any]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Series de Exportación Definitiva.
Clarion: decisión VALIDA_TODA vs VALIDA_PARCIAL según autonumerar, actualizar y si la serie existe.
rfc_exception_updated: set de números de factura que se consideran no actualizadas (ej. EGM0303257J1).
"""
+ errors: List[Dict[str, Any]] = []
+
desfase = _check_desfase(row, line_num)
if desfase and warnings is not None:
warnings.append(desfase)
err = _check_factura_vacia_expo(row, line_num)
if err:
- return err
+ errors.append(err)
+ return errors
invoice_number = _clip(
row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or row.get("FACTURA EXPO")
)
if not invoice_number:
- return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
-
- err = _check_factura_existe_expo(invoice_number, line_num, invoice_id_by_number)
- if err:
- return err
-
- err = _check_factura_no_actualizada_expo(
- invoice_number, line_num, invoice_processed_by_number, rfc_exception_updated
- )
- if err:
- return err
-
- err = _check_linea_factura_vacia(row, line_num)
- if err:
- return err
+ errors.append({"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"})
+ return errors
linea_factura = _clip(row.get("LINEA FACTURA") or row.get("LINEA") or row.get("PARTIDA"))
- err = _check_partida_existe_en_factura(
- invoice_number,
- linea_factura,
- line_num,
- partida_max_series,
- )
- if err:
- return err
- err = _check_cantidad_series_vs_partida(
- invoice_number,
- linea_factura,
- line_num,
- partida_max_series,
- csv_series_count_so_far,
- )
- if err:
- return err
+ for err in [
+ _check_factura_existe_expo(invoice_number, line_num, invoice_id_by_number),
+ _check_factura_no_actualizada_expo(invoice_number, line_num, invoice_processed_by_number, rfc_exception_updated),
+ _check_linea_factura_vacia(row, line_num),
+ _check_partida_existe_en_factura(invoice_number, linea_factura, line_num, partida_max_series),
+ _check_cantidad_series_vs_partida(invoice_number, linea_factura, line_num, partida_max_series, csv_series_count_so_far),
+ _check_linea_serie_si_no_autonumerar(row, line_num, autonumerar),
+ ]:
+ if err:
+ errors.append(err)
- err = _check_linea_serie_si_no_autonumerar(row, line_num, autonumerar)
- if err:
- return err
+ if errors:
+ return errors
_warn_apostrofes(row, line_num, warnings)
@@ -253,12 +235,13 @@ def validate_row_series_expo(
)
if use_partial and existing_series_data and key in existing_series_data:
- return valida_parcial_series_expo(
- row, line_num, existing_series_data[key], warnings
- )
- return valida_toda_series_expo(
- row, line_num, validar_series_exception, warnings
- )
+ err = valida_parcial_series_expo(row, line_num, existing_series_data[key], warnings)
+ else:
+ err = valida_toda_series_expo(row, line_num, validar_series_exception, warnings)
+
+ if err:
+ errors.append(err)
+ return errors
def row_to_series_normalized_expo(row: Dict[str, Any]) -> Dict[str, Any]:
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_def.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_def.py
index 1a44b7c6..053a94de 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_def.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_def.py
@@ -227,60 +227,44 @@ def validate_row_series_impo_def(
existing_series_data: Optional[Dict[Tuple[str, str, str], Dict[str, Any]]],
warnings: Optional[List[Dict[str, Any]]] = None,
catalog_label: str = "Importación Definitiva",
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Series de Importación Definitiva.
Clarion: decisión VALIDA_TODA vs VALIDA_PARCIAL según autonumerar, actualizar y si la serie existe.
partida_max_series: máximo de series por (invoice_number, linea_factura); 0 o ausente = no validar.
csv_series_count_so_far: conteo de filas ya aceptadas por (invoice_number, linea_factura); el caller debe incrementar al aceptar.
"""
+ errors: List[Dict[str, Any]] = []
+
desfase = _check_desfase(row, line_num)
if desfase and warnings is not None:
warnings.append(desfase)
err = _check_factura_vacia(row, line_num)
if err:
- return err
+ errors.append(err)
+ return errors
invoice_number = _clip(row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA"))
if not invoice_number:
- return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
-
- err = _check_factura_existe_def(invoice_number, line_num, invoice_id_by_number, catalog_label)
- if err:
- return err
-
- err = _check_factura_no_actualizada_def(invoice_number, line_num, invoice_processed_by_number, catalog_label)
- if err:
- return err
-
- err = _check_linea_factura_vacia(row, line_num)
- if err:
- return err
+ errors.append({"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"})
+ return errors
linea_factura = _clip(row.get("LINEA FACTURA") or row.get("LINEA") or row.get("PARTIDA"))
- err = _check_partida_existe_en_factura(
- invoice_number,
- linea_factura,
- line_num,
- partida_max_series,
- )
- if err:
- return err
- err = _check_cantidad_series_vs_partida(
- invoice_number,
- linea_factura,
- line_num,
- partida_max_series,
- csv_series_count_so_far,
- )
- if err:
- return err
+ for err in [
+ _check_factura_existe_def(invoice_number, line_num, invoice_id_by_number, catalog_label),
+ _check_factura_no_actualizada_def(invoice_number, line_num, invoice_processed_by_number, catalog_label),
+ _check_linea_factura_vacia(row, line_num),
+ _check_partida_existe_en_factura(invoice_number, linea_factura, line_num, partida_max_series),
+ _check_cantidad_series_vs_partida(invoice_number, linea_factura, line_num, partida_max_series, csv_series_count_so_far),
+ _check_linea_serie_si_no_autonumerar(row, line_num, autonumerar),
+ ]:
+ if err:
+ errors.append(err)
- err = _check_linea_serie_si_no_autonumerar(row, line_num, autonumerar)
- if err:
- return err
+ if errors:
+ return errors
_warn_apostrofes(row, line_num, warnings)
@@ -295,12 +279,13 @@ def validate_row_series_impo_def(
)
if use_partial and existing_series_data and key in existing_series_data:
- return valida_parcial_series_impo_def(
- row, line_num, existing_series_data[key], warnings
- )
- return valida_toda_series_impo_def(
- row, line_num, validar_series_exception, warnings
- )
+ err = valida_parcial_series_impo_def(row, line_num, existing_series_data[key], warnings)
+ else:
+ err = valida_toda_series_impo_def(row, line_num, validar_series_exception, warnings)
+
+ if err:
+ errors.append(err)
+ return errors
def row_to_series_normalized_def(row: Dict[str, Any]) -> Dict[str, Any]:
diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_temp.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_temp.py
index 9eb70de8..257e032b 100644
--- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_temp.py
+++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/series_impo_temp.py
@@ -265,49 +265,42 @@ def validate_row_series_impo_temp(
existing_series_keys: Set[Tuple[str, str, str]],
existing_series_data: Optional[Dict[Tuple[str, str, str], Dict[str, Any]]],
warnings: Optional[List[Dict[str, Any]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Punto de entrada: valida una fila de CSV de Series de Importación Temporal.
Clarion: decisión VALIDA_TODA vs VALIDA_PARCIAL según autonumerar, actualizar y si la serie existe.
"""
- # Desfase (solo advertencia)
+ errors: List[Dict[str, Any]] = []
+
+ # Desfase (solo advertencia, no bloqueante)
desfase = _check_desfase(row, line_num)
if desfase and warnings is not None:
warnings.append(desfase)
- # Factura vacía
err = _check_factura_vacia(row, line_num)
if err:
- return err
+ errors.append(err)
+ return errors
invoice_number = _clip(row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA"))
if not invoice_number:
- return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"}
+ errors.append({"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"})
+ return errors
- # Factura existe
- err = _check_factura_existe(invoice_number, line_num, invoice_id_by_number)
- if err:
- return err
+ for err in [
+ _check_factura_existe(invoice_number, line_num, invoice_id_by_number),
+ _check_factura_no_actualizada(invoice_number, line_num, invoice_processed_by_number),
+ _check_linea_factura_vacia(row, line_num),
+ _check_linea_serie_si_no_autonumerar(row, line_num, autonumerar),
+ ]:
+ if err:
+ errors.append(err)
- # Factura no actualizada
- err = _check_factura_no_actualizada(invoice_number, line_num, invoice_processed_by_number)
- if err:
- return err
+ if errors:
+ return errors
- # LINEA FACTURA vacía
- err = _check_linea_factura_vacia(row, line_num)
- if err:
- return err
-
- # Autonumerar NO y LINEA SERIE vacía
- err = _check_linea_serie_si_no_autonumerar(row, line_num, autonumerar)
- if err:
- return err
-
- # Advertencias apostrofes (no bloqueante)
_warn_apostrofes(row, line_num, warnings)
- # Decisión VALIDA_TODA vs VALIDA_PARCIAL
linea_factura = _clip(row.get("LINEA FACTURA") or row.get("LINEA") or row.get("PARTIDA"))
linea_serie = _clip(row.get("LINEA SERIE") or row.get("RENGLON"))
key = _series_key(invoice_number, linea_factura, linea_serie)
@@ -320,12 +313,13 @@ def validate_row_series_impo_temp(
)
if use_partial and existing_series_data and key in existing_series_data:
- return valida_parcial_series_impo_tem(
- row, line_num, existing_series_data[key], warnings
- )
- return valida_toda_series_impo_tem(
- row, line_num, validar_series_exception, warnings
- )
+ err = valida_parcial_series_impo_tem(row, line_num, existing_series_data[key], warnings)
+ else:
+ err = valida_toda_series_impo_tem(row, line_num, validar_series_exception, warnings)
+
+ if err:
+ errors.append(err)
+ return errors
def row_to_series_normalized(row: Dict[str, Any]) -> Dict[str, Any]:
diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py
index 364b6ee7..71c2d418 100644
--- a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py
@@ -25,6 +25,7 @@ from .tasks import (
PART_IMPORT_META_PREFIX,
PART_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -175,3 +176,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("part", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py
index a3a54a3d..4c3088ed 100644
--- a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py
@@ -100,7 +100,7 @@ def scan_file(self, job_id: str, config: str = None):
meta={"current": i, "total": total_rows, "errors": error_count},
)
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_part(
+ row_errors = validate_row_part(
row_norm,
i,
valid_class_codes=valid_class_codes,
@@ -114,24 +114,36 @@ def scan_file(self, job_id: str, config: str = None):
actualizar=actualizar,
existing_part_numbers=existing_part_numbers,
)
- if err:
+ warnings_list = get_row_warnings(row_norm, i)
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
- if len(errors_detail) < 500:
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
+
+ if len(errors_detail) < 500:
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
+ "line": e.get("line", i),
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
+ for w in warnings_list:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": w.get("line", i),
+ "col": w.get("col", ""),
+ "msg": w.get("msg", ""),
+ "solution": w.get("solution", ""),
+ "warning": True,
})
- else:
- for w in get_row_warnings(row_norm, i):
- if len(errors_detail) < 500:
- errors_detail.append({
- "line": w.get("line"),
- "col": w.get("col", ""),
- "msg": w.get("msg", ""),
- })
processed_rows += 1
if error_lines_list:
@@ -218,7 +230,7 @@ def insert_valid_rows(self, job_id: str):
and reemplazar_sin_preguntar
)
- err = validate_row_part(
+ row_errors = validate_row_part(
row_norm,
i,
valid_class_codes=valid_class_codes,
@@ -232,12 +244,15 @@ def insert_valid_rows(self, job_id: str):
actualizar=actualizar,
existing_part_numbers=set(existing_by_part_number.keys()),
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
if use_partial:
diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/parts/validators/common.py
index 0b0f8411..b26dd1f4 100644
--- a/backend/api/v1/modules/a76/layouts_csv/parts/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/parts/validators/common.py
@@ -2,7 +2,7 @@
Validaciones comunes de fila para import CSV de partes.
Paridad Clarion: VALIDA_TODA_PARTES (obligatorios A, B, E salvo excepción RFC), VALIDACIONES_PARTES.
"""
-from typing import Dict, Any, Optional, Set
+from typing import Dict, Any, Optional, Set, List
from ..common.common_validators import check_max_length, check_decimal
from decimal import Decimal, InvalidOperation
@@ -369,44 +369,27 @@ def validaciones_parte(
authorized_sector_keys: Optional[Set[str]] = None,
company_has_prosec: bool = False,
is_rfc_exception: bool = False,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""Reglas compartidas Clarion (VALIDACIONES_PARTES): longitudes, tipos, FKs, preferencia, sector."""
- err = validate_row_lengths(row, line_num)
- if err:
- return err
- err = validate_row_types(row, line_num)
- if err:
- return err
- err = validate_row_class_fk(row, line_num, valid_class_codes)
- if err:
- return err
- err = validate_row_uom_fk(row, line_num, valid_uom_codes)
- if err:
- return err
- err = validate_row_tipo_moneda(row, line_num)
- if err:
- return err
- err = validate_row_clave_moneda(row, line_num, valid_currency_codes)
- if err:
- return err
- err = validate_row_tipo_peso(row, line_num)
- if err:
- return err
- err = validate_row_fraction_mex_catalog(row, line_num, valid_fraction_mex_8)
- if err:
- return err
- err = validate_row_country_fk(row, line_num, valid_country_m3)
- if err:
- return err
- err = validate_row_preferencia(row, line_num, is_rfc_exception)
- if err:
- return err
- err = validate_row_sector(
- row, line_num, company_has_prosec, authorized_sector_keys
- )
- if err:
- return err
- return None
+ errors: List[Dict[str, Any]] = []
+
+ for err in [
+ validate_row_lengths(row, line_num),
+ validate_row_types(row, line_num),
+ validate_row_class_fk(row, line_num, valid_class_codes),
+ validate_row_uom_fk(row, line_num, valid_uom_codes),
+ validate_row_tipo_moneda(row, line_num),
+ validate_row_clave_moneda(row, line_num, valid_currency_codes),
+ validate_row_tipo_peso(row, line_num),
+ validate_row_fraction_mex_catalog(row, line_num, valid_fraction_mex_8),
+ validate_row_country_fk(row, line_num, valid_country_m3),
+ validate_row_preferencia(row, line_num, is_rfc_exception),
+ validate_row_sector(row, line_num, company_has_prosec, authorized_sector_keys),
+ ]:
+ if err:
+ errors.append(err)
+
+ return errors
def validate_row_warnings(
@@ -425,6 +408,7 @@ def validate_row_warnings(
"col": "RUTAIMAGEN",
"msg": "Advertencia: Podría existir un desfase en esta línea.",
"severity": "warning",
+ "warning": True,
})
# Advertencia apóstrofes en Número de Parte
num_parte = (row.get("NUMPARTE") or "").strip()
@@ -434,5 +418,6 @@ def validate_row_warnings(
"col": "NUMPARTE",
"msg": f"Advertencia: El Número de Parte: {num_parte} Contiene Apostrofes. Se Omitirá el Apostrofe para Subir el Número de Parte.",
"severity": "warning",
+ "warning": True,
})
return warnings
diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/parts/validators/create.py
index 6ab21b04..c37e400e 100644
--- a/backend/api/v1/modules/a76/layouts_csv/parts/validators/create.py
+++ b/backend/api/v1/modules/a76/layouts_csv/parts/validators/create.py
@@ -3,7 +3,7 @@ Punto de entrada de validación para import de una fila de parte.
Flujo Clarion: no ACT → siempre VALIDA_TODA_PARTES; ACT y parte existe → VALIDA_PARCIAL_PARTES;
ACT y parte no existe → VALIDA_TODA_PARTES (obligatorios + validaciones) y en commit se crea (ADD).
"""
-from typing import Dict, Any, Optional, Set
+from typing import Dict, Any, Optional, Set, List
from .common import (
validate_row_required,
@@ -26,7 +26,7 @@ def validate_row_part(
is_rfc_exception: bool = False,
actualizar: bool = False,
existing_part_numbers: Optional[Set[str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de partes para import.
- No actualizar: siempre validación completa (obligatorios A,B,E + validaciones_parte).
@@ -34,9 +34,10 @@ def validate_row_part(
- Actualizar y número de parte no existe: validación completa; si pasa, en commit se crea (ADD).
Si actualizar y validación full, además exige que el número de parte exista en catálogo.
"""
+ errors: List[Dict[str, Any]] = []
err = validate_row_required(row, line_num)
if err:
- return err
+ errors.append(err)
part_number = (row.get("NUMPARTE") or "").strip().upper()
use_partial = (
@@ -46,7 +47,8 @@ def validate_row_part(
)
if use_partial:
- return validate_row_part_partial(
+ errors.extend(
+ validate_row_part_partial(
row,
line_num,
valid_class_codes=valid_class_codes,
@@ -57,14 +59,17 @@ def validate_row_part(
authorized_sector_keys=authorized_sector_keys,
company_has_prosec=company_has_prosec,
is_rfc_exception=is_rfc_exception,
+ )
)
+ return errors
# Full validation (también cuando actualizar y parte no existe → ADD en commit)
err = validate_row_required_full(row, line_num, is_rfc_exception=is_rfc_exception)
if err:
- return err
+ errors.append(err)
# No exigir que exista en actualizar: si no existe se valida completa y en commit se crea (ADD)
- return validaciones_parte(
+ errors.extend(
+ validaciones_parte(
row,
line_num,
valid_class_codes=valid_class_codes,
@@ -75,7 +80,9 @@ def validate_row_part(
authorized_sector_keys=authorized_sector_keys,
company_has_prosec=company_has_prosec,
is_rfc_exception=is_rfc_exception,
+ )
)
+ return errors
def validate_row_part_partial(
@@ -89,15 +96,17 @@ def validate_row_part_partial(
authorized_sector_keys: Optional[Set[str]] = None,
company_has_prosec: bool = False,
is_rfc_exception: bool = False,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Validación parcial (modo Actualizar, parte existente): solo NUMPARTE + validaciones_parte.
Los campos vacíos se rellenan desde la parte existente en el mapper.
"""
+ errors: List[Dict[str, Any]] = []
err = validate_row_required(row, line_num)
if err:
- return err
- return validaciones_parte(
+ errors.append(err)
+ errors.extend(
+ validaciones_parte(
row,
line_num,
valid_class_codes=valid_class_codes,
@@ -108,7 +117,9 @@ def validate_row_part_partial(
authorized_sector_keys=authorized_sector_keys,
company_has_prosec=company_has_prosec,
is_rfc_exception=is_rfc_exception,
+ )
)
+ return errors
def get_row_warnings(row: Dict[str, Any], line_num: int) -> list:
diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/fk_loader.py
index e72ded53..6cdc17e8 100644
--- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/fk_loader.py
+++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/common/fk_loader.py
@@ -45,6 +45,9 @@ def load_pedimentos_fk_sets(
from api.v1.modules.public.reference_data.code_pedimento_regimens.models import (
CodePedimentoRegimen,
)
+ from api.v1.modules.public.reference_data.code_pedimento_regimens.seed import (
+ seed as code_pedimento_regimens_seed,
+ )
from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection
from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode
from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento
@@ -95,6 +98,14 @@ def load_pedimentos_fk_sets(
t,
)
)
+ # Respaldo desde seed: evita desalineación temporal cuando el catálogo en BD
+ # no ha sido re-sincronizado tras actualizar la semilla.
+ for ped_code, reg_code, type_code in code_pedimento_regimens_seed:
+ ped = (ped_code or "").strip().upper()
+ reg = (reg_code or "").strip().upper()
+ typ = (type_code or "").strip().upper()
+ if ped and reg and typ:
+ valid_clave_regimen_tipo.add((ped, reg, typ))
for cs in session.query(CustomsSection).all():
valid_aduana_seccion.add(cs.customs_code.strip())
diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py
index deb0b9d9..5eeb8890 100644
--- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/routes.py
@@ -25,6 +25,7 @@ from .tasks import (
PED_IMPORT_REDIS_TTL,
)
from ..common import storage as common_storage
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -167,3 +168,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream(PED_JOB_TYPE, job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py
index f8fe5d8d..58661da9 100644
--- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/tasks.py
@@ -103,7 +103,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
progress_callback(i, total_rows, error_count)
row_norm = _norm_row(row)
- err = validate_row_pedimento(
+ row_errors = validate_row_pedimento(
row_norm,
i,
short_name_to_id,
@@ -118,16 +118,25 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
raw_row=row,
date_format_preference=date_format_preference,
)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -236,7 +245,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue
row_norm = _norm_row(row)
- err = validate_row_pedimento(
+ row_errors = validate_row_pedimento(
row_norm,
i,
short_name_to_id,
@@ -251,12 +260,15 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
raw_row=row,
date_format_preference=date_format_preference,
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
key = _key_from_row(row_norm)
@@ -334,7 +346,13 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
updated_count += 1
else:
skipped_invalid += 1
- skipped_details.append({"line": i, "reason": "Pedimento no encontrado"})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": "Pedimento no encontrado",
+ "solution": "Verifica que el pedimento exista en el sistema usando las columnas PEDIMENTO (o AÑO/PATENTE/NUMERO) y ADUANA_SECCION_CRUCE; si aplica, cambia a modo creación.",
+ }
+ )
else:
# No existe: crear (ambos modos)
data = row_to_pedimento_data(
@@ -351,14 +369,32 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
except ValueError as ve:
if "Ya existe" in str(ve) or "duplicate" in str(ve).lower():
skipped_duplicate += 1
- skipped_details.append({"line": i, "reason": str(ve)})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": str(ve),
+ "solution": "Si deseas actualizar registros existentes, activa la opción de actualizar en la importación; si no, corrige el pedimento para evitar duplicados.",
+ }
+ )
else:
skipped_invalid += 1
- skipped_details.append({"line": i, "reason": str(ve)})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": str(ve),
+ "solution": "Revisa la línea del CSV y el valor que falló la validación; corrige el dato y vuelve a intentar.",
+ }
+ )
except Exception as e:
logger.warning("Pedimentos import line %s: %s", i, e)
skipped_invalid += 1
- skipped_details.append({"line": i, "reason": str(e)})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": str(e),
+ "solution": "Revisa la línea del CSV y los catálogos/fechas relacionados para resolver el error.",
+ }
+ )
except Exception as e:
logger.exception("Pedimentos import task failed")
diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py
index a8a06874..ce840f19 100644
--- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/common.py
@@ -3,7 +3,7 @@ Validaciones comunes de fila para import CSV de pedimentos.
Paridad Clarion: desfase (Col N), Cols 1-3 (AÑO, PATENTE, NUMERO) u Col A PEDIMENTO, obligatorios B–H (VALIDA_TODA),
VALIDACIONES_PEDIMENTO (Tipo I/E, Clave+Régimen+Tipo, Aduana, Patente, IND/CON, Método transporte).
"""
-from typing import Dict, Any, Optional, Set, Tuple
+from typing import Dict, Any, Optional, Set, Tuple, List
from ..template_config import (
AÑO_LEN,
@@ -87,6 +87,7 @@ def validate_row_desfase_pedimento(
"line": line_num,
"col": "",
"msg": f"{MSG_DESFASE} {MSG_DESFASE_SOLUCION}",
+ "warning": True,
}
return None
@@ -337,54 +338,44 @@ def validaciones_pedimento(
valid_patentes: Set[str],
short_name_to_id: Optional[Dict[str, int]] = None,
date_format_preference: Optional[str] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""Orquestador: formato PEDIMENTO si aplica, tipo B, combinación B+C+D, aduana H, patente, IND/CON J, transporte K/L/M, fechas E/F/G. CLIENTE_SHORT_NAME opcional."""
+ errors: List[Dict[str, Any]] = []
+
if (row.get("PEDIMENTO") or "").strip():
err = validate_row_pedimento_format(row, line_num)
if err:
- return err
- # CLIENTE_SHORT_NAME opcional: si viene valor se valida que exista en catálogo; si viene vacío no se exige
+ errors.append(err)
+
+ # CLIENTE_SHORT_NAME opcional: si viene valor se valida que exista en catálogo.
if short_name_to_id is not None:
- err = check_optional_short_name(row, "CLIENTE_SHORT_NAME", short_name_to_id, line_num)
- if err:
- return err
- err = validate_row_patente(row, line_num, valid_patentes)
- if err:
- return err
- err = validate_row_tipo_operacion(row, line_num)
- if err:
- return err
- err = validate_row_clave_regimen_tipo(row, line_num, valid_clave_regimen_tipo)
- if err:
- return err
- err = validate_row_aduana(row, line_num, valid_aduana_seccion)
- if err:
- return err
- err = validate_row_ind_con(row, line_num)
- if err:
- return err
- err = validate_row_transporte(
- row, line_num, "MET_TRANSP_ENTRADA", "K", valid_anexo22_claves
- )
- if err:
- return err
- err = validate_row_transporte(
- row, line_num, "MET_TRANSP_ARRIVO", "L", valid_anexo22_claves
- )
- if err:
- return err
- err = validate_row_transporte(
- row, line_num, "MET_TRANSP_SALIDA", "M", valid_anexo22_claves
- )
- if err:
- return err
- for col in ("FECHA_INICIO", "FECHA_FINAL", "FECHA_PAGO"):
- err = validate_row_fecha_pedimento(
- row, col, line_num, date_format_preference
+ err = check_optional_short_name(
+ row, "CLIENTE_SHORT_NAME", short_name_to_id, line_num
)
if err:
- return err
- return None
+ errors.append(err)
+
+ for err in [
+ validate_row_patente(row, line_num, valid_patentes),
+ validate_row_tipo_operacion(row, line_num),
+ validate_row_clave_regimen_tipo(
+ row, line_num, valid_clave_regimen_tipo
+ ),
+ validate_row_aduana(row, line_num, valid_aduana_seccion),
+ validate_row_ind_con(row, line_num),
+ validate_row_transporte(row, line_num, "MET_TRANSP_ENTRADA", "K", valid_anexo22_claves),
+ validate_row_transporte(row, line_num, "MET_TRANSP_ARRIVO", "L", valid_anexo22_claves),
+ validate_row_transporte(row, line_num, "MET_TRANSP_SALIDA", "M", valid_anexo22_claves),
+ ]:
+ if err:
+ errors.append(err)
+
+ for col in ("FECHA_INICIO", "FECHA_FINAL", "FECHA_PAGO"):
+ err = validate_row_fecha_pedimento(row, col, line_num, date_format_preference)
+ if err:
+ errors.append(err)
+
+ return errors
# --- Legacy (layout sin PEDIMENTO único): mantener para compatibilidad ---
diff --git a/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/create.py
index 107daf66..1888b802 100644
--- a/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/create.py
+++ b/backend/api/v1/modules/a76/layouts_csv/pedmientos/validators/create.py
@@ -5,7 +5,7 @@ Flujo Clarion:
- Modo crear: crear siempre; si existe reemplazar. Siempre VALIDA_TODA.
Legacy: si la fila no tiene PEDIMENTO (Col A Clarion), se usa validación por AÑO/ADUANA/PATENTE/NUMERO.
"""
-from typing import Dict, Any, Optional, Set, Tuple
+from typing import Dict, Any, Optional, Set, Tuple, List
from ..template_config import is_clarion_layout, parse_pedimento_col_a
from ..common.fk_loader import pedimento_key_from_parsed
@@ -52,28 +52,36 @@ def validate_row_pedimento(
actualizar: bool = False,
raw_row: Optional[Dict[str, Any]] = None,
date_format_preference: Optional[str] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de pedimentos.
- Si raw_row está presente, se valida desfase (Col N) primero.
- Layout Clarion (PEDIMENTO con valor): flujo VALIDA_TODA / VALIDA_PARCIAL según actualizar y existing_pedimento_keys.
- Layout legacy (sin PEDIMENTO): requeridos AÑO, ADUANA, PATENTE, NUMERO, CODIGO_PEDIMENTO, REGIMEN; CLIENTE_SHORT_NAME opcional.
"""
+ errors: List[Dict[str, Any]] = []
+
if raw_row is not None:
err = validate_row_desfase_pedimento(raw_row, line_num)
if err:
- return err
+ errors.append(err)
if not is_clarion_layout(row):
err = validate_row_pedimento_required_legacy(row, line_num)
if err:
- return err
+ errors.append(err)
+
err = validate_row_pedimento_fk_legacy(
row, line_num, short_name_to_id, valid_regimes, valid_pedimento_codes
)
if err:
- return err
- return validate_row_pedimento_optionals_legacy(row, line_num)
+ errors.append(err)
+
+ err = validate_row_pedimento_optionals_legacy(row, line_num)
+ if err:
+ errors.append(err)
+
+ return errors
# Layout Clarion: validar Cols 1-3 (AÑO, PATENTE, NUMERO) o Col A (PEDIMENTO) si compatibilidad
has_three_cols = (
@@ -85,10 +93,13 @@ def validate_row_pedimento(
err = validate_row_pedimento_required_three_cols(row, line_num)
else:
err = validate_row_pedimento_required_col_a(row, line_num)
- if not err:
- err = validate_row_pedimento_format(row, line_num)
+ # Formato solo aplica cuando hay valor en Col A, pero podemos acumular igualmente.
+ fmt_err = validate_row_pedimento_format(row, line_num)
+ if fmt_err:
+ errors.append(fmt_err)
+
if err:
- return err
+ errors.append(err)
key = _clarion_pedimento_key(row)
# Actualizar: si existe → validación parcial; si no existe → validación completa (crear).
@@ -100,7 +111,8 @@ def validate_row_pedimento(
)
if use_partial:
- return validaciones_pedimento(
+ errors.extend(
+ validaciones_pedimento(
row,
line_num,
valid_clave_regimen_tipo,
@@ -110,17 +122,23 @@ def validate_row_pedimento(
short_name_to_id=short_name_to_id,
date_format_preference=date_format_preference,
)
+ )
+ return errors
err = validate_row_pedimento_required_full(row, line_num)
if err:
- return err
- return validaciones_pedimento(
- row,
- line_num,
- valid_clave_regimen_tipo,
- valid_aduana_seccion,
- valid_anexo22_claves,
- valid_patentes,
- short_name_to_id=short_name_to_id,
- date_format_preference=date_format_preference,
+ errors.append(err)
+
+ errors.extend(
+ validaciones_pedimento(
+ row,
+ line_num,
+ valid_clave_regimen_tipo,
+ valid_aduana_seccion,
+ valid_anexo22_claves,
+ valid_patentes,
+ short_name_to_id=short_name_to_id,
+ date_format_preference=date_format_preference,
+ )
)
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py b/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py
index 0c9475fd..71c80a26 100644
--- a/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/trailers/routes.py
@@ -28,6 +28,7 @@ from .tasks import (
TRL_IMPORT_STATUS_PREFIX,
TRL_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -189,3 +190,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": job_id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("trl", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py
index 3102971d..a920076b 100644
--- a/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/trailers/tasks.py
@@ -125,9 +125,18 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
progress_callback(i, total_rows, error_count)
row_norm = row_from_template(row, common_normalize.normalize_header)
- # Desfase: advertencia no bloqueante (no se añade a error_lines)
- _ = validate_row_trailer_desfase(row_norm, i)
- err = validate_row_trailer(
+ # Desfase: advertencia no bloqueante (se muestra en UI pero no se agrega a error_lines)
+ warn = validate_row_trailer_desfase(row_norm, i)
+ if warn and len(errors_detail) < 500:
+ errors_detail.append({
+ "line": warn.get("line", i),
+ "col": warn.get("col", ""),
+ "msg": warn.get("msg", ""),
+ "solution": warn.get("solution", ""),
+ "warning": bool(warn.get("warning", False)),
+ })
+
+ row_errors = validate_row_trailer(
row_norm,
i,
actualizar=actualizar,
@@ -138,16 +147,25 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
state_country_set=state_country_set,
state_ame_to_description=state_ame_to_description,
)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e.get("line", i),
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -272,7 +290,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_trailer(
+ row_errors = validate_row_trailer(
row_norm,
i,
actualizar=actualizar,
@@ -283,15 +301,18 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
state_country_set=state_country_set,
state_ame_to_description=state_ame_to_description,
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
tn = (row_norm.get("NUMERO TRAILER") or row_norm.get("CLAVE TRAILER") or "").strip()[:20] or "-"
- skipped_details.append({
- "line": i,
- "trailer_number": tn,
- "invoice": tn,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "trailer_number": tn,
+ "invoice": tn,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
tn = (row_norm.get("NUMERO TRAILER") or row_norm.get("CLAVE TRAILER") or "").strip()[:20] or ""
@@ -306,6 +327,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
"trailer_number": tn,
"invoice": tn,
"reason": "Clave duplicada en el archivo (se usa la primera)",
+ "solution": "El sistema conserva la primera ocurrencia; no dupliques el NUMERO TRAILER (CLAVE TRAILER) en el archivo.",
})
continue
seen_keys_in_file[tn] = i
@@ -335,7 +357,11 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
session.rollback()
skipped_invalid += 1
skipped_details.append({
- "line": i, "trailer_number": tn, "invoice": tn, "reason": str(db_err),
+ "line": i,
+ "trailer_number": tn,
+ "invoice": tn,
+ "reason": str(db_err),
+ "solution": "Revisar el CSV para el NUMERO TRAILER indicado y corregir los datos/catálogos para resolver el error.",
})
continue
diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/trailers/validators/common.py
index 056ce6f2..cefda59f 100644
--- a/backend/api/v1/modules/a76/layouts_csv/trailers/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/trailers/validators/common.py
@@ -2,7 +2,7 @@
Validaciones comunes de fila para import CSV de trailers.
Paridad Clarion: VALIDACIONES_TRAILER, VALIDA_TODA_TRAILER, VALIDA_PARCIAL_TRAILER.
"""
-from typing import Dict, Any, Optional, Set, Tuple
+from typing import Dict, Any, Optional, Set, Tuple, List
from ..common.common_validators import (
MAX_LEN,
@@ -47,37 +47,46 @@ def validaciones_trailer(
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
state_ame_to_description: Optional[Dict[str, str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
VALIDACIONES_TRAILER: reglas compartidas (longitudes, tipo trailer, código entidad,
país, estado, estado-país). No exige CODIGO DE ENTIDAD obligatorio.
"""
+ errors: List[Dict[str, Any]] = []
+
err = validate_row_trailer_required(row, line_num)
if err:
- return err
+ errors.append(err)
+
err = validate_row_trailer_lengths(row, line_num)
if err:
- return err
+ errors.append(err)
+
err = check_codigo_entidad_valores(row, line_num)
if err:
- return err
+ errors.append(err)
+
err = check_trailer_type_catalog(row, line_num, valid_trailer_type_keys)
if err:
- return err
+ errors.append(err)
+
err = check_pais_catalog_trailers(row, line_num, valid_country_ame)
if err:
- return err
+ errors.append(err)
+
err_estado, estado_resuelto_upper = check_estado_catalog_trailers(
row, line_num, state_descriptions_upper, state_ame_to_description
)
if err_estado:
- return err_estado
+ errors.append(err_estado)
+
err = check_estado_pais_consistency_trailers(
row, line_num, state_country_set, estado_resuelto_upper
)
if err:
- return err
- return None
+ errors.append(err)
+
+ return errors
def valida_toda_trailer(
@@ -88,7 +97,7 @@ def valida_toda_trailer(
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
state_ame_to_description: Optional[Dict[str, str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDA_TODA_TRAILER: mismo que validaciones_trailer (Col A ya validada antes)."""
return validaciones_trailer(
row,
@@ -109,7 +118,7 @@ def valida_parcial_trailer(
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
state_ame_to_description: Optional[Dict[str, str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDA_PARCIAL_TRAILER: solo validaciones_trailer (actualizar registro existente)."""
return validaciones_trailer(
row,
diff --git a/backend/api/v1/modules/a76/layouts_csv/trailers/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/trailers/validators/create.py
index cc8bdf39..f4f087f3 100644
--- a/backend/api/v1/modules/a76/layouts_csv/trailers/validators/create.py
+++ b/backend/api/v1/modules/a76/layouts_csv/trailers/validators/create.py
@@ -2,7 +2,7 @@
Punto de entrada de validación para import de una fila trailer.
Paridad Clarion: desfase (advertencia), CLAVE vacía, VALIDA_TODA vs VALIDA_PARCIAL según actualizar y clave existente.
"""
-from typing import Dict, Any, Optional, Set, Tuple
+from typing import Dict, Any, Optional, Set, Tuple, List
from .common import (
validate_row_trailer_required,
@@ -22,7 +22,7 @@ def validate_row_trailer(
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
state_ame_to_description: Optional[Dict[str, str]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de trailers.
1. Clave (NUMERO TRAILER) vacía → error.
@@ -30,16 +30,18 @@ def validate_row_trailer(
3. Si no actualizar o clave no existe → valida_toda_trailer.
Desfase (COL_EXTRA) no se valida aquí; el caller puede llamar validate_row_trailer_desfase para advertencias no bloqueantes.
"""
+ errors: List[Dict[str, Any]] = []
err = validate_row_trailer_required(row, line_num)
if err:
- return err
+ errors.append(err)
existing = existing_trailer_numbers or set()
clave = (row.get("NUMERO TRAILER") or row.get("CLAVE TRAILER") or "").strip()
use_partial = actualizar and bool(clave and clave in existing)
if use_partial:
- err = valida_parcial_trailer(
+ errors.extend(
+ valida_parcial_trailer(
row,
line_num,
valid_trailer_type_keys=valid_trailer_type_keys,
@@ -47,9 +49,11 @@ def validate_row_trailer(
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
state_ame_to_description=state_ame_to_description,
+ )
)
else:
- err = valida_toda_trailer(
+ errors.extend(
+ valida_toda_trailer(
row,
line_num,
valid_trailer_type_keys=valid_trailer_type_keys,
@@ -57,8 +61,9 @@ def validate_row_trailer(
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
state_ame_to_description=state_ame_to_description,
+ )
)
- return err
+ return errors
def validate_row_trailer_desfase(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py
index 26af415f..7cf41b14 100644
--- a/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/routes.py
@@ -28,6 +28,7 @@ from .tasks import (
TRP_IMPORT_STATUS_PREFIX,
TRP_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -188,3 +189,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": job_id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("trp", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py
index 44128278..a1c9ac00 100644
--- a/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/tasks.py
@@ -83,8 +83,17 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
progress_callback(i, total_rows, error_count)
row_norm = row_from_template(row, common_normalize.normalize_header)
- _ = validate_row_transporter_desfase(row_norm, i)
- err = validate_row_transporter(
+ warn = validate_row_transporter_desfase(row_norm, i)
+ if warn and len(errors_detail) < 500:
+ errors_detail.append({
+ "line": warn.get("line", i),
+ "col": warn.get("col", ""),
+ "msg": warn.get("msg", ""),
+ "solution": warn.get("solution", ""),
+ "warning": bool(warn.get("warning", False)),
+ })
+
+ row_errors = validate_row_transporter(
row_norm,
i,
actualizar=actualizar,
@@ -93,16 +102,25 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e.get("line", i),
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -193,7 +211,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_transporter(
+ row_errors = validate_row_transporter(
row_norm,
i,
actualizar=actualizar,
@@ -202,14 +220,17 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
tk = (row_norm.get("CLAVE TRANSPORTISTA") or "").strip()[:23] or "-"
- skipped_details.append({
- "line": i,
- "transporter_key": tk,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "transporter_key": tk,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
tk = (row_norm.get("CLAVE TRANSPORTISTA") or "").strip()[:23] or ""
@@ -223,6 +244,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
"line": i,
"transporter_key": tk,
"reason": "Clave duplicada en el archivo (se usa la primera)",
+ "solution": "El sistema conserva la primera ocurrencia; no dupliques el valor de CLAVE TRANSPORTISTA en el archivo.",
})
continue
seen_keys_in_file[tk_upper] = i
@@ -257,6 +279,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
"line": i,
"transporter_key": tk,
"reason": str(db_err),
+ "solution": "Revisar el valor de CLAVE TRANSPORTISTA del CSV y los catálogos relacionados para corregir el error.",
})
continue
diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/common.py
index b4c06dca..20e53cf7 100644
--- a/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/common.py
@@ -2,7 +2,7 @@
Validaciones comunes de fila para import CSV de transportistas.
Paridad Clarion: VALIDACIONES_TRANSPORTISTAS, VALIDA_TODA_TRANSPORTISTAS, VALIDA_PARCIAL_TRANSPORTISTAS.
"""
-from typing import Dict, Any, Optional, Set, Tuple
+from typing import Dict, Any, Optional, Set, Tuple, List
from ..common.common_validators import (
MAX_LEN,
@@ -41,20 +41,19 @@ def validaciones_transportistas(
valid_country_ame: Optional[Set[str]] = None,
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
VALIDACIONES_TRANSPORTISTAS: estado, país, estado-país (Clarion).
"""
- err = check_estado_catalog_transportistas(row, line_num, state_descriptions_upper)
- if err:
- return err
- err = check_pais_catalog_transportistas(row, line_num, valid_country_ame)
- if err:
- return err
- err = check_estado_pais_consistency_transportistas(row, line_num, state_country_set)
- if err:
- return err
- return None
+ errors: List[Dict[str, Any]] = []
+ for err in [
+ check_estado_catalog_transportistas(row, line_num, state_descriptions_upper),
+ check_pais_catalog_transportistas(row, line_num, valid_country_ame),
+ check_estado_pais_consistency_transportistas(row, line_num, state_country_set),
+ ]:
+ if err:
+ errors.append(err)
+ return errors
def valida_toda_transportistas(
@@ -63,18 +62,22 @@ def valida_toda_transportistas(
valid_country_ame: Optional[Set[str]] = None,
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDA_TODA: obligatorios A y B + VALIDACIONES_TRANSPORTISTAS (usado para agregar nuevo o reemplazar)."""
+ errors: List[Dict[str, Any]] = []
err = validate_row_transporter_required_full(row, line_num)
if err:
- return err
- return validaciones_transportistas(
- row,
- line_num,
- valid_country_ame=valid_country_ame,
- state_descriptions_upper=state_descriptions_upper,
- state_country_set=state_country_set,
+ errors.append(err)
+ errors.extend(
+ validaciones_transportistas(
+ row,
+ line_num,
+ valid_country_ame=valid_country_ame,
+ state_descriptions_upper=state_descriptions_upper,
+ state_country_set=state_country_set,
+ )
)
+ return errors
def valida_parcial_transportistas(
@@ -83,7 +86,7 @@ def valida_parcial_transportistas(
valid_country_ame: Optional[Set[str]] = None,
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDA_PARCIAL_TRANSPORTISTAS: solo VALIDACIONES_TRANSPORTISTAS (actualizar registro existente)."""
return validaciones_transportistas(
row,
diff --git a/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py
index 384e6e05..d3daa7e8 100644
--- a/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py
+++ b/backend/api/v1/modules/a76/layouts_csv/transportistas/validators/create.py
@@ -3,7 +3,7 @@ Punto de entrada de validación para import de una fila transportista.
Actualizar = merge: si no existe la clave se crea (valida_toda), si existe se actualizan solo campos enviados (valida_parcial).
Reemplazar = reescribir: siempre valida_toda; en commit se sustituye el registro completo o se agrega.
"""
-from typing import Dict, Any, Optional, Set, Tuple
+from typing import Dict, Any, Optional, Set, Tuple, List
from .common import (
validate_row_transporter_required,
@@ -21,38 +21,43 @@ def validate_row_transporter(
valid_country_ame: Optional[Set[str]] = None,
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de transportistas.
1. CLAVE TRANSPORTISTA (Col A) vacía → error.
2. Si actualizar y clave existe en catálogo → valida_parcial (solo validaciones de catálogo; campos vacíos = mantener actual).
3. Si reemplazar o (actualizar y clave no existe) → valida_toda (A y B obligatorios + validaciones).
"""
+ errors: List[Dict[str, Any]] = []
err = validate_row_transporter_required(row, line_num)
if err:
- return err
+ errors.append(err)
existing = existing_transporter_keys or set()
clave = (row.get("CLAVE TRANSPORTISTA") or "").strip().upper()
use_partial = actualizar and bool(clave and clave in existing)
if use_partial:
- err = valida_parcial_transportistas(
+ errors.extend(
+ valida_parcial_transportistas(
row,
line_num,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
+ )
)
else:
- err = valida_toda_transportistas(
+ errors.extend(
+ valida_toda_transportistas(
row,
line_num,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
+ )
)
- return err
+ return errors
def validate_row_transporter_desfase(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py
index 87e4ad57..1d23acd1 100644
--- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/routes.py
@@ -25,6 +25,7 @@ from .tasks import (
FA_IMPORT_META_PREFIX,
FA_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -162,3 +163,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": task.id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("fa", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py
index a7714ada..1924e229 100644
--- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/tasks.py
@@ -80,7 +80,7 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
})
row_norm = _norm_row(row)
- err = validate_row_us_tariff_fraction(
+ row_errors = validate_row_us_tariff_fraction(
row_norm,
i,
actualizar=actualizar,
@@ -88,16 +88,24 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
valid_uom_codes=valid_uom_codes,
raw_row=row,
)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e["line"],
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ })
processed_rows += 1
if error_lines_list:
@@ -159,7 +167,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue
row_norm = _norm_row(row)
- err = validate_row_us_tariff_fraction(
+ row_errors = validate_row_us_tariff_fraction(
row_norm,
i,
actualizar=actualizar,
@@ -167,18 +175,27 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
valid_uom_codes=valid_uom_codes,
raw_row=row,
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
- skipped_details.append({
- "line": i,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
data = row_to_us_tariff_fraction_data(row_norm, tenant_id, company_id)
if not data or not data.get("code"):
skipped_invalid += 1
- skipped_details.append({"line": i, "reason": "FRACCION_ARANCELARIA vacío"})
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": "FRACCION_ARANCELARIA vacío",
+ "solution": "Capturar en la columna FRACCION_ARANCELARIA una fracción arancelaria válida.",
+ }
+ )
continue
existing = (
diff --git a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/validators/common.py
index 4734240b..9b72342f 100644
--- a/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/us_tariff_fractions/validators/common.py
@@ -2,7 +2,7 @@
Validaciones comunes de fila para import CSV de fracciones arancelarias americanas.
Paridad Clarion: desfase Col H, VALIDA_TODA_FRACCIONAME / VALIDA_PARCIAL_FRACCIONAME, VALIDACIONES_FRACCIONAME.
"""
-from typing import Dict, Any, Optional, Set
+from typing import Dict, Any, Optional, Set, List
from ..template_config import DESFASE_COLUMN_INDEX
from ...common.cell_value import cell_to_str
@@ -57,6 +57,7 @@ def validate_row_desfase_fa(
"msg": MSG_DESFASE,
"solution": MSG_DESFASE_SOLUCION,
"severity": "warning",
+ "warning": True,
}
@@ -107,30 +108,23 @@ def _validaciones_fraccioname(
row: Dict[str, Any],
line_num: int,
valid_uom_codes: Optional[Set[str]],
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDACIONES_FRACCIONAME: Col A len ≤16, Col C en catálogo U.M., Col E PO/ME/vacío, F/G ≥0."""
- err = _validate_col_a_required(row, line_num)
- if err:
- return err
- err = check_optional_max_length(row, "PREFIJO", PREFIX_MAX, line_num)
- if err:
- return err
- err = check_optional_max_length(row, "UNIDAD_DE_MEDIDA", UNIT_MAX, line_num)
- if err:
- return err
- err = _validate_uom_catalog(row, line_num, valid_uom_codes)
- if err:
- return err
- err = check_tipo_advalorem(row, "TIPO_DE_ADVALOREM", line_num)
- if err:
- return err
- err = check_optional_decimal_min_zero(row, "ADVALOREM_PCT", line_num)
- if err:
- return err
- err = check_optional_decimal_min_zero(row, "ADVALOREM_DLLS", line_num)
- if err:
- return err
- return None
+ errors: List[Dict[str, Any]] = []
+
+ for err in [
+ _validate_col_a_required(row, line_num),
+ check_optional_max_length(row, "PREFIJO", PREFIX_MAX, line_num),
+ check_optional_max_length(row, "UNIDAD_DE_MEDIDA", UNIT_MAX, line_num),
+ _validate_uom_catalog(row, line_num, valid_uom_codes),
+ check_tipo_advalorem(row, "TIPO_DE_ADVALOREM", line_num),
+ check_optional_decimal_min_zero(row, "ADVALOREM_PCT", line_num),
+ check_optional_decimal_min_zero(row, "ADVALOREM_DLLS", line_num),
+ ]:
+ if err:
+ errors.append(err)
+
+ return errors
def validate_row_us_tariff_fraction(
@@ -141,7 +135,7 @@ def validate_row_us_tariff_fraction(
existing_fraction_codes: Optional[Set[str]] = None,
valid_uom_codes: Optional[Set[str]] = None,
raw_row: Optional[Dict[str, Any]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de fracción arancelaria americana.
- raw_row: si se pasa y la 8ª columna tiene valor, se devuelve advertencia de desfase (no bloqueante; el caller decide si la trata como error).
@@ -154,9 +148,11 @@ def validate_row_us_tariff_fraction(
# Plan: "advertencia no bloqueante" → no añadimos a error_lines; guardamos en warnings. Para simplificar, desfase lo devolvemos como error de severidad warning y en tasks no lo añadimos a error_lines (solo a warnings_detail). Mejor: desfase retornamos None (no error) y el caller puede llamar a validate_row_desfase_fa por separado y acumular warnings. Así no bloqueamos. Entonces en validate_row_us_tariff_fraction no llamamos desfase como error; en tasks llamamos primero validate_row_desfase_fa y si hay warning lo guardamos en warnings_detail, luego llamamos validate_row_us_tariff_fraction que puede devolver error. OK.
# So we don't return desfase from validate_row_us_tariff_fraction; tasks will call validate_row_desfase_fa and collect warnings. So no change here for desfase inside this function.
+ errors: List[Dict[str, Any]] = []
+
err = _validate_col_a_required(row, line_num)
if err:
- return err
+ errors.append(err)
code_norm = normalize_code((row.get("FRACCION_ARANCELARIA") or "").strip())
fraction_exists = (
@@ -164,12 +160,17 @@ def validate_row_us_tariff_fraction(
)
if actualizar and not fraction_exists:
- return {"line": line_num, "col": "FRACCION_ARANCELARIA", "msg": MSG_FRACCION_NO_EXISTE}
+ errors.append({
+ "line": line_num,
+ "col": "FRACCION_ARANCELARIA",
+ "msg": MSG_FRACCION_NO_EXISTE,
+ })
use_full = not actualizar or not fraction_exists
if use_full:
err = _validate_col_d_required(row, line_num)
if err:
- return err
+ errors.append(err)
- return _validaciones_fraccioname(row, line_num, valid_uom_codes)
+ errors.extend(_validaciones_fraccioname(row, line_num, valid_uom_codes))
+ return errors
diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py
index 1d6c2e5f..3b13656c 100644
--- a/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/routes.py
@@ -28,6 +28,7 @@ from .tasks import (
VEHL_IMPORT_STATUS_PREFIX,
VEHL_IMPORT_REDIS_TTL,
)
+from ..common.error_csv import download_scan_errors_csv_stream
router = APIRouter()
logger = logging.getLogger(__name__)
@@ -193,3 +194,8 @@ async def commit_import_job(job_id: str):
"message": "Inserción iniciada.",
"commit_job_id": job_id,
}
+
+
+@router.get("/{job_id}/errors/scan-csv")
+async def download_scan_errors_csv(job_id: str):
+ return download_scan_errors_csv_stream("veh", job_id)
diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py
index 30436b5f..9bebb21a 100644
--- a/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/tasks.py
@@ -97,8 +97,17 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
row_norm = row_from_template(row, common_normalize.normalize_header)
# Desfase: advertencia no bloqueante (no se añade a error_lines)
- _ = validate_row_vehicle_desfase(row_norm, i)
- err = validate_row_vehicle(
+ warn = validate_row_vehicle_desfase(row_norm, i)
+ if warn and len(errors_detail) < 500:
+ errors_detail.append({
+ "line": warn.get("line", i),
+ "col": warn.get("col", ""),
+ "msg": warn.get("msg", ""),
+ "solution": warn.get("solution", ""),
+ "warning": True,
+ })
+
+ row_errors = validate_row_vehicle(
row_norm,
i,
actualizar=actualizar,
@@ -108,16 +117,25 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
)
- if err:
- error_count += 1
- error_lines_list.append(err["line"])
- f_err.write(json.dumps(err) + "\n")
+ if row_errors:
+ has_blocking_error = any(not e.get("warning", False) for e in row_errors)
+ if has_blocking_error:
+ error_count += 1
+ error_lines_list.append(i)
+ for e in row_errors:
+ if not e.get("warning", False):
+ f_err.write(json.dumps(e) + "\n")
if len(errors_detail) < 500:
- errors_detail.append({
- "line": err["line"],
- "col": err.get("col", ""),
- "msg": err.get("msg", ""),
- })
+ for e in row_errors:
+ if len(errors_detail) >= 500:
+ break
+ errors_detail.append({
+ "line": e.get("line", i),
+ "col": e.get("col", ""),
+ "msg": e.get("msg", ""),
+ "solution": e.get("solution", ""),
+ "warning": bool(e.get("warning", False)),
+ })
processed_rows += 1
if error_lines_list:
@@ -226,7 +244,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
continue
row_norm = row_from_template(row, common_normalize.normalize_header)
- err = validate_row_vehicle(
+ row_errors = validate_row_vehicle(
row_norm,
i,
actualizar=actualizar,
@@ -236,15 +254,18 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
)
- if err:
+ if row_errors and any(not e.get("warning", False) for e in row_errors):
skipped_invalid += 1
vk = (row_norm.get("CLAVE") or "").strip()[:14] or "-"
- skipped_details.append({
- "line": i,
- "vehicle_key": vk,
- "invoice": vk,
- "reason": f"{err.get('col', '')}: {err.get('msg', '')}",
- })
+ blocking_errors = [e for e in row_errors if not e.get("warning", False)]
+ for blocking in blocking_errors:
+ skipped_details.append({
+ "line": i,
+ "vehicle_key": vk,
+ "invoice": vk,
+ "reason": f"{blocking.get('col', '')}: {blocking.get('msg', '')}",
+ "solution": blocking.get("solution", ""),
+ })
continue
vk = (row_norm.get("CLAVE") or "").strip()[:14] or ""
@@ -258,6 +279,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
"vehicle_key": vk,
"invoice": vk,
"reason": "Clave duplicada en el archivo (se usa la primera)",
+ "solution": "El sistema conserva la primera ocurrencia; no dupliques el valor de CLAVE en el archivo.",
})
continue
seen_keys_in_file[vk] = i
@@ -287,7 +309,11 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
session.rollback()
skipped_invalid += 1
skipped_details.append({
- "line": i, "vehicle_key": vk, "invoice": vk, "reason": str(db_err),
+ "line": i,
+ "vehicle_key": vk,
+ "invoice": vk,
+ "reason": str(db_err),
+ "solution": "Revisar el valor de CLAVE del vehículo y los catálogos relacionados para corregir el error.",
})
continue
diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/validators/common.py
index 30a50c66..5eea7142 100644
--- a/backend/api/v1/modules/a76/layouts_csv/vehicles/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/validators/common.py
@@ -2,7 +2,7 @@
Validaciones comunes de fila para import CSV de vehículos.
Paridad Clarion: VALIDACIONES_TRANSPORTE, VALIDA_TODA_TRANSPORTE, VALIDA_PARCIAL_TRANSPORTE.
"""
-from typing import Dict, Any, Optional, Set, Tuple
+from typing import Dict, Any, Optional, Set, Tuple, List
from ..common.common_validators import (
MAX_LEN,
@@ -72,39 +72,25 @@ def validaciones_transporte(
valid_country_ame: Optional[Set[str]] = None,
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
VALIDACIONES_TRANSPORTE: reglas compartidas (longitudes, tipo transporte, código entidad dominio,
país, estado, estado-país). No exige CODIGO DE ENTIDAD obligatorio (eso es solo VALIDA_TODA).
"""
- err = validate_row_vehicle_required(row, line_num)
- if err:
- return err
- err = validate_row_vehicle_lengths(row, line_num)
- if err:
- return err
- err = validate_row_vehicle_amount(row, line_num)
- if err:
- return err
- err = validate_row_vehicle_insurance_date(row, line_num)
- if err:
- return err
- err = check_codigo_entidad_valores(row, line_num)
- if err:
- return err
- err = check_transport_type_catalog(row, line_num, valid_transport_codes)
- if err:
- return err
- err = check_pais_catalog_vehicles(row, line_num, valid_country_ame)
- if err:
- return err
- err = check_estado_catalog(row, line_num, state_descriptions_upper)
- if err:
- return err
- err = check_estado_pais_consistency(row, line_num, state_country_set)
- if err:
- return err
- return None
+ errors: List[Dict[str, Any]] = []
+ for err in [
+ validate_row_vehicle_lengths(row, line_num),
+ validate_row_vehicle_amount(row, line_num),
+ validate_row_vehicle_insurance_date(row, line_num),
+ check_codigo_entidad_valores(row, line_num),
+ check_transport_type_catalog(row, line_num, valid_transport_codes),
+ check_pais_catalog_vehicles(row, line_num, valid_country_ame),
+ check_estado_catalog(row, line_num, state_descriptions_upper),
+ check_estado_pais_consistency(row, line_num, state_country_set),
+ ]:
+ if err:
+ errors.append(err)
+ return errors
def valida_toda_transporte(
@@ -114,18 +100,22 @@ def valida_toda_transporte(
valid_country_ame: Optional[Set[str]] = None,
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDA_TODA_TRANSPORTE: CODIGO DE ENTIDAD obligatorio + VALIDACIONES_TRANSPORTE (registro nuevo)."""
+ errors: List[Dict[str, Any]] = []
err = check_codigo_entidad_required(row, line_num)
if err:
- return err
- return validaciones_transporte(
+ errors.append(err)
+ errors.extend(
+ validaciones_transporte(
row, line_num,
valid_transport_codes=valid_transport_codes,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
+ )
)
+ return errors
def valida_parcial_transporte(
@@ -135,7 +125,7 @@ def valida_parcial_transporte(
valid_country_ame: Optional[Set[str]] = None,
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""VALIDA_PARCIAL_TRANSPORTE: solo VALIDACIONES_TRANSPORTE (actualizar registro existente)."""
return validaciones_transporte(
row, line_num,
diff --git a/backend/api/v1/modules/a76/layouts_csv/vehicles/validators/create.py b/backend/api/v1/modules/a76/layouts_csv/vehicles/validators/create.py
index 15eaf052..002cc239 100644
--- a/backend/api/v1/modules/a76/layouts_csv/vehicles/validators/create.py
+++ b/backend/api/v1/modules/a76/layouts_csv/vehicles/validators/create.py
@@ -2,7 +2,7 @@
Punto de entrada de validación para import de una fila vehículo.
Paridad Clarion: desfase (advertencia), CLAVE vacía, VALIDA_TODA vs VALIDA_PARCIAL según actualizar y clave existente.
"""
-from typing import Dict, Any, Optional, Set, Tuple
+from typing import Dict, Any, Optional, Set, Tuple, List
from .common import (
validate_row_vehicle_required,
@@ -21,7 +21,7 @@ def validate_row_vehicle(
valid_country_ame: Optional[Set[str]] = None,
state_descriptions_upper: Optional[Set[str]] = None,
state_country_set: Optional[Set[Tuple[str, str]]] = None,
-) -> Optional[Dict[str, Any]]:
+) -> List[Dict[str, Any]]:
"""
Valida una fila de CSV de vehículos.
1. CLAVE vacía → error.
@@ -29,31 +29,36 @@ def validate_row_vehicle(
3. Si no actualizar o CLAVE no existe → valida_toda_transporte (CODIGO DE ENTIDAD obligatorio).
Desfase (COL_EXTRA) no se valida aquí; el caller puede llamar validate_row_vehicle_desfase para advertencias no bloqueantes.
"""
+ errors: List[Dict[str, Any]] = []
err = validate_row_vehicle_required(row, line_num)
if err:
- return err
+ errors.append(err)
existing = existing_vehicle_keys or set()
clave = (row.get("CLAVE") or "").strip()
use_partial = actualizar and bool(clave and clave in existing)
if use_partial:
- err = valida_parcial_transporte(
+ errors.extend(
+ valida_parcial_transporte(
row, line_num,
valid_transport_codes=valid_transport_codes,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
+ )
)
else:
- err = valida_toda_transporte(
+ errors.extend(
+ valida_toda_transporte(
row, line_num,
valid_transport_codes=valid_transport_codes,
valid_country_ame=valid_country_ame,
state_descriptions_upper=state_descriptions_upper,
state_country_set=state_country_set,
+ )
)
- return err
+ return errors
def validate_row_vehicle_desfase(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
diff --git a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/seed.py b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/seed.py
index 1df2120c..0537b53e 100644
--- a/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/seed.py
+++ b/backend/api/v1/modules/public/reference_data/code_pedimento_regimens/seed.py
@@ -33,8 +33,8 @@ seed = [
("H1", "EXD", "E"),
("H8", "EXD", "E"),
("I1", "EXD", "E"),
- # ("J1", "EXD", "E"),
- # ("J2", "EXD", "E"),
+ ("J1", "EXD", "E"),
+ ("J2", "EXD", "E"),
("K1", "EXD", "E"),
("K2", "EXD", "E"),
("K3", "EXD", "E"),
@@ -53,7 +53,7 @@ seed = [
("A1", "IMD", "I"),
("A3", "IMD", "I"),
("C1", "IMD", "I"),
- # ("C2", "IMD", "I"),
+ ("C2", "IMD", "I"),
("C3", "IMD", "I"),
("D1", "IMD", "I"),
("F3", "IMD", "I"),
@@ -78,19 +78,19 @@ seed = [
("V9", "IMD", "I"),
("VF", "IMD", "I"),
("VU", "IMD", "I"),
- # ("A2", "ITE", "I"),
- # ("A8", "ITE", "I"),
- # ("AA", "ITE", "I"),
+ ("A2", "ITE", "I"),
+ ("A8", "ITE", "I"),
+ ("AA", "ITE", "I"),
("AF", "ITE", "I"),
("E1", "ITE", "I"),
("E3", "ITE", "I"),
- # ("H3", "ITE", "I"),
+ ("H3", "ITE", "I"),
("IN", "ITE", "I"),
("R1", "ITE", "I"),
("V1", "ITE", "I"),
("A6", "ITR", "I"),
- # ("A7", "ITR", "I"),
- # ("A9", "ITR", "I"),
+ ("A7", "ITR", "I"),
+ ("A9", "ITR", "I"),
("AD", "ITR", "I"),
("AF", "ITR", "I"),
("AJ", "ITR", "I"),
@@ -104,7 +104,7 @@ seed = [
("BP", "ITR", "I"),
("E2", "ITR", "I"),
("E4", "ITR", "I"),
- # ("H3", "ITR", "I"),
+ ("H3", "ITR", "I"),
("R1", "ITR", "I"),
("V1", "ITR", "I"),
("V4", "ITR", "I"),
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index fadc28b4..a6df4b43 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -243,6 +243,29 @@ async function fetchApi(
}
}
+async function fetchBlob(endpoint: string, options: RequestInit = {}): Promise {
+ const token = getToken();
+ const headers: Record = {
+ ...((options.headers as Record) || {})
+ };
+ if (token) {
+ headers['Authorization'] = `Bearer ${token}`;
+ }
+
+ const response = await fetch(`${API_BASE_URL}${endpoint}`, {
+ ...options,
+ headers,
+ credentials: 'include'
+ });
+
+ if (!response.ok) {
+ // Try to extract some useful message for debugging/UI.
+ const text = await response.text().catch(() => '');
+ throw new Error(text || `Error ${response.status} descargando archivo`);
+ }
+ return await response.blob();
+}
+
// Métodos HTTP
export const api = {
get: (endpoint: string) => fetchApi(endpoint, { method: 'GET' }),
@@ -357,7 +380,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`),
commit: (jobId: string, modelTarget: string) =>
- api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget })
+ api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget }),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Operaciones de Exportación (encabezado y partidas).
@@ -391,7 +416,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/imports/exportacion/${jobId}/status`),
commit: (jobId: string, modelTarget: string) =>
- api.post(`/v1/a76/imports/exportacion/${jobId}/commit`, { model_target: modelTarget })
+ api.post(`/v1/a76/imports/exportacion/${jobId}/commit`, { model_target: modelTarget }),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/imports/exportacion/${jobId}/errors/scan-csv`)
},
// CSV import for Agentes Aduanales (flujo propio en customs_brokers/imports)
@@ -406,7 +433,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/customs-brokers/imports/${jobId}/status`),
commit: (jobId: string) =>
- api.post(`/v1/a76/customs-brokers/imports/${jobId}/commit`, {})
+ api.post(`/v1/a76/customs-brokers/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/customs-brokers/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Clientes y Proveedores (flujo propio en clients_and_providers/imports)
@@ -421,7 +450,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/clients-providers/imports/${jobId}/status`),
commit: (jobId: string) =>
- api.post(`/v1/a76/clients-providers/imports/${jobId}/commit`, {})
+ api.post(`/v1/a76/clients-providers/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/clients-providers/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Tipos de Cambio (flujo en general_catalogs/exchange_rate/imports)
@@ -445,7 +476,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/exchange-rate/imports/${jobId}/status`),
commit: (jobId: string) =>
- api.post(`/v1/a76/exchange-rate/imports/${jobId}/commit`, {})
+ api.post(`/v1/a76/exchange-rate/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/exchange-rate/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Fracción Americana (us_tariff_fractions/imports)
@@ -460,7 +493,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/us-tariff-fractions/imports/${jobId}/status`),
commit: (jobId: string) =>
- api.post(`/v1/a76/us-tariff-fractions/imports/${jobId}/commit`, {})
+ api.post(`/v1/a76/us-tariff-fractions/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/us-tariff-fractions/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Pedimentos (pedimentos/imports)
@@ -482,7 +517,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/pedimentos/imports/${jobId}/status`),
commit: (jobId: string) =>
- api.post(`/v1/a76/pedimentos/imports/${jobId}/commit`, {})
+ api.post(`/v1/a76/pedimentos/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/pedimentos/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Clases de Materiales (classes/imports)
@@ -504,7 +541,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/classes/imports/${jobId}/status`),
commit: (jobId: string) =>
- api.post(`/v1/a76/classes/imports/${jobId}/commit`, {})
+ api.post(`/v1/a76/classes/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/classes/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Vehículos / Transportes (transportation/vehicles/imports)
@@ -521,7 +560,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/transportation/vehicles/imports/${jobId}/status`),
commit: (jobId: string) =>
- api.post(`/v1/a76/transportation/vehicles/imports/${jobId}/commit`, {})
+ api.post(`/v1/a76/transportation/vehicles/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/transportation/vehicles/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Conductores (drivers/imports)
@@ -535,7 +576,9 @@ export const api = {
);
},
status: (jobId: string) => api.get(`/v1/a76/drivers/imports/${jobId}/status`),
- commit: (jobId: string) => api.post(`/v1/a76/drivers/imports/${jobId}/commit`, {})
+ commit: (jobId: string) => api.post(`/v1/a76/drivers/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/drivers/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Trailers y Cajas (transportation/trailers/imports)
@@ -551,7 +594,9 @@ export const api = {
},
status: (jobId: string) => api.get(`/v1/a76/transportation/trailers/imports/${jobId}/status`),
commit: (jobId: string) =>
- api.post(`/v1/a76/transportation/trailers/imports/${jobId}/commit`, {})
+ api.post(`/v1/a76/transportation/trailers/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/transportation/trailers/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Transportistas (transporters/imports)
@@ -566,7 +611,9 @@ export const api = {
);
},
status: (jobId: string) => api.get(`/v1/a76/transporters/imports/${jobId}/status`),
- commit: (jobId: string) => api.post(`/v1/a76/transporters/imports/${jobId}/commit`, {})
+ commit: (jobId: string) => api.post(`/v1/a76/transporters/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/transporters/imports/${jobId}/errors/scan-csv`)
},
// CSV import for Números de parte (parts/imports)
@@ -583,7 +630,9 @@ export const api = {
);
},
status: (jobId: string) => api.get(`/v1/a76/parts/imports/${jobId}/status`),
- commit: (jobId: string) => api.post(`/v1/a76/parts/imports/${jobId}/commit`, {})
+ commit: (jobId: string) => api.post(`/v1/a76/parts/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) =>
+ fetchBlob(`/v1/a76/parts/imports/${jobId}/errors/scan-csv`)
},
// CSV import for BOMs (boms/imports)
@@ -597,7 +646,8 @@ export const api = {
);
},
status: (jobId: string) => api.get(`/v1/a76/boms/imports/${jobId}/status`),
- commit: (jobId: string) => api.post(`/v1/a76/boms/imports/${jobId}/commit`, {})
+ commit: (jobId: string) => api.post(`/v1/a76/boms/imports/${jobId}/commit`, {}),
+ downloadScanErrorsCsv: (jobId: string) => fetchBlob(`/v1/a76/boms/imports/${jobId}/errors/scan-csv`)
},
// Generic request for custom needs (like file uploads)
diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte
index f6cbbd8d..154e6acb 100644
--- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte
+++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte
@@ -10,12 +10,14 @@
UploadCloud
} from 'lucide-svelte';
import { tick } from 'svelte';
+ import { api } from '$lib/api';
let {
open = $bindable(false),
scanResults = null,
commitResults = null,
isUploading = false,
+ scanErrorsDownloadType = 'imports',
onConfirm,
onCancel,
onClose
@@ -24,6 +26,21 @@
scanResults: any;
commitResults: any;
isUploading: boolean;
+ scanErrorsDownloadType?:
+ | 'imports'
+ | 'exportacion'
+ | 'customs_brokers'
+ | 'clients_providers'
+ | 'exchange_rate'
+ | 'us_tariff_fractions'
+ | 'pedimentos'
+ | 'classes'
+ | 'vehicles'
+ | 'drivers'
+ | 'trailers'
+ | 'transporters'
+ | 'parts'
+ | 'boms';
onConfirm: () => void;
onCancel: () => void;
onClose: () => void;
@@ -40,6 +57,10 @@
commitResults?.skipped_missing_fk > 0
);
+ let scanErrorsShown = $derived(Array.isArray(scanResults?.errors) ? scanResults.errors.length : 0);
+ let scanErrorsTotal = $derived(scanResults?.error_count || 0);
+ let scanErrorsTruncated = $derived(scanErrorsShown > 0 && scanErrorsShown < scanErrorsTotal);
+
let totalSkipped = $derived(
(commitResults?.skipped_invalid || 0) +
(commitResults?.skipped_missing_fk || 0) +
@@ -47,6 +68,12 @@
(commitResults?.skipped_duplicate || 0)
);
+ let insertedCount = $derived(commitResults?.inserted || 0);
+ let updatedCount = $derived(commitResults?.updated || 0);
+ // Algunos módulos usan `inserted` solo para altas y `updated` para modo "actualizar".
+ // Para la UI, reflejamos "cambios positivos" como insertados + actualizados.
+ let insertedOrUpdatedCount = $derived(insertedCount + updatedCount);
+
function handleOpenChange(newOpen: boolean) {
if (!newOpen) {
if (isPending) onCancel();
@@ -55,6 +82,124 @@
open = newOpen;
}
+ function csvEscape(value: unknown): string {
+ const s = value === null || value === undefined ? '' : String(value);
+ // Quote fields when they contain CSV-breaking characters.
+ if (/[",\n\r]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
+ return s;
+ }
+
+ function downloadCsv(filename: string, headers: string[], rows: Array>, columns: string[]) {
+ const delimiter = ','; // CSV por comas
+ const lines: string[] = [];
+ lines.push(headers.map(csvEscape).join(delimiter));
+ for (const r of rows) {
+ lines.push(columns.map((k) => csvEscape(r?.[k])).join(delimiter));
+ }
+ const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = filename;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ }
+
+ async function handleDownloadScanErrorsCsv() {
+ const errors = scanResults?.errors;
+ if (!Array.isArray(errors) || errors.length === 0) return;
+
+ const jobId = scanResults?.job_id;
+
+ // Try backend download (sin límite). If it fails, fallback to preview CSV.
+ if (jobId) {
+ try {
+ let blob: Blob;
+ switch (scanErrorsDownloadType) {
+ case 'exportacion':
+ blob = await api.exportacionImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'customs_brokers':
+ blob = await api.customsBrokerImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'clients_providers':
+ blob = await api.clientProviderImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'exchange_rate':
+ blob = await api.exchangeRateImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'us_tariff_fractions':
+ blob = await api.americanFractionImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'pedimentos':
+ blob = await api.pedimentosImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'classes':
+ blob = await api.materialClassImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'vehicles':
+ blob = await api.vehicleImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'drivers':
+ blob = await api.driverImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'trailers':
+ blob = await api.trailerImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'transporters':
+ blob = await api.transporterImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'parts':
+ blob = await api.partNumberImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'boms':
+ blob = await api.bomImports.downloadScanErrorsCsv(jobId);
+ break;
+ case 'imports':
+ default:
+ blob = await api.imports.downloadScanErrorsCsv(jobId);
+ break;
+ }
+
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = `errores_${jobId}.csv`;
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+ URL.revokeObjectURL(url);
+ return;
+ } catch (e) {
+ // fallback below
+ }
+ }
+
+ const filename = `errores_${Date.now()}.csv`;
+ downloadCsv(
+ filename,
+ ['linea', 'columna', 'mensaje', 'solucion'],
+ errors,
+ ['line', 'col', 'msg', 'solution']
+ );
+ }
+
+ function handleDownloadCommitSkippedDetailsCsv() {
+ const details = commitResults?.skipped_details;
+ if (!Array.isArray(details) || details.length === 0) return;
+
+ // Table columns: line, invoice, reason, solution
+ const filename = `errores_importacion_${Date.now()}.csv`;
+ downloadCsv(
+ filename,
+ ['linea', 'referencia', 'motivo', 'solucion'],
+ details,
+ ['line', 'invoice', 'reason', 'solution']
+ );
+ }
+
// When modal shows finished state with rejection details, scroll the detail table into view
$effect(() => {
if (open && isFinished && commitResults?.skipped_details?.length > 0) {
@@ -166,42 +311,63 @@
- {#if scanResults.errors && scanResults.errors.length > 0}
-
-
-
- Detalle de errores (para corregir en el CSV)
-
+ {#if scanResults.errors && scanResults.errors.length > 0}
+
+
+
+ Detalle de errores (para corregir en el CSV)
+
+
- {scanResults.errors.length} error(es)
+ {scanErrorsShown} de {scanErrorsTotal} error(es)
-
-
-
-
-
- Línea
- Columna
- Mensaje
-
-
-
- {#each scanResults.errors as err}
-
- {err.line}
- {err.col || '-'}
- {err.msg || '-'}
-
- {/each}
-
-
+
+
+ Descargar CSV
+
- {/if}
+
+
+
+
+ Línea
+ Columna
+ Mensaje
+ Solución
+
+
+
+ {#each scanResults.errors as err}
+
+ {err.line}
+ {err.col || '-'}
+ {err.msg || '-'}
+ {err.solution || '-'}
+
+ {/each}
+
+
+
+ {#if scanErrorsTruncated}
+
+ Para consultar el resto de errores, descargue el CSV.
+
+ {/if}
+
+ {:else}
+
+ Se detectaron {scanResults.error_count} fila(s) con errores pero el detalle no está disponible. Asegúrese de que el servidor esté actualizado y vuelva a subir el archivo.
+
+ {/if}
{:else}
@@ -230,8 +396,13 @@
>
{commitResults.inserted || 0} {insertedOrUpdatedCount}
+ {#if updatedCount > 0}
+
+ {insertedCount} insertados, {updatedCount} actualizados
+
+ {/if}
@@ -260,11 +431,21 @@
Detalle de Errores
-
- {commitResults.skipped_details.length} filas
-
+
+
+ {commitResults.skipped_details.length} filas
+
+
+
+ Descargar CSV
+
+
@@ -275,6 +456,7 @@
Línea
Referencia
Motivo
+ Solución
@@ -285,6 +467,7 @@
>{detail.invoice || '-'}
{detail.reason}
+ {detail.solution || '-'}
{/each}
diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte
index ea90ee62..80a8958d 100644
--- a/frontend/src/routes/dashboard/csv-upload/+page.svelte
+++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte
@@ -27,15 +27,20 @@
const validRowsMatch = s.match(/'valid_rows':\s*(\d+)/);
const error_count = errorCountMatch ? parseInt(errorCountMatch[1], 10) : 0;
const valid_rows = validRowsMatch ? parseInt(validRowsMatch[1], 10) : 0;
- const errors: { line: number; col: string; msg: string }[] = [];
- // Buscar cada bloque {'line': N, 'col': 'X', 'msg': '...'} en la cadena
- const errRegex = /\{'line':\s*(\d+),\s*'col':\s*'([^']*)',\s*'msg':\s*'((?:[^'\\]|\\.)*)'\}/g;
+ const errors: { line: number; col: string; msg: string; solution?: string; warning?: boolean }[] = [];
+ // Buscar cada diccionario de error en la cadena.
+ // Nota: el backend suele serializar dicts con claves extra (p.ej. 'solution', 'warning'),
+ // así que el regex no puede asumir que el dict termina justo después de 'msg'.
+ const errRegex =
+ /\{'line':\s*(\d+),\s*'col':\s*'([^']*)',\s*'msg':\s*'((?:[^'\\]|\\.)*)'(?:,\s*'solution':\s*'((?:[^'\\]|\\.)*)')?(?:,\s*'warning':\s*(True|False))?[^}]*\}/g;
let m: RegExpExecArray | null;
while ((m = errRegex.exec(s)) !== null) {
errors.push({
line: parseInt(m[1], 10),
col: m[2],
- msg: m[3].replace(/\\'/g, "'")
+ msg: m[3].replace(/\\'/g, "'"),
+ solution: m[4]?.replace(/\\'/g, "'") ?? '',
+ warning: m[5] ? m[5] === 'True' : false
});
}
return {
@@ -48,6 +53,84 @@
};
}
+ /** Intenta extraer un resultado tipo commit (finished/warning) desde repr Python. */
+ function parsePythonReprCommit(s: string): Record | null {
+ if (!s || typeof s !== 'string') return null;
+
+ const unescapePy = (v: string) =>
+ v.replace(/\\'/g, "'").replace(/\\\\n/g, '\n').replace(/\\\\t/g, '\t').replace(/\\\\r/g, '\r');
+
+ const safeInt = (value: unknown): number => {
+ if (value === null || value === undefined) return 0;
+ const raw = String(value).replace(/['"]/g, '').trim();
+ const n = parseInt(raw, 10);
+ return Number.isFinite(n) ? n : 0;
+ };
+
+ // Acepta tanto repr Python como JSON (comillas simples/dobles).
+ const statusMatch =
+ s.match(/['"]status['"]:\s*['"]([^'"]+)['"]/) || s.match(/['"]status['"]:\s*([^,}]+)/);
+ if (!statusMatch) return null;
+ const status = String(statusMatch[1] ?? '').replace(/['"]/g, '').trim();
+ if (status !== 'finished' && status !== 'warning') return null;
+
+ const insertedMatch = s.match(/['"]inserted['"]:\s*['"]?(-?\d+)['"]?/) || s.match(/['"]inserted['"]:\s*([^,}]+)/);
+ const inserted = insertedMatch ? safeInt(insertedMatch[1]) : 0;
+
+ const updatedMatch = s.match(/['"]updated['"]:\s*['"]?(-?\d+)['"]?/) || s.match(/['"]updated['"]:\s*([^,}]+)/);
+ const updated = updatedMatch ? safeInt(updatedMatch[1]) : 0;
+
+ const skippedInvalidMatch =
+ s.match(/['"]skipped_invalid['"]:\s*['"]?(\d+)['"]?/) || s.match(/['"]skipped_invalid['"]:\s*([^,}]+)/);
+ const skippedMissingFkMatch =
+ s.match(/['"]skipped_missing_fk['"]:\s*['"]?(\d+)['"]?/) || s.match(/['"]skipped_missing_fk['"]:\s*([^,}]+)/);
+ const skippedDuplicateMatch =
+ s.match(/['"]skipped_duplicate['"]:\s*['"]?(\d+)['"]?/) || s.match(/['"]skipped_duplicate['"]:\s*([^,}]+)/);
+
+ const skipped_invalid = skippedInvalidMatch ? safeInt(skippedInvalidMatch[1]) : 0;
+ const skipped_missing_fk = skippedMissingFkMatch
+ ? safeInt(skippedMissingFkMatch[1])
+ : 0;
+ const skipped_duplicate = skippedDuplicateMatch
+ ? safeInt(skippedDuplicateMatch[1])
+ : 0;
+
+ // Detalle opcional: lista de dicts con {line, reason, solution?}
+ const skipped_details: Array<{ line: number; reason: string; solution?: string }> = [];
+ const detailRegex =
+ /\{'line':\s*(\d+),\s*'reason':\s*'((?:[^'\\]|\\.)*)'(?:,\s*'solution':\s*'((?:[^'\\]|\\.)*)')?[^}]*\}/g;
+ let m: RegExpExecArray | null;
+ while ((m = detailRegex.exec(s)) !== null) {
+ skipped_details.push({
+ line: parseInt(m[1], 10),
+ reason: unescapePy(m[2]),
+ solution: m[3] ? unescapePy(m[3]) : undefined
+ });
+ }
+
+ const messageMatch =
+ s.match(/'message':\s*'((?:[^'\\]|\\.)*)'/) ||
+ s.match(/"message":\s*"((?:[^"\\]|\\.)*)"/);
+ const message = messageMatch ? unescapePy(messageMatch[1]) : undefined;
+
+ // Si no hay contadores en el texto, no es un commit.
+ if (inserted === 0 && updated === 0 && skipped_invalid === 0 && skipped_missing_fk === 0 && skipped_duplicate === 0) {
+ return null;
+ }
+
+ // Mínimo requerido por el modal
+ return {
+ status,
+ inserted,
+ updated,
+ skipped_invalid,
+ skipped_missing_fk,
+ skipped_duplicate,
+ skipped_details,
+ ...(message ? { message } : {})
+ };
+ }
+
// We no longer need modal state
let activeTab = $state('catalogos');
@@ -497,6 +580,23 @@
} else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') {
const errRaw = res.data.error;
// Si el backend devolvió el resultado del scan dentro de error (p. ej. string JSON), usarlo para mostrar el modal
+ const errStringCandidate =
+ typeof errRaw === 'string'
+ ? errRaw
+ : errRaw && typeof errRaw === 'object' && typeof (errRaw as any).message === 'string'
+ ? (errRaw as any).message
+ : null;
+
+ const parsedCommit = errStringCandidate ? parsePythonReprCommit(errStringCandidate) : null;
+ if (parsedCommit) {
+ commitResults = parsedCommit;
+ showResultModal = true;
+ toast.success('Importación completada. Revisa el listado de registros.');
+ isUploading = false;
+ currentJobId = null;
+ return;
+ }
+
let parsedScan: Record | null = null;
if (typeof errRaw === 'string' && (errRaw.includes('waiting_confirmation') || (errRaw.includes('total_rows') && errRaw.includes('job_id')))) {
try {
@@ -668,6 +768,35 @@
{scanResults}
{commitResults}
{isUploading}
+ scanErrorsDownloadType={
+ useCustomsBrokerImport
+ ? 'customs_brokers'
+ : useClientProviderImport
+ ? 'clients_providers'
+ : useExchangeRateImport
+ ? 'exchange_rate'
+ : useAmericanFractionImport
+ ? 'us_tariff_fractions'
+ : usePedimentosImport
+ ? 'pedimentos'
+ : useMaterialClassesImport
+ ? 'classes'
+ : useVehicleImport
+ ? 'vehicles'
+ : useDriverImport
+ ? 'drivers'
+ : useTrailerImport
+ ? 'trailers'
+ : useTransporterImport
+ ? 'transporters'
+ : usePartNumbersImport
+ ? 'parts'
+ : useBomImport
+ ? 'boms'
+ : useExportacionImport
+ ? 'exportacion'
+ : 'imports'
+ }
onConfirm={async () => {
if (!currentJobId) return;
try {