feature/clarion-validations-parts-csv

This commit is contained in:
hreyes
2026-03-04 14:57:26 -07:00
parent f6f23dacd2
commit afc0a35f1c
13 changed files with 1087 additions and 99 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<string, unknown> | 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<string, unknown> | 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<string, unknown>;
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;