diff --git a/.gitignore b/.gitignore index 48bbc048..ff9b622c 100644 --- a/.gitignore +++ b/.gitignore @@ -23,9 +23,11 @@ wheels/ *.egg .pnpm-store/ -# Environment +# Environment (no subir: cada quien puede usar puertos distintos vía .env) .env .env.local +backend/.env +frontend/.env backend/SCRIPTS/ # IDEs .vscode/ @@ -62,4 +64,3 @@ node_modules/ *.dockerignore postgres-data/ backend/uploads/ -docker-compose.yml diff --git a/backend/api/__init__.py b/backend/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/__init__.py b/backend/api/v1/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/__init__.py b/backend/api/v1/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/__init__.py b/backend/api/v1/modules/a76/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/imports/routes.py b/backend/api/v1/modules/a76/imports/routes.py index 1ea76a74..f3c3d9ee 100644 --- a/backend/api/v1/modules/a76/imports/routes.py +++ b/backend/api/v1/modules/a76/imports/routes.py @@ -1,5 +1,6 @@ from datetime import datetime from uuid import uuid4 +import base64 import os import json import logging @@ -12,24 +13,39 @@ from core.config import settings from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource -from .tasks import scan_file, insert_valid_rows +from .tasks import ( + scan_file, + insert_valid_rows, + IMPORT_FILE_KEY_PREFIX, + IMPORT_META_KEY_PREFIX, + IMPORT_REDIS_TTL, +) from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest router = APIRouter() logger = logging.getLogger(__name__) + +def _get_redis(): + """Redis client (same broker as Celery so worker can read).""" + 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/{model_target}", response_model=ImportJobResponse) async def upload_import_file( model_target: Literal["invoice_header", "invoice_details"], file: UploadFile = File(...), footer_config: Optional[str] = Form(None), # JSON string with settings - company_id: int = Query(..., description="Company ID"), # Required for context + template_id: Optional[str] = Form(None), # id de la plantilla (ej. imp_temp_header) para respetar columnas + company_id: int = Query(..., description="Company ID"), # Required for context operation_type: Optional[str] = Query("imp"), db: Session = Depends(get_core_db), current_user: Dict[str, Any] = Depends(get_current_user), ): """ Step 1: Upload CSV, save to temp, trigger scan task. + Si se envía template_id, solo se leen las columnas de esa plantilla. """ # 1. Validate Access & Get Tenant try: @@ -40,40 +56,51 @@ async def upload_import_file( if not file.filename.endswith(".csv"): raise HTTPException(status_code=400, detail="Only .csv files allowed") - + job_id = str(uuid4()) - - # Ensure directory exists (Safety check) - upload_dir = os.path.join(os.getcwd(), "uploads", "temp") - os.makedirs(upload_dir, exist_ok=True) - - file_path = os.path.join(upload_dir, f"{job_id}.csv") - meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") - + contents = await file.read() + + meta_data = { + "tenant_id": tenant_id, + "company_id": company_id, + "user_id": current_user.get("id"), + "footer_config": footer_config, + "operation_type": operation_type, + "template_id": template_id, + } + + # Store file and meta in Redis so the Celery worker can read them (no shared filesystem needed) try: - # Save CSV - contents = await file.read() + redis_client = _get_redis() + redis_client.set( + f"{IMPORT_FILE_KEY_PREFIX}{job_id}", + base64.b64encode(contents), + ex=IMPORT_REDIS_TTL, + ) + redis_client.set( + f"{IMPORT_META_KEY_PREFIX}{job_id}", + json.dumps(meta_data).encode("utf-8"), + ex=IMPORT_REDIS_TTL, + ) + except Exception as e: + logger.error(f"Redis store error: {e}") + raise HTTPException(status_code=500, detail="Failed to queue file for processing.") + + # Optional: also write to local disk (e.g. for same-machine worker or debugging) + try: + upload_dir = os.path.join(os.getcwd(), "uploads", "temp") + os.makedirs(upload_dir, exist_ok=True) + file_path = os.path.join(upload_dir, f"{job_id}.csv") + meta_path = os.path.join(upload_dir, f"{job_id}.meta.json") with open(file_path, "wb") as f: f.write(contents) - - # Save Metadata (Context) - meta_data = { - "tenant_id": tenant_id, - "company_id": company_id, - "user_id": current_user.get("id"), - "footer_config": footer_config, - "operation_type": operation_type, - } with open(meta_path, "w") as f: json.dump(meta_data, f) - except Exception as e: - logger.error(f"File save error: {e}") - raise HTTPException(status_code=500, detail=f"Failed to save file: {str(e)}") + logger.warning(f"Local file save failed (worker will use Redis): {e}") - # Trigger Celery Task (Async) - # Use our job_id as the Celery task_id for easier tracking - scan_file.apply_async(args=[job_id, file_path, model_target, footer_config], task_id=job_id) + # Trigger Celery Task (Async). Worker loads file from Redis. + scan_file.apply_async(args=[job_id, model_target, footer_config], task_id=job_id) return ImportJobResponse( job_id=job_id, @@ -84,24 +111,56 @@ async def upload_import_file( @router.get("/{job_id}/status") async def get_import_status(job_id: str): """ - Poll this endpoint to get % progress or final report. + Poll to get progress or final report. Always returns an object with "status". """ - # In a real app, query Redis or DB. - # For MVP, we might mock or use Celery AsyncResult if backend shares Redis. task_result = celery_app.AsyncResult(job_id) - - if task_result.state == 'PENDING': + + if task_result.state == "PENDING": return {"status": "processing", "progress": 0} - elif task_result.state == 'PROGRESS': + if task_result.state == "PROGRESS": return { - "status": "processing", - "progress": task_result.info.get('current', 0), - "total": task_result.info.get('total', 0) + "status": "processing", + "progress": (task_result.info or {}).get("current", 0), + "total": (task_result.info or {}).get("total", 0), } - elif task_result.state == 'SUCCESS': - return task_result.result # Should return the report - else: - return {"status": task_result.state, "error": str(task_result.info)} + if task_result.state == "SUCCESS": + result = task_result.result + if isinstance(result, dict) and "status" in result: + return result + return {"status": "finished", "result": result} + # FAILURE: obtener mensaje real (traceback, result o get(propagate=False)) + logger.warning("Import task %s failed: state=%s", job_id, task_result.state) + err_msg = None + tb = getattr(task_result, "traceback", None) + if tb: + logger.debug("Task traceback: %s", tb[:500] if isinstance(tb, str) else tb) + 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 and len(lines) > 1: + err_msg = lines[-2] + " " + (lines[-1] or "") + 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: + result = getattr(task_result, "result", None) + info = getattr(task_result, "info", None) + if result is not None and not isinstance(result, dict): + err_msg = str(result) + elif isinstance(result, dict) and (result.get("error") or result.get("message")): + err_msg = result.get("error") or result.get("message") + if not err_msg and isinstance(info, str): + err_msg = info + elif not err_msg and isinstance(info, dict) and "error" in info: + err_msg = str(info["error"]) + if not err_msg: + err_msg = "Task failed" + return {"status": "failed", "error": err_msg} @router.post("/{job_id}/commit") diff --git a/backend/api/v1/modules/a76/imports/tasks.py b/backend/api/v1/modules/a76/imports/tasks.py index dfb82e74..2acde6a5 100644 --- a/backend/api/v1/modules/a76/imports/tasks.py +++ b/backend/api/v1/modules/a76/imports/tasks.py @@ -1,4 +1,5 @@ import os +import base64 from datetime import datetime from decimal import Decimal import csv @@ -6,9 +7,12 @@ import json import logging import re import unicodedata -from celery import shared_task -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List + +from core.celery_app import celery_app from core.database import CoreSessionLocal + +from .template_config import row_from_template # Models are imported inside tasks to avoid circular dependencies and mapper initialization issues in the API process # We'll need schemas for validation @@ -17,6 +21,78 @@ from core.database import CoreSessionLocal logger = logging.getLogger(__name__) +# Redis keys and TTL for import file/meta (shared between API and worker when no shared filesystem) +IMPORT_FILE_KEY_PREFIX = "import_file:" +IMPORT_META_KEY_PREFIX = "import_meta:" +IMPORT_ERROR_LINES_KEY_PREFIX = "import_error_lines:" +IMPORT_REDIS_TTL = 3600 # 1 hour + + +def _get_redis(): + """Redis client using same URL as Celery broker (worker and API can share data).""" + 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: + """Directory on the worker for temp CSV and meta (same structure as API, but local to worker).""" + return os.path.join(os.getcwd(), "uploads", "temp") + + +def _ensure_worker_has_file_from_redis(job_id: str) -> Optional[str]: + """ + Load file content from Redis and write to worker's upload dir. + Returns local file_path if successful, None otherwise. + """ + redis_client = _get_redis() + key = f"{IMPORT_FILE_KEY_PREFIX}{job_id}" + data = redis_client.get(key) + if not data: + return None + try: + raw = base64.b64decode(data) + except Exception as e: + logger.warning(f"Failed to decode import 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"{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: + """Load meta from Redis and write to worker's meta file. Returns True if meta was found and written.""" + redis_client = _get_redis() + key = f"{IMPORT_META_KEY_PREFIX}{job_id}" + data = redis_client.get(key) + if not data: + return False + try: + meta = json.loads(data.decode("utf-8")) + except Exception as e: + logger.warning(f"Failed to decode import 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: + """Remove file, meta and error lines from Redis after commit (cleanup).""" + try: + r = _get_redis() + r.delete( + f"{IMPORT_FILE_KEY_PREFIX}{job_id}", + f"{IMPORT_META_KEY_PREFIX}{job_id}", + f"{IMPORT_ERROR_LINES_KEY_PREFIX}{job_id}", + ) + except Exception as e: + logger.warning(f"Failed to delete import keys from Redis: {e}") + class ForeignKeyValidator: def __init__(self, session, tenant_id, company_id): self.session = session @@ -40,26 +116,90 @@ class ForeignKeyValidator: self.cache[key] = exists return exists -@shared_task(bind=True) -def scan_file(self, job_id: str, file_path: str, model_target: str, config: str = None): + +TRANSPORT_TYPE_VALUES = { + "none", + "transport", + "box", + "licence plates", + "truck", + "vessel", + "rail_barge", + "container", + "airplane", + "gondola", + "flatbed", +} + + +def normalize_public_code(value: Optional[str]) -> Optional[str]: + if value is None: + return None + text = str(value).strip().upper() + return text or None + + +def validate_public_code( + validator: ForeignKeyValidator, + model, + value: Optional[str], + line_num: int, + col_name: str, + field_name: str = "code", + required: bool = False, +) -> Optional[Dict[str, Any]]: + code = normalize_public_code(value) + if not code: + if required: + return {"line": line_num, "col": col_name, "msg": "Requerido"} + return None + if not validator.check_exists(model, code, field_name=field_name, is_public=True): + return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"} + return None + + +def validate_tenant_fk_id( + validator: ForeignKeyValidator, + model, + value: Optional[int], + line_num: int, + col_name: str, + required: bool = False, +) -> Optional[Dict[str, Any]]: + if value is None: + if required: + return {"line": line_num, "col": col_name, "msg": "Requerido"} + return None + if not validator.check_exists(model, value): + return {"line": line_num, "col": col_name, "msg": "No existe en el catalogo"} + return None + +@celery_app.task(bind=True) +def scan_file(self, job_id: str, model_target: str, config: str = None): """ Pass 1: Read CSV, Validate types, Write Errors to JSONL. + File content is loaded from Redis (written by API on upload) so worker does not need shared filesystem. """ logger.info(f"Starting scan for job {job_id} target {model_target}") - - # 1. Setup Error Log + + # 1. Get file from Redis and write to worker local disk + file_path = _ensure_worker_has_file_from_redis(job_id) + if not file_path: + return {"status": "failed", "error": "File not found (missing or expired in queue). Please upload again."} + _ensure_worker_has_meta_from_redis(job_id, file_path) + + # 2. Setup Error Log error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl") os.makedirs(os.path.dirname(error_path), exist_ok=True) - + total_rows = 0 error_count = 0 processed_rows = 0 - - # 2. Count Total (Quick Pass) or just estimate - # For better progress, we can get file line count first + + # 3. Count Total (Quick Pass) or just estimate try: with open(file_path, 'r', encoding='utf-8-sig') as f: - total_rows = sum(1 for _ in f) - 1 # Minus header + total_rows = sum(1 for _ in f) - 1 # Minus header except Exception as e: return {"status": "failed", "error": f"Cannot read file: {e}"} @@ -70,10 +210,65 @@ def scan_file(self, job_id: str, file_path: str, model_target: str, config: str if not date_format: date_format = "yyyy-mm-dd" # Default to ISO format logger.info(f"No date_format specified in config, using default: {date_format}") + + meta_path = file_path.replace(".csv", ".meta.json") + meta = {} + tenant_id = None + company_id = None + if os.path.exists(meta_path): + try: + with open(meta_path, "r", encoding="utf-8") as f_meta: + meta = json.load(f_meta) or {} + tenant_id = meta.get("tenant_id") + company_id = meta.get("company_id") + except Exception as e: + logger.warning(f"Failed to read meta for job {job_id}: {e}") + + if not tenant_id or not company_id: + return {"status": "failed", "error": "Missing context (tenant/company)"} + + template_id = meta.get("template_id") or ( + "imp_temp_header" if model_target == "invoice_header" else "imp_temp_details" + ) + + inv_type_value = normalize_public_code(footer_config.get("invoice_type") or meta.get("invoice_type") or "TEM") + if not inv_type_value: + inv_type_value = "TEM" try: - with open(file_path, 'r', encoding='utf-8-sig') as f_in, \ + from api.v1.modules.a76.invoices.models import InvoiceHeader + from api.v1.modules.a76.clients_and_providers.models import ClientProvider + from api.v1.modules.a76.customs_brokers.models import CustomsBroker + from api.v1.modules.public.reference_data.currency_types.models import CurrencyType + from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode + from api.v1.modules.public.reference_data.code_pedimento_regimens.models import ( + CodePedimentoRegimen, + ) + from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento + from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType + from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection + from api.v1.modules.public.reference_data.incoterms.models import Incoterm + from api.v1.modules.a76.parts.models import Part + + models = { + "InvoiceHeader": InvoiceHeader, + "InvoiceType": InvoiceType, + "ClientProvider": ClientProvider, + "CustomsBroker": CustomsBroker, + "RegimenPedimento": RegimenPedimento, + "CodePedimentoRegimen": CodePedimentoRegimen, + "PedimentoCode": PedimentoCode, + "CurrencyType": CurrencyType, + "CustomsSection": CustomsSection, + "Incoterm": Incoterm, + "Part": Part, + } + + with CoreSessionLocal() as session, \ + open(file_path, 'r', encoding='utf-8-sig') as f_in, \ open(error_path, 'w', encoding='utf-8') as f_err: + validator = ForeignKeyValidator(session, tenant_id, company_id) + invoice_id_cache: Dict[str, Optional[int]] = {} # Detect Delimiter sample = f_in.read(2048) @@ -94,9 +289,18 @@ def scan_file(self, job_id: str, file_path: str, model_target: str, config: str 'errors': error_count }) - # Validation (Phase 1: Minimal) - row_norm = normalize_row(row) - errors = validate_row_phase_1(row_norm, model_target, i, date_format) + # Solo columnas de la plantilla (respetar plantilla tal cual) + row_norm = row_from_template(row, template_id, normalize_header) + errors = validate_row_strict( + row_norm, + model_target, + i, + date_format, + validator, + inv_type_value, + invoice_id_cache, + models, + ) if errors: error_count += 1 @@ -109,14 +313,45 @@ def scan_file(self, job_id: str, file_path: str, model_target: str, config: str logger.error(f"Scan failed: {e}") return {"status": "failed", "error": str(e)} - # 4. Result + # 4. Store error line numbers in Redis so insert_valid_rows can skip them (any worker) + error_lines_list = [] + errors_detail: List[Dict[str, Any]] = [] + try: + if os.path.exists(error_path): + with open(error_path, "r", encoding="utf-8") as f: + for line in f: + try: + err = json.loads(line) + if "line" in err: + error_lines_list.append(err["line"]) + if len(errors_detail) < 500: + errors_detail.append( + { + "line": err["line"], + "col": err.get("col", ""), + "msg": err.get("msg", ""), + } + ) + except Exception: + pass + if error_lines_list: + r = _get_redis() + r.set( + f"{IMPORT_ERROR_LINES_KEY_PREFIX}{job_id}", + json.dumps(error_lines_list).encode("utf-8"), + ex=IMPORT_REDIS_TTL, + ) + except Exception as e: + logger.warning(f"Failed to store error lines in Redis: {e}") + + # 5. Result (incluye lista de errores para que el usuario pueda corregir el CSV) return { "status": "waiting_confirmation", "job_id": job_id, "total_rows": processed_rows, "error_count": error_count, "valid_rows": processed_rows - error_count, - "error_file": error_path + "errors": errors_detail, } def validate_row_phase_1( @@ -124,13 +359,61 @@ def validate_row_phase_1( target: str, line_num: int, date_format: Optional[str], -) -> Dict[str, Any]: +) -> Optional[Dict[str, Any]]: """ - Minimal validation: Unique IDs and Dates. + Validation: Unique IDs, Dates, and Numeric constraint checks. Target: 'invoice_header' or 'invoice_details' """ - errors = {} - + def check_decimal(col_name): + val = row.get(col_name) + if val and str(val).strip(): + if parse_decimal(val) is None: + return {"line": line_num, "col": col_name, "msg": "Debe ser un número decimal válido"} + return None + + def check_int(col_name): + val = row.get(col_name) + if val and str(val).strip(): + if parse_int(val) is None: + return {"line": line_num, "col": col_name, "msg": "Debe ser un número entero válido"} + return None + + def check_date(col_name): + date_str = row.get(col_name) + if date_str and str(date_str).strip(): + if not is_valid_date(date_str, date_format): + expected = display_date_format(date_format) + return { + "line": line_num, + "col": col_name, + "msg": f"Formato de fecha inválido ({expected})", + } + return None + + def check_weight(col_name): + val = row.get(col_name) + if val and str(val).strip(): + if parse_weight_unit(val) is None: + return {"line": line_num, "col": col_name, "msg": "Unidad de peso inválida (ej. KGS, LBS)"} + return None + + def check_currency(col_name): + val = row.get(col_name) + if val and str(val).strip(): + parsed_currency = parse_currency(val, None) + val_norm = normalize_header(val) + # parse_currency returns MANUAL if unknown, so if it wasn't explicitly MANUAL, it's invalid + if parsed_currency.value == "manual" and "MANUAL" not in val_norm: + return {"line": line_num, "col": col_name, "msg": "Moneda inválida (ej. MN, ME, USD, PESOS)"} + return None + + def check_transport_type(col_name): + val = row.get(col_name) + if val and str(val).strip(): + if str(val).strip().lower() not in TRANSPORT_TYPE_VALUES: + return {"line": line_num, "col": col_name, "msg": "Tipo de transporte inválido (ej. box, truck, container)"} + return None + # A. Invoice Header if target == 'invoice_header': # 1. Unique ID @@ -138,33 +421,180 @@ def validate_row_phase_1( return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} # 2. Date Format - date_str = row.get('FECHA FACTURA') - if date_str: - if not is_valid_date(date_str, date_format): - expected = display_date_format(date_format) - return { - "line": line_num, - "col": "FECHA FACTURA", - "msg": f"Formato inválido ({expected})", - } - else: + date_str = row.get('FECHA FACTURA') or row.get('FECHA') + if not date_str or not str(date_str).strip(): return {"line": line_num, "col": "FECHA FACTURA", "msg": "Requerido"} + + err = check_date('FECHA FACTURA') or check_date('FECHA') + if err: return err + + err = check_date('FECHA EMISION') + if err: return err + + # 3. Numeric Fields + for col in ['TIPO DE CAMBIO', 'FLETES', 'VALOR SEGUROS', 'SEGUROS', 'EMBALAJES', 'OTROS INCREMENTABLES']: + err = check_decimal(col) + if err: return err + + # 4. Integer FKs + for col in ['CLAVE PROVEEDOR', 'CLAVE VENDIDO A', 'CLAVE ENVIADO A', 'AGENTE ADUANAL', 'REMESA']: + err = check_int(col) + if err: return err + + # 5. Enums + for col in ['TIPO PESO']: + err = check_weight(col) + if err: return err + + for col in ['TIPO MONEDA']: + err = check_currency(col) + if err: return err + + for col in ['TIPO TRANSPORTE']: + err = check_transport_type(col) + if err: return err # B. Invoice Details (Parts) elif target == 'invoice_details': # 1. Line Number - if not row.get('LINEA'): + if not row.get('LINEA') and not row.get('RENGLON') and not row.get('PARTIDA'): return {"line": line_num, "col": "LINEA", "msg": "Requerido"} # 2. Parent Link (Invoice Number) if not (row.get('NUMERO FACTURA') or row.get('NUM FACTURA') or row.get('FACTURA')): return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} - - # 2. Parent Link (Simplified for now, we assume parent exists or is in same batch) - # In a real scenario, we'd check if the invoice exists. - pass + + # 3. Numeric Fields + for col in ['PRECIO UNITARIO', 'PRECIOUNITARIO', 'VALOR COMERCIAL', 'VALORCOMERCIAL', 'CANTIDAD']: + err = check_decimal(col) + if err: return err + + for col in ['CANTIDAD BULTOS', 'CANTIDADBULTOS', 'LINEA', 'RENGLON', 'PARTIDA']: + err = check_int(col) + if err: return err - return errors if errors else None + return None + + +def validate_row_strict( + row: Dict[str, Any], + target: str, + line_num: int, + date_format: Optional[str], + validator: ForeignKeyValidator, + inv_type_value: str, + invoice_id_cache: Dict[str, Optional[int]], + models: Dict[str, Any], +) -> Optional[Dict[str, Any]]: + err = validate_row_phase_1(row, target, line_num, date_format) + if err: + return err + + InvoiceHeader = models["InvoiceHeader"] + InvoiceType = models["InvoiceType"] + ClientProvider = models["ClientProvider"] + CustomsBroker = models["CustomsBroker"] + RegimenPedimento = models["RegimenPedimento"] + CurrencyType = models["CurrencyType"] + CustomsSection = models["CustomsSection"] + Incoterm = models["Incoterm"] + Part = models["Part"] + + if target == "invoice_header": + if not validator.check_exists(InvoiceType, inv_type_value, field_name="key", is_public=True): + return {"line": line_num, "col": "TIPO FACTURA", "msg": "No existe en el catalogo"} + + provider_id = parse_int(row.get("CLAVE PROVEEDOR")) + err = validate_tenant_fk_id(validator, ClientProvider, provider_id, line_num, "CLAVE PROVEEDOR", required=True) + if err: + return err + + sold_to_id = parse_int(row.get("CLAVE VENDIDO A")) + err = validate_tenant_fk_id(validator, ClientProvider, sold_to_id, line_num, "CLAVE VENDIDO A", required=True) + if err: + return err + + shipped_to_id = parse_int(row.get("CLAVE ENVIADO A")) + err = validate_tenant_fk_id(validator, ClientProvider, shipped_to_id, line_num, "CLAVE ENVIADO A", required=True) + if err: + return err + + broker_id = parse_int(row.get("AGENTE ADUANAL")) + err = validate_tenant_fk_id(validator, CustomsBroker, broker_id, line_num, "AGENTE ADUANAL") + if err: + return err + + err = validate_public_code( + validator, + RegimenPedimento, + row.get("REGIMEN") or row.get("CLAVEDOCUMENTO"), + line_num, + "CLAVEDOCUMENTO", + ) + if err: + return err + + err = validate_public_code( + validator, + CustomsSection, + row.get("ADUANA DE CRUCE"), + line_num, + "ADUANA DE CRUCE", + field_name="customs_code", + ) + if err: + return err + + err = validate_public_code( + validator, + CurrencyType, + row.get("CLAVE MONEDA"), + line_num, + "CLAVE MONEDA", + ) + if err: + return err + + err = validate_public_code( + validator, + Incoterm, + row.get("CLAVE INCOTERM"), + line_num, + "CLAVE INCOTERM", + ) + if err: + return err + + elif target == "invoice_details": + invoice_number = (row.get("NUMERO FACTURA") or row.get("NUM FACTURA") or row.get("FACTURA") or "").strip() + if not invoice_number: + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Requerido"} + + cache_key = f"{invoice_number}|{inv_type_value}" + if cache_key in invoice_id_cache: + invoice_id = invoice_id_cache[cache_key] + else: + invoice_id = ( + validator.session.query(InvoiceHeader.id) + .filter( + InvoiceHeader.tenant_id == validator.tenant_id, + InvoiceHeader.company_id == validator.company_id, + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.invoice_type == inv_type_value, + ) + .scalar() + ) + invoice_id_cache[cache_key] = invoice_id + if not invoice_id: + return {"line": line_num, "col": "NUMERO FACTURA", "msg": "Factura no existe"} + + part_num = (row.get("NUMPARTE") or row.get("NUMERO PARTE") or "").strip() + if not part_num: + return {"line": line_num, "col": "NUMPARTE", "msg": "Requerido"} + if not validator.check_exists(Part, part_num, field_name="part_number"): + return {"line": line_num, "col": "NUMPARTE", "msg": "No existe en el catalogo"} + + return None def parse_footer_config(config: Optional[str]) -> Dict[str, Any]: if not config: @@ -247,6 +677,16 @@ def parse_decimal(value: Any) -> Optional[Decimal]: return None +def decimal_or_zero(value: Any) -> Decimal: + """Return parsed decimal or Decimal('0') for CSV nulls/empty (vanilla default).""" + return parse_decimal(value) or Decimal("0") + + +def int_or_zero(value: Any) -> int: + """Return parsed int or 0 for CSV nulls/empty (vanilla default).""" + return parse_int(value) if parse_int(value) is not None else 0 + + def parse_currency(value: Optional[str], currency_type: Optional[str]): from api.v1.modules.a76.invoices.models import Currency if value: @@ -319,13 +759,24 @@ def resolve_public_code( cache[normalized] = normalized if exists is not None else None return cache[normalized] -@shared_task(bind=True) +@celery_app.task(bind=True) def insert_valid_rows(self, job_id: str, model_target: str): """ Pass 2: Re-read CSV, Skip Errors, Bulk Insert. + File and meta are loaded from Redis if present (same as scan_file), so worker does not need shared filesystem. """ logger.info(f"Starting Commit for {job_id} target {model_target}") - + + # Ensure we have the file on this worker: prefer Redis (so any worker can run commit) + file_path = _ensure_worker_has_file_from_redis(job_id) + if not file_path: + upload_dir = _worker_upload_dir() + file_path = os.path.join(upload_dir, f"{job_id}.csv") + if not os.path.exists(file_path): + return {"status": "failed", "error": "File not found (missing or expired). Please upload and confirm again."} + else: + _ensure_worker_has_meta_from_redis(job_id, file_path) + try: from api.v1.modules.a76.invoices.models import ( InvoiceHeader, @@ -334,16 +785,18 @@ def insert_valid_rows(self, job_id: str, model_target: str): InvoiceLogistics, InvoiceSalesDetails, OperationType, + TransportType, WeightUnit, ) from api.v1.modules.a76.clients_and_providers.models import ClientProvider from api.v1.modules.a76.customs_brokers.models import CustomsBroker from api.v1.modules.public.reference_data.currency_types.models import CurrencyType - from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento - from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode + from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen + from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection + from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.a76.items.models import Item from api.v1.modules.a76.items.line_items.models import LineItem @@ -353,30 +806,37 @@ def insert_valid_rows(self, job_id: str, model_target: str): from api.v1.modules.a76.items.line_descriptions.models import LineDescription from api.v1.modules.a76.parts.models import Part - upload_dir = os.path.join(os.getcwd(), "uploads", "temp") - file_path = os.path.join(upload_dir, f"{job_id}.csv") error_path = file_path.replace("temp", "errors").replace(".csv", ".jsonl") - - # 1. Load Error Line Numbers + + # 1. Load Error Line Numbers (from Redis if scan ran on another worker, else from file) error_lines = set() - if os.path.exists(error_path): + try: + r = _get_redis() + raw = r.get(f"{IMPORT_ERROR_LINES_KEY_PREFIX}{job_id}") + if raw: + error_lines = set(json.loads(raw.decode("utf-8"))) + except Exception as e: + logger.debug(f"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: pass + except Exception: + pass # Load Metadata (Context) - meta_path = file_path.replace("temp", "temp").replace(".csv", ".meta.json") + meta_path = file_path.replace(".csv", ".meta.json") tenant_id = None company_id = None footer_config = {} + meta = {} if os.path.exists(meta_path): try: with open(meta_path, 'r') as f: - meta = json.load(f) + meta = json.load(f) or {} tenant_id = meta.get('tenant_id') company_id = meta.get('company_id') operation_type_raw = meta.get('operation_type', 'imp') @@ -408,7 +868,7 @@ def insert_valid_rows(self, job_id: str, model_target: str): # Default types from config or fallback op_type_value = OperationType(meta.get('operation_type', 'imp').lower()) - inv_type_value = footer_config.get('invoice_type', 'TEM') + inv_type_value = normalize_public_code(footer_config.get('invoice_type') or 'TEM') or 'TEM' logger.info(f"Processing CSV with operation_type={op_type_value}, invoice_type={inv_type_value}, date_format={date_format}") @@ -437,13 +897,17 @@ def insert_valid_rows(self, job_id: str, model_target: str): reader = csv.DictReader(f, dialect=dialect) + template_id = meta.get("template_id") or ( + "imp_temp_header" if model_target == "invoice_header" else "imp_temp_details" + ) + for i, row in enumerate(reader, start=1): if i in error_lines: continue - row_norm = normalize_row(row) + row_norm = row_from_template(row, template_id, normalize_header) - # Mapping Logic + # Mapping Logic (solo campos que acepta el modelo de facturas) if model_target == 'invoice_header': invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip() invoice_date = parse_date(row_norm.get('FECHA FACTURA') or row_norm.get('FECHA'), date_format) @@ -462,25 +926,148 @@ def insert_valid_rows(self, job_id: str, model_target: str): skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") continue - - # 2. Client/Provider (Tenant) + provider_id = parse_int(row_norm.get('CLAVE PROVEEDOR')) - if provider_id and not validator.check_exists(ClientProvider, provider_id): - skipped_missing_fk += 1 - reason = f"Proveedor ID '{provider_id}' no existe" + err = validate_tenant_fk_id( + validator, + ClientProvider, + provider_id, + i, + "CLAVE PROVEEDOR", + required=True, + ) + if err: + skipped_invalid += 1 + reason = f"{err['col']}: {err['msg']}" skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") continue - # 3. Customs Broker (Tenant) - broker_id = parse_int(row_norm.get('AGENTE ADUANAL')) - if broker_id and not validator.check_exists(CustomsBroker, broker_id): - skipped_missing_fk += 1 - reason = f"Agente Aduanal ID '{broker_id}' no existe" + sold_to_id = parse_int(row_norm.get('CLAVE VENDIDO A')) + err = validate_tenant_fk_id( + validator, + ClientProvider, + sold_to_id, + i, + "CLAVE VENDIDO A", + required=True, + ) + if err: + skipped_invalid += 1 + reason = f"{err['col']}: {err['msg']}" skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") continue + shipped_to_id = parse_int(row_norm.get('CLAVE ENVIADO A')) + err = validate_tenant_fk_id( + validator, + ClientProvider, + shipped_to_id, + i, + "CLAVE ENVIADO A", + required=True, + ) + if err: + skipped_invalid += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + broker_id = parse_int(row_norm.get('AGENTE ADUANAL')) + err = validate_tenant_fk_id( + validator, + CustomsBroker, + broker_id, + i, + "AGENTE ADUANAL", + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = validate_public_code( + validator, + RegimenPedimento, + row_norm.get('REGIMEN') or row_norm.get('CLAVEDOCUMENTO'), + i, + "CLAVEDOCUMENTO", + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = validate_public_code( + validator, + CustomsSection, + row_norm.get('ADUANA DE CRUCE'), + i, + "ADUANA DE CRUCE", + field_name="customs_code", + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = validate_public_code( + validator, + CurrencyType, + row_norm.get('CLAVE MONEDA'), + i, + "CLAVE MONEDA", + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + err = validate_public_code( + validator, + Incoterm, + row_norm.get('CLAVE INCOTERM'), + i, + "CLAVE INCOTERM", + ) + if err: + skipped_missing_fk += 1 + reason = f"{err['col']}: {err['msg']}" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + transport_type_val = row_norm.get('TIPO TRANSPORTE') + if transport_type_val and str(transport_type_val).strip().lower() not in TRANSPORT_TYPE_VALUES: + skipped_invalid += 1 + reason = "TIPO TRANSPORTE: Tipo de transporte invalido" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + currency_val = row_norm.get('TIPO MONEDA') + if currency_val and str(currency_val).strip(): + parsed_currency = parse_currency(currency_val, None) + val_norm = normalize_header(currency_val) + if parsed_currency.value == "manual" and "MANUAL" not in val_norm: + skipped_invalid += 1 + reason = "TIPO MONEDA: Moneda invalida" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + + # 2. Client/Provider and broker checks are handled above + # --- 4. Check for Existing Invoice (Upsert Logic) --- existing_header = None if invoice_number: @@ -609,12 +1196,12 @@ def insert_valid_rows(self, job_id: str, model_target: str): financials = InvoiceFinancials( currency=parse_currency(row_norm.get('TIPO MONEDA'), financials_currency_type), currency_type=financials_currency_type, - exchange_rate=parse_decimal(row_norm.get('TIPO DE CAMBIO')), - freight=parse_decimal(row_norm.get('FLETES')), - insurance_value=parse_decimal(row_norm.get('VALOR SEGUROS')), - insurance=parse_decimal(row_norm.get('SEGUROS')), - packaging=parse_decimal(row_norm.get('EMBALAJES')), - other_increments=parse_decimal(row_norm.get('OTROS INCREMENTABLES')), + exchange_rate=decimal_or_zero(row_norm.get('TIPO DE CAMBIO')), + freight=decimal_or_zero(row_norm.get('FLETES')), + insurance_value=decimal_or_zero(row_norm.get('VALOR SEGUROS')), + insurance=decimal_or_zero(row_norm.get('SEGUROS')), + packaging=decimal_or_zero(row_norm.get('EMBALAJES')), + other_increments=decimal_or_zero(row_norm.get('OTROS INCREMENTABLES')), tenant_id=tenant_id, company_id=company_id, ) @@ -622,10 +1209,16 @@ def insert_valid_rows(self, job_id: str, model_target: str): weight_type = parse_weight_unit(row_norm.get('TIPO PESO')) logistics = None if weight_type or row_norm.get('TIPO TRANSPORTE') or row_norm.get('NUMERO TRANSPORTE'): + raw_transport = (row_norm.get('TIPO TRANSPORTE') or "none") + transport_str = str(raw_transport).strip().lower() or "none" + try: + transport_type = TransportType(transport_str) + except ValueError: + transport_type = TransportType.NONE logistics = InvoiceLogistics( carrier_id=(row_norm.get('CLAVE TRANSPORTISTA') or None), driver_name=(row_norm.get('NOMBRE CONDUCTOR') or None), - transport_type=str(row_norm.get('TIPO TRANSPORTE') or "none").lower(), + transport_type=transport_type, transport_num=(row_norm.get('NUMERO TRANSPORTE') or None), weight_type=weight_type or WeightUnit.KGS, seal_number=(row_norm.get('PRECINTO') or None), @@ -648,8 +1241,9 @@ def insert_valid_rows(self, job_id: str, model_target: str): skipped_invalid += 1 continue - if invoice_number in invoice_id_cache: - invoice_id = invoice_id_cache[invoice_number] + cache_key = f"{invoice_number}|{inv_type_value}" + if cache_key in invoice_id_cache: + invoice_id = invoice_id_cache[cache_key] else: invoice_id = ( session.query(InvoiceHeader.id) @@ -657,10 +1251,11 @@ def insert_valid_rows(self, job_id: str, model_target: str): InvoiceHeader.tenant_id == tenant_id, InvoiceHeader.company_id == company_id, InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.invoice_type == inv_type_value, ) .scalar() ) - invoice_id_cache[invoice_number] = invoice_id + invoice_id_cache[cache_key] = invoice_id if not invoice_id: logger.warning( @@ -671,6 +1266,20 @@ def insert_valid_rows(self, job_id: str, model_target: str): skipped_missing_invoice += 1 continue + part_num = (row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or '').strip() + if not part_num: + skipped_invalid += 1 + reason = "NUMPARTE: Requerido" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + if not validator.check_exists(Part, part_num, field_name="part_number"): + skipped_missing_fk += 1 + reason = f"NUMPARTE '{part_num}' no existe" + skipped_fk_details.append({"line": i, "invoice": invoice_number, "reason": reason}) + logger.warning(f"Row {i} (Invoice {invoice_number}): {reason}") + continue + # --- Prevent Duplicates: Clear existing items for this invoice (Once per job) --- if invoice_id not in cleared_invoices: logger.info(f"Clearing existing details for Invoice {invoice_number} (ID: {invoice_id}) to prevent duplicates") @@ -689,19 +1298,17 @@ def insert_valid_rows(self, job_id: str, model_target: str): # --- NEW LOGIC: Expanded Anexo 76 Structure --- # A. Find/Cache Part - part_num = (row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or '').strip() part_id = None - if part_num: - part_id = part_cache.get(part_num) - if part_id is None: - p = session.query(Part.id).filter( - Part.part_number == part_num, - Part.tenant_id == tenant_id, - Part.company_id == company_id - ).first() - if p: - part_id = p.id - part_cache[part_num] = part_id + part_id = part_cache.get(part_num) + if part_id is None: + p = session.query(Part.id).filter( + Part.part_number == part_num, + Part.tenant_id == tenant_id, + Part.company_id == company_id + ).first() + if p: + part_id = p.id + part_cache[part_num] = part_id line_num_val = (row_norm.get('LINEA') or row_norm.get('RENGLON') or row_norm.get('PARTIDA')) line_num = parse_int(line_num_val) or (len(details_to_insert) + 1) @@ -728,23 +1335,23 @@ def insert_valid_rows(self, job_id: str, model_target: str): session.add(line) session.flush() # Need line.id - # 3. Financial Data + # 3. Financial Data (vanilla: nulls from CSV -> 0) price = parse_decimal(row_norm.get('PRECIO UNITARIO') or row_norm.get('PRECIOUNITARIO')) val_com = parse_decimal(row_norm.get('VALOR COMERCIAL') or row_norm.get('VALORCOMERCIAL')) qty = parse_decimal(row_norm.get('CANTIDAD')) - + commercial_total = val_com or (price * qty if price and qty else None) + session.add(LineFinancial( item_line_id=line.id, - unit_price=price, - commercial_value=val_com or (price * qty if price and qty else None), + unit_cost_capture=decimal_or_zero(price), + total_commercial_value=decimal_or_zero(commercial_total), )) - # 4. Quantities - if qty: - session.add(LineQuantity( - item_line_id=line.id, - quantity=qty, - )) + # 4. Quantities (vanilla: nulls -> 0 so we always have a quantity row) + session.add(LineQuantity( + item_line_id=line.id, + quantity=decimal_or_zero(qty), + )) # 5. Customs/Fraction origin = row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') @@ -764,12 +1371,12 @@ def insert_valid_rows(self, job_id: str, model_target: str): description_spanish=desc, )) - # 7. Legacy Sales Details (For specific audit/UI fields) + # 7. Legacy Sales Details (For specific audit/UI fields; vanilla: nulls -> 0) detail = InvoiceSalesDetails( invoice_id=invoice_id, line_number=line_num, sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or None), - line_bundles=parse_int(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), + line_bundles=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')), tenant_id=tenant_id, company_id=company_id, ) @@ -851,15 +1458,16 @@ def insert_valid_rows(self, job_id: str, model_target: str): logger.error(traceback.format_exc()) return {"status": "failed", "error": str(e)} - # 5. Cleanup + # 5. Cleanup: remove temp files and Redis keys so data is not kept indefinitely try: - if os.path.exists(file_path): + if file_path and os.path.exists(file_path): os.remove(file_path) if os.path.exists(error_path): os.remove(error_path) - except: - logger.warning("Failed to cleanup temp files") - + _delete_import_from_redis(job_id) + except Exception as cleanup_err: + logger.warning("Failed to cleanup temp files or Redis: %s", cleanup_err) + # Ensure response is defined (fallback in case of unexpected errors) if response is None: logger.error(f"Unexpected error: response not set for job {job_id}") diff --git a/backend/api/v1/modules/a76/imports/template_config.py b/backend/api/v1/modules/a76/imports/template_config.py new file mode 100644 index 00000000..1a3fc274 --- /dev/null +++ b/backend/api/v1/modules/a76/imports/template_config.py @@ -0,0 +1,118 @@ +""" +Configuración de plantillas CSV: columnas que trae cada plantilla y cómo se mapean. +La plantilla se respeta tal cual: solo se leen columnas definidas aquí; el resto se ignora. +Solo se escribe en BD lo que los modelos de facturas aceptan (respetando models). +""" + +from typing import Dict, List, Any, Optional + +# Cada plantilla define sus columnas canónicas y alias (otros nombres que aceptamos en el CSV). +# canonical = nombre estándar con el que trabajamos internamente; debe coincidir con lo que +# espera la lógica de validación e insert (tasks.py). +# aliases = cabeceras alternativas que la plantilla .xls puede traer (ej. "Num Factura" → NUM FACTURA). + +TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { + # --- Encabezado factura: Impo Temp (EstructuraEncFacImpoTemp.xls) --- + "imp_temp_header": [ + {"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]}, + {"canonical": "FECHA FACTURA", "aliases": ["FECHA"]}, + {"canonical": "FECHA EMISION"}, + {"canonical": "CLAVE PROVEEDOR"}, + {"canonical": "CLAVE VENDIDO A"}, + {"canonical": "CLAVE ENVIADO A"}, + {"canonical": "REGIMEN", "aliases": ["CLAVEDOCUMENTO"]}, + {"canonical": "ADUANA DE CRUCE"}, + {"canonical": "CLAVE MONEDA"}, + {"canonical": "CLAVE INCOTERM"}, + {"canonical": "TIPO MONEDA"}, + {"canonical": "TIPO DE CAMBIO"}, + {"canonical": "TIPO PESO"}, + {"canonical": "TIPO TRANSPORTE"}, + {"canonical": "REMESA"}, + {"canonical": "AGENTE ADUANAL"}, + {"canonical": "FLETES"}, + {"canonical": "VALOR SEGUROS"}, + {"canonical": "SEGUROS"}, + {"canonical": "EMBALAJES"}, + {"canonical": "OTROS INCREMENTABLES"}, + {"canonical": "NUM PROYECTO", "aliases": ["NUMPROYECTO"]}, + {"canonical": "ORDEN COMPRA", "aliases": ["ORDENCOMPRA"]}, + {"canonical": "FACTURA ALTERNA"}, + {"canonical": "FACTURA EXPO REF", "aliases": ["FACTURAEXPOREF"]}, + {"canonical": "OBSERVACIONES E"}, + {"canonical": "OBSERVACIONES I"}, + {"canonical": "E DOCUMENT"}, + {"canonical": "NUM OPERACION"}, + {"canonical": "CLAVE TRANSPORTISTA"}, + {"canonical": "NOMBRE CONDUCTOR"}, + {"canonical": "NUMERO TRANSPORTE"}, + {"canonical": "PRECINTO"}, + ], + # --- Encabezado factura: Impo Def (EstructuraEncFacImpoDef.xls) - misma estructura --- + "imp_def_header": None, # se resuelve igual que imp_temp_header + # --- Encabezado factura: Expo (EstructuraEncFacExpoCamReg.xls) - misma estructura --- + "exp_def_header": None, + # --- Partidas factura: Impo Temp (EstructuraParFacImpoTempAF.xls) --- + "imp_temp_details": [ + {"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA"]}, + {"canonical": "LINEA", "aliases": ["RENGLON", "PARTIDA"]}, + {"canonical": "NUMPARTE", "aliases": ["NUMERO PARTE"]}, + {"canonical": "PRECIO UNITARIO", "aliases": ["PRECIOUNITARIO"]}, + {"canonical": "VALOR COMERCIAL", "aliases": ["VALORCOMERCIAL"]}, + {"canonical": "CANTIDAD"}, + {"canonical": "CANTIDAD BULTOS", "aliases": ["CANTIDADBULTOS"]}, + {"canonical": "DESCRIPCION"}, + {"canonical": "PAIS ORIGEN", "aliases": ["PAISORIGEN"]}, + {"canonical": "FRACCION"}, + {"canonical": "ORDEN DE COMPRA", "aliases": ["ORDENCOMPRA"]}, + ], + # --- Partidas: Impo Def y Expo - misma estructura --- + "imp_def_details": None, + "exp_def_details": None, +} + + +def _resolve_template_columns(template_id: str) -> Optional[List[Dict[str, Any]]]: + cols = TEMPLATE_COLUMNS.get(template_id) + if cols is not None: + return cols + if template_id in ("imp_def_header", "exp_def_header"): + return TEMPLATE_COLUMNS.get("imp_temp_header") + if template_id in ("imp_def_details", "exp_def_details"): + return TEMPLATE_COLUMNS.get("imp_temp_details") + return None + + +def build_normalized_lookup(template_id: str, normalize_header_fn) -> Dict[str, str]: + """ + Construye un diccionario: normalized_header -> canonical_name. + normalize_header_fn(str) -> str debe ser la función que normaliza cabeceras (ej. mayúsculas, sin acentos). + """ + cols = _resolve_template_columns(template_id) + 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], template_id: str, normalize_header_fn) -> Dict[str, Any]: + """ + A partir de una fila CSV (dict header->value) y un template_id, devuelve un dict + solo con las columnas de la plantilla, usando nombres canónicos. + Así la plantilla se respeta: solo entran columnas definidas en la plantilla. + """ + lookup = build_normalized_lookup(template_id, normalize_header_fn) + if not lookup: + # Sin template definido: comportamiento legacy (normalizar todo) + 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/invoices/catalog_service.py b/backend/api/v1/modules/a76/invoices/catalog_service.py index 9ad64b0b..d81f14d9 100644 --- a/backend/api/v1/modules/a76/invoices/catalog_service.py +++ b/backend/api/v1/modules/a76/invoices/catalog_service.py @@ -7,6 +7,7 @@ from api.v1.modules.public.reference_data.invoice_types.models import InvoiceTyp from api.v1.modules.public.reference_data.currency_types.models import CurrencyType from api.v1.modules.public.reference_data.transport_types.models import TransportType from api.v1.modules.public.reference_data.customs_sections.models import CustomsSection +from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode from api.v1.modules.public.reference_data.code_pedimento_regimens.models import CodePedimentoRegimen from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.public.reference_data.transport_modes.models import TransportMode diff --git a/backend/api/v1/modules/a76/invoices/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index c658e9ae..46ba29ae 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -19,6 +19,14 @@ class InvoiceHeaderBase(BaseModel): operation_type: Optional[OperationType] = Field( ..., description="Operation type: imp/exp/sm/ctm" ) + + @field_validator("operation_type", mode="before") + @classmethod + def normalize_operation_type(cls, v): + """Accept DB string (e.g. 'IMP') and coerce to enum value ('imp').""" + if isinstance(v, str): + return v.lower() if v else v + return v invoice_type: Optional[str] = Field( None, max_length=5, description="Invoice type key" ) @@ -198,9 +206,9 @@ class InvoiceComplianceMxBase(BaseModel): class InvoiceFinancialsBase(BaseModel): """Base fields for Financials""" - currency: Currency = Field(None, max_length=7, description="Currency code") + currency: Optional[Currency] = Field(None, max_length=7, description="Currency code") currency_type: Optional[str] = Field("USD", description="Currency type") - exchange_rate: Decimal = Field(0.00, description="Exchange rate") + exchange_rate: Optional[Decimal] = Field(0.00, description="Exchange rate") exchange_rate_mm: Optional[Decimal] = Field( None, description="Exchange rate currency to currency" ) diff --git a/backend/api/v1/modules/a76/invoices/services.py b/backend/api/v1/modules/a76/invoices/services.py index f3b0681b..3c081ffa 100644 --- a/backend/api/v1/modules/a76/invoices/services.py +++ b/backend/api/v1/modules/a76/invoices/services.py @@ -45,11 +45,13 @@ class InvoiceService: # Apply filters if provided if filters: - if filters.get("status"): - query = query.filter(models.InvoiceHeader.status == filters["status"]) + if filters.get("status") is not None: + query = query.filter(models.InvoiceHeader.is_updated == filters["status"]) if filters.get("operation_type"): + ot = filters["operation_type"] + ot_val = ot.value if hasattr(ot, "value") else ot query = query.filter( - models.InvoiceHeader.operation_type == filters["operation_type"] + models.InvoiceHeader.operation_type == ot_val ) if filters.get("invoice_type"): query = query.filter( @@ -67,10 +69,9 @@ class InvoiceService: f"%{filters['pedimento']}%" ) ) - if ( - not filters.get("invoice_type") - and filters.get("operation_type") == "exp" - ): + ot_exp = filters.get("operation_type") + ot_exp_val = ot_exp.value if hasattr(ot_exp, "value") else ot_exp + if not filters.get("invoice_type") and ot_exp_val == "exp": query = query.filter(models.InvoiceHeader.operation_type != "REPAR") if filters.get("manifest_number"): diff --git a/backend/api/v1/modules/a76/router.py b/backend/api/v1/modules/a76/router.py index 96d93ab9..fd8e3829 100644 --- a/backend/api/v1/modules/a76/router.py +++ b/backend/api/v1/modules/a76/router.py @@ -12,7 +12,7 @@ from .general_catalogs.router import router as general_catalogs_router from .invoices.routes import router as invoices_router from .items.routes import router as items_router from .classes import router as classes_router -from .classes import router as classes_router + from .clients_and_providers import router as client_and_provider_router from .imports.routes import router as imports_router from .invoice_settings.routes import router as invoice_settings_router diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 1665bb6a..cf64817e 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -1,6 +1,13 @@ import os from celery import Celery +# Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen (mapper) +from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode +from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento +from api.v1.modules.public.reference_data.code_pedimento_regimens.models import ( + CodePedimentoRegimen, +) + valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") diff --git a/backend/core/error_handlers.py b/backend/core/error_handlers.py index e1f7783a..af645e09 100644 --- a/backend/core/error_handlers.py +++ b/backend/core/error_handlers.py @@ -11,11 +11,23 @@ from fastapi.encoders import jsonable_encoder from fastapi.exceptions import RequestValidationError from sqlalchemy.exc import IntegrityError, SQLAlchemyError +from .config import settings from .exceptions import BaseAPIException logger = logging.getLogger(__name__) +def _cors_headers(request: Request) -> Dict[str, str]: + """CORS headers for error responses so browser does not block on 4xx/5xx.""" + origin = request.headers.get("origin") + if not origin or origin not in settings.cors_origins_list: + return {} + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + } + + async def base_exception_handler( request: Request, exc: BaseAPIException, @@ -36,10 +48,13 @@ async def base_exception_handler( if hasattr(exc, "errors") and exc.errors: logger.warning(f"Validation errors details: {exc.errors}") - return JSONResponse( + response = JSONResponse( status_code=exc.status_code, content=jsonable_encoder(exc.to_dict()), ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response async def validation_exception_handler( @@ -65,7 +80,7 @@ async def validation_exception_handler( extra={"errors": errors}, ) - return JSONResponse( + response = JSONResponse( status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, content={ "error": "VALIDATION_ERROR", @@ -74,6 +89,9 @@ async def validation_exception_handler( "errors": errors, }, ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response async def integrity_error_handler( @@ -102,7 +120,7 @@ async def integrity_error_handler( elif "not null" in orig_msg: error_message = "Falta un campo requerido." - return JSONResponse( + response = JSONResponse( status_code=status.HTTP_409_CONFLICT, content={ "error": "DATABASE_INTEGRITY_ERROR", @@ -110,6 +128,9 @@ async def integrity_error_handler( "status_code": status.HTTP_409_CONFLICT, }, ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response async def sqlalchemy_error_handler( @@ -128,7 +149,7 @@ async def sqlalchemy_error_handler( exc_info=True, ) - return JSONResponse( + response = JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={ "error": "DATABASE_ERROR", @@ -136,6 +157,9 @@ async def sqlalchemy_error_handler( "status_code": status.HTTP_500_INTERNAL_SERVER_ERROR, }, ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response async def general_exception_handler( @@ -154,7 +178,7 @@ async def general_exception_handler( exc_info=True, ) - return JSONResponse( + response = JSONResponse( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, content={ "error": "INTERNAL_SERVER_ERROR", @@ -162,6 +186,9 @@ async def general_exception_handler( "status_code": status.HTTP_500_INTERNAL_SERVER_ERROR, }, ) + for k, v in _cors_headers(request).items(): + response.headers[k] = v + return response def register_exception_handlers(app) -> None: diff --git a/backend/main.py b/backend/main.py index 005dd653..ca5000f9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -17,8 +17,13 @@ from api.v1.modules.public.reference_data.incoterms.models import Incoterm from api.v1.modules.public.reference_data.invoice_types.models import InvoiceType from api.v1.modules.public.reference_data.material_types.models import MaterialType from api.v1.modules.public.reference_data.payment_methods.models import PaymentMethod +# Orden: PedimentoCode y RegimenPedimento antes de CodePedimentoRegimen para que +# SQLAlchemy resuelva los nombres en relationship() al configurar el mapper from api.v1.modules.public.reference_data.pedimento_codes.models import PedimentoCode from api.v1.modules.public.reference_data.pedimento_regimens.models import RegimenPedimento +from api.v1.modules.public.reference_data.code_pedimento_regimens.models import ( + CodePedimentoRegimen, +) from api.v1.modules.public.reference_data.sectors.models import Sector from api.v1.modules.public.reference_data.states.models import State from api.v1.modules.public.reference_data.transport_modes.models import TransportMode @@ -114,6 +119,20 @@ logger = logging.getLogger(__name__) register_exception_handlers(app) +def _cors_headers_for_request(request: Request): + """Return CORS headers if request Origin is allowed (so error responses don't get blocked by browser).""" + origin = request.headers.get("origin") + if not origin: + return {} + allowed = settings.cors_origins_list + if origin in allowed: + return { + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Credentials": "true", + } + return {} + + # Add validation error handler @app.exception_handler(RequestValidationError) async def validation_exception_handler(request: Request, exc: RequestValidationError): @@ -121,10 +140,13 @@ async def validation_exception_handler(request: Request, exc: RequestValidationE f"Validation error for {request.method} {request.url.path}: {exc.errors()}" ) logger.error(f"Request body: {await request.body()}") - return JSONResponse( + response = JSONResponse( status_code=status.HTTP_400_BAD_REQUEST, content={"detail": exc.errors(), "body": exc.body}, ) + for k, v in _cors_headers_for_request(request).items(): + response.headers[k] = v + return response # Add HTTP exception handler @@ -133,10 +155,13 @@ async def http_exception_handler(request: Request, exc: HTTPException): logger.error( f"HTTP {exc.status_code} for {request.method} {request.url.path}: {exc.detail}" ) - return JSONResponse( + response = JSONResponse( status_code=exc.status_code, content={"detail": exc.detail}, ) + for k, v in _cors_headers_for_request(request).items(): + response.headers[k] = v + return response def run_migrations(): diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index aed649d2..ef924366 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -341,6 +341,39 @@ export const api = { validate: (tenantId: number) => api.get(`/v1/licenses/validate/${tenantId}/`) }, + imports: { + upload: ( + file: File, + modelTarget: string, + footerConfig: any, + companyId: number, + operationType: string, + templateId?: string + ) => { + const formData = new FormData(); + formData.append('file', file); + if (footerConfig) { + formData.append('footer_config', JSON.stringify(footerConfig)); + } + if (templateId) { + formData.append('template_id', templateId); + } + + const queryParams = new URLSearchParams({ + company_id: String(companyId), + operation_type: operationType || 'imp' + }).toString(); + + return fetchApi(`/v1/a76/imports/upload/${modelTarget}?${queryParams}`, { + method: 'POST', + body: formData + }); + }, + status: (jobId: string) => api.get(`/v1/a76/imports/${jobId}/status`), + commit: (jobId: string, modelTarget: string) => + api.post(`/v1/a76/imports/${jobId}/commit`, { model_target: modelTarget }) + }, + // Generic request for custom needs (like file uploads) request: (endpoint: string, options: RequestInit = {}) => fetchApi(endpoint, options) }; diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index 7375b3ab..bd7bac46 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -143,17 +143,53 @@ {#if scanResults.error_count > 0}

