From 343054d27405c76e4940c4804645525a6ef2a815 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 3 Mar 2026 09:09:14 -0700 Subject: [PATCH] feature/csv-partes --- .../v1/modules/a76/parts/imports/__init__.py | 1 + .../v1/modules/a76/parts/imports/routes.py | 151 ++++ .../v1/modules/a76/parts/imports/schemas.py | 22 + .../api/v1/modules/a76/parts/imports/tasks.py | 639 +++++++++++++++ .../a76/parts/imports/template_config.py | 54 ++ backend/api/v1/modules/a76/parts/routes.py | 36 +- backend/core/celery_app.py | 1 + frontend/src/lib/config/csv-upload.ts | 774 +++++++++--------- 8 files changed, 1278 insertions(+), 400 deletions(-) create mode 100644 backend/api/v1/modules/a76/parts/imports/__init__.py create mode 100644 backend/api/v1/modules/a76/parts/imports/routes.py create mode 100644 backend/api/v1/modules/a76/parts/imports/schemas.py create mode 100644 backend/api/v1/modules/a76/parts/imports/tasks.py create mode 100644 backend/api/v1/modules/a76/parts/imports/template_config.py diff --git a/backend/api/v1/modules/a76/parts/imports/__init__.py b/backend/api/v1/modules/a76/parts/imports/__init__.py new file mode 100644 index 00000000..025db976 --- /dev/null +++ b/backend/api/v1/modules/a76/parts/imports/__init__.py @@ -0,0 +1 @@ +# CSV import for Parts (Números de parte) diff --git a/backend/api/v1/modules/a76/parts/imports/routes.py b/backend/api/v1/modules/a76/parts/imports/routes.py new file mode 100644 index 00000000..bffb87ac --- /dev/null +++ b/backend/api/v1/modules/a76/parts/imports/routes.py @@ -0,0 +1,151 @@ +""" +Rutas de importación CSV para Números de Parte. +Flujo: upload → scan → status (polling) → commit. +""" +import base64 +import json +import logging +import os +from uuid import uuid4 + +from fastapi import APIRouter, File, HTTPException, Query, UploadFile, Depends +from sqlalchemy.orm import Session +from typing import Dict, Any + +from core.celery_app import celery_app +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource + +from .schemas import ImportJobResponse +from .tasks import ( + scan_file, + insert_valid_rows, + PART_IMPORT_FILE_PREFIX, + PART_IMPORT_META_PREFIX, + PART_IMPORT_REDIS_TTL, +) + +router = APIRouter() +logger = logging.getLogger(__name__) + + +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) + + +@router.post("/upload", response_model=ImportJobResponse) +async def upload_import_file( + file: UploadFile = File(...), + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + try: + tenant_id = validate_access_to_resource(db, company_id, current_user) + except Exception as e: + logger.error(f"Parts import: access validation failed: {e}") + raise HTTPException(status_code=403, detail="Invalid company access") + + if not file.filename or not file.filename.lower().endswith(".csv"): + raise HTTPException(status_code=400, detail="Solo se permiten archivos .csv") + + job_id = str(uuid4()) + contents = await file.read() + + meta_data = { + "tenant_id": tenant_id, + "company_id": company_id, + "user_id": current_user.get("id"), + "template_id": "part_numbers", + } + + try: + r = _get_redis() + r.set( + f"{PART_IMPORT_FILE_PREFIX}{job_id}", + base64.b64encode(contents), + ex=PART_IMPORT_REDIS_TTL, + ) + r.set( + f"{PART_IMPORT_META_PREFIX}{job_id}", + json.dumps(meta_data).encode("utf-8"), + ex=PART_IMPORT_REDIS_TTL, + ) + except Exception as e: + logger.error(f"Parts import: Redis store error: {e}") + raise HTTPException(status_code=500, detail="No se pudo encolar el archivo.") + + try: + upload_dir = os.path.join(os.getcwd(), "uploads", "temp") + os.makedirs(upload_dir, exist_ok=True) + with open(os.path.join(upload_dir, f"part_{job_id}.csv"), "wb") as f: + f.write(contents) + with open(os.path.join(upload_dir, f"part_{job_id}.meta.json"), "w") as f: + json.dump(meta_data, f) + except Exception as e: + logger.warning(f"Parts import: local file save failed: {e}") + + scan_file.apply_async(args=[job_id], task_id=job_id) + + return ImportJobResponse( + job_id=job_id, + status="queued", + message="Archivo subido. Escaneo iniciado.", + ) + + +@router.get("/{job_id}/status") +async def get_import_status(job_id: str): + task_result = celery_app.AsyncResult(job_id) + + if task_result.state == "PENDING": + return {"status": "processing", "progress": 0} + if task_result.state == "PROGRESS": + info = (task_result.info or {}) + return { + "status": "processing", + "progress": info.get("current", 0), + "total": info.get("total", 0), + } + if task_result.state == "SUCCESS": + result = task_result.result + if isinstance(result, dict) and "status" in result: + return result + return {"status": "finished", "result": result} + + result = getattr(task_result, "result", None) + if isinstance(result, dict) and result.get("status") in ("finished", "warning"): + 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) + if tb and isinstance(tb, str): + lines = [l.strip() for l in tb.strip().split("\n") if l.strip()] + if lines: + err_msg = lines[-1] + if not err_msg: + try: + exc = task_result.get(propagate=False) + if exc is not None: + err_msg = str(exc) + except Exception: + pass + if not err_msg and result is not None: + if not isinstance(result, dict): + err_msg = str(result) + elif result.get("error") or result.get("message"): + err_msg = result.get("error") or result.get("message") + return {"status": "failed", "error": err_msg or "Task failed"} + + +@router.post("/{job_id}/commit") +async def commit_import_job(job_id: str): + task = insert_valid_rows.delay(job_id) + return { + "status": "committing", + "message": "Inserción iniciada.", + "commit_job_id": task.id, + } diff --git a/backend/api/v1/modules/a76/parts/imports/schemas.py b/backend/api/v1/modules/a76/parts/imports/schemas.py new file mode 100644 index 00000000..7827dae6 --- /dev/null +++ b/backend/api/v1/modules/a76/parts/imports/schemas.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel +from typing import Optional + + +class ImportJobResponse(BaseModel): + job_id: str + status: str + message: str + + +class ImportJobStatus(BaseModel): + status: str + job_id: str + total_rows: Optional[int] = 0 + error_count: Optional[int] = 0 + valid_rows: Optional[int] = 0 + error: Optional[str] = None + inserted: Optional[int] = 0 + skipped_invalid: Optional[int] = 0 + skipped_missing_fk: Optional[int] = 0 + skipped_duplicate: Optional[int] = 0 + skipped_details: Optional[list] = None diff --git a/backend/api/v1/modules/a76/parts/imports/tasks.py b/backend/api/v1/modules/a76/parts/imports/tasks.py new file mode 100644 index 00000000..c87bca7c --- /dev/null +++ b/backend/api/v1/modules/a76/parts/imports/tasks.py @@ -0,0 +1,639 @@ +""" +Tareas Celery para importación CSV de Números de Parte. +Flujo: scan_file (validación) → insert_valid_rows (commit). +""" +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 .template_config import row_from_template + +logger = logging.getLogger(__name__) + +PART_IMPORT_FILE_PREFIX = "part_import_file:" +PART_IMPORT_META_PREFIX = "part_import_meta:" +PART_IMPORT_ERROR_LINES_PREFIX = "part_import_error_lines:" +PART_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 os.path.join(os.getcwd(), "uploads", "temp") + + +def _ensure_worker_has_file_from_redis(job_id: str) -> Optional[str]: + r = _get_redis() + data = r.get(f"{PART_IMPORT_FILE_PREFIX}{job_id}") + if not data: + return None + try: + raw = base64.b64decode(data) + except Exception as e: + logger.warning(f"Parts 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"part_{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"{PART_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"Parts 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"{PART_IMPORT_FILE_PREFIX}{job_id}", + f"{PART_IMPORT_META_PREFIX}{job_id}", + f"{PART_IMPORT_ERROR_LINES_PREFIX}{job_id}", + ) + except Exception as e: + logger.warning(f"Parts 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_part( + 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, +) -> Optional[Dict[str, Any]]: + part_number = (row.get("NUMPARTE") or "").strip() + if not part_number: + return {"line": line_num, "col": "NUMPARTE", "msg": "Requerido"} + if len(part_number) > 70: + return {"line": line_num, "col": "NUMPARTE", "msg": "Máximo 70 caracteres"} + + commercial = (row.get("NUMPARTECOM") or "").strip() + if commercial and len(commercial) > 70: + return {"line": line_num, "col": "NUMPARTECOM", "msg": "Máximo 70 caracteres"} + + desc_es = (row.get("DESCRIPCIONE") or "").strip() + if desc_es and len(desc_es) > 500: + return {"line": line_num, "col": "DESCRIPCIONE", "msg": "Máximo 500 caracteres"} + desc_en = (row.get("DESCRIPCIONI") or "").strip() + if desc_en and len(desc_en) > 500: + return {"line": line_num, "col": "DESCRIPCIONI", "msg": "Máximo 500 caracteres"} + + part_class = (row.get("CLASE") or "").strip() + if part_class and len(part_class) > 8: + return {"line": line_num, "col": "CLASE", "msg": "Máximo 8 caracteres"} + # Si CLASE no existe en catálogo se guardará null (no se rechaza la fila) + + uom = (row.get("UNIMED") or "").strip() + if uom and len(uom) > 5: + return {"line": line_num, "col": "UNIMED", "msg": "Máximo 5 caracteres"} + # Si UNIMED no existe en catálogo se guardará null (no se rechaza la fila) + + currency_key = (row.get("MONEDA") or "").strip() + if currency_key and len(currency_key) > 3: + return {"line": line_num, "col": "MONEDA", "msg": "Máximo 3 caracteres"} + # Si MONEDA no existe en catálogo se guardará null (no se rechaza la fila) + + unit_cost = row.get("COSTOUNIT") + if unit_cost is not None and unit_cost != "": + try: + Decimal(str(unit_cost)) + except (InvalidOperation, ValueError, TypeError): + return {"line": line_num, "col": "COSTOUNIT", "msg": "Debe ser número"} + + unit_weight = row.get("PESOUNIT") + if unit_weight is not None and unit_weight != "": + try: + Decimal(str(unit_weight)) + except (InvalidOperation, ValueError, TypeError): + return {"line": line_num, "col": "PESOUNIT", "msg": "Debe ser número"} + + fraction = (row.get("FRACCION") or "").strip() + if fraction and len(fraction) > 10: + return {"line": line_num, "col": "FRACCION", "msg": "Máximo 10 caracteres"} + us_fraction = (row.get("FRACCIONAME") or "").strip() + if us_fraction and len(us_fraction) > 16: + return {"line": line_num, "col": "FRACCIONAME", "msg": "Máximo 16 caracteres"} + fda_key = (row.get("FDAKEY") or "").strip() + if fda_key and len(fda_key) > 20: + return {"line": line_num, "col": "FDAKEY", "msg": "Máximo 20 caracteres"} + fcc_key = (row.get("FCCKEY") or "").strip() + if fcc_key and len(fcc_key) > 30: + return {"line": line_num, "col": "FCCKEY", "msg": "Máximo 30 caracteres"} + license_code = (row.get("LICENCIA") or "").strip() + if license_code and len(license_code) > 3: + return {"line": line_num, "col": "LICENCIA", "msg": "Máximo 3 caracteres"} + eccn = (row.get("ECCN") or "").strip() + if eccn and len(eccn) > 20: + return {"line": line_num, "col": "ECCN", "msg": "Máximo 20 caracteres"} + export_code = (row.get("EXPORTCODE") or "").strip() + if export_code and len(export_code) > 2: + return {"line": line_num, "col": "EXPORTCODE", "msg": "Máximo 2 caracteres"} + exclusion = (row.get("EXCLUSION") or "").strip() + if exclusion and len(exclusion) > 19: + return {"line": line_num, "col": "EXCLUSION", "msg": "Máximo 19 caracteres"} + weight_type = (row.get("TIPOPESO") or "").strip() + if weight_type and len(weight_type) > 6: + return {"line": line_num, "col": "TIPOPESO", "msg": "Máximo 6 caracteres"} + + return None + + +@celery_app.task(bind=True) +def scan_file(self, job_id: str, config: str = None): + logger.info(f"Parts 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 = os.path.join(os.path.dirname(file_path).replace("temp", "errors"), "") + os.makedirs(error_dir, exist_ok=True) + error_path = os.path.join(error_dir, f"part_{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"Parts 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_class_codes: Set[str] = set() + valid_uom_codes: Set[str] = set() + valid_currency_codes: Set[str] = set() + 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 + for c in ( + session.query(Class.class_code) + .filter( + Class.tenant_id == tenant_id, + Class.company_id == company_id, + ) + .all() + ): + valid_class_codes.add(c[0]) + for u in ( + session.query(UnitOfMeasure.code) + .filter( + UnitOfMeasure.tenant_id == tenant_id, + UnitOfMeasure.company_id == company_id, + ) + .all() + ): + valid_uom_codes.add(u[0]) + for cur in session.query(CurrencyType.code).all(): + valid_currency_codes.add(cur[0]) + except Exception as e: + logger.warning(f"Parts import: could not load FK sets: {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_part( + row_norm, + i, + valid_class_codes=valid_class_codes, + valid_uom_codes=valid_uom_codes, + valid_currency_codes=valid_currency_codes, + ) + 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"Parts 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"{PART_IMPORT_ERROR_LINES_PREFIX}{job_id}", + json.dumps(error_lines_list).encode("utf-8"), + ex=PART_IMPORT_REDIS_TTL, + ) + except Exception as e: + logger.warning(f"Parts 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 _str_or_none(val: Any, max_len: Optional[int] = None) -> Optional[str]: + if val is None: + return None + s = str(val).strip() + if not s: + return None + if max_len and len(s) > max_len: + return s[:max_len] + return s + + +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 + + +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 _bool_from_row(val: Any) -> bool: + if val is None or val == "": + return True + s = str(val).strip().upper() + if s in ("0", "F", "FALSE", "NO", "N"): + return False + return True + + +@celery_app.task(bind=True) +def insert_valid_rows(self, job_id: str): + logger.info(f"Parts 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"part_{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) + + base_dir = os.path.dirname(file_path) + error_dir = base_dir.replace("temp", "errors") + error_path = os.path.join(error_dir, f"part_{job_id}.jsonl") + + error_lines = set() + try: + r = _get_redis() + raw = r.get(f"{PART_IMPORT_ERROR_LINES_PREFIX}{job_id}") + if raw: + error_lines = set(json.loads(raw.decode("utf-8"))) + except Exception as e: + logger.debug(f"Parts 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)"} + + from api.v1.modules.a76.parts.models import Part + + valid_class_codes: Set[str] = set() + valid_uom_codes: Set[str] = set() + valid_currency_codes: Set[str] = set() + 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 + for c in ( + session.query(Class.class_code) + .filter( + Class.tenant_id == tenant_id, + Class.company_id == company_id, + ) + .all() + ): + valid_class_codes.add(c[0]) + for u in ( + session.query(UnitOfMeasure.code) + .filter( + UnitOfMeasure.tenant_id == tenant_id, + UnitOfMeasure.company_id == company_id, + ) + .all() + ): + valid_uom_codes.add(u[0]) + for cur in session.query(CurrencyType.code).all(): + valid_currency_codes.add(cur[0]) + except Exception as e: + logger.warning(f"Parts import: could not load FK sets: {e}") + + inserted_count = 0 + skipped_invalid = 0 + skipped_missing_fk = 0 + skipped_duplicate = 0 + skipped_details: List[Dict[str, Any]] = [] + response = None + + try: + with CoreSessionLocal() as session: + existing_by_part_number: Dict[str, Part] = {} + for p in ( + session.query(Part) + .filter( + Part.tenant_id == tenant_id, + Part.company_id == company_id, + ) + .all() + ): + existing_by_part_number[p.part_number] = p + + 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_part( + row_norm, + i, + valid_class_codes=valid_class_codes, + valid_uom_codes=valid_uom_codes, + valid_currency_codes=valid_currency_codes, + ) + if err: + skipped_invalid += 1 + skipped_details.append( + {"line": i, "reason": f"{err.get('col', '')}: {err.get('msg', '')}"} + ) + continue + + part_number = _str_or_none(row_norm.get("NUMPARTE"), 70) + if not part_number: + skipped_invalid += 1 + continue + + 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: + 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: + unit_of_measure = None + currency_key = _str_or_none(row_norm.get("MONEDA"), 3) + if currency_key and currency_key not in valid_currency_codes: + currency_key = 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) + fraction = _str_or_none(row_norm.get("FRACCION"), 10) + us_fraction = _str_or_none(row_norm.get("FRACCIONAME"), 16) + 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) + eccn = _str_or_none(row_norm.get("ECCN"), 20) + export_code = _str_or_none(row_norm.get("EXPORTCODE"), 2) + exclusion_symbol = _str_or_none(row_norm.get("EXCLUSION"), 19) + is_active = _bool_from_row(row_norm.get("ACTIVO")) + + existing = existing_by_part_number.get(part_number) + if existing: + existing.commercial_part_number = commercial + existing.description_spanish = desc_es + existing.description_english = desc_en + existing.part_class = part_class + existing.unit_of_measure = unit_of_measure + existing.unit_cost = unit_cost + existing.currency_type = currency_type + existing.currency_key = currency_key + existing.unit_weight = unit_weight + existing.weight_type = weight_type + existing.fraction = fraction + existing.us_fraction = us_fraction + existing.fda_key = fda_key + existing.fcc_key = fcc_key + existing.license_code = license_code + existing.eccn = eccn + existing.export_code = export_code + existing.exclusion_symbol = exclusion_symbol + existing.is_active = is_active + session.add(existing) + inserted_count += 1 + else: + new_part = Part( + tenant_id=tenant_id, + company_id=company_id, + client_id=company_id, + part_number=part_number, + commercial_part_number=commercial, + description_spanish=desc_es, + description_english=desc_en, + part_class=part_class, + unit_of_measure=unit_of_measure, + unit_cost=unit_cost, + currency_type=currency_type, + currency_key=currency_key, + unit_weight=unit_weight, + weight_type=weight_type, + fraction=fraction, + us_fraction=us_fraction, + fda_key=fda_key, + fcc_key=fcc_key, + license_code=license_code, + eccn=eccn, + export_code=export_code, + exclusion_symbol=exclusion_symbol, + is_active=is_active, + ) + session.add(new_part) + existing_by_part_number[part_number] = new_part + inserted_count += 1 + + try: + session.commit() + except Exception as db_err: + session.rollback() + logger.error(f"Parts import DB error: {db_err}") + return {"status": "failed", "error": str(db_err)} + + total_skipped = skipped_invalid + skipped_missing_fk + skipped_duplicate + if inserted_count == 0 and total_skipped > 0: + response = { + "status": "warning", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_fk": skipped_missing_fk, + "skipped_duplicate": skipped_duplicate, + "skipped_details": skipped_details, + "message": f"No se insertaron registros. {total_skipped} rechazados.", + } + elif inserted_count == 0: + response = { + "status": "failed", + "error": "No hay registros válidos en el archivo CSV", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_fk": skipped_missing_fk, + "skipped_duplicate": skipped_duplicate, + "skipped_details": skipped_details, + } + else: + response = { + "status": "finished", + "inserted": inserted_count, + "skipped_invalid": skipped_invalid, + "skipped_missing_fk": skipped_missing_fk, + "skipped_duplicate": skipped_duplicate, + "skipped_details": skipped_details, + } + + except Exception as e: + logger.error(f"Parts import task failed: {e}") + import traceback + logger.error(traceback.format_exc()) + return {"status": "failed", "error": str(e)} + + 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"Parts import cleanup failed: {cleanup_err}") + + if response is None: + response = { + "status": "failed", + "error": "Error inesperado", + "inserted": 0, + "skipped_invalid": skipped_invalid, + "skipped_missing_fk": skipped_missing_fk, + "skipped_duplicate": skipped_duplicate, + "skipped_details": skipped_details, + } + return response diff --git a/backend/api/v1/modules/a76/parts/imports/template_config.py b/backend/api/v1/modules/a76/parts/imports/template_config.py new file mode 100644 index 00000000..7cc240c4 --- /dev/null +++ b/backend/api/v1/modules/a76/parts/imports/template_config.py @@ -0,0 +1,54 @@ +""" +Configuración de plantilla CSV para Números de Parte (EstructuraCatPartesAF.xls). +""" + +from typing import Dict, List, Any + +TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { + "part_numbers": [ + {"canonical": "NUMPARTE", "aliases": ["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": "CLASE", "aliases": ["CLASS", "CLASE MATERIAL", "PART CLASS"]}, + {"canonical": "UNIMED", "aliases": ["UNIDAD MEDIDA", "UNIT", "UOM", "UNIT OF MEASURE"]}, + {"canonical": "COSTOUNIT", "aliases": ["COSTO UNITARIO", "UNIT COST", "COSTO"]}, + {"canonical": "MONEDA", "aliases": ["CURRENCY", "MONEDA CLAVE", "CURRENCY KEY"]}, + {"canonical": "PESOUNIT", "aliases": ["PESO UNITARIO", "UNIT WEIGHT", "PESO"]}, + {"canonical": "TIPOPESO", "aliases": ["WEIGHT TYPE", "TIPO PESO"]}, + {"canonical": "FRACCION", "aliases": ["FRACCION MEX"]}, + {"canonical": "FRACCIONAME", "aliases": ["FRACCION USA", "US FRACTION"]}, + {"canonical": "FDAKEY", "aliases": ["FDA", "FDA KEY"]}, + {"canonical": "FCCKEY", "aliases": ["FCC", "FCC KEY"]}, + {"canonical": "LICENCIA", "aliases": ["LICENSE CODE", "LICENSE"]}, + {"canonical": "ECCN", "aliases": ["ECCN CODE"]}, + {"canonical": "EXPORTCODE", "aliases": ["EXPORT CODE", "CODIGO EXPORT"]}, + {"canonical": "EXCLUSION", "aliases": ["EXCLUSION SYMBOL", "SIMBOLO EXCLUSION"]}, + {"canonical": "ACTIVO", "aliases": ["IS ACTIVE", "ACTIVE", "ACTIVO"]}, + ], +} + + +def build_normalized_lookup(normalize_header_fn) -> Dict[str, str]: + cols = TEMPLATE_COLUMNS.get("part_numbers") + if not cols: + return {} + lookup: Dict[str, str] = {} + for item in cols: + canonical = item["canonical"] + lookup[normalize_header_fn(canonical)] = canonical + for alias in item.get("aliases") or []: + lookup[normalize_header_fn(alias)] = canonical + return lookup + + +def row_from_template(row: Dict[str, Any], normalize_header_fn) -> Dict[str, Any]: + lookup = build_normalized_lookup(normalize_header_fn) + if not lookup: + return {normalize_header_fn(k): v for k, v in row.items()} + out: Dict[str, Any] = {} + for csv_header, value in row.items(): + key_norm = normalize_header_fn(csv_header) + if key_norm in lookup: + out[lookup[key_norm]] = value + return out diff --git a/backend/api/v1/modules/a76/parts/routes.py b/backend/api/v1/modules/a76/parts/routes.py index 033ad472..17bf937a 100644 --- a/backend/api/v1/modules/a76/parts/routes.py +++ b/backend/api/v1/modules/a76/parts/routes.py @@ -2,21 +2,31 @@ Endpoints API para gestión de partes (SCAII) """ -from api.v1.common.tenant_crud_routes import TenantCRUDRoutes +from fastapi import APIRouter + +from api.v1.common.tenant_crud_routes import TenantCRUDRoutes from .dto import PartCreateDTO, PartResponseDTO, PartUpdateDTO from .service import PartService +from .imports.routes import router as imports_router +router = APIRouter() -router = TenantCRUDRoutes( - service=PartService, - create_schema=PartCreateDTO, - update_schema=PartUpdateDTO, - response_schema=PartResponseDTO, - prefix="/parts", - tags=["a76 / parts"], - resource_name="Part", - id_name="part_id", - enable_list=True, - enable_filters=True, -).router \ No newline at end of file +# CSV import (upload → scan → status → commit) +router.include_router(imports_router, prefix="/parts/imports", tags=["a76 / parts / csv_import"]) + +# CRUD +router.include_router( + TenantCRUDRoutes( + service=PartService, + create_schema=PartCreateDTO, + update_schema=PartUpdateDTO, + response_schema=PartResponseDTO, + prefix="/parts", + tags=["a76 / parts"], + resource_name="Part", + id_name="part_id", + enable_list=True, + enable_filters=True, + ).router +) \ No newline at end of file diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 279b0a01..a856f339 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -39,6 +39,7 @@ celery_app.conf.update( "api.v1.modules.a76.general_catalogs.exchange_rate.imports.tasks", "api.v1.modules.a76.general_catalogs.fractions.us_tariff_fractions.imports.tasks", "api.v1.modules.a76.classes.imports.tasks", + "api.v1.modules.a76.parts.imports.tasks", "api.v1.modules.a76.transportation.vehicles.imports.tasks", "api.v1.modules.a76.reports.exportacion.transmission.MAINX30.task", "api.v1.modules.a76.reports.importacion.transmission.temporal.MAINX30.task", diff --git a/frontend/src/lib/config/csv-upload.ts b/frontend/src/lib/config/csv-upload.ts index da79ab77..9ab11d00 100644 --- a/frontend/src/lib/config/csv-upload.ts +++ b/frontend/src/lib/config/csv-upload.ts @@ -1,387 +1,387 @@ -import { - User, - Users, - FileText, - Truck, - Container, - Ship, - Plane, - Package, - Briefcase, - Globe, - CreditCard, - DollarSign, - Calendar, - Hash, - MapPin, - ShieldCheck, - FileDigit, - Scale, -} from 'lucide-svelte'; - -// --- Interfaces --- - -export interface CsvUploadItem { - id: string; - title: string; - icon: any; - group?: string; // For grouping within a tab - modelTarget?: string; // The backend model this maps to - description?: string; - templateUrl?: string; // Path to the template file in static/ - disabled?: boolean; // New property to mark items as "Coming Soon" -} - -export interface CsvUploadField { - name: string; - label: string; - type: 'text' | 'select' | 'boolean' | 'date' | 'radio'; - options?: { label: string; value: string | boolean | number }[]; - required?: boolean; - defaultValue?: any; -} - -// Map of Tab ID -> Array of Fields -export const tabSettings: Record = { - catalogos: [ - { - name: 'mode', - label: 'Modo de Carga', - type: 'radio', - options: [ - { label: 'Actualizar', value: 'update' }, - { label: 'Reemplazar', value: 'replace' } - ], - defaultValue: 'update' - } - ], - transportes: [ - { - name: 'mode', - label: 'Modo de Carga', - type: 'radio', - options: [ - { label: 'Actualizar', value: 'update' }, - { label: 'Reemplazar', value: 'replace' } - ], - defaultValue: 'update' - } - ], - importacion: [ - { - name: 'autonumber_remesas', - label: 'Autonumerar Remesas', - type: 'boolean', - defaultValue: false - }, - { - name: 'recalculate_dates', - label: 'Recalcular Fechas', - type: 'boolean', - defaultValue: false - }, - { - name: 'dateFormat', - label: 'Formato de Fecha', - type: 'select', - options: [ - { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, - { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, - { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } - ], - defaultValue: 'dd/mm/yyyy' - } - ], - exportacion: [ - { - name: 'invoice_type', - label: 'Tipo de Factura', - type: 'select', - options: [ - { label: 'AFIJO', value: 'AFIJO' }, - { label: 'NORMAL', value: 'NORMAL' }, - ], - defaultValue: 'AFIJO', - }, - { - name: 'is_regime_change', - label: 'Es Cambio de Régimen', - type: 'boolean', - defaultValue: false, - }, - { - name: 'dateFormat', - label: 'Formato de Fecha', - type: 'select', - options: [ - { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, - { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, - { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } - ], - defaultValue: 'dd/mm/yyyy' - } - ] -}; - -// --- DATA DEFINITIONS (Items only, no config) --- - -export const catalogosConfig: CsvUploadItem[] = [ - { - id: 'customs_brokers', - title: 'Agentes Aduanales', - icon: User, - modelTarget: 'CustomsBroker', - templateUrl: '/csv/EstructuraCatAgenteAduanal.xls' - }, - { - id: 'clients_providers', - title: 'Clientes y Proveedores', - icon: Users, - modelTarget: 'ClientProvider', - templateUrl: '/csv/EstructuraCatClienteProv.xls' - }, - { - id: 'exchange_rates', - title: 'Tipo de Cambios', - icon: DollarSign, - modelTarget: 'ExchangeRate', - templateUrl: '/csv/EstructuraCatTiposCambio.xls' - }, - { - id: 'american_fractions', - title: 'Fracc. Ame.', - icon: Globe, - modelTarget: 'AmericanFraction', - templateUrl: '/csv/EstructuraCatFraccAme.xls' - }, - { - id: 'material_classes', - title: 'Clases de Materiales', - icon: Package, - modelTarget: 'MaterialClass', - templateUrl: '/csv/EstructuraCatClasesAF.xls' - }, - { - id: 'part_numbers', - title: 'Números de parte', - icon: Hash, - modelTarget: 'Part', - templateUrl: '/csv/EstructuraCatNumerosParte.xlsx', - }, - { - id: 'boms', - title: 'BOMs', - icon: Briefcase, - modelTarget: 'Bom', - templateUrl: '/csv/EstructuraBOMS.xlsx', - }, - { - id: 'items', - title: 'Partidas (Permisos)', - icon: FileText, - group: 'Permisos', - modelTarget: 'ItemPermission', - templateUrl: '/csv/EstructuraCatPartesAF.xls' - }, - { - id: 'headers', - title: 'Encabezados (Permisos)', - icon: FileText, - group: 'Permisos', - modelTarget: 'HeaderPermission', - disabled: true, - }, - { - id: 'historical_fractions', - title: 'Fracciones Históricas', - icon: Calendar, - modelTarget: 'HistoricalFraction', - disabled: true, - }, - { - id: 'pedimentos', - title: 'Pedimentos', - icon: FileDigit, - modelTarget: 'Pedimento', - templateUrl: '/csv/EstructuraCatPedimentos.xls' - }, -]; - -export const transportesConfig: CsvUploadItem[] = [ - { - id: 'transporters', - title: 'Transportistas', - icon: Ship, - modelTarget: 'Transporter', - templateUrl: '/csv/EstructuraCatTransportistas.xlsx', - }, - { - id: 'transports', - title: 'Transportes', - icon: Truck, - modelTarget: 'Transport', - templateUrl: '/csv/EstructuraCatTransportes.xls' - }, - { - id: 'drivers', - title: 'Conductores', - icon: User, - modelTarget: 'Driver', - templateUrl: '/csv/EstructuraCatConductor.xls' - }, - { - id: 'trailers', - title: 'Trailers y Cajas', - icon: Container, - modelTarget: 'Trailer', - templateUrl: '/csv/EstructuraCatTrailers.xls' - }, -]; - -export const importacionConfig: CsvUploadItem[] = [ - // Impo Temp - { - id: 'imp_temp_header', - title: 'Encabezado', - icon: FileText, - group: 'Impo. Temp.', - modelTarget: 'invoice_header', - templateUrl: '/csv/EstructuraEncFacImpoTemp.xls' - }, - { - id: 'imp_temp_details', - title: 'Partidas', - icon: Package, - group: 'Impo. Temp.', - modelTarget: 'invoice_details', - templateUrl: '/csv/EstructuraParFacImpoTempAF.xls' - }, - { - id: 'imp_temp_series', - title: 'Series', - icon: Hash, - group: 'Impo. Temp.', - modelTarget: 'InvoiceSeries', - disabled: true, - }, - // Impo Def - { - id: 'imp_def_header', - title: 'Encabezado', - icon: FileText, - group: 'Impo. Def.', - modelTarget: 'invoice_header', - templateUrl: '/csv/EstructuraEncFacImpoDef.xls' - }, - { - id: 'imp_def_details', - title: 'Partidas', - icon: Package, - group: 'Impo. Def.', - modelTarget: 'invoice_details', - templateUrl: '/csv/EstructuraParFacImpoDefAF.xls' - }, - { - id: 'imp_def_series', - title: 'Series', - icon: Hash, - group: 'Impo. Def.', - modelTarget: 'InvoiceSeries', - disabled: true, - }, - // Compras Mex - { - id: 'comp_mex_header', - title: 'Encabezado', - icon: FileText, - group: 'Compras Mex.', - modelTarget: 'invoice_header', - disabled: true, - }, - { - id: 'comp_mex_details', - title: 'Partidas', - icon: Package, - group: 'Compras Mex.', - modelTarget: 'invoice_details', - disabled: true, - }, - { - id: 'comp_mex_series', - title: 'Series', - icon: Hash, - group: 'Compras Mex.', - modelTarget: 'InvoiceSeries', - disabled: true, - }, -]; - -export const exportacionConfig: CsvUploadItem[] = [ - // Expo Def / Cam. Reg. - { - id: 'exp_def_header', - title: 'Encabezado', - icon: FileText, - group: 'Expo. Def./Cam. Reg.', - modelTarget: 'invoice_header', - templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls' - }, - { - id: 'exp_def_details', - title: 'Partidas', - icon: Package, - group: 'Expo. Def./Cam. Reg.', - modelTarget: 'invoice_details', - templateUrl: '/csv/EstructuraParExpoCamReg.xls' - }, - { - id: 'exp_def_series', - title: 'Series', - icon: Hash, - group: 'Expo. Def./Cam. Reg.', - modelTarget: 'InvoiceSeries', - disabled: true, - }, - { - id: 'exp_def_nodes', - title: 'NODES', - icon: Briefcase, - group: 'Expo. Def./Cam. Reg.', - modelTarget: 'Nodes', - disabled: true, - }, - // Expo Rep - { - id: 'exp_rep_header', - title: 'Encabezado', - icon: FileText, - group: 'Expo. Rep.', - modelTarget: 'InvoiceHeader', - disabled: true, - }, - { - id: 'exp_rep_details', - title: 'Partidas', - icon: Package, - group: 'Expo. Rep.', - modelTarget: 'InvoiceSalesDetails', - disabled: true, - }, - { - id: 'exp_rep_series', - title: 'Series', - icon: Hash, - group: 'Expo. Rep.', - modelTarget: 'InvoiceSeries', - disabled: true, - }, - // Manifiesto - { - id: 'manifest_header', - title: 'Encabezado', - icon: FileText, - group: 'Manifiesto', - modelTarget: 'Manifest', - disabled: true, - }, -]; +import { + User, + Users, + FileText, + Truck, + Container, + Ship, + Plane, + Package, + Briefcase, + Globe, + CreditCard, + DollarSign, + Calendar, + Hash, + MapPin, + ShieldCheck, + FileDigit, + Scale, +} from 'lucide-svelte'; + +// --- Interfaces --- + +export interface CsvUploadItem { + id: string; + title: string; + icon: any; + group?: string; // For grouping within a tab + modelTarget?: string; // The backend model this maps to + description?: string; + templateUrl?: string; // Path to the template file in static/ + disabled?: boolean; // New property to mark items as "Coming Soon" +} + +export interface CsvUploadField { + name: string; + label: string; + type: 'text' | 'select' | 'boolean' | 'date' | 'radio'; + options?: { label: string; value: string | boolean | number }[]; + required?: boolean; + defaultValue?: any; +} + +// Map of Tab ID -> Array of Fields +export const tabSettings: Record = { + catalogos: [ + { + name: 'mode', + label: 'Modo de Carga', + type: 'radio', + options: [ + { label: 'Actualizar', value: 'update' }, + { label: 'Reemplazar', value: 'replace' } + ], + defaultValue: 'update' + } + ], + transportes: [ + { + name: 'mode', + label: 'Modo de Carga', + type: 'radio', + options: [ + { label: 'Actualizar', value: 'update' }, + { label: 'Reemplazar', value: 'replace' } + ], + defaultValue: 'update' + } + ], + importacion: [ + { + name: 'autonumber_remesas', + label: 'Autonumerar Remesas', + type: 'boolean', + defaultValue: false + }, + { + name: 'recalculate_dates', + label: 'Recalcular Fechas', + type: 'boolean', + defaultValue: false + }, + { + name: 'dateFormat', + label: 'Formato de Fecha', + type: 'select', + options: [ + { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, + { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, + { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } + ], + defaultValue: 'dd/mm/yyyy' + } + ], + exportacion: [ + { + name: 'invoice_type', + label: 'Tipo de Factura', + type: 'select', + options: [ + { label: 'AFIJO', value: 'AFIJO' }, + { label: 'NORMAL', value: 'NORMAL' }, + ], + defaultValue: 'AFIJO', + }, + { + name: 'is_regime_change', + label: 'Es Cambio de Régimen', + type: 'boolean', + defaultValue: false, + }, + { + name: 'dateFormat', + label: 'Formato de Fecha', + type: 'select', + options: [ + { label: 'DD/MM/YYYY', value: 'dd/mm/yyyy' }, + { label: 'MM/DD/YYYY', value: 'mm/dd/yyyy' }, + { label: 'YYYY-MM-DD', value: 'yyyy-mm-dd' } + ], + defaultValue: 'dd/mm/yyyy' + } + ] +}; + +// --- DATA DEFINITIONS (Items only, no config) --- + +export const catalogosConfig: CsvUploadItem[] = [ + { + id: 'customs_brokers', + title: 'Agentes Aduanales', + icon: User, + modelTarget: 'CustomsBroker', + templateUrl: '/csv/EstructuraCatAgenteAduanal.xls' + }, + { + id: 'clients_providers', + title: 'Clientes y Proveedores', + icon: Users, + modelTarget: 'ClientProvider', + templateUrl: '/csv/EstructuraCatClienteProv.xls' + }, + { + id: 'exchange_rates', + title: 'Tipo de Cambios', + icon: DollarSign, + modelTarget: 'ExchangeRate', + templateUrl: '/csv/EstructuraCatTiposCambio.xls' + }, + { + id: 'american_fractions', + title: 'Fracc. Ame.', + icon: Globe, + modelTarget: 'AmericanFraction', + templateUrl: '/csv/EstructuraCatFraccAme.xls' + }, + { + id: 'material_classes', + title: 'Clases de Materiales', + icon: Package, + modelTarget: 'MaterialClass', + templateUrl: '/csv/EstructuraCatClasesAF.xls' + }, + { + id: 'part_numbers', + title: 'Números de parte', + icon: Hash, + modelTarget: 'Part', + templateUrl: '/csv/EstructuraCatPartesAF.xls', + }, + { + id: 'boms', + title: 'BOMs', + icon: Briefcase, + modelTarget: 'Bom', + templateUrl: '/csv/EstructuraBOMS.xlsx', + }, + { + id: 'items', + title: 'Partidas (Permisos)', + icon: FileText, + group: 'Permisos', + modelTarget: 'ItemPermission', + templateUrl: '/csv/EstructuraCatPartesAF.xls' + }, + { + id: 'headers', + title: 'Encabezados (Permisos)', + icon: FileText, + group: 'Permisos', + modelTarget: 'HeaderPermission', + disabled: true, + }, + { + id: 'historical_fractions', + title: 'Fracciones Históricas', + icon: Calendar, + modelTarget: 'HistoricalFraction', + disabled: true, + }, + { + id: 'pedimentos', + title: 'Pedimentos', + icon: FileDigit, + modelTarget: 'Pedimento', + templateUrl: '/csv/EstructuraCatPedimentos.xls' + }, +]; + +export const transportesConfig: CsvUploadItem[] = [ + { + id: 'transporters', + title: 'Transportistas', + icon: Ship, + modelTarget: 'Transporter', + templateUrl: '/csv/EstructuraCatTransportistas.xlsx', + }, + { + id: 'transports', + title: 'Transportes', + icon: Truck, + modelTarget: 'Transport', + templateUrl: '/csv/EstructuraCatTransportes.xls' + }, + { + id: 'drivers', + title: 'Conductores', + icon: User, + modelTarget: 'Driver', + templateUrl: '/csv/EstructuraCatConductor.xls' + }, + { + id: 'trailers', + title: 'Trailers y Cajas', + icon: Container, + modelTarget: 'Trailer', + templateUrl: '/csv/EstructuraCatTrailers.xls' + }, +]; + +export const importacionConfig: CsvUploadItem[] = [ + // Impo Temp + { + id: 'imp_temp_header', + title: 'Encabezado', + icon: FileText, + group: 'Impo. Temp.', + modelTarget: 'invoice_header', + templateUrl: '/csv/EstructuraEncFacImpoTemp.xls' + }, + { + id: 'imp_temp_details', + title: 'Partidas', + icon: Package, + group: 'Impo. Temp.', + modelTarget: 'invoice_details', + templateUrl: '/csv/EstructuraParFacImpoTempAF.xls' + }, + { + id: 'imp_temp_series', + title: 'Series', + icon: Hash, + group: 'Impo. Temp.', + modelTarget: 'InvoiceSeries', + disabled: true, + }, + // Impo Def + { + id: 'imp_def_header', + title: 'Encabezado', + icon: FileText, + group: 'Impo. Def.', + modelTarget: 'invoice_header', + templateUrl: '/csv/EstructuraEncFacImpoDef.xls' + }, + { + id: 'imp_def_details', + title: 'Partidas', + icon: Package, + group: 'Impo. Def.', + modelTarget: 'invoice_details', + templateUrl: '/csv/EstructuraParFacImpoDefAF.xls' + }, + { + id: 'imp_def_series', + title: 'Series', + icon: Hash, + group: 'Impo. Def.', + modelTarget: 'InvoiceSeries', + disabled: true, + }, + // Compras Mex + { + id: 'comp_mex_header', + title: 'Encabezado', + icon: FileText, + group: 'Compras Mex.', + modelTarget: 'invoice_header', + disabled: true, + }, + { + id: 'comp_mex_details', + title: 'Partidas', + icon: Package, + group: 'Compras Mex.', + modelTarget: 'invoice_details', + disabled: true, + }, + { + id: 'comp_mex_series', + title: 'Series', + icon: Hash, + group: 'Compras Mex.', + modelTarget: 'InvoiceSeries', + disabled: true, + }, +]; + +export const exportacionConfig: CsvUploadItem[] = [ + // Expo Def / Cam. Reg. + { + id: 'exp_def_header', + title: 'Encabezado', + icon: FileText, + group: 'Expo. Def./Cam. Reg.', + modelTarget: 'invoice_header', + templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls' + }, + { + id: 'exp_def_details', + title: 'Partidas', + icon: Package, + group: 'Expo. Def./Cam. Reg.', + modelTarget: 'invoice_details', + templateUrl: '/csv/EstructuraParExpoCamReg.xls' + }, + { + id: 'exp_def_series', + title: 'Series', + icon: Hash, + group: 'Expo. Def./Cam. Reg.', + modelTarget: 'InvoiceSeries', + disabled: true, + }, + { + id: 'exp_def_nodes', + title: 'NODES', + icon: Briefcase, + group: 'Expo. Def./Cam. Reg.', + modelTarget: 'Nodes', + disabled: true, + }, + // Expo Rep + { + id: 'exp_rep_header', + title: 'Encabezado', + icon: FileText, + group: 'Expo. Rep.', + modelTarget: 'InvoiceHeader', + disabled: true, + }, + { + id: 'exp_rep_details', + title: 'Partidas', + icon: Package, + group: 'Expo. Rep.', + modelTarget: 'InvoiceSalesDetails', + disabled: true, + }, + { + id: 'exp_rep_series', + title: 'Series', + icon: Hash, + group: 'Expo. Rep.', + modelTarget: 'InvoiceSeries', + disabled: true, + }, + // Manifiesto + { + id: 'manifest_header', + title: 'Encabezado', + icon: FileText, + group: 'Manifiesto', + modelTarget: 'Manifest', + disabled: true, + }, +];