Files
plantillas-proyectos/backend/api/v1/modules/a76/layouts_csv/boms/tasks.py
2026-03-03 16:45:12 -07:00

431 lines
15 KiB
Python

"""
Tareas Celery para importación CSV de BOMs.
Flujo: scan_file (validación) → insert_valid_rows (commit).
Sin tabla BOM dedicada aún: insert_valid_rows solo valida y devuelve resultado; el mapeo a tabla se añadirá cuando exista.
"""
import os
import base64
import csv
import json
import logging
import re
import unicodedata
from decimal import Decimal, InvalidOperation
from typing import Dict, Any, Optional, List, Set
from core.celery_app import celery_app
from core.database import CoreSessionLocal
from core.paths import layout_path
from .template_config import row_from_template
logger = logging.getLogger(__name__)
BOM_IMPORT_FILE_PREFIX = "bom_import_file:"
BOM_IMPORT_META_PREFIX = "bom_import_meta:"
BOM_IMPORT_ERROR_LINES_PREFIX = "bom_import_error_lines:"
BOM_IMPORT_REDIS_TTL = 3600
def _get_redis():
import redis
url = os.getenv("VALKEY_URL", os.getenv("REDIS_URL", "redis://valkey:6379/0"))
return redis.Redis.from_url(url, decode_responses=False)
def _worker_upload_dir() -> str:
return layout_path("imports", "temp")
def _ensure_worker_has_file_from_redis(job_id: str) -> Optional[str]:
r = _get_redis()
data = r.get(f"{BOM_IMPORT_FILE_PREFIX}{job_id}")
if not data:
return None
try:
raw = base64.b64decode(data)
except Exception as e:
logger.warning(f"BOMs import: failed to decode file from Redis: {e}")
return None
upload_dir = _worker_upload_dir()
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, f"bom_{job_id}.csv")
with open(file_path, "wb") as f:
f.write(raw)
return file_path
def _ensure_worker_has_meta_from_redis(job_id: str, file_path: str) -> bool:
r = _get_redis()
data = r.get(f"{BOM_IMPORT_META_PREFIX}{job_id}")
if not data:
return False
try:
meta = json.loads(data.decode("utf-8"))
except Exception as e:
logger.warning(f"BOMs import: failed to decode meta from Redis: {e}")
return False
meta_path = file_path.replace(".csv", ".meta.json")
with open(meta_path, "w", encoding="utf-8") as f:
json.dump(meta, f)
return True
def _delete_import_from_redis(job_id: str) -> None:
try:
r = _get_redis()
r.delete(
f"{BOM_IMPORT_FILE_PREFIX}{job_id}",
f"{BOM_IMPORT_META_PREFIX}{job_id}",
f"{BOM_IMPORT_ERROR_LINES_PREFIX}{job_id}",
)
except Exception as e:
logger.warning(f"BOMs import: failed to delete Redis keys: {e}")
def normalize_header(name: Optional[str]) -> str:
if not name:
return ""
name = unicodedata.normalize("NFKD", str(name)).upper()
name = "".join(ch for ch in name if not unicodedata.combining(ch))
name = re.sub(r"[^A-Z0-9]+", " ", name)
return re.sub(r"\s+", " ", name).strip()
def _validate_row_bom(
row: Dict[str, Any],
line_num: int,
valid_part_numbers: Optional[Set[str]] = None,
) -> Optional[Dict[str, Any]]:
"""Valida una fila BOM según columnas del template. FK opcional a a76.parts."""
parent = (row.get("NUMPARTE_PADRE") or "").strip()
if not parent:
return {"line": line_num, "col": "NUMPARTE_PADRE", "msg": "Requerido"}
if len(parent) > 70:
return {"line": line_num, "col": "NUMPARTE_PADRE", "msg": "Máximo 70 caracteres"}
component = (row.get("NUMPARTE_COMPONENTE") or "").strip()
if not component:
return {"line": line_num, "col": "NUMPARTE_COMPONENTE", "msg": "Requerido"}
if len(component) > 70:
return {"line": line_num, "col": "NUMPARTE_COMPONENTE", "msg": "Máximo 70 caracteres"}
qty = row.get("CANTIDAD")
if qty is None or qty == "":
return {"line": line_num, "col": "CANTIDAD", "msg": "Requerido"}
try:
val = Decimal(str(qty))
if val < 0:
return {"line": line_num, "col": "CANTIDAD", "msg": "Debe ser mayor o igual a cero"}
except (InvalidOperation, ValueError, TypeError):
return {"line": line_num, "col": "CANTIDAD", "msg": "Debe ser número"}
uom = (row.get("UNIMED") or "").strip()
if uom and len(uom) > 10:
return {"line": line_num, "col": "UNIMED", "msg": "Máximo 10 caracteres"}
def _optional_number(val: Any) -> bool:
if val is None:
return True
s = re.sub(r"\s+", "", str(val).strip())
if not s:
return True
try:
float(s.replace(",", "."))
return True
except (ValueError, TypeError):
return False
version_bom = row.get("VERSION_BOM")
if not _optional_number(version_bom):
return {"line": line_num, "col": "VERSION_BOM", "msg": "Debe ser número"}
version_bill = row.get("VERSION_BILL")
if not _optional_number(version_bill):
return {"line": line_num, "col": "VERSION_BILL", "msg": "Debe ser número"}
# Solo exigir que padre/componente existan en catálogo si hay partes cargadas (evita rechazar todo cuando el catálogo está vacío o en pruebas)
if valid_part_numbers is not None and len(valid_part_numbers) > 0:
if parent not in valid_part_numbers:
return {"line": line_num, "col": "NUMPARTE_PADRE", "msg": "Parte padre no existe en catálogo"}
if component not in valid_part_numbers:
return {"line": line_num, "col": "NUMPARTE_COMPONENTE", "msg": "Parte componente no existe en catálogo"}
return None
@celery_app.task(bind=True)
def scan_file(self, job_id: str, config: str = None):
logger.info(f"BOMs import: starting scan for job {job_id}")
file_path = _ensure_worker_has_file_from_redis(job_id)
if not file_path:
return {"status": "failed", "error": "Archivo no encontrado (expirado o no subido). Sube de nuevo."}
_ensure_worker_has_meta_from_redis(job_id, file_path)
error_dir = layout_path("imports", "errors")
os.makedirs(error_dir, exist_ok=True)
error_path = os.path.join(error_dir, f"bom_{job_id}.jsonl")
total_rows = 0
try:
with open(file_path, "r", encoding="utf-8-sig") as f:
total_rows = sum(1 for _ in f) - 1
except Exception as e:
return {"status": "failed", "error": str(e)}
meta_path = file_path.replace(".csv", ".meta.json")
meta = {}
if os.path.exists(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f) or {}
except Exception as e:
logger.warning(f"BOMs import: failed to read meta: {e}")
tenant_id = meta.get("tenant_id")
company_id = meta.get("company_id")
if not tenant_id or not company_id:
return {"status": "failed", "error": "Falta contexto (tenant/company)"}
valid_part_numbers: Set[str] = set()
try:
with CoreSessionLocal() as session:
from api.v1.modules.a76.parts.models import Part
for p in (
session.query(Part.part_number)
.filter(
Part.tenant_id == tenant_id,
Part.company_id == company_id,
)
.all()
):
valid_part_numbers.add(p[0])
except Exception as e:
logger.warning(f"BOMs import: could not load parts for FK validation: {e}")
error_count = 0
processed_rows = 0
errors_detail: List[Dict[str, Any]] = []
try:
with open(file_path, "r", encoding="utf-8-sig") as f_in, open(
error_path, "w", encoding="utf-8"
) as f_err:
sample = f_in.read(2048)
f_in.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f_in, dialect=dialect)
for i, row in enumerate(reader, start=1):
if i % 500 == 0:
self.update_state(
state="PROGRESS",
meta={"current": i, "total": total_rows, "errors": error_count},
)
row_norm = row_from_template(row, normalize_header)
err = _validate_row_bom(row_norm, i, valid_part_numbers=valid_part_numbers)
if err:
error_count += 1
f_err.write(json.dumps(err) + "\n")
if len(errors_detail) < 500:
errors_detail.append(
{"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}
)
processed_rows += 1
except Exception as e:
logger.error(f"BOMs import scan failed: {e}")
return {"status": "failed", "error": str(e)}
error_lines_list = []
try:
if os.path.exists(error_path):
with open(error_path, "r", encoding="utf-8") as f:
for line in f:
try:
err = json.loads(line)
if "line" in err:
error_lines_list.append(err["line"])
except Exception:
pass
if error_lines_list:
r = _get_redis()
r.set(
f"{BOM_IMPORT_ERROR_LINES_PREFIX}{job_id}",
json.dumps(error_lines_list).encode("utf-8"),
ex=BOM_IMPORT_REDIS_TTL,
)
except Exception as e:
logger.warning(f"BOMs import: failed to store error lines in Redis: {e}")
return {
"status": "waiting_confirmation",
"job_id": job_id,
"total_rows": processed_rows,
"error_count": error_count,
"valid_rows": processed_rows - error_count,
"errors": errors_detail,
}
def _decimal_or_none(val: Any) -> Optional[Decimal]:
if val is None or val == "":
return None
try:
return Decimal(str(val))
except (InvalidOperation, ValueError, TypeError):
return None
def _int_or_none(val: Any) -> Optional[int]:
if val is None or val == "":
return None
try:
return int(val)
except (ValueError, TypeError):
return None
@celery_app.task(bind=True)
def insert_valid_rows(self, job_id: str):
logger.info(f"BOMs import: starting commit for job {job_id}")
file_path = _ensure_worker_has_file_from_redis(job_id)
if not file_path:
alt_path = os.path.join(_worker_upload_dir(), f"bom_{job_id}.csv")
if not os.path.exists(alt_path):
return {
"status": "failed",
"error": "Archivo no encontrado (expirado). Sube y confirma de nuevo.",
}
file_path = alt_path
else:
_ensure_worker_has_meta_from_redis(job_id, file_path)
error_dir = layout_path("imports", "errors")
error_path = os.path.join(error_dir, f"bom_{job_id}.jsonl")
error_lines = set()
try:
r = _get_redis()
raw = r.get(f"{BOM_IMPORT_ERROR_LINES_PREFIX}{job_id}")
if raw:
error_lines = set(json.loads(raw.decode("utf-8")))
except Exception as e:
logger.debug(f"BOMs import: could not load error lines from Redis: {e}")
if not error_lines and os.path.exists(error_path):
with open(error_path, "r", encoding="utf-8") as f:
for line in f:
try:
err = json.loads(line)
error_lines.add(err["line"])
except Exception:
pass
meta_path = file_path.replace(".csv", ".meta.json")
tenant_id = None
company_id = None
meta = {}
if os.path.exists(meta_path):
try:
with open(meta_path, "r", encoding="utf-8") as f:
meta = json.load(f) or {}
tenant_id = meta.get("tenant_id")
company_id = meta.get("company_id")
except Exception:
pass
if not tenant_id or not company_id:
return {"status": "failed", "error": "Falta contexto (tenant/company)"}
valid_part_numbers: Set[str] = set()
try:
with CoreSessionLocal() as session:
from api.v1.modules.a76.parts.models import Part
for p in (
session.query(Part.part_number)
.filter(
Part.tenant_id == tenant_id,
Part.company_id == company_id,
)
.all()
):
valid_part_numbers.add(p[0])
except Exception as e:
logger.warning(f"BOMs import: could not load parts: {e}")
inserted_count = 0
skipped_invalid = 0
skipped_details: List[Dict[str, Any]] = []
valid_count = 0
try:
with open(file_path, "r", encoding="utf-8-sig") as f:
sample = f.read(2048)
f.seek(0)
try:
dialect = csv.Sniffer().sniff(sample, delimiters=",;\t")
except Exception:
dialect = "excel"
reader = csv.DictReader(f, dialect=dialect)
for i, row in enumerate(reader, start=1):
if i in error_lines:
continue
row_norm = row_from_template(row, normalize_header)
err = _validate_row_bom(row_norm, i, valid_part_numbers=valid_part_numbers)
if err:
skipped_invalid += 1
skipped_details.append(
{"line": i, "reason": f"{err.get('col', '')}: {err.get('msg', '')}"}
)
continue
valid_count += 1
# Sin tabla BOM dedicada: no se escribe en DB; solo se cuentan filas válidas.
# Cuando exista la tabla de destino, aquí se hará insert/update.
response = {
"status": "finished",
"inserted": inserted_count,
"skipped_invalid": skipped_invalid,
"skipped_missing_fk": 0,
"skipped_duplicate": 0,
"skipped_details": skipped_details,
}
if valid_count > 0 and inserted_count == 0:
response["message"] = f"WIP: {valid_count} filas válidas. La tabla BOM aún no existe en el sistema; no se insertó nada."
except Exception as e:
logger.error(f"BOMs import task failed: {e}")
import traceback
logger.error(traceback.format_exc())
response = {
"status": "failed",
"error": str(e),
"inserted": 0,
"skipped_invalid": skipped_invalid,
"skipped_missing_fk": 0,
"skipped_duplicate": 0,
"skipped_details": skipped_details,
}
try:
if file_path and os.path.exists(file_path):
os.remove(file_path)
if os.path.exists(error_path):
os.remove(error_path)
if os.path.exists(meta_path):
os.remove(meta_path)
_delete_import_from_redis(job_id)
except Exception as cleanup_err:
logger.warning(f"BOMs import cleanup failed: {cleanup_err}")
return response