From afc0a35f1cd099593174624236379ca37cf2e453 Mon Sep 17 00:00:00 2001 From: hreyes Date: Wed, 4 Mar 2026 14:57:26 -0700 Subject: [PATCH] feature/clarion-validations-parts-csv --- .../v1/modules/a76/csv_templates/registry.py | 18 +- .../modules/a76/layouts_csv/classes/routes.py | 21 + .../a76/layouts_csv/common/csv_reader.py | 24 +- .../a76/layouts_csv/parts/common/fk_loader.py | 113 ++++- .../a76/layouts_csv/parts/common/mappers.py | 109 ++++- .../modules/a76/layouts_csv/parts/routes.py | 25 ++ .../v1/modules/a76/layouts_csv/parts/tasks.py | 167 ++++++-- .../a76/layouts_csv/parts/template_config.py | 104 ++++- .../layouts_csv/parts/validators/__init__.py | 4 +- .../layouts_csv/parts/validators/common.py | 386 +++++++++++++++++- .../layouts_csv/parts/validators/create.py | 97 ++++- frontend/src/lib/api.ts | 7 +- .../routes/dashboard/csv-upload/+page.svelte | 111 ++++- 13 files changed, 1087 insertions(+), 99 deletions(-) diff --git a/backend/api/v1/modules/a76/csv_templates/registry.py b/backend/api/v1/modules/a76/csv_templates/registry.py index 1f4dc3dc..32bd90fc 100644 --- a/backend/api/v1/modules/a76/csv_templates/registry.py +++ b/backend/api/v1/modules/a76/csv_templates/registry.py @@ -11,7 +11,10 @@ from api.v1.modules.a76.layouts_csv.facturas.template_config import ( TEMPLATE_COLUMNS as IMPORTS_TEMPLATE_COLUMNS, _resolve_template_columns as resolve_imports_template, ) -from api.v1.modules.a76.layouts_csv.parts.template_config import TEMPLATE_COLUMNS as PARTS_TEMPLATE_COLUMNS +from api.v1.modules.a76.layouts_csv.parts.template_config import ( + TEMPLATE_COLUMNS as PARTS_TEMPLATE_COLUMNS, + TEMPLATE_DOWNLOAD_HEADERS as PARTS_TEMPLATE_DOWNLOAD_HEADERS, +) from api.v1.modules.a76.layouts_csv.boms.template_config import TEMPLATE_COLUMNS as BOMS_TEMPLATE_COLUMNS from api.v1.modules.a76.layouts_csv.classes.template_config import ( TEMPLATE_COLUMNS as CLASSES_TEMPLATE_COLUMNS, @@ -59,9 +62,16 @@ def _build_registry() -> Dict[str, List[str]]: registry[tid] = _canonicals_from_columns(cols) # part_numbers (parts); "items" usa la misma plantilla - part_cols = PARTS_TEMPLATE_COLUMNS.get("part_numbers") - registry["part_numbers"] = _canonicals_from_columns(part_cols) - registry["items"] = _canonicals_from_columns(part_cols) + registry["part_numbers"] = ( + PARTS_TEMPLATE_DOWNLOAD_HEADERS + if PARTS_TEMPLATE_DOWNLOAD_HEADERS + else _canonicals_from_columns(PARTS_TEMPLATE_COLUMNS.get("part_numbers")) + ) + registry["items"] = ( + PARTS_TEMPLATE_DOWNLOAD_HEADERS + if PARTS_TEMPLATE_DOWNLOAD_HEADERS + else _canonicals_from_columns(PARTS_TEMPLATE_COLUMNS.get("part_numbers")) + ) # boms registry["boms"] = _canonicals_from_columns(BOMS_TEMPLATE_COLUMNS.get("boms")) 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 3af7cee1..338c97ea 100644 --- a/backend/api/v1/modules/a76/layouts_csv/classes/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/classes/routes.py @@ -124,6 +124,27 @@ async def get_import_status(job_id: str): if isinstance(result, dict) and result.get("status") in ("finished", "warning"): return result + # Si Celery devolvió el resultado como string (p. ej. JSON), parsear y devolver como scan si aplica + if isinstance(result, str): + try: + parsed = json.loads(result) + if isinstance(parsed, dict) and ( + parsed.get("status") == "waiting_confirmation" + or (parsed.get("job_id") and "total_rows" in parsed) + ): + return parsed + if isinstance(parsed, dict) and parsed.get("status") in ("finished", "warning"): + return parsed + except (json.JSONDecodeError, TypeError): + pass + + # Si el resultado tiene forma de escaneo (waiting_confirmation), devolverlo para que el front muestre el modal + if isinstance(result, dict) and ( + result.get("status") == "waiting_confirmation" + or (result.get("job_id") and "total_rows" in result) + ): + return result + logger.warning("Classes import task %s failed: state=%s", job_id, task_result.state) err_msg = None tb = getattr(task_result, "traceback", None) diff --git a/backend/api/v1/modules/a76/layouts_csv/common/csv_reader.py b/backend/api/v1/modules/a76/layouts_csv/common/csv_reader.py index a0e783b4..d5fb0c2e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/csv_reader.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/csv_reader.py @@ -1,12 +1,26 @@ """ Lectura de CSV con detección de delimitador (compartida por layouts_csv). Si se pasa fieldnames, no se usa la primera fila como cabecera y se toma como dato (CSV sin cabeceras). +Si fieldnames es None, cabeceras vacías se normalizan a _COL_0_, _COL_1_, ... para no colapsar columnas. """ import csv import io from typing import Iterator, Tuple, Dict, Any, Optional, List +def _normalize_empty_headers(headers: List[str]) -> List[str]: + """Sustituye cabeceras vacías por _COL_0_, _COL_1_, ... para que DictReader no colapse columnas.""" + result: List[str] = [] + empty_idx = 0 + for h in headers: + if (h or "").strip() == "": + result.append(f"_COL_{empty_idx}_") + empty_idx += 1 + else: + result.append(h) + return result + + def iter_csv_rows( file_path: str, fieldnames: Optional[List[str]] = None, @@ -29,7 +43,15 @@ def iter_csv_rows( for i, row in enumerate(reader, start=1): yield i, dict(row) else: - reader = csv.DictReader(f, dialect=dialect) + first_line = f.readline() + if not first_line: + return + row_reader = csv.reader(io.StringIO(first_line), dialect=dialect) + raw_headers = next(row_reader, None) + if not raw_headers: + return + normalized = _normalize_empty_headers(raw_headers) + reader = csv.DictReader(f, fieldnames=normalized, dialect=dialect, restval="") for i, row in enumerate(reader, start=1): yield i, row diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/common/fk_loader.py b/backend/api/v1/modules/a76/layouts_csv/parts/common/fk_loader.py index 35d9ba71..f173a0eb 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/common/fk_loader.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/common/fk_loader.py @@ -1,27 +1,65 @@ """ Carga de conjuntos FK para validación/mapeo de import CSV de partes. +Clarion: Clases, UOM, monedas, fracción Mex (+ histórico), países, sectores autorizados, excepción RFC. """ -from typing import Set, Tuple +from typing import Set, Tuple, Optional +import logging from core.database import CoreSessionLocal +logger = logging.getLogger(__name__) + +# RFCs con excepción: E (UOM) obligatorio solo cuando D (Clase) está vacía; Preferencia solo cuando D no vacía +RFC_EXCEPTION_SET = {"CLA940831AZ5", "CTE980130518"} + def load_parts_fk_sets( tenant_id: int, company_id: int, -) -> Tuple[Set[str], Set[str], Set[str]]: +) -> Tuple[ + Set[str], + Set[str], + Set[str], + Set[str], + Set[str], + Set[str], + bool, + bool, +]: """ - Carga valid_class_codes, valid_uom_codes, valid_currency_codes desde BD. - Devuelve (valid_class_codes, valid_uom_codes, valid_currency_codes). + Carga conjuntos para validación CSV de partes (paridad Clarion). + Devuelve: + - valid_class_codes + - valid_uom_codes + - valid_currency_codes (claves moneda para G=MC y genéricas) + - valid_fraction_mex_8 (fracción Mex 8 chars: TariffFraction + HistoricalTariffFraction) + - valid_country_m3 (códigos país m3_key) + - authorized_sector_keys (sectores con authorized=True) + - company_has_prosec (Company.prosec) + - is_rfc_exception (Company.rfc en RFC_EXCEPTION_SET) """ valid_class_codes: Set[str] = set() valid_uom_codes: Set[str] = set() valid_currency_codes: Set[str] = set() + valid_fraction_mex_8: Set[str] = set() + valid_country_m3: Set[str] = set() + authorized_sector_keys: Set[str] = set() + company_has_prosec = False + is_rfc_exception = False + try: with CoreSessionLocal() as session: from api.v1.modules.a76.classes.models import Class from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure from api.v1.modules.public.reference_data.currency_types.models import CurrencyType + from api.v1.modules.a76.general_catalogs.company.models import Company + from api.v1.modules.public.reference_data.countries.models import Country + from api.v1.modules.public.reference_data.sectors.models import Sector + from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction + from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import ( + HistoricalTariffFraction, + ) + for c in ( session.query(Class.class_code) .filter( @@ -30,7 +68,9 @@ def load_parts_fk_sets( ) .all() ): - valid_class_codes.add(c[0]) + if c[0]: + valid_class_codes.add((c[0] or "").strip().upper()) + for u in ( session.query(UnitOfMeasure.code) .filter( @@ -39,10 +79,63 @@ def load_parts_fk_sets( ) .all() ): - valid_uom_codes.add(u[0]) + if u[0]: + valid_uom_codes.add((u[0] or "").strip().upper()) + for cur in session.query(CurrencyType.code).all(): - valid_currency_codes.add(cur[0]) + if cur[0]: + valid_currency_codes.add((cur[0] or "").strip().upper()) + + company = ( + session.query(Company) + .filter(Company.id == company_id) + .first() + ) + if company: + company_has_prosec = bool(company.prosec) + rfc = (company.rfc or "").strip().upper() + is_rfc_exception = rfc in RFC_EXCEPTION_SET + + for row in session.query(Country.m3_key).all(): + if row[0]: + valid_country_m3.add((row[0] or "").strip().upper()) + + for row in ( + session.query(Sector.key) + .filter(Sector.authorized == True) + .all() + ): + if row[0]: + authorized_sector_keys.add((row[0] or "").strip().upper()) + + for row in session.query(TariffFraction.code).all(): + if row[0]: + code = (row[0] or "").strip() + valid_fraction_mex_8.add(code[:8]) + + for row in ( + session.query(HistoricalTariffFraction.historical_fraction) + .filter( + HistoricalTariffFraction.tenant_id == tenant_id, + HistoricalTariffFraction.company_id == company_id, + HistoricalTariffFraction.historical_fraction.isnot(None), + ) + .distinct() + .all() + ): + if row[0] and (row[0] or "").strip(): + valid_fraction_mex_8.add((row[0] or "").strip()[:8]) + except Exception as e: - import logging - logging.getLogger(__name__).warning("Parts import: could not load FK sets: %s", e) - return valid_class_codes, valid_uom_codes, valid_currency_codes + logger.warning("Parts import: could not load FK sets: %s", e) + + return ( + valid_class_codes, + valid_uom_codes, + valid_currency_codes, + valid_fraction_mex_8, + valid_country_m3, + authorized_sector_keys, + company_has_prosec, + is_rfc_exception, + ) diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/common/mappers.py b/backend/api/v1/modules/a76/layouts_csv/parts/common/mappers.py index d0d57713..75b32962 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/common/mappers.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/common/mappers.py @@ -1,6 +1,6 @@ """ Mapeo fila CSV → datos para Part. Helpers de normalización de valores. -Referencia: a76/invoices common/mappers.py +Clarion: ME/USD, MN/MXP, MC/ClaveMoneda; apóstrofes omitidos en NUMPARTE; RFC excepción rellena desde Class. """ from decimal import Decimal, InvalidOperation from typing import Dict, Any, Optional, Set @@ -48,6 +48,42 @@ def _bool_from_row(val: Any) -> bool: return True +def _normalize_num_parte(val: Optional[str], max_len: int = 70) -> Optional[str]: + """NUMPARTE: mayúsculas, sin apóstrofes (Clarion: Se Omitirá el Apostrofe).""" + if val is None: + return None + s = str(val).strip().replace("'", "") + if not s: + return None + s = s.upper() + if max_len and len(s) > max_len: + return s[:max_len] + return s + + +def _currency_from_row( + row_norm: Dict[str, Any], + valid_currency_codes: Set[str], +) -> tuple: + """ + Deriva currency_type y currency_key desde TIPOMONEDA (Col G) y CLAVEMONEDA (Col H). + Clarion: G vacío -> ME/USD; G=ME -> USD; G=MN -> MXP; G=MC -> H obligatoria. + """ + g = (row_norm.get("TIPOMONEDA") or row_norm.get("MONEDA") or "").strip().upper() + h = (row_norm.get("CLAVEMONEDA") or "").strip().upper() + if not g: + return "ME", "USD" + if g == "ME": + return "ME", "USD" + if g == "MN": + return "MN", "MXP" + if g == "MC" and h and h in valid_currency_codes: + return "MC", h + if h and h in valid_currency_codes: + return g[:2], h + return None, None + + def row_to_part_data( row_norm: Dict[str, Any], valid_class_codes: Set[str], @@ -56,29 +92,33 @@ def row_to_part_data( ) -> Dict[str, Any]: """ Mapea una fila normalizada del CSV a un diccionario de datos para Part. - Ajusta FKs opcionales (part_class, unit_of_measure, currency_key) a None si no están en los conjuntos. - El caller debe añadir tenant_id, company_id, client_id al crear Part. + Clarion: NUMPARTE sin apóstrofes; Tipo Moneda ME/MN/MC con Clave; PAIS, PREFERENCIA, SECTOR, RUTA IMAGEN. """ - part_number = _str_or_none(row_norm.get("NUMPARTE"), 70) + part_number = _normalize_num_parte(row_norm.get("NUMPARTE"), 70) commercial = _str_or_none(row_norm.get("NUMPARTECOM"), 70) desc_es = _str_or_none(row_norm.get("DESCRIPCIONE"), 500) desc_en = _str_or_none(row_norm.get("DESCRIPCIONI"), 500) part_class = _str_or_none(row_norm.get("CLASE"), 8) - if part_class and part_class not in valid_class_codes: + if part_class and part_class.upper() not in valid_class_codes: part_class = None unit_of_measure = _str_or_none(row_norm.get("UNIMED"), 5) - if unit_of_measure and unit_of_measure not in valid_uom_codes: + if unit_of_measure and unit_of_measure.upper() not in valid_uom_codes: unit_of_measure = None - currency_key = _str_or_none(row_norm.get("MONEDA"), 3) + + currency_type, currency_key = _currency_from_row(row_norm, valid_currency_codes) if currency_key and currency_key not in valid_currency_codes: currency_key = None + currency_type = None unit_cost = _decimal_or_none(row_norm.get("COSTOUNIT")) - currency_type = _str_or_none(row_norm.get("MONEDA"), 2) if currency_key else None unit_weight = _decimal_or_none(row_norm.get("PESOUNIT")) weight_type = _str_or_none(row_norm.get("TIPOPESO"), 6) + if weight_type and weight_type.upper() not in ("KILOS", "LIBRAS"): + weight_type = "KILOS" fraction = _str_or_none(row_norm.get("FRACCION"), 10) us_fraction = _str_or_none(row_norm.get("FRACCIONAME"), 16) + part_photo = _str_or_none(row_norm.get("RUTAIMAGEN"), 255) + fda_key = _str_or_none(row_norm.get("FDAKEY"), 20) fcc_key = _str_or_none(row_norm.get("FCCKEY"), 30) license_code = _str_or_none(row_norm.get("LICENCIA"), 3) @@ -101,6 +141,7 @@ def row_to_part_data( "weight_type": weight_type, "fraction": fraction, "us_fraction": us_fraction, + "part_photo": part_photo, "fda_key": fda_key, "fcc_key": fcc_key, "license_code": license_code, @@ -109,3 +150,55 @@ def row_to_part_data( "exclusion_symbol": exclusion_symbol, "is_active": is_active, } + + +def row_to_part_data_merge_existing( + row_norm: Dict[str, Any], + existing_data: Dict[str, Any], + valid_class_codes: Set[str], + valid_uom_codes: Set[str], + valid_currency_codes: Set[str], +) -> Dict[str, Any]: + """ + Para modo Actualizar (parcial): valores del CSV si no vacíos, sino los de la parte existente (Clarion VALIDA_PARCIAL_PARTES). + """ + data = row_to_part_data( + row_norm, valid_class_codes, valid_uom_codes, valid_currency_codes + ) + if not data.get("part_number"): + return data + merge_keys = ( + "description_spanish", + "description_english", + "part_class", + "unit_of_measure", + "unit_cost", + "currency_type", + "currency_key", + "unit_weight", + "weight_type", + "fraction", + "us_fraction", + "part_photo", + ) + for key in merge_keys: + val = data.get(key) + if val is None or (isinstance(val, str) and not val.strip()): + data[key] = existing_data.get(key) + return data + + +def apply_rfc_exception_from_class( + data: Dict[str, Any], + class_obj: Any, +) -> Dict[str, Any]: + """ + Clarion LLENA_PARTES: si empresa CLA/CTE y part_class informado, sobrescribir desde la clase: + UniMed, DescripcionE, DescripcionI, Fraccion, FraccionAme, TipoFraccion='GENERAL'. + """ + data["unit_of_measure"] = getattr(class_obj, "unit_of_measure", None) or data.get("unit_of_measure") + data["description_spanish"] = getattr(class_obj, "description_es", None) or data.get("description_spanish") + data["description_english"] = getattr(class_obj, "description_en", None) or data.get("description_english") + data["fraction"] = getattr(class_obj, "fraction", None) or data.get("fraction") + data["us_fraction"] = getattr(class_obj, "us_fraction", None) or data.get("us_fraction") + return data 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 9f1befa4..364b6ee7 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/routes.py @@ -40,6 +40,8 @@ def _get_redis(): async def upload_import_file( file: UploadFile = File(...), company_id: int = Query(..., description="Company ID"), + actualizar: bool = Query(False, description="Modo actualizar (ACT): validación parcial si la parte existe"), + reemplazar_sin_preguntar: bool = Query(True, description="Si False, solo agregar nuevas (no sobrescribir existentes)"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): @@ -60,6 +62,8 @@ async def upload_import_file( "company_id": company_id, "user_id": current_user.get("id"), "template_id": "part_numbers", + "actualizar": actualizar, + "reemplazar_sin_preguntar": reemplazar_sin_preguntar, } try: @@ -120,6 +124,27 @@ async def get_import_status(job_id: str): if isinstance(result, dict) and result.get("status") in ("finished", "warning"): return result + # Si Celery devolvió el resultado como string (p. ej. JSON), parsear y devolver como scan si aplica + if isinstance(result, str): + try: + parsed = json.loads(result) + if isinstance(parsed, dict) and ( + parsed.get("status") == "waiting_confirmation" + or (parsed.get("job_id") and "total_rows" in parsed) + ): + return parsed + if isinstance(parsed, dict) and parsed.get("status") in ("finished", "warning"): + return parsed + except (json.JSONDecodeError, TypeError): + pass + + # Si el resultado tiene forma de escaneo (waiting_confirmation), devolverlo para que el front muestre el modal + if isinstance(result, dict) and ( + result.get("status") == "waiting_confirmation" + or (result.get("job_id") and "total_rows" in result) + ): + return result + logger.warning("Parts import task %s failed: state=%s", job_id, task_result.state) err_msg = None tb = getattr(task_result, "traceback", None) 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 7d116e61..7c878b19 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/tasks.py @@ -1,7 +1,7 @@ """ Tareas Celery para importación CSV de Números de Parte. Flujo: scan_file (validación) → insert_valid_rows (commit). -Orquestación usa common (storage, normalize, csv_reader, meta, responses) y common.fk_loader. +Paridad Clarion: actualizar (ACT), validación full/parcial, merge existente, reemplazar_sin_preguntar, RFC desde clase. """ import json import logging @@ -16,9 +16,13 @@ from ..common import normalize as common_normalize from ..common import csv_reader as common_csv from ..common import meta as common_meta from ..common import responses as common_responses -from .template_config import row_from_template -from .validators import validate_row_part -from .common.mappers import row_to_part_data +from .template_config import row_from_template, detect_headers_or_data +from .validators import validate_row_part, get_row_warnings +from .common.mappers import ( + row_to_part_data, + row_to_part_data_merge_existing, + apply_rfc_exception_from_class, +) from .common.fk_loader import load_parts_fk_sets logger = logging.getLogger(__name__) @@ -43,8 +47,9 @@ def scan_file(self, job_id: str, config: str = None): error_path = common_storage.error_path_for_job(JOB_TYPE, job_id) + fieldnames, has_header = detect_headers_or_data(file_path, common_normalize.normalize_header) try: - total_rows = common_csv.count_csv_rows(file_path) + total_rows = common_csv.count_csv_rows(file_path, has_header=has_header) except Exception as e: return {"status": "failed", "error": str(e)} @@ -53,7 +58,32 @@ def scan_file(self, job_id: str, config: str = None): except ValueError as e: return {"status": "failed", "error": str(e)} - valid_class_codes, valid_uom_codes, valid_currency_codes = load_parts_fk_sets(tenant_id, company_id) + meta = common_meta.load_meta(file_path) + actualizar = meta.get("actualizar", False) + + ( + valid_class_codes, + valid_uom_codes, + valid_currency_codes, + valid_fraction_mex_8, + valid_country_m3, + authorized_sector_keys, + company_has_prosec, + is_rfc_exception, + ) = load_parts_fk_sets(tenant_id, company_id) + + from api.v1.modules.a76.parts.models import Part + existing_part_numbers = set() + try: + with CoreSessionLocal() as session: + for p in session.query(Part.part_number).filter( + Part.tenant_id == tenant_id, + Part.company_id == company_id, + ).all(): + if p[0]: + existing_part_numbers.add((p[0] or "").strip().upper()) + except Exception as e: + logger.warning("Parts import: could not load existing part numbers: %s", e) error_count = 0 processed_rows = 0 @@ -62,7 +92,7 @@ def scan_file(self, job_id: str, config: str = None): try: with open(error_path, "w", encoding="utf-8") as f_err: - for i, row in common_csv.iter_csv_rows(file_path): + for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames): if i % 500 == 0: self.update_state( state="PROGRESS", @@ -75,6 +105,13 @@ def scan_file(self, job_id: str, config: str = None): valid_class_codes=valid_class_codes, valid_uom_codes=valid_uom_codes, valid_currency_codes=valid_currency_codes, + valid_fraction_mex_8=valid_fraction_mex_8, + valid_country_m3=valid_country_m3, + authorized_sector_keys=authorized_sector_keys, + company_has_prosec=company_has_prosec, + is_rfc_exception=is_rfc_exception, + actualizar=actualizar, + existing_part_numbers=existing_part_numbers, ) if err: error_count += 1 @@ -86,6 +123,14 @@ def scan_file(self, job_id: str, config: str = None): "col": err.get("col", ""), "msg": err.get("msg", ""), }) + 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: @@ -120,9 +165,23 @@ def insert_valid_rows(self, job_id: str): except ValueError as e: return {"status": "failed", "error": str(e)} - from api.v1.modules.a76.parts.models import Part + meta = common_meta.load_meta(file_path) + actualizar = meta.get("actualizar", False) + reemplazar_sin_preguntar = meta.get("reemplazar_sin_preguntar", True) - valid_class_codes, valid_uom_codes, valid_currency_codes = load_parts_fk_sets(tenant_id, company_id) + from api.v1.modules.a76.parts.models import Part + from api.v1.modules.a76.classes.models import Class + + ( + valid_class_codes, + valid_uom_codes, + valid_currency_codes, + valid_fraction_mex_8, + valid_country_m3, + authorized_sector_keys, + company_has_prosec, + is_rfc_exception, + ) = load_parts_fk_sets(tenant_id, company_id) inserted_count = 0 skipped_invalid = 0 @@ -132,27 +191,45 @@ def insert_valid_rows(self, job_id: str): response = None meta_path = common_meta.get_meta_path(file_path) + fieldnames, _ = detect_headers_or_data(file_path, common_normalize.normalize_header) + try: with CoreSessionLocal() as session: - existing_by_part_number = { - p.part_number: p - for p in session.query(Part).filter( - Part.tenant_id == tenant_id, - Part.company_id == company_id, - ).all() - } + existing_by_part_number = {} + for p in session.query(Part).filter( + Part.tenant_id == tenant_id, + Part.company_id == company_id, + ).all(): + key = (p.part_number or "").strip().upper() + if key: + existing_by_part_number[key] = p - for i, row in common_csv.iter_csv_rows(file_path): + for i, row in common_csv.iter_csv_rows(file_path, fieldnames=fieldnames): if i in error_lines: continue row_norm = row_from_template(row, common_normalize.normalize_header) + part_number_raw = (row_norm.get("NUMPARTE") or "").strip().upper() + use_partial = ( + actualizar + and part_number_raw + and part_number_raw in existing_by_part_number + and reemplazar_sin_preguntar + ) + err = validate_row_part( row_norm, i, valid_class_codes=valid_class_codes, valid_uom_codes=valid_uom_codes, valid_currency_codes=valid_currency_codes, + valid_fraction_mex_8=valid_fraction_mex_8, + valid_country_m3=valid_country_m3, + authorized_sector_keys=authorized_sector_keys, + company_has_prosec=company_has_prosec, + is_rfc_exception=is_rfc_exception, + actualizar=actualizar, + existing_part_numbers=set(existing_by_part_number.keys()), ) if err: skipped_invalid += 1 @@ -162,21 +239,63 @@ def insert_valid_rows(self, job_id: str): }) continue - data = row_to_part_data( - row_norm, - valid_class_codes, - valid_uom_codes, - valid_currency_codes, - ) + if use_partial: + existing = existing_by_part_number.get(part_number_raw) + existing_data = { + "description_spanish": existing.description_spanish, + "description_english": existing.description_english, + "part_class": existing.part_class, + "unit_of_measure": existing.unit_of_measure, + "unit_cost": existing.unit_cost, + "currency_type": existing.currency_type, + "currency_key": existing.currency_key, + "unit_weight": existing.unit_weight, + "weight_type": existing.weight_type, + "fraction": existing.fraction, + "us_fraction": existing.us_fraction, + "part_photo": existing.part_photo, + } + data = row_to_part_data_merge_existing( + row_norm, + existing_data, + valid_class_codes, + valid_uom_codes, + valid_currency_codes, + ) + else: + data = row_to_part_data( + row_norm, + valid_class_codes, + valid_uom_codes, + valid_currency_codes, + ) + part_number = data.get("part_number") if not part_number: skipped_invalid += 1 continue + if is_rfc_exception and data.get("part_class"): + class_obj = ( + session.query(Class) + .filter( + Class.tenant_id == tenant_id, + Class.company_id == company_id, + Class.class_code == (data.get("part_class") or "").strip().upper(), + ) + .first() + ) + if class_obj: + data = apply_rfc_exception_from_class(data, class_obj) + existing = existing_by_part_number.get(part_number) if existing: + if not reemplazar_sin_preguntar: + skipped_duplicate += 1 + continue for key, value in data.items(): - setattr(existing, key, value) + if hasattr(existing, key): + setattr(existing, key, value) session.add(existing) inserted_count += 1 else: diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py b/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py index 7cc240c4..5a5e9da7 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/template_config.py @@ -1,23 +1,111 @@ """ -Configuración de plantilla CSV para Números de Parte (EstructuraCatPartesAF.xls). +Configuración de plantilla CSV para Números de Parte (EstructuraCatPartesAF). +Cabeceras de descarga = Clarion: NUMERO DE PARTE, DESCRIPCION EN ESPAÑOL, etc. """ +import csv +import io +from typing import Dict, List, Any, Optional, Tuple -from typing import Dict, List, Any + +# Valores que indican que la primera fila es cabecera (primera columna normalizada) +FIRST_COLUMN_HEADER_VALUES = ("NUMERO DE PARTE", "NUMPARTE") + + +def detect_headers_or_data( + file_path: str, + normalize_header_fn, + encoding: str = "utf-8-sig", +) -> Tuple[Optional[List[str]], bool]: + """ + Lee la primera línea del CSV y decide si es cabecera o dato. + - Si la primera celda normalizada está en FIRST_COLUMN_HEADER_VALUES -> has_header=True, fieldnames=None. + - Si no -> has_header=False, fieldnames=TEMPLATE_DOWNLOAD_HEADERS (primera fila = dato). + """ + try: + with open(file_path, "r", encoding=encoding) as f: + sample = f.read(2048) + except Exception: + return None, True + lines = sample.splitlines() + if not lines: + return None, True + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except Exception: + dialect = csv.excel + reader = csv.reader(io.StringIO(lines[0]), dialect=dialect) + first_row = next(reader, None) + if not first_row: + return None, True + first_cell = (first_row[0] or "").strip() + first_cell_norm = normalize_header_fn(first_cell) + if first_cell_norm in FIRST_COLUMN_HEADER_VALUES: + return None, True + return list(TEMPLATE_FIELDNAMES_FOR_READING), False + + +# Cabeceras que se escriben al descargar la plantilla CSV (igual que Clarion EstructuraCatPartesAF) +# Columnas I y J vacías en Clarion: dos comas entre CLAVE MONEDA y PESO UNITARIO (espacios en blanco separados por coma) +TEMPLATE_DOWNLOAD_HEADERS: List[str] = [ + "NUMERO DE PARTE", + "DESCRIPCION EN ESPAÑOL", + "DESCRIPCION EN INGLES", + "CLASE", + "UNIDAD DE MEDIDA COMERCIAL", + "COSTO UNITARIO", + "TIPO MONEDA COSTO", + "CLAVE MONEDA", + "", # Col I vacía + "", # Col J vacía + "PESO UNITARIO", + "TIPO PESO", + "FRACCION", + "PAIS", + "PREFERENCIA", + "SECTOR", + "RUTA DE LA IMAGEN", +] + +# Para lectura cuando la primera fila es dato (sin cabecera): nombres únicos para columnas I y J +TEMPLATE_FIELDNAMES_FOR_READING: List[str] = [ + "NUMERO DE PARTE", + "DESCRIPCION EN ESPAÑOL", + "DESCRIPCION EN INGLES", + "CLASE", + "UNIDAD DE MEDIDA COMERCIAL", + "COSTO UNITARIO", + "TIPO MONEDA COSTO", + "CLAVE MONEDA", + "_COL_I_", + "_COL_J_", + "PESO UNITARIO", + "TIPO PESO", + "FRACCION", + "PAIS", + "PREFERENCIA", + "SECTOR", + "RUTA DE LA IMAGEN", +] TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { "part_numbers": [ - {"canonical": "NUMPARTE", "aliases": ["NUMERO PARTE", "PART NUMBER", "NUM PARTE"]}, + {"canonical": "NUMPARTE", "aliases": ["NUMERO DE PARTE", "NUMERO PARTE", "PART NUMBER", "NUM PARTE"]}, {"canonical": "NUMPARTECOM", "aliases": ["NUMERO PARTE COMERCIAL", "COMMERCIAL PART", "PARTE COMERCIAL"]}, - {"canonical": "DESCRIPCIONE", "aliases": ["DESCRIPCION", "DESCRIPCION ES", "DESC ESPANOL"]}, - {"canonical": "DESCRIPCIONI", "aliases": ["DESCRIPCION EN", "DESCRIPTION", "DESC INGLES"]}, + {"canonical": "DESCRIPCIONE", "aliases": ["DESCRIPCION EN ESPAÑOL", "DESCRIPCION EN ESPANOL", "DESCRIPCION", "DESCRIPCION ES", "DESC ESPANOL"]}, + {"canonical": "DESCRIPCIONI", "aliases": ["DESCRIPCION EN INGLES", "DESCRIPCION EN", "DESCRIPTION", "DESC INGLES"]}, {"canonical": "CLASE", "aliases": ["CLASS", "CLASE MATERIAL", "PART CLASS"]}, - {"canonical": "UNIMED", "aliases": ["UNIDAD MEDIDA", "UNIT", "UOM", "UNIT OF MEASURE"]}, + {"canonical": "UNIMED", "aliases": ["UNIDAD DE MEDIDA COMERCIAL", "UNIDAD MEDIDA", "UNIT", "UOM", "UNIT OF MEASURE"]}, {"canonical": "COSTOUNIT", "aliases": ["COSTO UNITARIO", "UNIT COST", "COSTO"]}, - {"canonical": "MONEDA", "aliases": ["CURRENCY", "MONEDA CLAVE", "CURRENCY KEY"]}, + {"canonical": "TIPOMONEDA", "aliases": ["TIPO MONEDA COSTO", "TIPO MONEDA", "MONEDA"]}, + {"canonical": "CLAVEMONEDA", "aliases": ["CLAVE MONEDA", "CURRENCY", "MONEDA CLAVE", "CURRENCY KEY"]}, {"canonical": "PESOUNIT", "aliases": ["PESO UNITARIO", "UNIT WEIGHT", "PESO"]}, - {"canonical": "TIPOPESO", "aliases": ["WEIGHT TYPE", "TIPO PESO"]}, + {"canonical": "TIPOPESO", "aliases": ["TIPO PESO", "WEIGHT TYPE"]}, {"canonical": "FRACCION", "aliases": ["FRACCION MEX"]}, {"canonical": "FRACCIONAME", "aliases": ["FRACCION USA", "US FRACTION"]}, + {"canonical": "PAIS", "aliases": ["PAIS", "COUNTRY"]}, + {"canonical": "PREFERENCIA", "aliases": ["PREFERENCIA", "PREFERENCIA ARANCELARIA"]}, + {"canonical": "SECTOR", "aliases": ["SECTOR"]}, + {"canonical": "RUTAIMAGEN", "aliases": ["RUTA DE LA IMAGEN", "RUTA IMAGEN", "FOTO"]}, {"canonical": "FDAKEY", "aliases": ["FDA", "FDA KEY"]}, {"canonical": "FCCKEY", "aliases": ["FCC", "FCC KEY"]}, {"canonical": "LICENCIA", "aliases": ["LICENSE CODE", "LICENSE"]}, diff --git a/backend/api/v1/modules/a76/layouts_csv/parts/validators/__init__.py b/backend/api/v1/modules/a76/layouts_csv/parts/validators/__init__.py index cf0b1d03..18aa9521 100644 --- a/backend/api/v1/modules/a76/layouts_csv/parts/validators/__init__.py +++ b/backend/api/v1/modules/a76/layouts_csv/parts/validators/__init__.py @@ -1,4 +1,4 @@ # validators for parts CSV import row validation -from .create import validate_row_part +from .create import validate_row_part, validate_row_part_partial, get_row_warnings -__all__ = ["validate_row_part"] +__all__ = ["validate_row_part", "validate_row_part_partial", "get_row_warnings"] 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 b21147d9..ef48142f 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 @@ -1,35 +1,99 @@ """ Validaciones comunes de fila para import CSV de partes. -Usa helpers de common.common_validators; agrupa por tipo (requeridos, longitudes, tipos). -Referencia: a76/invoices imports/temporary/validators/common.py +Paridad Clarion: VALIDA_TODA_PARTES (obligatorios A, B, E salvo excepción RFC), VALIDACIONES_PARTES. """ -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Set from ..common.common_validators import check_max_length, check_decimal +MSG_NUMPARTE_VACIO = ( + "Error: (Col. A) La columna de Número de Parte esta vacio y no se pueden hacer las validaciones. " + "Capturar en la Columna A un Número de Parte nuevo o una ya existente a la cual desee actualizar campos" +) +NUMPARTE_MAX_LEN_CLARION = 30 +NUMPARTE_MAX_LEN_DB = 70 + + def validate_row_required(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: - """Valida campos obligatorios de una fila de parte.""" - return check_max_length(row, "NUMPARTE", 70, line_num, required=True) + """Col A (NUMPARTE) obligatorio; mensaje Clarion si está vacío.""" + val = (row.get("NUMPARTE") or "").strip() + if not val: + return {"line": line_num, "col": "NUMPARTE", "msg": MSG_NUMPARTE_VACIO} + if len(val) > NUMPARTE_MAX_LEN_DB: + return { + "line": line_num, + "col": "NUMPARTE", + "msg": f"Error: (Col. A) El Número de Parte: {val} supera la longitud de caracteres. " + f"Capturar en la columna A un Número de Parte de {NUMPARTE_MAX_LEN_DB} caracteres como máximo.", + } + return None + + +def validate_row_required_full( + row: Dict[str, Any], + line_num: int, + is_rfc_exception: bool = False, +) -> Optional[Dict[str, Any]]: + """Obligatorios en validación completa: A, B; y E (U.M. Comercial) salvo excepción RFC con D vacía.""" + err = validate_row_required(row, line_num) + if err: + return err + if not (row.get("DESCRIPCIONE") or "").strip(): + return { + "line": line_num, + "col": "DESCRIPCIONE", + "msg": "Existen campos vacios que son obligatorios, es la (Col.B) Descripción Español. " + "Revisar la línea del archivo y capturar los campos con la información correcta.", + } + # E obligatorio salvo que empresa sea excepción RFC y Col D (Clase) esté vacía + col_d = (row.get("CLASE") or "").strip() + if not is_rfc_exception or col_d: + if not (row.get("UNIMED") or "").strip(): + return { + "line": line_num, + "col": "UNIMED", + "msg": "Existen campos vacios que son obligatorios, es la (Col.E) U.M. Comercial. " + "Revisar la línea del archivo y capturar los campos con la información correcta.", + } + return None + + +def validate_row_required_act_part_exists( + row: Dict[str, Any], + line_num: int, + existing_part_numbers: Set[str], +) -> Optional[Dict[str, Any]]: + """Cuando actualizar=True y se hace validación full: el número de parte debe existir en catálogo.""" + num_parte = (row.get("NUMPARTE") or "").strip().upper() + if not num_parte: + return None + if num_parte not in existing_part_numbers: + return { + "line": line_num, + "col": "NUMPARTE", + "msg": "(Col.A) El número de parte no existe.", + } + return None def validate_row_lengths(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: - """Valida longitudes máximas de todos los campos de texto.""" + """Longitudes máximas (Clarion: Col A 30; resto según modelo).""" + err = check_max_length(row, "NUMPARTE", NUMPARTE_MAX_LEN_DB, line_num) + if err: + return err checks = [ - ("NUMPARTECOM", 70), ("DESCRIPCIONE", 500), ("DESCRIPCIONI", 500), ("CLASE", 8), ("UNIMED", 5), - ("MONEDA", 3), + ("TIPOMONEDA", 3), + ("CLAVEMONEDA", 3), ("FRACCION", 10), - ("FRACCIONAME", 16), - ("FDAKEY", 20), - ("FCCKEY", 30), - ("LICENCIA", 3), - ("ECCN", 20), - ("EXPORTCODE", 2), - ("EXCLUSION", 19), + ("PAIS", 3), + ("PREFERENCIA", 7), + ("SECTOR", 5), + ("RUTAIMAGEN", 255), ("TIPOPESO", 6), ] for col, max_len in checks: @@ -40,7 +104,7 @@ def validate_row_lengths(row: Dict[str, Any], line_num: int) -> Optional[Dict[st def validate_row_types(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: - """Valida tipos numéricos (decimales) de la fila.""" + """Tipos numéricos (decimales) de la fila.""" err = check_decimal(row, "COSTOUNIT", line_num) if err: return err @@ -48,3 +112,293 @@ def validate_row_types(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, if err: return err return None + + +def validate_row_class_fk( + row: Dict[str, Any], + line_num: int, + valid_class_codes: Optional[Set[str]], +) -> Optional[Dict[str, Any]]: + """Col D (Clase): si no vacía, debe existir en catálogo de clases.""" + val = (row.get("CLASE") or "").strip() + if not val or valid_class_codes is None: + return None + if val.upper() in valid_class_codes: + return None + return { + "line": line_num, + "col": "CLASE", + "msg": f"Error: (Col. D) La Clase: {val} no existe en el Catálogo de Clases de Activo Fijo. " + "Dar de alta la Clase en el Catálogo de Clases de Activo Fijo.", + } + + +def validate_row_uom_fk( + row: Dict[str, Any], + line_num: int, + valid_uom_codes: Optional[Set[str]], +) -> Optional[Dict[str, Any]]: + """Col E (U.M. Comercial): si no vacía, debe existir en catálogo UOM.""" + val = (row.get("UNIMED") or "").strip() + if not val or valid_uom_codes is None: + return None + if val.upper() in valid_uom_codes: + return None + return { + "line": line_num, + "col": "UNIMED", + "msg": f"Error: (Col. E) La Unidad de Medida Comercial: {val} no existe en el Catálogo de U.M. " + "Revisar esta Unidad de Medida en el archivo, en caso de ser correcta dar la de alta en el Catálogo de U.M.", + } + + +def validate_row_tipo_moneda( + row: Dict[str, Any], + line_num: int, +) -> Optional[Dict[str, Any]]: + """Col G (Tipo Moneda): solo ME, MN o MC (o vacío).""" + val = (row.get("TIPOMONEDA") or "").strip().upper() + if not val: + return None + if val in ("ME", "MN", "MC"): + return None + return { + "line": line_num, + "col": "TIPOMONEDA", + "msg": f"Error: (Col. G) La opción de Tipo Moneda: {val} no es valida. " + "Capturar una opción valida: ME para Dolares, MN para Pesos, MC para Moneda de Captura o dejar el campo vacio.", + } + + +def validate_row_clave_moneda( + row: Dict[str, Any], + line_num: int, + valid_currency_codes: Optional[Set[str]], +) -> Optional[Dict[str, Any]]: + """Col H (Clave Moneda): si G = MC, H obligatoria y debe existir en catálogo.""" + g = (row.get("TIPOMONEDA") or "").strip().upper() + if g != "MC": + return None + val = (row.get("CLAVEMONEDA") or "").strip() + if not val: + return { + "line": line_num, + "col": "CLAVEMONEDA", + "msg": "Error: (Col. H) La Clave de la Moneda es obligatoria cuando Tipo Moneda es MC.", + } + if valid_currency_codes is not None and val.upper() not in valid_currency_codes: + return { + "line": line_num, + "col": "CLAVEMONEDA", + "msg": f"Error: (Col. H) La Clave de la Moneda: {val} no existe en el Catálogo de Claves de Moneda. " + "Revisar esta clave de la Moneda en el archivo, en caso de ser correcta Actualizar los Catálogos Fijos.", + } + return None + + +def validate_row_tipo_peso(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]: + """Col L (Tipo Peso): solo KILOS o LIBRAS (o vacío).""" + val = (row.get("TIPOPESO") or "").strip() + if not val: + return None + if val.upper() in ("KILOS", "LIBRAS"): + return None + return { + "line": line_num, + "col": "TIPOPESO", + "msg": f"Error: (Col. L) La opción del Tipo de Peso {val} no es valida. " + "Capturar una opción valida: KILOS, LIBRAS o dejar el campo vacio y automaticamente se asigna KILOS.", + } + + +def _normalize_fraction_mex_8(value: str) -> str: + """Primeros 8 caracteres si len>=10, sino hasta 8 (Clarion SUB).""" + if not value: + return "" + v = value.strip() + if len(v) >= 10: + return v[:8] + return v[:8] if len(v) > 8 else v + + +def validate_row_fraction_mex_catalog( + row: Dict[str, Any], + line_num: int, + valid_fraction_mex_8: Optional[Set[str]], +) -> Optional[Dict[str, Any]]: + """Col M (Fracción Mex): si no vacía, debe existir en catálogo Mex o histórico.""" + val = (row.get("FRACCION") or "").strip() + if not val or valid_fraction_mex_8 is None: + return None + code_8 = _normalize_fraction_mex_8(val) + if not code_8: + return None + if code_8 in valid_fraction_mex_8: + return None + return { + "line": line_num, + "col": "FRACCION", + "msg": f"Error: (Col. M) La Fraccion Mexicana: {val} no existe en el Catálogo de Fracciones Arancelarias Sifr@ ni en el Historico. " + "Revisar esta Fracción Arancelaria en el archivo, en caso de ser correcta Actualizar las Fracciones Arancelarias.", + } + + +def validate_row_country_fk( + row: Dict[str, Any], + line_num: int, + valid_country_m3: Optional[Set[str]], +) -> Optional[Dict[str, Any]]: + """Col N (País): si no vacía, debe existir en catálogo de países (m3_key).""" + val = (row.get("PAIS") or "").strip() + if not val or valid_country_m3 is None: + return None + if val.upper() in valid_country_m3: + return None + return { + "line": line_num, + "col": "PAIS", + "msg": f"Error: (Col. N) La Clave del País: {val} no existe en el Catálogo de Paises. Dar lo de alta en el Catálogo de Paises.", + } + + +def validate_row_preferencia( + row: Dict[str, Any], + line_num: int, + is_rfc_exception: bool, +) -> Optional[Dict[str, Any]]: + """Col O (Preferencia): solo GENERAL, TLCS, PROSEC o ALADI. Excepción RFC: solo aplica si D no vacía.""" + col_d = (row.get("CLASE") or "").strip() + if is_rfc_exception and not col_d: + return None + val = (row.get("PREFERENCIA") or "").strip().upper() + if not val: + return None + if val in ("GENERAL", "TLCS", "PROSEC", "ALADI"): + return None + return { + "line": line_num, + "col": "PREFERENCIA", + "msg": "Error: (Col. O) La opción de Preferencia Arancelaria no es valida. " + "Capturar una opción valida: GENERAL, TLCS, PROSEC, ALADI.", + } + + +def validate_row_sector( + row: Dict[str, Any], + line_num: int, + company_has_prosec: bool, + authorized_sector_keys: Optional[Set[str]], +) -> Optional[Dict[str, Any]]: + """Col P (Sector): si O=PROSEC entonces P obligatorio, empresa PROSEC, sector autorizado; si O≠PROSEC y P no vacío error.""" + pref = (row.get("PREFERENCIA") or "").strip().upper() + sector = (row.get("SECTOR") or "").strip() + if pref == "PROSEC": + if not sector: + return { + "line": line_num, + "col": "SECTOR", + "msg": "Error: (Col. P) no hay un sector capturado y se tiene la opción de Preferencia Arancelaria: PROSEC. Capturar un Sector valido en la columna P", + } + if not company_has_prosec: + return { + "line": line_num, + "col": "SECTOR", + "msg": f"Error: (Col. P) Se quiere aplicar el sector {sector} pero la empresa no cuenta con Permiso PROSEC. " + "Marcar la opción que cuenta con un Permiso PROSEC en los Datos de la Empresa.", + } + if authorized_sector_keys is not None and sector.upper() not in authorized_sector_keys: + return { + "line": line_num, + "col": "SECTOR", + "msg": f"Error: (Col. P) El sector {sector} no esta promovido para esta Empresa. " + "Capturar un Sector en la columna P que este promovido en los Datos de la Empresa o active el Sector en el Catálogo de Sectores.", + } + else: + if sector: + return { + "line": line_num, + "col": "SECTOR", + "msg": f"Error: (Col. P) Hay un sector capturado y se tiene la opción de Preferencia Arancelaria: {pref or '(vacío)'}. " + "Borra el sector en la columna P o cambiar la preferencia en la columna O.", + } + return None + + +def validaciones_parte( + row: Dict[str, Any], + line_num: int, + valid_class_codes: Optional[Set[str]] = None, + valid_uom_codes: Optional[Set[str]] = None, + valid_currency_codes: Optional[Set[str]] = None, + valid_fraction_mex_8: Optional[Set[str]] = None, + valid_country_m3: Optional[Set[str]] = None, + authorized_sector_keys: Optional[Set[str]] = None, + company_has_prosec: bool = False, + is_rfc_exception: bool = False, +) -> Optional[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 + + +def validate_row_warnings( + row: Dict[str, Any], + line_num: int, +) -> list: + """ + Devuelve lista de advertencias (no bloquean; no se añaden a error_lines). + Clarion: desfase cuando Col Q tiene valor; apóstrofes en Número de Parte. + """ + warnings: list = [] + # Advertencia desfase: Clarion cuando CSVArc:ColumnaQ <> '' + if (row.get("RUTAIMAGEN") or "").strip(): + warnings.append({ + "line": line_num, + "col": "RUTAIMAGEN", + "msg": "Advertencia: Podría existir un desfase en esta línea.", + "severity": "warning", + }) + # Advertencia apóstrofes en Número de Parte + num_parte = (row.get("NUMPARTE") or "").strip() + if "'" in num_parte: + warnings.append({ + "line": line_num, + "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", + }) + 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 0154b059..6ab21b04 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 @@ -1,14 +1,15 @@ """ -Punto de entrada de validación para creación/import de una fila de parte. -Encadena validaciones comunes (requeridos, longitudes, tipos). -Referencia: a76/invoices imports/temporary/validators/create.py +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 .common import ( validate_row_required, - validate_row_lengths, - validate_row_types, + validate_row_required_full, + validaciones_parte, + validate_row_warnings, ) @@ -18,22 +19,98 @@ def validate_row_part( valid_class_codes: Optional[Set[str]] = None, valid_uom_codes: Optional[Set[str]] = None, valid_currency_codes: Optional[Set[str]] = None, + valid_fraction_mex_8: Optional[Set[str]] = None, + valid_country_m3: Optional[Set[str]] = None, + authorized_sector_keys: Optional[Set[str]] = None, + company_has_prosec: bool = False, + is_rfc_exception: bool = False, + actualizar: bool = False, + existing_part_numbers: Optional[Set[str]] = None, ) -> Optional[Dict[str, Any]]: """ Valida una fila de CSV de partes para import. - Encadena: requeridos → longitudes → tipos. - Los conjuntos valid_* se mantienen en la firma por compatibilidad con el caller. + - No actualizar: siempre validación completa (obligatorios A,B,E + validaciones_parte). + - Actualizar y número de parte ya existe: validación parcial (solo validaciones_parte). + - 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. """ err = validate_row_required(row, line_num) if err: return err - err = validate_row_lengths(row, line_num) + part_number = (row.get("NUMPARTE") or "").strip().upper() + use_partial = ( + actualizar + and existing_part_numbers is not None + and part_number in existing_part_numbers + ) + + if use_partial: + return validate_row_part_partial( + row, + line_num, + valid_class_codes=valid_class_codes, + valid_uom_codes=valid_uom_codes, + valid_currency_codes=valid_currency_codes, + valid_fraction_mex_8=valid_fraction_mex_8, + valid_country_m3=valid_country_m3, + authorized_sector_keys=authorized_sector_keys, + company_has_prosec=company_has_prosec, + is_rfc_exception=is_rfc_exception, + ) + + # 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 + # No exigir que exista en actualizar: si no existe se valida completa y en commit se crea (ADD) + return validaciones_parte( + row, + line_num, + valid_class_codes=valid_class_codes, + valid_uom_codes=valid_uom_codes, + valid_currency_codes=valid_currency_codes, + valid_fraction_mex_8=valid_fraction_mex_8, + valid_country_m3=valid_country_m3, + authorized_sector_keys=authorized_sector_keys, + company_has_prosec=company_has_prosec, + is_rfc_exception=is_rfc_exception, + ) - err = validate_row_types(row, line_num) + +def validate_row_part_partial( + row: Dict[str, Any], + line_num: int, + valid_class_codes: Optional[Set[str]] = None, + valid_uom_codes: Optional[Set[str]] = None, + valid_currency_codes: Optional[Set[str]] = None, + valid_fraction_mex_8: Optional[Set[str]] = None, + valid_country_m3: Optional[Set[str]] = None, + authorized_sector_keys: Optional[Set[str]] = None, + company_has_prosec: bool = False, + is_rfc_exception: bool = False, +) -> Optional[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. + """ + err = validate_row_required(row, line_num) if err: return err + return validaciones_parte( + row, + line_num, + valid_class_codes=valid_class_codes, + valid_uom_codes=valid_uom_codes, + valid_currency_codes=valid_currency_codes, + valid_fraction_mex_8=valid_fraction_mex_8, + valid_country_m3=valid_country_m3, + authorized_sector_keys=authorized_sector_keys, + company_has_prosec=company_has_prosec, + is_rfc_exception=is_rfc_exception, + ) - return None + +def get_row_warnings(row: Dict[str, Any], line_num: int) -> list: + """Devuelve lista de advertencias para la fila (desfase, apóstrofes). No bloquean.""" + return validate_row_warnings(row, line_num) diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 6bea5605..f40d5776 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -562,11 +562,14 @@ export const api = { // CSV import for Números de parte (parts/imports) partNumberImports: { - upload: (file: File, companyId: number) => { + upload: (file: File, companyId: number, options?: { actualizar?: boolean; reemplazar_sin_preguntar?: boolean }) => { const formData = new FormData(); formData.append('file', file); + const params = new URLSearchParams({ company_id: String(companyId) }); + if (options?.actualizar !== undefined) params.set('actualizar', String(options.actualizar)); + if (options?.reemplazar_sin_preguntar !== undefined) params.set('reemplazar_sin_preguntar', String(options.reemplazar_sin_preguntar)); return fetchApi( - `/v1/a76/parts/imports/upload?company_id=${companyId}`, + `/v1/a76/parts/imports/upload?${params.toString()}`, { method: 'POST', body: formData } ); }, diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index b1388a01..29efd51d 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -16,6 +16,38 @@ import { toast } from 'svelte-sonner'; import { companyStore } from '$lib/stores/company.svelte'; + /** Intenta extraer un objeto tipo scan desde string tipo repr de Python. */ + function parsePythonReprScan(s: string): Record | null { + const jobIdMatch = s.match(/'job_id':\s*'([^']*)'/); + const totalRowsMatch = s.match(/'total_rows':\s*(\d+)/); + if (!jobIdMatch || !totalRowsMatch) return null; + const job_id = jobIdMatch[1]; + const total_rows = parseInt(totalRowsMatch[1], 10); + const errorCountMatch = s.match(/'error_count':\s*(\d+)/); + 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; + 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, "'") + }); + } + return { + status: 'waiting_confirmation', + job_id, + total_rows, + error_count, + valid_rows, + errors + }; + } + // We no longer need modal state let activeTab = $state('catalogos'); @@ -293,7 +325,12 @@ if (usePartNumbersImport) { try { - const res = await api.partNumberImports.upload(file, companyId); + const catalogosSettings = allSettings['catalogos'] || {}; + const actualizar = catalogosSettings['mode'] === 'update'; + const res = await api.partNumberImports.upload(file, companyId, { + actualizar, + reemplazar_sin_preguntar: true + }); if (res.data?.job_id) { currentJobId = res.data.job_id; pollStatus(); @@ -396,25 +433,71 @@ currentJobId = null; return; } - if (res.data?.status === 'waiting_confirmation') { + // Tratar como resultado de escaneo si viene status waiting_confirmation O si el payload tiene forma de scan (job_id + total_rows) + const looksLikeScanResult = + res.data?.status === 'waiting_confirmation' || + (res.data?.job_id && typeof res.data?.total_rows === 'number'); + if (looksLikeScanResult) { scanResults = res.data; showResultModal = true; toast.success('Escaneo completado. Revisa los resultados.'); isUploading = false; } else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') { const errRaw = res.data.error; - const errText = - typeof errRaw === 'string' - ? errRaw.includes('finished') && errRaw.includes('inserted') - ? 'La importación pudo completarse. Revisa el listado de registros.' - : errRaw - : (errRaw?.message ?? 'Error desconocido'); - toast.error('Error en el procesamiento: ' + errText); - isUploading = false; - currentJobId = null; - scanResults = null; - commitResults = null; - showResultModal = false; + // Si el backend devolvió el resultado del scan dentro de error (p. ej. string JSON), usarlo para mostrar el modal + let parsedScan: Record | null = null; + if (typeof errRaw === 'string' && (errRaw.includes('waiting_confirmation') || (errRaw.includes('total_rows') && errRaw.includes('job_id')))) { + try { + const parsed = JSON.parse(errRaw) as Record; + if (parsed && typeof parsed.job_id === 'string' && typeof parsed.total_rows === 'number') { + parsedScan = parsed; + } + } catch { + // No es JSON; intentar parsear como repr de Python + parsedScan = parsePythonReprScan(errRaw); + } + } + if (parsedScan) { + scanResults = parsedScan; + showResultModal = true; + toast.success('Escaneo completado. Revisa los resultados.'); + isUploading = false; + } else { + // Mensaje parece resultado de escaneo pero no se pudo parsear (p. ej. repr Python) → no asustar con error + const looksLikeScanInError = + typeof errRaw === 'string' && + (errRaw.includes('waiting_confirmation') || (errRaw.includes('total_rows') && errRaw.includes('job_id'))); + if (looksLikeScanInError) { + toast.info('El escaneo terminó. Si no ves el modal, revisa el listado de registros.'); + isUploading = false; + currentJobId = null; + return; + } + let errText: string; + if (typeof errRaw === 'string') { + errText = + errRaw.includes('finished') && errRaw.includes('inserted') + ? 'La importación pudo completarse. Revisa el listado de registros.' + : errRaw.includes("'status'") && errRaw.includes('waiting_confirmation') + ? 'Error al obtener el resultado. Revisa el modal de resultados.' + : errRaw; + } else if (typeof errRaw === 'object' && errRaw !== null) { + errText = (errRaw as { message?: string })?.message || 'Error en el procesamiento. Revisa el modal o los detalles.'; + } else { + errText = 'Error desconocido'; + } + // No mostrar como error si el mensaje indica éxito + if (errText.includes('pudo completarse') || errText.includes('Revisa el listado')) { + toast.success(errText); + } else { + toast.error('Error en el procesamiento: ' + errText); + } + isUploading = false; + currentJobId = null; + scanResults = null; + commitResults = null; + showResultModal = false; + } } else if (res.data?.status === 'warning') { // Caso cuando no se insertaron registros pero hay información de rechazo commitResults = res.data;