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) -
-
- - - - - - - - - - {#each scanResults.errors as err} - - - - - - {/each} - -
LíneaColumnaMensaje
{err.line}{err.col || '-'}{err.msg || '-'}
+
- {/if} +
+ + + + + + + + + + + {#each scanResults.errors as err} + + + + + + + {/each} + +
LíneaColumnaMensajeSolución
{err.line}{err.col || '-'}{err.msg || '-'}{err.solution || '-'}
+
+ {#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 + + +
@@ -275,6 +456,7 @@ + @@ -285,6 +467,7 @@ >{detail.invoice || '-'} + {/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 {
Línea Referencia MotivoSolución
{detail.reason}{detail.solution || '-'}