Se detectaron problemas en el archivo

- Las filas con errores serán omitidas automáticamente. Solo se importarán los - registros válidos. + Corrija los datos indicados abajo en su CSV y vuelva a subir, o confirme para + importar solo las filas válidas (las erróneas se omitirán).

+ {#if scanResults.errors && scanResults.errors.length > 0} +
+
+
+ Detalle de errores (para corregir en el CSV) +
+ + {scanResults.errors.length} error(es) + +
+
+ + + + + + + + + + {#each scanResults.errors as err} + + + + + + {/each} + +
LíneaColumnaMensaje
{err.line}{err.col || '-'}{err.msg || '-'}
+
+
+ {/if} {:else}
diff --git a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte index 8084f135..62830c66 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/UploadLauncherGrid.svelte @@ -123,7 +123,7 @@ ondragover={(e) => handleDragOver(e, item.disabled)} ondrop={(e) => handleDrop(e, item)} oncontextmenu={(e) => handleContextMenu(e, item)} - roles="button" + role="button" tabindex={item.disabled ? -1 : 0} onclick={() => handleClick(item.id, item.disabled)} onkeydown={(e) => !item.disabled && e.key === 'Enter' && handleClick(item.id)} diff --git a/frontend/src/lib/components/dashboard/customs_brokers/columns.ts b/frontend/src/lib/components/dashboard/customs_brokers/columns.ts index 24400b55..1e770110 100644 --- a/frontend/src/lib/components/dashboard/customs_brokers/columns.ts +++ b/frontend/src/lib/components/dashboard/customs_brokers/columns.ts @@ -8,6 +8,20 @@ export type { CustomsBroker }; export function createColumns(onSuccess?: () => void): ColumnDef[] { return [ + { + accessorKey: "id", + header: "ID", + cell: ({ row }) => { + const idSnippet = createRawSnippet<[{ id: number }]>((getId) => { + const { id } = getId(); + return { + render: () => + `${id}` + }; + }); + return renderSnippet(idSnippet, { id: row.original.id }); + } + }, { accessorKey: "broker_key", header: "Clave", diff --git a/frontend/src/lib/config/csv-upload.ts b/frontend/src/lib/config/csv-upload.ts index 656b0459..2deb6775 100644 --- a/frontend/src/lib/config/csv-upload.ts +++ b/frontend/src/lib/config/csv-upload.ts @@ -249,7 +249,7 @@ export const importacionConfig: CsvUploadItem[] = [ title: 'Encabezado', icon: FileText, group: 'Impo. Def.', - modelTarget: 'InvoiceHeader', + modelTarget: 'invoice_header', templateUrl: '/csv/EstructuraEncFacImpoDef.xls' }, { @@ -257,7 +257,7 @@ export const importacionConfig: CsvUploadItem[] = [ title: 'Partidas', icon: Package, group: 'Impo. Def.', - modelTarget: 'InvoiceSalesDetails', + modelTarget: 'invoice_details', templateUrl: '/csv/EstructuraParFacImpoDefAF.xls' }, { @@ -274,7 +274,7 @@ export const importacionConfig: CsvUploadItem[] = [ title: 'Encabezado', icon: FileText, group: 'Compras Mex.', - modelTarget: 'InvoiceHeader', + modelTarget: 'invoice_header', disabled: true, }, { @@ -282,7 +282,7 @@ export const importacionConfig: CsvUploadItem[] = [ title: 'Partidas', icon: Package, group: 'Compras Mex.', - modelTarget: 'InvoiceSalesDetails', + modelTarget: 'invoice_details', disabled: true, }, { @@ -302,7 +302,7 @@ export const exportacionConfig: CsvUploadItem[] = [ title: 'Encabezado', icon: FileText, group: 'Expo. Def./Cam. Reg.', - modelTarget: 'InvoiceHeader', + modelTarget: 'invoice_header', templateUrl: '/csv/EstructuraEncFacExpoCamReg.xls' }, { @@ -310,7 +310,7 @@ export const exportacionConfig: CsvUploadItem[] = [ title: 'Partidas', icon: Package, group: 'Expo. Def./Cam. Reg.', - modelTarget: 'InvoiceSalesDetails', + modelTarget: 'invoice_details', templateUrl: '/csv/EstructuraParExpoCamReg.xls' }, { diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte index 6c55bc56..b1efc2ed 100644 --- a/frontend/src/routes/dashboard/csv-upload/+page.svelte +++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte @@ -38,25 +38,45 @@ }); async function handleUpload(file: File, config: CsvUploadItem) { + console.log('handleUpload started', { file, config }); isUploading = true; activeModelTarget = config.modelTarget || null; scanResults = null; const currentSettings = allSettings[activeTab] || {}; + const footerConfig = { ...currentSettings }; + if (activeTab === 'importacion') { + footerConfig.invoice_type = config.id?.startsWith('imp_def_') ? 'DEF' : 'TEM'; + } const companyId = companyStore.activeCompany?.id || 1; const opType = activeTab === 'exportacion' ? 'exp' : 'imp'; - const res = await api.imports.upload( - file, - config.modelTarget || '', - currentSettings, + console.log('Calling api.imports.upload', { + activeModelTarget, + footerConfig, companyId, opType - ); - if (res.data?.job_id) { - currentJobId = res.data.job_id; - pollStatus(); - } else { - toast.error('Error al subir el archivo'); + }); + try { + const res = await api.imports.upload( + file, + config.modelTarget || '', + footerConfig, + companyId, + opType, + config.id + ); + console.log('Upload response', res); + if (res.data?.job_id) { + currentJobId = res.data.job_id; + pollStatus(); + } else { + console.error('Upload failed with response', res); + toast.error(res.error || 'Error al subir el archivo'); + isUploading = false; + } + } catch (e) { + console.error('Upload exception', e); + toast.error('Error inesperado al subir el archivo'); isUploading = false; } } @@ -64,54 +84,71 @@ async function pollStatus() { if (!currentJobId) return; - const res = await api.imports.status(currentJobId); - if (res.data?.status === 'waiting_confirmation') { - scanResults = res.data; - showResultModal = true; - toast.success('Escaneo completado. Revisa los resultados.'); - isUploading = false; - } else if (res.data?.status === 'failed') { - toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido')); - 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; - showResultModal = true; - const inserted = res.data?.inserted || 0; - const skippedInvalid = res.data?.skipped_invalid || 0; - const skippedFk = res.data?.skipped_missing_fk || 0; - const totalSkipped = skippedInvalid + skippedFk; - - if (inserted === 0) { - toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`); - } else { - toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`); + console.log('Polling status for job', currentJobId); + try { + const res = await api.imports.status(currentJobId); + console.log('Poll response', res); + if (res.error && !res.data) { + toast.error(res.error || 'Error al consultar el estado'); + isUploading = false; + currentJobId = null; + return; } - isUploading = false; - } else if (res.data?.status === 'finished') { - commitResults = res.data; - showResultModal = true; - const inserted = res.data?.inserted || 0; - const skippedInvalid = res.data?.skipped_invalid || 0; - const skippedFk = res.data?.skipped_missing_fk || 0; - const skippedDetails = res.data?.skipped_details || []; + if (res.data?.status === 'waiting_confirmation') { + scanResults = res.data; + showResultModal = true; + toast.success('Escaneo completado. Revisa los resultados.'); + isUploading = false; + } else if (res.data?.status === 'failed' || res.data?.status === 'FAILURE') { + toast.error('Error en el procesamiento: ' + (res.data.error || 'Error desconocido')); + 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; + showResultModal = true; + const inserted = res.data?.inserted || 0; + const skippedInvalid = res.data?.skipped_invalid || 0; + const skippedFk = res.data?.skipped_missing_fk || 0; + const totalSkipped = skippedInvalid + skippedFk; - if (inserted > 0) { - toast.success(`Importación completada: ${inserted} registros insertados`); - if (skippedInvalid > 0 || skippedFk > 0) { - const totalSkipped = skippedInvalid + skippedFk; - toast.warning(`${totalSkipped} registros fueron rechazados`); + if (inserted === 0) { + toast.error(`No se insertaron registros. ${totalSkipped} fueron rechazados.`); + } else { + toast.warning(`Solo se insertaron ${inserted} de ${inserted + totalSkipped} registros.`); } + isUploading = false; + } else if (res.data?.status === 'finished') { + commitResults = res.data; + showResultModal = true; + const inserted = res.data?.inserted || 0; + const skippedInvalid = res.data?.skipped_invalid || 0; + const skippedFk = res.data?.skipped_missing_fk || 0; + const skippedDetails = res.data?.skipped_details || []; + + if (inserted > 0) { + toast.success(`Importación completada: ${inserted} registros insertados`); + if (skippedInvalid > 0 || skippedFk > 0) { + const totalSkipped = skippedInvalid + skippedFk; + toast.warning(`${totalSkipped} registros fueron rechazados`); + } + } else { + toast.error('No se insertaron registros. Revisa los errores a continuación.'); + } + isUploading = false; } else { - toast.error('No se insertaron registros. Revisa los errores a continuación.'); + // Continue polling + console.log('Status not final, polling again in 2s...', res.data?.status); + setTimeout(pollStatus, 2000); } - isUploading = false; - } else { - // Continue polling + } catch (e) { + console.error('Poll exception', e); + // Retry on network error? Or fail? + // For now, let's keep retrying a few times or hard fail. + // Let's just log and retry. setTimeout(pollStatus, 2000); } } @@ -174,36 +211,38 @@ {/if}
- { - if (currentJobId && activeModelTarget) { - try { - isUploading = true; - const res = await api.imports.commit(currentJobId, activeModelTarget); - if (res.data?.commit_job_id) { - currentJobId = res.data.commit_job_id; - pollStatus(); +{#if scanResults || commitResults} + { + if (currentJobId && activeModelTarget) { + try { + isUploading = true; + const res = await api.imports.commit(currentJobId, activeModelTarget); + if (res.data?.commit_job_id) { + currentJobId = res.data.commit_job_id; + pollStatus(); + } + } catch (err) { + toast.error('Error al iniciar la importación'); + isUploading = false; } - } catch (err) { - toast.error('Error al iniciar la importación'); - isUploading = false; } - } - }} - onCancel={() => { - currentJobId = null; - scanResults = null; - commitResults = null; - showResultModal = false; - }} - onClose={() => { - currentJobId = null; - scanResults = null; - commitResults = null; - showResultModal = false; - }} -/> + }} + onCancel={() => { + currentJobId = null; + scanResults = null; + commitResults = null; + showResultModal = false; + }} + onClose={() => { + currentJobId = null; + scanResults = null; + commitResults = null; + showResultModal = false; + }} + /> +{/if}