diff --git a/backend/alembic/versions/e4f5a6b7c8d9_add_incoterm_daf_legacy.py b/backend/alembic/versions/e4f5a6b7c8d9_add_incoterm_daf_legacy.py new file mode 100644 index 00000000..b0c99c61 --- /dev/null +++ b/backend/alembic/versions/e4f5a6b7c8d9_add_incoterm_daf_legacy.py @@ -0,0 +1,30 @@ +"""add incoterm DAF legacy catalog row + +Revision ID: e4f5a6b7c8d9 +Revises: ca7d3c4e8b2a +Create Date: 2026-05-08 + +Incoterm histórico DAF (Delivered At Frontier) para CSV y referencias legacy. +""" +from typing import Sequence, Union + +from alembic import op + +revision: str = "e4f5a6b7c8d9" +down_revision: Union[str, None] = "ca7d3c4e8b2a" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + INSERT INTO public.incoterms (code, description_es, description_en) VALUES + ('DAF', 'ENTREGADO EN FRONTERA', 'DELIVERED AT FRONTIER') + ON CONFLICT (code) DO NOTHING; + """ + ) + + +def downgrade() -> None: + op.execute("DELETE FROM public.incoterms WHERE code = 'DAF';") diff --git a/backend/api/v1/modules/a76/invoices/common/common_validators.py b/backend/api/v1/modules/a76/invoices/common/common_validators.py index 799efd72..fb5d0813 100644 --- a/backend/api/v1/modules/a76/invoices/common/common_validators.py +++ b/backend/api/v1/modules/a76/invoices/common/common_validators.py @@ -389,7 +389,8 @@ def validate_common( ) if pedimento.pedimento_type == "consolidated": - if not pedimento.pedimento_dates: + pd_dates = pedimento.pedimento_dates + if not pd_dates: errors.add_error( field="compliance_mx.pedimento_id", message="El Pedimento seleccionado no tiene fechas registradas.", @@ -404,27 +405,30 @@ def validate_common( if hasattr(invoice.invoice_date, "date") else invoice.invoice_date ) - entry_date = ( - pedimento.pedimento_dates.entry_date.date() - if hasattr(pedimento.pedimento_dates.entry_date, "date") - else pedimento.pedimento_dates.entry_date + # Periodo consolidado: si existe start_date en catálogo, es el inicio del rango; + # si no, se usa entry_date (paridad con validación CSV). + period_start_src = getattr(pd_dates, "start_date", None) or pd_dates.entry_date + period_start_date = ( + period_start_src.date() + if hasattr(period_start_src, "date") + else period_start_src ) end_date = ( - pedimento.pedimento_dates.end_date.date() - if hasattr(pedimento.pedimento_dates.end_date, "date") - else pedimento.pedimento_dates.end_date + pd_dates.end_date.date() + if hasattr(pd_dates.end_date, "date") + else pd_dates.end_date ) - if pedimento.pedimento_dates and (invoice_date < entry_date or invoice_date > end_date): - errors.add_error( - field="invoice_date", - message=f"La Fecha de la Factura {invoice.invoice_date} no está dentro del rango de fechas del Pedimento {pedimento.customs_office}-{pedimento.license}-{pedimento.pedimento_number}.", - solution=[ - f"Capturar una Fecha de Factura, entre la Fecha de Inicio: {pedimento.pedimento_dates.entry_date} y la Fecha Final: {pedimento.pedimento_dates.end_date} ." - ], - code="DATE_OUT_OF_RANGE", - value=invoice.invoice_date, - ) + if invoice_date < period_start_date or invoice_date > end_date: + errors.add_error( + field="invoice_date", + message=f"La Fecha de la Factura {invoice.invoice_date} no está dentro del rango de fechas del Pedimento {pedimento.customs_office}-{pedimento.license}-{pedimento.pedimento_number}.", + solution=[ + f"Capturar una Fecha de Factura, entre la Fecha de Inicio: {period_start_src} y la Fecha Final: {pd_dates.end_date} ." + ], + code="DATE_OUT_OF_RANGE", + value=invoice.invoice_date, + ) # Remesa check if pedimento.pedimento_type == "consolidated": diff --git a/backend/api/v1/modules/a76/layouts_csv/common/error_csv.py b/backend/api/v1/modules/a76/layouts_csv/common/error_csv.py index 1782536a..0d170d01 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/error_csv.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/error_csv.py @@ -42,8 +42,10 @@ def _iter_jsonl_rows(error_path: str) -> Iterable[Dict[str, Any]]: def download_scan_errors_csv_stream(job_type: str, job_id: str, *, filename_prefix: str = "errores") -> StreamingResponse: """ - Devuelve un StreamingResponse con cabecera: - `linea, columna, mensaje, solucion`. + Devuelve un StreamingResponse leyendo el JSONL del worker. + + Columnas (paridad con el payload JSONL / `_invoice_scan_error_row_payload` en facturas): + linea, columna, mensaje, solucion, advertencia, codigo, not_found_reason """ error_path = common_storage.error_path_for_job(job_type, job_id) if not os.path.exists(error_path): @@ -56,10 +58,20 @@ def download_scan_errors_csv_stream(job_type: str, job_id: str, *, filename_pref "Content-Disposition": f'attachment; filename="{filename_prefix}_{job_id}.csv"', } + _CSV_ERRORS_HEADER = [ + "linea", + "columna", + "mensaje", + "solucion", + "advertencia", + "codigo", + "not_found_reason", + ] + def row_iter(): buffer = io.StringIO() writer = csv.writer(buffer) - writer.writerow(["linea", "columna", "mensaje", "solucion"]) + writer.writerow(_CSV_ERRORS_HEADER) yield buffer.getvalue() buffer.seek(0) buffer.truncate(0) @@ -73,6 +85,9 @@ def download_scan_errors_csv_stream(job_type: str, job_id: str, *, filename_pref err.get("col", ""), err.get("msg", ""), err.get("solution", ""), + "si" if bool(err.get("warning")) else "no", + err.get("code", ""), + err.get("not_found_reason", ""), ] ) yield buffer.getvalue() diff --git a/backend/api/v1/modules/a76/layouts_csv/common/responses.py b/backend/api/v1/modules/a76/layouts_csv/common/responses.py index cac67a28..984be8dc 100644 --- a/backend/api/v1/modules/a76/layouts_csv/common/responses.py +++ b/backend/api/v1/modules/a76/layouts_csv/common/responses.py @@ -31,12 +31,13 @@ def scan_result( Si total_rows_in_file no se pasa, se usa processed_rows como total (comportamiento anterior). """ total = total_rows_in_file if total_rows_in_file is not None else processed_rows + valid_rows = processed_rows - error_count out: Dict[str, Any] = { "status": "waiting_confirmation", "job_id": job_id, "total_rows": total, "error_count": error_count, - "valid_rows": processed_rows - error_count, + "valid_rows": valid_rows, # Para no saturar el front: devolvemos solo un preview. # El detalle completo se descarga desde CSV usando el job_id. "errors": errors_detail[:ERRORS_PREVIEW_LIMIT], @@ -44,6 +45,13 @@ def scan_result( } if message: out["message"] = message + elif valid_rows == 0 and total > 0: + out["message"] = ( + "Ninguna fila quedó libre de observaciones en el escaneo. " + "Revise el reporte de errores (incluye advertencias por línea), " + "complete catálogos (clientes/proveedores, transporte, conductores, etc.) " + "y vuelva a cargar, o descargue el CSV de errores para corregir el archivo." + ) return out diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py index bb7aa1d6..d9dafb55 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/routes.py @@ -1,12 +1,8 @@ from datetime import datetime from uuid import uuid4 import os -import json import logging -import csv -import io from fastapi import APIRouter, UploadFile, File, Form, HTTPException, Depends, Query -from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from typing import Optional, Literal, Dict, Any @@ -25,6 +21,7 @@ from .tasks import ( ) from .schemas import ImportJobResponse, ImportJobStatus, CommitRequest from ..common import storage as common_storage +from ..common.error_csv import download_scan_errors_csv_stream from ..common.responses import normalize_commit_status_payload from ..common.track_commit_dispatch import dispatch_tracked_layouts_csv_commit @@ -53,6 +50,19 @@ async def upload_import_file( Step 1: Upload CSV, save to temp, trigger scan task. Si se envía template_id, solo se leen las columnas de esa plantilla. """ + header_company_id = None + try: + if db is not None and getattr(db, "info", None): + header_company_id = db.info.get("rls_company_id") + except Exception: + header_company_id = None + if header_company_id is not None and int(header_company_id) != int(company_id): + logger.warning( + "Facturas upload company mismatch request_company_id=%s db_rls_company_id=%s", + company_id, + header_company_id, + ) + # 1. Validate Access & Get Tenant try: tenant_id = validate_access_to_resource(db, company_id, current_user, ["csv_upload.process"]) @@ -100,6 +110,14 @@ async def upload_import_file( raise HTTPException(status_code=500, detail="Failed to queue file for processing.") # Trigger Celery Task (Async). Worker loads file from Redis / MinIO. + logger.info( + "Queueing facturas scan job=%s tenant_id=%s company_id=%s template_id=%s operation_type=%s", + job_id, + tenant_id, + company_id, + template_id, + operation_type, + ) track_and_dispatch( db=db, task=scan_file, @@ -128,6 +146,15 @@ async def get_import_status(job_id: str): if task_result.state == "PENDING": return {"status": "processing", "progress": 0} + # task_track_started=True: el worker pasa por STARTED (y a veces RECEIVED) antes de SUCCESS/PROGRESS. + # Sin esto, el polling cae en la rama final y devuelve status=failed aunque el scan vaya bien. + if task_result.state in ("STARTED", "RECEIVED"): + info = task_result.info if isinstance(task_result.info, dict) else {} + return { + "status": "processing", + "progress": info.get("current", 0), + "total": info.get("total", 0), + } if task_result.state == "PROGRESS": return { "status": "processing", @@ -139,6 +166,17 @@ async def get_import_status(job_id: str): if isinstance(result, dict) and "status" in result: return normalize_commit_status_payload(result) return {"status": "finished", "result": result} + + # Recuperación: raro pero posible con backend de resultados — el payload ya está pero el estado no es SUCCESS. + raw = getattr(task_result, "result", None) + if isinstance(raw, dict) and raw.get("status") in ( + "waiting_confirmation", + "finished", + "warning", + "failed", + ): + return normalize_commit_status_payload(raw) + # 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 @@ -178,54 +216,11 @@ async def get_import_status(job_id: str): async def download_scan_errors_csv(job_id: str): """ Descarga CSV con TODO el detalle de errores del scan (sin límite), - leyendo el archivo JSONL generado por el worker. + leyendo el archivo JSONL generado por el worker (mismas columnas que otros layouts_csv). """ # Facturas (impo/exp delega a facturas) usan job_type vacío (""). - error_path = common_storage.error_path_for_job("", job_id) - if not os.path.exists(error_path): - raise HTTPException(status_code=404, detail="Archivo de errores no encontrado. Vuelve a escanear o intenta más tarde.") - - def row_iter(): - buffer = io.StringIO() - writer = csv.writer(buffer) - writer.writerow(["linea", "columna", "mensaje", "solucion"]) - yield buffer.getvalue() - buffer.seek(0) - buffer.truncate(0) - - with open(error_path, "r", encoding="utf-8") as f: - for line in f: - if not line.strip(): - continue - try: - payload = json.loads(line) - except Exception: - continue - - # Algunos flujos pueden escribir un dict por línea o una lista de dicts. - items = payload if isinstance(payload, list) else [payload] - for err in items: - if not isinstance(err, dict): - continue - buffer.seek(0) - buffer.truncate(0) - writer.writerow( - [ - err.get("line", ""), - err.get("col", ""), - err.get("msg", ""), - err.get("solution", ""), - ] - ) - yield buffer.getvalue() - buffer.seek(0) - buffer.truncate(0) - - headers = { - "Content-Disposition": f'attachment; filename="errores_{job_id}.csv"' - } - return StreamingResponse(row_iter(), media_type="text/csv; charset=utf-8", headers=headers) + return download_scan_errors_csv_stream("", job_id, filename_prefix="errores") @router.post("/{job_id}/commit") diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py index 2bf46101..15244b51 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -19,9 +19,10 @@ import unicodedata from typing import Dict, Any, Optional, List, Set, Tuple from core.celery_app import celery_app -from core.database import CoreSessionLocal +from core.database import scoped_core_db, rls_tenant_var, rls_company_var from core.paths import layout_path from sqlalchemy import func +from sqlalchemy.orm import Session from ..common import storage as common_storage from ..common import meta as common_meta @@ -29,6 +30,9 @@ from ..common import responses as common_responses from ..common import csv_reader as common_csv_reader from .template_config import row_from_template from .validators.encabezados_impo_temp import csv_tipo_moneda_es_me_mn_mc +from .validators.pedimento_resolution import ( + normalize_catalog_pedimento_identity as _normalize_catalog_pedimento_identity, +) from api.v1.modules.a76.app_settings.service import AppSettingsService # Models are imported inside tasks to avoid circular dependencies and mapper initialization issues in the API process @@ -43,6 +47,25 @@ def _merge_unique_invoice_scan_error_lines( return sorted(set(precheck_lines) | set(row_validation_lines)) +def _invoice_scan_error_row_payload(e: Dict[str, Any]) -> Dict[str, Any]: + """ + Unifica campos para archivo JSONL de errores y para errors_detail en scan de facturas. + Incluye code / not_found_reason cuando el validador los provee (paridad descarga CSV ↔ API). + """ + row: Dict[str, Any] = { + "line": e["line"], + "col": e.get("col", ""), + "msg": e.get("msg", ""), + "solution": e.get("solution", ""), + "warning": bool(e.get("warning", False)), + } + if e.get("not_found_reason"): + row["not_found_reason"] = e["not_found_reason"] + if e.get("code"): + row["code"] = e["code"] + return row + + # Job type vacío para facturas (prefijo Redis "import_" sin tipo, ver common/storage.py) JOB_TYPE = "" @@ -508,10 +531,34 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = date_format = "yyyy-mm-dd" # Default to ISO format logger.info(f"No date_format specified in config, using default: {date_format}") + ctx_tenant = rls_tenant_var.get() + ctx_company = rls_company_var.get() + logger.info( + "facturas_scan worker_context job_id=%s task_id=%s rls_tenant_ctx=%s rls_company_ctx=%s", + job_id, + getattr(getattr(self, "request", None), "id", None), + ctx_tenant, + ctx_company, + ) + try: tenant_id, company_id = common_meta.require_tenant_context(file_path) except ValueError as e: return {"status": "failed", "error": str(e)} + if ctx_tenant is not None and int(ctx_tenant) != int(tenant_id): + logger.warning( + "facturas_scan tenant mismatch between celery context and file meta job_id=%s task_tenant=%s meta_tenant=%s", + job_id, + ctx_tenant, + tenant_id, + ) + if company_id is not None and ctx_company is not None and int(ctx_company) != int(company_id): + logger.warning( + "facturas_scan company mismatch between celery context and file meta job_id=%s task_company=%s meta_company=%s", + job_id, + ctx_company, + company_id, + ) meta = common_meta.load_meta(file_path) or {} template_id, inv_type_value, op_type_scan = _resolve_import_context( @@ -522,8 +569,16 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = ) logger.info( - "Scan job %s template_id=%s model_target=%s job_type_override=%s op_type=%s inv_type=%s", - job_id, template_id, model_target, job_type_override, op_type_scan, inv_type_value, + "Scan job %s tenant_id=%s company_id=%s template_id=%s model_target=%s job_type_override=%s " + "op_type=%s inv_type=%s", + job_id, + tenant_id, + company_id, + template_id, + model_target, + job_type_override, + op_type_scan, + inv_type_value, ) # --- Series de Importación Definitiva: flujo específico (Clarion VALIDA_TODA_SERIES_IMPO_DEF / VALIDA_PARCIAL) --- @@ -564,7 +619,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = if "validar_series" in _fc: validar_series_exception = bool(_fc["validar_series"]) - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: q = ( session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.status) .filter( @@ -669,22 +724,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = existing_series_data=existing_series_data, warnings=warnings_list, ) + for e in row_errors + warnings_list: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) else: inv_num = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip() line_fac = (row_norm.get("LINEA FACTURA") or row_norm.get("LINEA") or row_norm.get("PARTIDA") or "").strip() @@ -693,17 +737,10 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1 for e in row_errors + warnings_list: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 + error_count = len(error_lines_list) common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result( job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows @@ -739,7 +776,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = RFC_EXCEPTION_EGM = {"EGM0303257J1"} - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: company = session.query(Company).filter(Company.id == company_id).first() company_rfc = (company.rfc or "").strip().upper() if company else "" @@ -863,22 +900,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = existing_series_data=existing_series_data, warnings=warnings_list, ) + for e in row_errors + warnings_list: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) else: inv_num = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or row_norm.get("FACTURA EXPO") or "").strip() line_fac = (row_norm.get("LINEA FACTURA") or row_norm.get("LINEA") or row_norm.get("PARTIDA") or "").strip() @@ -887,15 +913,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1 for e in row_errors + warnings_list: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 # El commit usa líneas únicas para omitir filas; el resumen preliminar @@ -938,7 +956,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = if "validar_series" in _fc: validar_series_exception = bool(_fc["validar_series"]) - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: q = ( session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.status) .filter( @@ -1044,22 +1062,11 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = warnings=warnings_list, catalog_label="Compras Mexicanas", ) + for e in row_errors + warnings_list: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) else: inv_num = (row_norm.get("NUMERO FACTURA") or row_norm.get("NUM FACTURA") or "").strip() line_fac = (row_norm.get("LINEA FACTURA") or row_norm.get("LINEA") or row_norm.get("PARTIDA") or "").strip() @@ -1068,17 +1075,10 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = csv_series_count_so_far[key_csv] = csv_series_count_so_far.get(key_csv, 0) + 1 for e in row_errors + warnings_list: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 + error_count = len(error_lines_list) common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result( job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows @@ -1116,7 +1116,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = if "validar_series" in _fc: validar_series_exception = bool(_fc["validar_series"]) - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: # Invoice lookup: imp + TEM q = ( session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.status) @@ -1195,33 +1195,14 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = existing_series_data=existing_series_data, warnings=warnings_list, ) + for e in row_errors + warnings_list: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) for e in row_errors + warnings_list: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 # El commit usa líneas únicas para omitir filas; el resumen preliminar @@ -1275,7 +1256,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = RFC_EXCEPTION_UPDATED = {"TPI121217SF6", "TCI170502858"} RFC_EXCEPTION_NUM_PARTE = {"CTE980130518"} - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: # Resolver parámetros del sistema: validadecencant (decimales PZA) y validarseries (switch maestro) settings = AppSettingsService.get_resolved_settings(session, int(tenant_id), int(company_id)) gen_params = settings.get("ssisgen", {}) or settings.get("qsisgen", {}) @@ -1424,22 +1405,13 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = "warning": False, } with open(error_path, "w", encoding="utf-8") as f_err: - f_err.write( - json.dumps( - { - "line": structure_error["line"], - "col": structure_error["col"], - "msg": structure_error["msg"], - "solution": structure_error["solution"], - } - ) + "\n" - ) + f_err.write(json.dumps(_invoice_scan_error_row_payload(structure_error)) + "\n") common_storage.store_error_lines(effective_job_type, job_id, [1]) return common_responses.scan_result( job_id=job_id, processed_rows=len(rows_list), error_count=1, - errors_detail=[structure_error], + errors_detail=[_invoice_scan_error_row_payload(structure_error)], total_rows_in_file=total_rows, message="Precheck estructural falló: la columna de número de factura no fue detectada.", ) @@ -1469,22 +1441,13 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = "warning": False, } with open(error_path, "w", encoding="utf-8") as f_err: - f_err.write( - json.dumps( - { - "line": structure_error["line"], - "col": structure_error["col"], - "msg": structure_error["msg"], - "solution": structure_error["solution"], - } - ) + "\n" - ) + f_err.write(json.dumps(_invoice_scan_error_row_payload(structure_error)) + "\n") common_storage.store_error_lines(effective_job_type, job_id, [1]) return common_responses.scan_result( job_id=job_id, processed_rows=len(rows_list), error_count=1, - errors_detail=[structure_error], + errors_detail=[_invoice_scan_error_row_payload(structure_error)], total_rows_in_file=total_rows, message=f"Precheck estructural falló: {empty_invoice_lines}/{len(rows_list)} filas sin número de factura.", ) @@ -1551,33 +1514,14 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = valid_part_numbers=valid_part_numbers, warnings=None, ) + for e in row_errors: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) for e in row_errors: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 unique_error_lines = sorted(set(error_lines_list)) @@ -1654,7 +1598,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = RFC_EXCEPTION_UPDATED = {"TPI121217SF6", "TCI170502858"} RFC_EXCEPTION_NUM_PARTE = {"CTE980130518"} - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: # Resolver parámetros del sistema: validadecencant (decimales PZA) y validarseries (switch maestro) settings = AppSettingsService.get_resolved_settings(session, int(tenant_id), int(company_id)) gen_params = settings.get("ssisgen", {}) or settings.get("qsisgen", {}) @@ -1863,35 +1807,17 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = valid_part_numbers=valid_part_numbers, warnings=None, ) + for e in row_errors: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) for e in row_errors: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 + error_count = len(error_lines_list) common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result(job_id, processed_rows, error_count, errors_detail) except Exception as e: @@ -1931,7 +1857,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = RFC_EXCEPTION_UPDATED = set() RFC_EXCEPTION_EGM = {"EGM0303257J1"} - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: # Resolver parámetros del sistema: validadecencant (decimales PZA) y validarseries (switch maestro) settings = AppSettingsService.get_resolved_settings(session, int(tenant_id), int(company_id)) gen_params = settings.get("ssisgen", {}) or settings.get("qsisgen", {}) @@ -2148,35 +2074,17 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = rfc_exception_egm=rfc_exception_egm, validar_decimales_pza=validar_decimales_pza, ) + for e in row_errors: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) for e in row_errors: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 + error_count = len(error_lines_list) common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result( job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows @@ -2224,7 +2132,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = RFC_EXCEPTION_UPDATED = {"TPI121217SF6", "TCI170502858"} RFC_EXCEPTION_NUM_PARTE = {"CTE980130518"} - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: # Resolver parámetros del sistema: validadecencant (decimales PZA) y validarseries (switch maestro) settings = AppSettingsService.get_resolved_settings(session, int(tenant_id), int(company_id)) gen_params = settings.get("ssisgen", {}) or settings.get("qsisgen", {}) @@ -2434,35 +2342,17 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = warnings=None, catalog_label="Compras Mexicanas", ) + for e in row_errors: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) for e in row_errors: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 + error_count = len(error_lines_list) common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result(job_id, processed_rows, error_count, errors_detail) except Exception as e: @@ -2484,10 +2374,13 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate from api.v1.modules.a76.transportation.transporters.models import Transporter from .validators.encabezados_impo_temp import ( + REGIMENES_VALIDOS, validate_row_encabezados_impo_temp, parse_pedimento_col_a, _pedimento_key_from_parsed, + _patente_from_agente_aduanal, ) + from .validators.pedimento_resolution import resolve_pedimento_candidates from .validators.transport_catalog import logistics_scan_row_errors def _ped_key_from_row(ped_str: str) -> Optional[str]: @@ -2517,8 +2410,10 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = actualizar = bool(_fc["actualizar"]) if "autonumerar_remesas" in _fc: autonumerar_remesas = bool(_fc["autonumerar_remesas"]) + date_format = _fc.get("dateFormat") or meta.get("date_format") + logger.warning(f"Using date format: {date_format} for job {job_id}") - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: q_inv = ( session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.status) .filter( @@ -2536,8 +2431,12 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = invoice_exists_by_number[n] = True invoice_processed_by_number[n] = _is_invoice_processed_status(is_upd) + logger.warning(f"Scan Job {job_id} started for tenant {tenant_id}, company {company_id}. Loaded {len(invoice_exists_by_number)} invoices for pre-check.") + pedimento_rows: List[Dict[str, Any]] = [] - for p in ( + skipped_ped_catalog_empty = 0 + _seen_pedimento_catalog_ids: Set[int] = set() + for p_id, p_year, p_co, p_lic, p_num, p_op, p_reg, p_type, entry_date, end_date, start_date in ( session.query( Pedimentos.id, Pedimentos.year, @@ -2547,39 +2446,50 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = Pedimentos.operation_type, Pedimentos.regime, Pedimentos.pedimento_type, + PedimentoDates.entry_date, + PedimentoDates.end_date, + PedimentoDates.start_date, ) + .outerjoin(PedimentoDates, PedimentoDates.pedimento_id == Pedimentos.id) .filter( Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id, ) .all() ): - co = (p.customs_office or "").strip() - lic = (p.license or "").strip() - num = (p.pedimento_number or "").strip() - if not lic or not num: + # Catálogo completo por empresa: el resolver debe ver todos los pedimentos. + # Si el alta vino como exp por error, igual se encuentra fila; validate_row_encabezados_impo_temp + # rechaza operation_type != IMP con mensaje explícito (no solo “no existe”). + if p_id in _seen_pedimento_catalog_ids: continue - entry_date = None - end_date = None - pd = ( - session.query(PedimentoDates.entry_date, PedimentoDates.end_date) - .filter(PedimentoDates.pedimento_id == p.id).first() - ) - if pd: - entry_date = pd[0] - end_date = pd[1] + _seen_pedimento_catalog_ids.add(p_id) + tri = _normalize_catalog_pedimento_identity(p_co, p_lic, p_num) + if not tri: + skipped_ped_catalog_empty += 1 + continue + co, lic, num = tri info = { - "id": p.id, + "id": p_id, "customs_office": co, "license": lic, "pedimento_number": num, - "regime": (p.regime or "").strip(), - "operation_type": (p.operation_type or "").strip(), - "pedimento_type": (p.pedimento_type or "").strip(), + "regime": (p_reg or "").strip(), + "operation_type": (getattr(p_op, "value", None) or str(p_op or "")).strip(), + "pedimento_type": (getattr(p_type, "value", None) or str(p_type or "")).strip(), "entry_date": entry_date, "end_date": end_date, + "start_date": start_date, } pedimento_rows.append(info) + logger.info( + "facturas_scan imp_temp_header pedimento_catalog job_id=%s tenant_id=%s company_id=%s " + "catalog_rows=%s skipped_empty_license_or_number=%s", + job_id, + tenant_id, + company_id, + len(pedimento_rows), + skipped_ped_catalog_empty, + ) remesa_por_pedimento_bd: Dict[str, Set[int]] = {} q_rem = ( @@ -2737,16 +2647,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = if rem_int not in remesa_por_pedimento_csv[key]: remesa_por_pedimento_csv[key][rem_int] = factura - # Precheck de referencias críticas para detectar desalineaciones antes del scan completo. - pedimento_keys = { - _pedimento_key_from_parsed( - (p.get("customs_office") or "").strip(), - (p.get("license") or "").strip(), - (p.get("pedimento_number") or "").strip(), - ) - for p in pedimento_rows - if p.get("customs_office") and p.get("license") and p.get("pedimento_number") - } + # Precheck de referencias críticas (misma resolución que validate_row: catálogo + aduana/patente). precheck_errors: List[Dict[str, Any]] = [] precheck_lines: Set[int] = set() for i, row in enumerate(rows_list, start=1): @@ -2756,6 +2657,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = aduana_cruce = (row_norm.get("ADUANA DE CRUCE") or "").strip() 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) + regimen_raw = (row_norm.get("REGIMEN") or row_norm.get("CLAVEDOCUMENTO") or "").strip().upper() if not invoice_number: precheck_lines.add(i) @@ -2793,19 +2695,98 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = ) if ped_raw: - key = _ped_key_from_row(ped_raw) - if not key or key not in pedimento_keys: + parsed_ped = parse_pedimento_col_a(ped_raw) + if not parsed_ped: precheck_lines.add(i) precheck_errors.append( { "line": i, "col": "PEDIMENTO", - "msg": f"Error: Número de Pedimento: {ped_raw} no existe en el Catálogo de Pedimentos.", - "solution": "Carga/corrige el pedimento antes de volver a importar facturas.", + "msg": f"Error: Número de Pedimento: {ped_raw} formato inválido (use CC-LLLL-NNNNNNN).", + "solution": "Corrige el formato del pedimento en columna A o verifica el catálogo.", "warning": False, } ) - if not aduana_cruce: + else: + customs_office_p, license_p, num_p = parsed_ped + patente_lookup_pre = _patente_from_agente_aduanal(row_norm, license_p) + ped_chk = resolve_pedimento_candidates( + customs_office=customs_office_p, + license_val=license_p, + pedimento_number=num_p, + patente_lookup=patente_lookup_pre, + pedimento_rows=pedimento_rows, + ) + if ped_chk["status"] == "not_found": + not_found_reason = ped_chk.get("not_found_reason") + # Paridad con _validaciones_pedimento_remesa: cuando no hay match en catálogo + # pero el régimen es ITE/ITR, se permite capturar la factura como pendiente de asignar + # pedimento. En precheck solo se registra advertencia, no error bloqueante. + if not_found_reason == "no_catalog_match" and regimen_raw in REGIMENES_VALIDOS: + precheck_errors.append( + { + "line": i, + "col": "PEDIMENTO", + "msg": ( + f"Advertencia: Número de Pedimento: {ped_raw} no está aún en el Catálogo de " + "Pedimentos. Con régimen ITE/ITR la factura se importará pendiente de asignar " + "pedimento." + ), + "solution": ( + "Da de alta el pedimento en el catálogo cuando esté disponible o confirma la " + "carga para dejar la factura pendiente de asignar pedimento." + ), + "warning": True, + "not_found_reason": "no_catalog_match", + } + ) + else: + precheck_lines.add(i) + if not_found_reason == "customs_mismatch": + ped_msg = ( + f"Error: Pedimento {ped_raw} está en catálogo pero la aduana no coincide con la registrada. " + "Revise columna A y Aduana de cruce (Col.AB)." + ) + ped_sol = "Alinee la aduana del CSV con el alta del pedimento o corrija el catálogo." + elif not_found_reason == "license_or_patente_mismatch": + ped_msg = ( + f"Error: El número de pedimento aparece en el catálogo pero la patente/agente no coincide " + f"con el alta ({ped_raw}). Revise columna A y columna J (agente aduanal)." + ) + ped_sol = ( + "Alinee la patente del pedimento en columna A con el alta o corrija AGENTE ADUANAL (col. J)." + ) + else: + ped_msg = ( + f"Error: Número de Pedimento: {ped_raw} no existe en el Catálogo de Pedimentos." + ) + ped_sol = "Carga/corrige el pedimento antes de volver a importar facturas." + precheck_errors.append( + { + "line": i, + "col": "PEDIMENTO", + "msg": ped_msg, + "solution": ped_sol, + "warning": False, + "not_found_reason": not_found_reason, + } + ) + elif ped_chk["status"] == "ambiguous": + precheck_lines.add(i) + precheck_errors.append( + { + "line": i, + "col": "PEDIMENTO", + "msg": ( + f"Error: Número de Pedimento: {ped_raw} coincide con múltiples registros. " + "Valide aduana/patente para identificar un único pedimento." + ), + "solution": "Corrija duplicados en el catálogo o especifique datos que identifiquen un solo pedimento.", + "warning": False, + } + ) + # Solo es obligatorio si no viene en el pedimento de la columna A + if not aduana_cruce and not (parsed_ped and parsed_ped[0]): precheck_lines.add(i) precheck_errors.append( { @@ -2831,30 +2812,12 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = errors_detail: List[Dict[str, Any]] = [] for e in precheck_errors: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": False, - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: with open(error_path, "w", encoding="utf-8") as f_err: for e in precheck_errors: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") for i, row in enumerate(rows_list, start=1): self.update_state( state="PROGRESS", @@ -2908,42 +2871,43 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = blocking = [e for e in row_errors if not e.get("warning")] if blocking: error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) + # CSV/jsonl de errores: incluir bloqueantes y advertencias (paridad con errors_detail). + for e in row_errors + warnings_row: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") for e in row_errors + warnings_row: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 # El commit usa líneas únicas para omitir filas; unir precheck + validación por fila. unique_error_lines = _merge_unique_invoice_scan_error_lines(precheck_lines, error_lines_list) error_count = len(unique_error_lines) common_storage.store_error_lines(effective_job_type, job_id, unique_error_lines) + scan_message_final: Optional[str] = scan_precheck_message + if processed_rows > 0 and errors_detail: + reason_counts_header: Dict[str, int] = {} + for e in errors_detail: + if e.get("warning"): + continue + msg_h = str(e.get("msg", "")).strip() + if not msg_h: + continue + reason_counts_header[msg_h] = reason_counts_header.get(msg_h, 0) + 1 + if reason_counts_header: + top_reason_h, top_count_h = max(reason_counts_header.items(), key=lambda kv: kv[1]) + if top_count_h / max(processed_rows, 1) >= 0.8: + scan_message_final = ( + f"Causa dominante de rechazo ({top_count_h}/{processed_rows}): {top_reason_h}. " + f"contexto template_id={template_id}, invoice_type={inv_type_value}, operation_type={op_type_scan}." + ) + logger.warning("Scan encabezados impo temp causa dominante: %s", scan_message_final) return common_responses.scan_result( job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows, - message=scan_precheck_message, + message=scan_message_final, ) except Exception as e: logger.exception("Encabezados importación temporal scan failed: %s", e) @@ -3000,7 +2964,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = if "autonumerar_remesas" in _fc: autonumerar_remesas = bool(_fc["autonumerar_remesas"]) - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: q_inv = ( session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.status) .filter( @@ -3019,7 +2983,8 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = invoice_processed_by_number[n] = _is_invoice_processed_status(is_upd) pedimento_rows = [] - for p in ( + skipped_ped_def_catalog = 0 + for p_id, p_year, p_co, p_lic, p_num, p_op, p_reg, p_type, entry_date, end_date, start_date in ( session.query( Pedimentos.id, Pedimentos.year, @@ -3029,41 +2994,45 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = Pedimentos.operation_type, Pedimentos.regime, Pedimentos.pedimento_type, + PedimentoDates.entry_date, + PedimentoDates.end_date, + PedimentoDates.start_date, ) + .outerjoin(PedimentoDates, PedimentoDates.pedimento_id == Pedimentos.id) .filter( Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id, Pedimentos.operation_type == "imp", - Pedimentos.regime == "IMD", ) .all() ): - co = (p.customs_office or "").strip() - lic = (p.license or "").strip() - num = (p.pedimento_number or "").strip() - if not lic or not num: + tri = _normalize_catalog_pedimento_identity(p_co, p_lic, p_num) + if not tri: + skipped_ped_def_catalog += 1 continue - entry_date = None - end_date = None - pd = ( - session.query(PedimentoDates.entry_date, PedimentoDates.end_date) - .filter(PedimentoDates.pedimento_id == p.id).first() - ) - if pd: - entry_date = pd[0] - end_date = pd[1] + co, lic, num = tri info = { - "id": p.id, + "id": p_id, "customs_office": co, "license": lic, "pedimento_number": num, - "regime": (p.regime or "").strip(), - "operation_type": (p.operation_type or "").strip(), - "pedimento_type": (p.pedimento_type or "").strip(), + "regime": (p_reg or "").strip(), + "operation_type": (getattr(p_op, "value", None) or str(p_op or "")).strip(), + "pedimento_type": (getattr(p_type, "value", None) or str(p_type or "")).strip(), "entry_date": entry_date, "end_date": end_date, + "start_date": start_date, } pedimento_rows.append(info) + logger.info( + "facturas_scan imp_def_header pedimento_catalog job_id=%s tenant_id=%s company_id=%s " + "catalog_rows=%s skipped_empty_license_or_number=%s", + job_id, + tenant_id, + company_id, + len(pedimento_rows), + skipped_ped_def_catalog, + ) remesa_por_pedimento_bd = {} q_rem = ( @@ -3269,33 +3238,15 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = ) blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) + for e in row_errors + warnings_row: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") for e in row_errors + warnings_row: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 + error_count = len(error_lines_list) common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result( job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows @@ -3359,7 +3310,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = return None return _pedimento_key_from_parsed(parsed[0], parsed[1], parsed[2]) - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: q_inv = ( session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.status, InvoiceHeader.status_rep) .filter( @@ -3380,13 +3331,12 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = if cambio_regimen: ped_filter_op = "imp" - ped_filter_regimes = ["IMD"] else: ped_filter_op = "exp" - ped_filter_regimes = ["EXD", "ETE", "ETR"] pedimento_rows = [] - for p in ( + skipped_ped_expo_catalog = 0 + for p_id, p_co, p_lic, p_num, p_op, p_reg, p_type, p_code, entry_date, end_date, start_date in ( session.query( Pedimentos.id, Pedimentos.customs_office, @@ -3396,42 +3346,47 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = Pedimentos.regime, Pedimentos.pedimento_type, Pedimentos.pedimento_code, + PedimentoDates.entry_date, + PedimentoDates.end_date, + PedimentoDates.start_date, ) + .outerjoin(PedimentoDates, PedimentoDates.pedimento_id == Pedimentos.id) .filter( Pedimentos.tenant_id == tenant_id, Pedimentos.company_id == company_id, Pedimentos.operation_type == ped_filter_op, - Pedimentos.regime.in_(ped_filter_regimes), ) .all() ): - co = (p.customs_office or "").strip() - lic = (p.license or "").strip() - num = (p.pedimento_number or "").strip() - if not lic or not num: + tri = _normalize_catalog_pedimento_identity(p_co, p_lic, p_num) + if not tri: + skipped_ped_expo_catalog += 1 continue - entry_date = None - end_date = None - pd = ( - session.query(PedimentoDates.entry_date, PedimentoDates.end_date) - .filter(PedimentoDates.pedimento_id == p.id).first() - ) - if pd: - entry_date = pd[0] - end_date = pd[1] + co, lic, num = tri info = { - "id": p.id, + "id": p_id, "customs_office": co, "license": lic, "pedimento_number": num, - "regime": (p.regime or "").strip(), - "operation_type": (p.operation_type or "").strip().upper()[:3], - "pedimento_type": (p.pedimento_type or "").strip(), - "pedimento_code": (p.pedimento_code or "").strip().upper(), + "operation_type": (getattr(p_op, "value", None) or str(p_op or "")).strip(), + "regime": (p_reg or "").strip(), + "pedimento_type": (getattr(p_type, "value", None) or str(p_type or "")).strip(), + "pedimento_code": (p_code or "").strip(), "entry_date": entry_date, "end_date": end_date, + "start_date": start_date, } pedimento_rows.append(info) + logger.info( + "facturas_scan exp_def_header pedimento_catalog job_id=%s tenant_id=%s company_id=%s " + "filter_op=%s catalog_rows=%s skipped_empty_license_or_number=%s", + job_id, + tenant_id, + company_id, + ped_filter_op, + len(pedimento_rows), + skipped_ped_expo_catalog, + ) remesa_por_pedimento_bd = {} q_rem = ( @@ -3566,6 +3521,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = existing_tipo_moneda_by_number[str(num).strip()] = (cur_str or "").upper()[:2] date_format = _fc.get("dateFormat") or meta.get("date_format") + logger.warning(f"Using date format: {date_format} for job {job_id}") with open(file_path, "r", encoding=_facturas_csv_encoding(file_path)) as f_in: sample = f_in.read(2048) @@ -3649,33 +3605,15 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = ) blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) + for e in row_errors + warnings_row: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") for e in row_errors + warnings_row: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 + error_count = len(error_lines_list) common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result( job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows @@ -3702,7 +3640,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = if _fc and "actualizar" in _fc: actualizar = bool(_fc["actualizar"]) - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: q_inv = ( session.query(InvoiceHeader.invoice_number, InvoiceHeader.id, InvoiceHeader.status) .filter( @@ -3817,7 +3755,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = error_lines_list = [] errors_detail = [] - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: with open(error_path, "w", encoding="utf-8") as f_err: for i, row in enumerate(rows_list, start=1): self.update_state( @@ -3853,33 +3791,15 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = ) blocking = [e for e in row_errors if not e.get("warning")] if blocking: - error_count += 1 error_lines_list.append(i) - for e in blocking: - f_err.write( - json.dumps( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - } - ) - + "\n" - ) + for e in row_errors + warnings_row: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") for e in row_errors + warnings_row: if len(errors_detail) < 5000: - errors_detail.append( - { - "line": e["line"], - "col": e.get("col", ""), - "msg": e.get("msg", ""), - "solution": e.get("solution", ""), - "warning": bool(e.get("warning", False)), - } - ) + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 + error_count = len(error_lines_list) common_storage.store_error_lines(effective_job_type, job_id, error_lines_list) return common_responses.scan_result( job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows @@ -3917,7 +3837,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = "Part": Part, } - with CoreSessionLocal() as session, \ + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session, \ open(file_path, 'r', encoding=_facturas_csv_encoding(file_path)) as f_in, \ open(error_path, 'w', encoding='utf-8') as f_err: validator = ForeignKeyValidator(session, tenant_id, company_id) @@ -3978,7 +3898,7 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = # Solo columnas de la plantilla (respetar plantilla tal cual) row_norm = row_from_template(row, template_id, normalize_header) - errors = validate_row_strict( + row_errs = validate_row_strict( row_norm, model_target, i, @@ -3988,18 +3908,14 @@ def _do_scan_file(self, job_id: str, model_target: str, config: Optional[str] = invoice_id_cache, models, ) - - if errors: + + if row_errs: error_count += 1 - f_err.write(json.dumps(errors) + "\n") - if len(errors_detail) < 5000: - errors_detail.append({ - "line": errors.get("line", i), - "col": errors.get("col", ""), - "msg": errors.get("msg", ""), - "solution": errors.get("solution", ""), - "warning": bool(errors.get("warning", False)), - }) + for e in row_errs: + f_err.write(json.dumps(_invoice_scan_error_row_payload(e)) + "\n") + for e in row_errs: + if len(errors_detail) < 5000: + errors_detail.append(_invoice_scan_error_row_payload(e)) processed_rows += 1 @@ -4207,10 +4123,14 @@ def validate_row_strict( inv_type_value: str, invoice_id_cache: Dict[str, Optional[int]], models: Dict[str, Any], -) -> Optional[Dict[str, Any]]: +) -> List[Dict[str, Any]]: + """ + Devuelve lista vacía si la fila es válida; en caso contrario, todas las observaciones + detectadas (el CSV/jsonl de errores debe listar cada una). + """ err = validate_row_phase_1(row, target, line_num, date_format) if err: - return err + return [err] InvoiceHeader = models["InvoiceHeader"] InvoiceType = models["InvoiceType"] @@ -4224,36 +4144,40 @@ def validate_row_strict( 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", - "solution": "Capturar un TIPO FACTURA válido que exista en el catálogo.", - } + return [ + { + "line": line_num, + "col": "TIPO FACTURA", + "msg": "No existe en el catalogo", + "solution": "Capturar un TIPO FACTURA válido que exista en el catálogo.", + } + ] + + errs: List[Dict[str, Any]] = [] err = _validate_client_provider_ref( validator, ClientProvider, row.get("CLAVE PROVEEDOR"), line_num, "CLAVE PROVEEDOR", required=True ) if err: - return err + errs.append(err) err = _validate_client_provider_ref( validator, ClientProvider, row.get("CLAVE VENDIDO A"), line_num, "CLAVE VENDIDO A", required=True ) if err: - return err + errs.append(err) err = _validate_client_provider_ref( validator, ClientProvider, row.get("CLAVE ENVIADO A"), line_num, "CLAVE ENVIADO A", required=True ) if err: - return err + errs.append(err) err = _validate_customs_broker_ref( validator, CustomsBroker, row.get("AGENTE ADUANAL"), line_num, "AGENTE ADUANAL", required=False ) if err: - return err + errs.append(err) err = validate_public_code( validator, @@ -4263,7 +4187,7 @@ def validate_row_strict( "CLAVEDOCUMENTO", ) if err: - return err + errs.append(err) err = validate_public_code( validator, @@ -4274,7 +4198,7 @@ def validate_row_strict( field_name="customs_code", ) if err: - return err + errs.append(err) err = validate_public_code( validator, @@ -4284,7 +4208,7 @@ def validate_row_strict( "CLAVE MONEDA", ) if err: - return err + errs.append(err) err = validate_public_code( validator, @@ -4294,7 +4218,7 @@ def validate_row_strict( "CLAVE INCOTERM", ) if err: - return err + errs.append(err) from .validators.transport_catalog import logistics_scan_row_errors @@ -4305,18 +4229,21 @@ def validate_row_strict( validator.company_id, line_num, ) - if log_errs: - return log_errs[0] + errs.extend(log_errs) - elif target == "invoice_details": + return errs + + if 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", - "solution": "Capturar el número de Factura en la columna NUMERO FACTURA.", - } + return [ + { + "line": line_num, + "col": "NUMERO FACTURA", + "msg": "Requerido", + "solution": "Capturar el número de Factura en la columna NUMERO FACTURA.", + } + ] cache_key = f"{invoice_number}|{inv_type_value}" if cache_key in invoice_id_cache: @@ -4334,30 +4261,36 @@ def validate_row_strict( ) invoice_id_cache[cache_key] = invoice_id if not invoice_id: - return { - "line": line_num, - "col": "NUMERO FACTURA", - "msg": "Factura no existe", - "solution": "Capturar un número de Factura que exista en el sistema (verificar NUMERO FACTURA).", - } + return [ + { + "line": line_num, + "col": "NUMERO FACTURA", + "msg": "Factura no existe", + "solution": "Capturar un número de Factura que exista en el sistema (verificar NUMERO FACTURA).", + } + ] part_num = (row.get("NUMPARTE") or row.get("NUMERO PARTE") or "").strip() if not part_num: - return { - "line": line_num, - "col": "NUMPARTE", - "msg": "Requerido", - "solution": "Capturar el número de parte en la columna NUMPARTE.", - } + return [ + { + "line": line_num, + "col": "NUMPARTE", + "msg": "Requerido", + "solution": "Capturar el número de parte en la columna NUMPARTE.", + } + ] if not validator.check_exists(Part, part_num, field_name="part_number"): - return { - "line": line_num, - "col": "NUMPARTE", - "msg": "No existe en el catalogo", - "solution": "Capturar un número de parte que exista en el catálogo (verificar NUMPARTE).", - } + return [ + { + "line": line_num, + "col": "NUMPARTE", + "msg": "No existe en el catalogo", + "solution": "Capturar un número de parte que exista en el catálogo (verificar NUMPARTE).", + } + ] - return None + return [] def parse_footer_config(config: Optional[str]) -> Dict[str, Any]: if not config: @@ -4510,7 +4443,7 @@ def parse_weight_unit(value: Optional[str]): def resolve_tenant_fk_id( - session: CoreSessionLocal, + session: Session, model, value: Optional[int], tenant_id: int, @@ -4535,7 +4468,7 @@ def resolve_tenant_fk_id( def resolve_client_provider_id( - session: CoreSessionLocal, + session: Session, model, value: Any, tenant_id: int, @@ -4578,7 +4511,7 @@ def resolve_client_provider_id( def resolve_customs_broker_id( - session: CoreSessionLocal, + session: Session, model, value: Any, tenant_id: int, @@ -4630,7 +4563,7 @@ def resolve_customs_broker_id( def resolve_public_code( - session: CoreSessionLocal, + session: Session, model, column, value: Optional[str], @@ -4662,7 +4595,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt effective_job_type = job_type_override if job_type_override is not None else JOB_TYPE log_prefix = "Exportación import" if effective_job_type else "Invoices import" - logger.info(f"Starting Commit for {job_id} target {model_target}") + logger.info("Starting Commit for job_id=%s target=%s", job_id, model_target) # Ensure we have the file on this worker: prefer Redis (so any worker can run commit) file_path = common_storage.ensure_file_from_redis(effective_job_type, job_id, log_prefix) @@ -4680,6 +4613,14 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt except ValueError as e: return {"status": "failed", "error": str(e)} + logger.info( + "facturas_commit context job_id=%s tenant_id=%s company_id=%s target=%s", + job_id, + tenant_id, + company_id, + model_target, + ) + meta = common_meta.load_meta(file_path) meta_path = common_meta.get_meta_path(file_path) error_path = common_storage.error_path_for_job(effective_job_type, job_id) @@ -4723,7 +4664,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt as_val = _fc.get("autonumber_series", "true") autonumerar = str(as_val).lower() in ("true", "1", "si", "sí", "yes") - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: q_inv = ( session.query(InvoiceHeader.invoice_number, InvoiceHeader.id) .filter( @@ -4933,7 +4874,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt if "validar_series" in _fc: validar_series_exception = bool(_fc["validar_series"]) - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: # Switch maestro: validarseries desactiva toda la validación de series cuando = 0 _sys_settings = AppSettingsService.get_resolved_settings(session, int(tenant_id), int(company_id)) _gen_p = _sys_settings.get("ssisgen", {}) or _sys_settings.get("qsisgen", {}) @@ -5226,7 +5167,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt if "validar_series" in _fc: validar_series_exception = bool(_fc["validar_series"]) - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: # Switch maestro: validarseries desactiva toda la validación de series cuando = 0 _sys_settings = AppSettingsService.get_resolved_settings(session, int(tenant_id), int(company_id)) _gen_p = _sys_settings.get("ssisgen", {}) or _sys_settings.get("qsisgen", {}) @@ -5541,7 +5482,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt inserted_count = 0 response = None - with CoreSessionLocal() as session: + with scoped_core_db(tenant_id=tenant_id, company_id=company_id) as session: invoice_id_cache = {} cleared_invoices = set() # Track invoices where we've already cleared items in this job provider_cache: Dict[Any, Optional[int]] = {} @@ -5653,6 +5594,7 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt CustomsBroker.company_id == company_id, ).all() broker_lookup = _build_customs_broker_lookup(broker_rows) + skipped_ped_commit_catalog = 0 for p in ( session.query( Pedimentos.id, @@ -5668,20 +5610,32 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt ) .all() ): - co = (p.customs_office or "").strip() - lic = (p.license or "").strip() - num = (p.pedimento_number or "").strip() - if not lic or not num: + tri = _normalize_catalog_pedimento_identity( + p.customs_office, p.license, p.pedimento_number + ) + if not tri: + skipped_ped_commit_catalog += 1 continue + co, lic, num = tri info = { "id": p.id, "customs_office": co, "license": lic, "pedimento_number": num, - "operation_type": (p.operation_type or "").strip(), + "operation_type": ( + getattr(p.operation_type, "value", None) or str(p.operation_type or "") + ).strip(), "regime": (p.regime or "").strip(), } pedimento_rows_insert.append(info) + logger.info( + "facturas_commit invoice_header pedimento_catalog tenant_id=%s company_id=%s " + "catalog_rows=%s skipped_empty_license_or_number=%s", + tenant_id, + company_id, + len(pedimento_rows_insert), + skipped_ped_commit_catalog, + ) error_msg_by_line: Dict[int, str] = {} if error_path and os.path.exists(error_path): diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py index d878f58a..a1158fe1 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/template_config.py @@ -19,11 +19,12 @@ from .invoice_csv_column_hints import apply_invoice_csv_hints TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { # --- Encabezado factura: Impo Temp (EstructuraEncFacImpoTemp.xls) - Clarion A-AD --- "imp_temp_header": [ - {"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED"]}, + {"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED", "NO. PEDIMENTO", "PEDIMENTO NO.", "NO PEDIMENTO"]}, {"canonical": "REMESA"}, {"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]}, {"canonical": "FECHA FACTURA", "aliases": ["FECHA"]}, {"canonical": "TIPO DE CAMBIO"}, + # Clarion columna F — ITE/ITR cuando el pedimento aún no está en catálogo (pendiente de asignar). {"canonical": "REGIMEN", "aliases": ["CLAVEDOCUMENTO"]}, {"canonical": "CLAVE PROVEEDOR"}, {"canonical": "CLAVE VENDIDO A"}, @@ -48,7 +49,7 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "TIPO PESO"}, {"canonical": "E DOCUMENT", "aliases": ["E-DOCUMENT", "EDOCUMENT", "E DOCUMENT"]}, {"canonical": "NUM OPERACION", "aliases": ["NUM. OPERACION", "NUMOPERACION", "NUM OPERACION"]}, - {"canonical": "ADUANA DE CRUCE"}, + {"canonical": "ADUANA DE CRUCE", "aliases": ["ADUANA", "ADUANA CRUCE", "ADUANA DE CRUCE.", "ADUANA DE CRUCE / SECCION"]}, {"canonical": "OBSERVACIONES E"}, {"canonical": "OBSERVACIONES I"}, {"canonical": "FACTURA ALTERNA"}, @@ -58,7 +59,7 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { ], # --- Encabezado factura: Impo Def (EstructuraEncFacImpoDef.xls) - 30 columnas Clarion --- "imp_def_header": [ - {"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED"]}, + {"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED", "NO. PEDIMENTO", "PEDIMENTO NO.", "NO PEDIMENTO"]}, {"canonical": "REMESA"}, {"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]}, {"canonical": "FECHA FACTURA", "aliases": ["FECHA"]}, @@ -87,7 +88,7 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "TIPO PESO"}, {"canonical": "E DOCUMENT", "aliases": ["E-DOCUMENT", "EDOCUMENT", "E DOCUMENT"]}, {"canonical": "NUM OPERACION", "aliases": ["NUM. OPERACION", "NUMOPERACION", "NUM OPERACION"]}, - {"canonical": "ADUANA DE CRUCE"}, + {"canonical": "ADUANA DE CRUCE", "aliases": ["ADUANA", "ADUANA CRUCE", "ADUANA DE CRUCE.", "ADUANA DE CRUCE / SECCION"]}, {"canonical": "OBSERVACIONES E"}, {"canonical": "OBSERVACIONES I"}, ], @@ -96,7 +97,7 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { # CLAVE VENDIDO A:, CLAVE ENVIADO A, AGENTE ADUANAL, ... MANIFIESTO, E-DOCUMENT, NUM. OPERACION, # ENVIADO POR, ADUANA DE CRUCE, OBSERVACIONES E, OBSERVACIONES I, FACTURA ALTERNA "exp_def_header": [ - {"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED"]}, + {"canonical": "PEDIMENTO", "aliases": ["NUMERO PEDIMENTO", "PEDIMENTO NUMERO", "NUMERO DE PEDIMENTO", "PED", "NO. PEDIMENTO", "PEDIMENTO NO.", "NO PEDIMENTO"]}, {"canonical": "REMESA"}, {"canonical": "NUMERO FACTURA", "aliases": ["NUM FACTURA", "FACTURA", "ID"]}, {"canonical": "FECHA FACTURA", "aliases": ["FECHA"]}, @@ -126,7 +127,7 @@ TEMPLATE_COLUMNS: Dict[str, List[Dict[str, Any]]] = { {"canonical": "E DOCUMENT", "aliases": ["E-DOCUMENT", "EDOCUMENT", "E DOCUMENT"]}, {"canonical": "NUM OPERACION", "aliases": ["NUM. OPERACION", "NUMOPERACION", "NUM OPERACION"]}, {"canonical": "ENVIADO POR"}, - {"canonical": "ADUANA DE CRUCE"}, + {"canonical": "ADUANA DE CRUCE", "aliases": ["ADUANA", "ADUANA CRUCE", "ADUANA DE CRUCE.", "ADUANA DE CRUCE / SECCION"]}, {"canonical": "OBSERVACIONES E"}, {"canonical": "OBSERVACIONES I"}, {"canonical": "FACTURA ALTERNA"}, diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py index 2c363064..3ec8ac98 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/__init__.py @@ -21,6 +21,7 @@ from .series_expo import ( validate_row_series_expo, row_to_series_normalized_expo, ) +from .consolidated_invoice_dates import validate_consolidated_invoice_date_csv __all__ = [ "validate_row_encabezados_impo_temp", @@ -37,4 +38,5 @@ __all__ = [ "parse_pedimento_col_a", "parse_pedimento_col_a_impo_def", "_pedimento_key_from_parsed", + "validate_consolidated_invoice_date_csv", ] diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/consolidated_invoice_dates.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/consolidated_invoice_dates.py new file mode 100644 index 00000000..19895328 --- /dev/null +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/consolidated_invoice_dates.py @@ -0,0 +1,107 @@ +""" +Validación de FECHA FACTURA vs pedimento consolidado en CSV de encabezados. + +Paridad con `validate_common` (factura manual) cuando `pedimento_type == "consolidated"`: +- Sin fechas de pedimento en catálogo → equivalente a MISSING_PEDIMENTO_DATES. +- Rango permitido: [start_date, end_date] si `start_date` está capturada en el catálogo; + si no, [entry_date, end_date] (mismo criterio que captura manual / periodo consolidado). +""" +from datetime import date, datetime +from typing import Any, Dict, Optional + + +def _to_calendar_date(val: Any) -> Optional[date]: + if val is None: + return None + if isinstance(val, datetime): + return val.date() + if isinstance(val, date): + return val + if hasattr(val, "date"): + return val.date() # type: ignore[union-attr] + return None + + +def validate_consolidated_invoice_date_csv( + *, + line_num: int, + pedimento_key_display: str, + pedimento_type: str, + invoice_date_parsed: Optional[datetime], + entry_raw: Any, + end_raw: Any, + start_raw: Any = None, +) -> Optional[Dict[str, Any]]: + """ + Retorna dict de error compatible con validadores CSV (line, col, msg, …) o None si aplica/no hay error. + + No aplica si el pedimento no es consolidado. + """ + pt = (pedimento_type or "").strip().lower() + if pt != "consolidated": + return None + + if end_raw is None: + return { + "line": line_num, + "col": "PEDIMENTO", + "msg": ( + f"Error: (Celda A{line_num}) El Pedimento {pedimento_key_display} no tiene fechas registradas " + "en el catálogo." + ), + "solution": "Verifica las fechas del Pedimento en el catálogo", + "code": "MISSING_PEDIMENTO_DATES", + } + + range_low_raw = start_raw if start_raw is not None else entry_raw + if range_low_raw is None: + return { + "line": line_num, + "col": "PEDIMENTO", + "msg": ( + f"Error: (Celda A{line_num}) El Pedimento {pedimento_key_display} no tiene fechas registradas " + "en el catálogo." + ), + "solution": "Verifica las fechas del Pedimento en el catálogo", + "code": "MISSING_PEDIMENTO_DATES", + } + + if not invoice_date_parsed: + return None + + entry = _to_calendar_date(range_low_raw) + end = _to_calendar_date(end_raw) + if entry is None or end is None: + return { + "line": line_num, + "col": "PEDIMENTO", + "msg": ( + f"Error: (Celda A{line_num}) El Pedimento {pedimento_key_display} no tiene fechas registradas " + "en el catálogo." + ), + "solution": "Verifica las fechas del Pedimento en el catálogo", + "code": "MISSING_PEDIMENTO_DATES", + } + + if isinstance(invoice_date_parsed, datetime): + inv_d = invoice_date_parsed.date() + elif isinstance(invoice_date_parsed, date): + inv_d = invoice_date_parsed + else: + return None + + if inv_d < entry or inv_d > end: + return { + "line": line_num, + "col": "FECHA FACTURA", + "msg": ( + f"Error: (Celda D{line_num} y A{line_num}) La Fecha de la Factura no corresponde al rango " + f"de fechas del Pedimento {pedimento_key_display}." + ), + "solution": ( + "Capturar una Fecha de Factura dentro del rango de fechas del pedimento consolidado " + "registrado en el catálogo." + ), + "code": "DATE_OUT_OF_RANGE", + } + return None diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py index a06c1e39..accb13a7 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_expo.py @@ -7,6 +7,7 @@ NUM. OPERACION (AA), ENVIADO POR (AB), ADUANA DE CRUCE (AC), OBSERVACIONES E/I, from datetime import datetime from typing import Any, Dict, List, Optional, Set from .pedimento_resolution import resolve_pedimento_candidates +from .consolidated_invoice_dates import validate_consolidated_invoice_date_csv from .encabezados_impo_temp import ( _clip, @@ -148,6 +149,13 @@ def _validaciones_pedimento_remesa_expo( pedimento_rows=pedimento_rows, ) if ped_resolved["status"] == "not_found": + if ped_resolved.get("not_found_reason") == "customs_mismatch": + return _err( + line_num, + "PEDIMENTO", + f"Error: (Celda A{line_num}) El pedimento {col_a} está en el catálogo pero la aduana no coincide con la registrada. " + "Revise columna A y el alta del pedimento en el sistema.", + ) return _err( line_num, "PEDIMENTO", @@ -219,20 +227,17 @@ def _validaciones_pedimento_remesa_expo( # Rango de fechas si pedimento consolidado (omitir si recalcular_fecha_pedimentos = True, paridad Clarion) if not recalcular_fecha_pedimentos: pedimento_type = (ped_info.get("pedimento_type") or "").strip().lower() - if pedimento_type == "consolidated" and invoice_date_parsed and ped_info.get("entry_date") and ped_info.get("end_date"): - entry = ped_info["entry_date"] - end = ped_info["end_date"] - if hasattr(entry, "date"): - entry = entry.date() - if hasattr(end, "date"): - end = end.date() - inv_d = invoice_date_parsed.date() if hasattr(invoice_date_parsed, "date") else invoice_date_parsed - if inv_d < entry or inv_d > end: - return _err( - line_num, - "FECHA FACTURA", - f"Error: (Celda D{line_num} y A{line_num}) La Fecha de la Factura no corresponde al rango de fechas del Pedimento {col_a}.", - ) + err_consolidated = validate_consolidated_invoice_date_csv( + line_num=line_num, + pedimento_key_display=col_a, + pedimento_type=pedimento_type, + invoice_date_parsed=invoice_date_parsed, + entry_raw=ped_info.get("entry_date"), + end_raw=ped_info.get("end_date"), + start_raw=ped_info.get("start_date"), + ) + if err_consolidated: + return err_consolidated if not autonumerar_remesas and not col_b: return _err( diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py index 67fecd6c..69fa9b20 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_def.py @@ -7,6 +7,7 @@ moneda, tipo peso y tipo cambio (misma lógica que TEM). from datetime import datetime from typing import Any, Dict, List, Optional, Set, Tuple from .pedimento_resolution import resolve_pedimento_candidates +from .consolidated_invoice_dates import validate_consolidated_invoice_date_csv # Reutilizar del TEM: helpers y validaciones de catálogos (claves/short names), transporte, moneda, tipo peso, tipo cambio from .encabezados_impo_temp import ( @@ -26,26 +27,34 @@ from .encabezados_impo_temp import ( # Impo Def: régimen único y pedimento formato ##-####-####### (15 chars) REGIMEN_IMD = "IMD" -MAX_LEN_PEDIMENTO_DEF = 15 +MAX_LEN_PEDIMENTO_DEF = 16 +ALLOWED_DEF_REGIMENS = {"IMD", "V1", "A1", "F4", "F5", "D1"} MAX_LEN_FACTURA = 15 # mismo que TEM def parse_pedimento_col_a_impo_def(pedimento_str: str) -> Optional[Tuple[str, str, str]]: """ - Parsea Col A (PEDIMENTO) formato ##-####-####### (15 caracteres). - Guiones en posiciones 3 y 8 (1-based): índices 2 y 7. Retorna (aduana_2, patente_4, numero_7) o None. - Ejemplo válido: 01-1234-2312412 + Parsea Col A (PEDIMENTO) formato ##-####-####### (15 chars) o ###-####-####### (16 chars). + Retorna (aduana, patente, numero) o None. """ if not pedimento_str or not isinstance(pedimento_str, str): return None s = (pedimento_str or "").strip() - if len(s) != MAX_LEN_PEDIMENTO_DEF: + if not (15 <= len(s) <= 16): return None - if s[2:3] != "-" or s[7:8] != "-": + + # Buscamos los guiones. Deben ser 2. + parts = s.split("-") + if len(parts) != 3: return None - part0, part1, part2 = s[0:2], s[3:7], s[8:15] + + part0, part1, part2 = parts + if not (2 <= len(part0) <= 3) or len(part1) != 4 or len(part2) != 7: + return None + if not part0.isdigit() or not part1.isdigit() or not part2.isdigit(): return None + return (part0, part1, part2) @@ -143,6 +152,13 @@ def _validaciones_pedimento_remesa_def( pedimento_rows=pedimento_rows, ) if ped_resolved["status"] == "not_found": + if ped_resolved.get("not_found_reason") == "customs_mismatch": + return _err( + line_num, + "PEDIMENTO", + f"Error: (Celda A{line_num}) El pedimento {col_a} está en el catálogo pero la aduana no coincide con la registrada. " + "Revise columna A y el alta del pedimento en el sistema.", + ) return _err( line_num, "PEDIMENTO", @@ -159,17 +175,17 @@ def _validaciones_pedimento_remesa_def( ped_info = ped_resolved["pedimento"] regimen_ped = (ped_info.get("regime") or "").strip().upper() - if regimen_ped != REGIMEN_IMD: + if regimen_ped not in ALLOWED_DEF_REGIMENS: return _err( line_num, "PEDIMENTO", - f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} tiene el Régimen {regimen_ped}, no válido para este tipo de movimiento. Válidos: IMD.", + f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} tiene el Régimen {regimen_ped}, no válido para este tipo de movimiento. Válidos: {', '.join(sorted(ALLOWED_DEF_REGIMENS))}.", ) - if col_f and col_f != REGIMEN_IMD: + if col_f and col_f not in ALLOWED_DEF_REGIMENS: return _err( line_num, "REGIMEN", - f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} no coincide con el del Pedimento. Debe ser IMD.", + f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} no es válido para este tipo de movimiento. Válidos: {', '.join(sorted(ALLOWED_DEF_REGIMENS))}.", ) if col_f and col_f != regimen_ped: return _err( @@ -179,20 +195,17 @@ def _validaciones_pedimento_remesa_def( ) pedimento_type = (ped_info.get("pedimento_type") or "").strip().lower() - if pedimento_type == "consolidated" and invoice_date_parsed and ped_info.get("entry_date") and ped_info.get("end_date"): - entry = ped_info["entry_date"] - end = ped_info["end_date"] - if hasattr(entry, "date"): - entry = entry.date() - if hasattr(end, "date"): - end = end.date() - inv_d = invoice_date_parsed.date() if hasattr(invoice_date_parsed, "date") else invoice_date_parsed - if inv_d < entry or inv_d > end: - return _err( - line_num, - "FECHA FACTURA", - f"Error: (Celda D{line_num} y A{line_num}) La Fecha de la Factura no corresponde al rango de fechas del Pedimento {col_a}.", - ) + err_consolidated = validate_consolidated_invoice_date_csv( + line_num=line_num, + pedimento_key_display=col_a, + pedimento_type=pedimento_type, + invoice_date_parsed=invoice_date_parsed, + entry_raw=ped_info.get("entry_date"), + end_raw=ped_info.get("end_date"), + start_raw=ped_info.get("start_date"), + ) + if err_consolidated: + return err_consolidated if not autonumerar_remesas and not col_b: return _err( diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py index bd6910ab..171dab04 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/encabezados_impo_temp.py @@ -4,10 +4,19 @@ Paridad Clarion: VALIDA_TODA_FACIMPO_TEM, VALIDA_PARCIAL_FACIMPO_TEM, VALIDACION Mapeo a BD en commit: LLENA_FACIMPO_TEM (tasks.insert_valid_rows). Estructura CSV: PEDIMENTO (A), REMESA (B), NUMERO FACTURA (C), ... ADUANA DE CRUCE (AB), OBSERVACIONES E/I, FACTURA ALTERNA. """ +import logging from datetime import datetime from decimal import Decimal, InvalidOperation from typing import Any, Dict, List, Optional, Set, Tuple -from .pedimento_resolution import resolve_pedimento_candidates +from .pedimento_resolution import ( + aduana_cruce_matches_catalog, + canonical_customs_office_for_pedimento_key, + normalize_pedimento_number_str, + resolve_pedimento_candidates, +) +from .consolidated_invoice_dates import validate_consolidated_invoice_date_csv + +logger = logging.getLogger(__name__) # Longitudes máximas Clarion MAX_LEN_PEDIMENTO = 18 # CC-LLLL-NNNNNNN o CCC-LLLL-NNNNNNN (sin año; ej. 01-1234-2312412 o 640-1234-2312412) @@ -85,45 +94,80 @@ def _parse_int(val: Any) -> Optional[int]: return None -def parse_pedimento_col_a(pedimento_str: str) -> Optional[Tuple[str, str, str]]: +def _sanitize_pedimento_csv_delimiters(raw: Any) -> str: + """Guiones Unicode / BOM → ASCII (Excel a veces exporta caracteres distintos de '-').""" + s = str(raw if raw is not None else "").strip().strip("\ufeff") + for ch in ("\u2212", "\u2013", "\u2014"): + s = s.replace(ch, "-") + return s + + +def _digits_segment_from_csv_cell(seg: Any, *, max_digits: int, min_digits: int = 1) -> Optional[str]: """ - Parsea Col A (PEDIMENTO) formato CC-LLLL-NNNNNNN o CCC-LLLL-NNNNNNN (18 caracteres, sin año). + Segmento numérico desde CSV: tolera celdas Excel como 3428.0, 7011747.0 o enteros. + """ + raw = str(seg if seg is not None else "").strip().replace(",", "").replace(" ", "") + if not raw: + return None + if raw.endswith(".0") and raw[:-2].isdigit(): + raw = raw[:-2] + elif "." in raw or "e" in raw.lower(): + try: + fv = float(raw) + if fv < 0 or fv != int(fv): + return None + raw = str(int(fv)) + except (ValueError, OverflowError): + return None + if not raw.isdigit(): + return None + if len(raw) > max_digits or len(raw) < min_digits: + return None + return raw + + +def parse_pedimento_col_a(pedimento_str: Any) -> Optional[Tuple[str, str, str]]: + """ + Parsea Col A (PEDIMENTO) formato CC-LLLL-NNNNNNN o CCC-LLLL-NNNNNNN (sin año). + El número puede llevar 1–7 dígitos; se normaliza a 7 con ceros a la izquierda. + Tolera exportación Excel (ej. aduana 7 → 07, 3428.0, 7011747.0). Retorna (customs_office_2o3, license_4, pedimento_number_7) o None si formato inválido. """ - if not pedimento_str or not isinstance(pedimento_str, str): + if pedimento_str is None or (isinstance(pedimento_str, str) and not pedimento_str.strip()): return None - s = (pedimento_str or "").strip() + s = _sanitize_pedimento_csv_delimiters(pedimento_str) parts = s.split("-") if len(parts) != 3: return None - customs_office, license_val, pedimento_number = parts[0], parts[1], parts[2] - if len(customs_office) not in (2, 3) or not customs_office.isdigit(): + + customs_raw = _digits_segment_from_csv_cell(parts[0], max_digits=3, min_digits=1) + if not customs_raw: return None - if len(license_val) != 4 or not license_val.isdigit(): + if len(customs_raw) == 1: + customs_office = customs_raw.zfill(2) + else: + customs_office = customs_raw + if len(customs_office) not in (2, 3): return None - if len(pedimento_number) != 7 or not pedimento_number.isdigit(): + + lic_raw = _digits_segment_from_csv_cell(parts[1], max_digits=4, min_digits=1) + if not lic_raw: return None + license_val = lic_raw.zfill(4) + + num_raw = _digits_segment_from_csv_cell(parts[2], max_digits=7, min_digits=1) + if not num_raw: + return None + pedimento_number = normalize_pedimento_number_str(num_raw) return (customs_office, license_val, pedimento_number) def _pedimento_key_from_parsed(customs_office: str, license_val: str, pedimento_number: str) -> str: - """Clave para lookup: CC-LLLL-NNNNNNN (solo primeros 2 dígitos de aduana).""" - co = (customs_office or "").strip() - - # La aduana en el catálogo puede venir con 3 dígitos (String(3)). - # El CSV de encabezados normalmente usa 2 dígitos, por lo que normalizamos: - # - 2 dígitos: se usan tal cual - # - 3 dígitos: - # - usar siempre los primeros 2 (ej. 007 -> 00, 640 -> 64) - # - 1 dígito: left-pad a 2 - if len(co) == 3: - co = co[:2] - elif len(co) == 1: - co = co.zfill(2) - else: - co = co[:2] - - return f"{co}-{license_val}-{pedimento_number}" + """Clave para lookup: CC-LLLL-NNNNNNN (aduana canónica 2 dígitos, número 7 dígitos).""" + co = canonical_customs_office_for_pedimento_key(customs_office) + num = normalize_pedimento_number_str(pedimento_number) + lic = (license_val or "").strip() + return f"{co}-{lic}-{num}" def _patente_from_agente_aduanal(row: Dict[str, Any], default_license: str) -> str: @@ -141,8 +185,11 @@ def _patente_from_agente_aduanal(row: Dict[str, Any], default_license: str) -> s return (default_license or "").strip() -def _err(line_num: int, col: str, msg: str) -> Dict[str, Any]: - return {"line": line_num, "col": col, "msg": msg} +def _err(line_num: int, col: str, msg: str, not_found_reason: Optional[str] = None) -> Dict[str, Any]: + out: Dict[str, Any] = {"line": line_num, "col": col, "msg": msg} + if not_found_reason: + out["not_found_reason"] = not_found_reason + return out # --- Obligatorios VALIDA_TODA (cuando no es actualizar) --- @@ -170,10 +217,12 @@ def _validaciones_obligatorios_toda( if tiene_pedimento and not _get(row, "ADUANA DE CRUCE"): obligatorios.append("(Col.AB) Aduana de Cruce") if obligatorios: + msg = f"Existen campos vacíos que son obligatorios: {', '.join(obligatorios)}." + logger.warning(f"Line {line_num} rejected: {msg}") return _err( line_num, "ARCHIVO CSV", - f"Existen campos vacíos que son obligatorios: {', '.join(obligatorios)}.", + msg, ) return None @@ -218,7 +267,7 @@ def _validaciones_pedimento_remesa( return _err( line_num, "PEDIMENTO", - f"Error: (Celda A{line_num}) El Formato del Pedimento: {col_a} es incorrecto. Use CC-LLLL-NNNNNNN (ej. 01-1234-2312412, 18 caracteres sin año).", + f"Error: (Celda A{line_num}) El Formato del Pedimento: {col_a} es incorrecto. Use CC-LLLL-NNNNNNN (aduana 2–3 dígitos, patente 4, número 1–7 dígitos, sin año).", ) customs_office, license_val, pedimento_number = parsed @@ -232,58 +281,84 @@ def _validaciones_pedimento_remesa( pedimento_rows=pedimento_rows, ) if ped_resolved["status"] == "not_found": - return _err( - line_num, - "PEDIMENTO", - f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} no existe en el Catálogo de Pedimentos. " - f"Verifique que esté dado de alta (formato CC-LLLL-NNNNNNN: aduana 2-3, patente 4, número 7, sin año) para esta empresa.", - ) - if ped_resolved["status"] == "ambiguous": + if ped_resolved.get("not_found_reason") == "customs_mismatch": + return _err( + line_num, + "PEDIMENTO", + f"Error: (Celda A{line_num}) El pedimento {col_a} está en el catálogo pero la aduana no coincide con la registrada. " + "Revise columna A (aduana en el pedimento) y Col.AB Aduana de cruce frente al alta del pedimento.", + not_found_reason="customs_mismatch", + ) + if ped_resolved.get("not_found_reason") == "license_or_patente_mismatch": + return _err( + line_num, + "PEDIMENTO", + f"Error: (Celda A{line_num}) El número de pedimento aparece en el catálogo pero la patente/agente no coincide " + f"con el alta (clave {col_a}). Revise columna A y columna J (agente aduanal).", + not_found_reason="license_or_patente_mismatch", + ) + # no_catalog_match: paridad captura manual — pedimento aún no dado de alta; se permite continuar + # si el régimen en columna F es ITE/ITR (validación local sin catálogo). + if not col_f or col_f.upper() not in REGIMENES_VALIDOS: + return _err( + line_num, + "REGIMEN", + f"Error: (Celda F{line_num}) Cuando el pedimento no está en el catálogo, el Régimen (ITE o ITR) es obligatorio " + "para poder importar la factura como pendiente de asignar pedimento.", + not_found_reason="no_catalog_match", + ) + ped_info = None + elif ped_resolved["status"] == "ambiguous": return _err( line_num, "PEDIMENTO", f"Error: (Celda A{line_num}) El Número de Pedimento: {col_a} coincide con múltiples registros. " "Valide aduana/patente para identificar un único pedimento.", ) + else: + ped_info = ped_resolved["pedimento"] - ped_info = ped_resolved["pedimento"] - if (ped_info.get("operation_type") or "").upper() != "IMP": - return _err( - line_num, - "PEDIMENTO", - f"Error: (Celda A{line_num}) Este Número de Pedimento: {col_a} no está marcado como Importación Temporal.", - ) - regimen_ped = (ped_info.get("regime") or "").strip().upper() - if regimen_ped not in REGIMENES_VALIDOS: - return _err( - line_num, - "PEDIMENTO", - f"Error: (Celda A{line_num}) El Pedimento: {col_a} tiene el Régimen {regimen_ped}, no válido (ITE o ITR).", - ) - if col_f and col_f not in REGIMENES_VALIDOS: - pass - elif col_f and col_f != regimen_ped: - return _err( - line_num, - "REGIMEN", - f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} no coincide con el del Pedimento: {regimen_ped}.", - ) - - pedimento_type = (ped_info.get("pedimento_type") or "").strip().lower() - if pedimento_type == "consolidated" and invoice_date_parsed and ped_info.get("entry_date") and ped_info.get("end_date"): - entry = ped_info["entry_date"] - end = ped_info["end_date"] - if hasattr(entry, "date"): - entry = entry.date() - if hasattr(end, "date"): - end = end.date() - inv_d = invoice_date_parsed.date() if hasattr(invoice_date_parsed, "date") else invoice_date_parsed - if inv_d < entry or inv_d > end: + if ped_info is not None: + if (ped_info.get("operation_type") or "").upper() != "IMP": return _err( line_num, - "FECHA FACTURA", - f"Error: (Celda D{line_num} y A{line_num}) La Fecha de la Factura no corresponde al rango de fechas del Pedimento {col_a}.", + "PEDIMENTO", + f"Error: (Celda A{line_num}) Este Número de Pedimento: {col_a} no está marcado como Importación Temporal.", ) + regimen_ped = (ped_info.get("regime") or "").strip().upper() + if regimen_ped not in REGIMENES_VALIDOS: + msg = f"Error: (Celda A{line_num}) El Pedimento: {col_a} tiene el Régimen {regimen_ped}, no válido (ITE o ITR)." + logger.warning(f"Line {line_num} rejected: {msg}") + return _err( + line_num, + "PEDIMENTO", + msg, + ) + if col_f and col_f not in REGIMENES_VALIDOS: + pass + elif col_f and col_f != regimen_ped: + # Relax: allow ITE/ITR interchange for temporal imports + if col_f in REGIMENES_VALIDOS and regimen_ped in REGIMENES_VALIDOS: + pass + else: + return _err( + line_num, + "REGIMEN", + f"Error: (Celda F{line_num}) El Régimen Aduanero: {col_f} no coincide con el del Pedimento: {regimen_ped}.", + ) + + pedimento_type = (ped_info.get("pedimento_type") or "").strip().lower() + err_consolidated = validate_consolidated_invoice_date_csv( + line_num=line_num, + pedimento_key_display=col_a, + pedimento_type=pedimento_type, + invoice_date_parsed=invoice_date_parsed, + entry_raw=ped_info.get("entry_date"), + end_raw=ped_info.get("end_date"), + start_raw=ped_info.get("start_date"), + ) + if err_consolidated: + return err_consolidated if not autonumerar_remesas and not col_b: return _err( @@ -510,7 +585,7 @@ def _validaciones_catalogos( return _err(line_num, "CLAVE INCOTERM", f"Error: (Celda V{line_num}) La Clave de INCOTERM: {v} no existe en el Catálogo.") ab = _get(row, "ADUANA DE CRUCE") - if ab and valid_aduana_codes and ab not in valid_aduana_codes: + if ab and valid_aduana_codes and not aduana_cruce_matches_catalog(ab, valid_aduana_codes): return _err(line_num, "ADUANA DE CRUCE", f"Error: (Celda AB{line_num}) La Aduana de Cruce: {ab} no existe en el Catálogo.") return None diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/pedimento_resolution.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/pedimento_resolution.py index 11faa6c5..7e135899 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/pedimento_resolution.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/pedimento_resolution.py @@ -1,8 +1,118 @@ -from typing import Any, Dict, List, Optional, Set +from typing import Any, Dict, List, Optional, Set, Tuple +import logging + +logger = logging.getLogger(__name__) + + +def scalar_trim(val: Any) -> str: + """ORM/csv pueden devolver tipos no-str; el matching debe ser estable.""" + if val is None: + return "" + return str(val).strip() + + +def normalize_license_for_match(s: Any) -> str: + """ + Patente aduanal: 4 dígitos. Alinea CSV vs BD (ej. 428 → 0428; espacios). + Si no es numérico o está vacío, retorna el valor strip (compatibilidad). + """ + t = scalar_trim(s) + if not t: + return "" + if t.isdigit() and len(t) <= 4: + return t.zfill(4) + return t + + +def normalize_pedimento_number_str(s: Any) -> str: + """ + Número de pedimento a 7 dígitos para comparar CSV vs BD. + + - CSV (parse_pedimento_col_a) ya entrega segmentos numéricos ≤7 dígitos. + - BD puede traer espacios, basura no numérica, o datos legacy con más de 7 dígitos + (p. ej. clave larga pegada en la columna); se extraen dígitos y, si hay más de 7, + se usan los últimos 7 (el número oficial que coincide con lo capturado en facturas). + """ + t = scalar_trim(s) + if not t: + return "" + digits = "".join(ch for ch in t if ch.isdigit()) + if not digits: + return "" + if len(digits) <= 7: + return digits.zfill(7) + return digits[-7:] + + +def _row_number_looks_like_csv_number(row_number: Any, csv_num_norm: str) -> bool: + """ + Heurística defensiva para catálogos legacy: + además del match canónico por últimos 7 dígitos, considera match + si los dígitos crudos contienen el número CSV normalizado. + """ + if not csv_num_norm: + return False + row_norm = normalize_pedimento_number_str(row_number) + if row_norm == csv_num_norm: + return True + raw_digits = "".join(ch for ch in scalar_trim(row_number) if ch.isdigit()) + return bool(raw_digits and csv_num_norm in raw_digits) + + +def normalize_catalog_pedimento_identity( + customs_office: Any, + license_val: Any, + pedimento_number: Any, +) -> Optional[Tuple[str, str, str]]: + """ + Normaliza aduana, patente y número tal como los usa resolve_pedimento_candidates + al cargar filas del catálogo en memoria. + + La patente puede venir vacía en BD (alta incompleta); se usa "" y el resolver + no exige coincidencia de patente CSV vs catálogo en ese caso. + El número de pedimento es obligatorio (sin número no hay fila válida). + """ + co = scalar_trim(customs_office) + lic_raw = scalar_trim(license_val) + num_raw = scalar_trim(pedimento_number) + if not num_raw: + return None + if lic_raw: + lic = normalize_license_for_match(lic_raw) or lic_raw + else: + lic = "" + num = normalize_pedimento_number_str(num_raw) or num_raw + return co, lic, num + + +def canonical_customs_office_for_pedimento_key(customs_office: Any) -> str: + """ + Aduana canónica de 2 dígitos para claves y matching (007 vs 07, 640 vs 64). + Tres dígitos con ceros a la izquierda tipo 007 → últimos dos (07); otros tres dígitos → primeros dos (64). + """ + co = scalar_trim(customs_office) + if not co: + return "" + if len(co) == 3 and co.isdigit(): + if co[0] == "0": + return co[1:3] + return co[:2] + if len(co) == 1 and co.isdigit(): + return co.zfill(2) + return co[:2] if len(co) >= 2 else co + + +def customs_offices_equivalent(a: str, b: str) -> bool: + """True si dos códigos de aduana representan la misma sección para efectos de pedimento.""" + ca = canonical_customs_office_for_pedimento_key(a) + cb = canonical_customs_office_for_pedimento_key(b) + if ca and cb: + return ca == cb + return _same_customs_office(a or "", b or "") def _co_variants(customs_office: str) -> Set[str]: - co = (customs_office or "").strip() + co = scalar_trim(customs_office) if not co: return set() out: Set[str] = {co} @@ -24,6 +134,23 @@ def _same_customs_office(csv_customs: str, candidate_customs: str) -> bool: return bool(csv_variants.intersection(cand_variants)) +def aduana_cruce_matches_catalog(ab: str, valid_codes: Set[str]) -> bool: + """ + Valida Col.AB contra public.customs_sections: mismo criterio que pedimento/aduana + (p. ej. CSV `070` vs catálogo `07`). + Si no hay catálogo cargado o la celda viene vacía, no rechaza. + """ + if not scalar_trim(ab) or not valid_codes: + return True + a = scalar_trim(ab) + if a in valid_codes: + return True + for vc in valid_codes: + if _same_customs_office(a, scalar_trim(vc)): + return True + return False + + def resolve_pedimento_candidates( customs_office: str, license_val: str, @@ -39,38 +166,113 @@ def resolve_pedimento_candidates( seen_ids: Set[Any] = set() lookup_licenses: List[str] = [] - lic = (license_val or "").strip() + lic = scalar_trim(license_val) if lic: lookup_licenses.append(lic) - patente = (patente_lookup or "").strip() + patente = scalar_trim(patente_lookup) if patente and patente not in lookup_licenses: lookup_licenses.append(patente) + lookup_license_raw: Set[str] = set() + lookup_license_norm: Set[str] = set() + for x in lookup_licenses: + if not x: + continue + rx = x.strip() + lookup_license_raw.add(rx) + nx = normalize_license_for_match(rx) + if nx: + lookup_license_norm.add(nx) + + csv_num_norm = normalize_pedimento_number_str(pedimento_number) + + logger.debug(f"Resolving pedimento: customs={customs_office}, license={license_val}, num={pedimento_number} (norm={csv_num_norm}), lookup_licenses={lookup_license_norm}") + + total_catalog_rows = len(pedimento_rows or []) + num_matches = 0 + for info in (pedimento_rows or []): - row_num = (info.get("pedimento_number") or "").strip() - row_lic = (info.get("license") or "").strip() - if row_num != (pedimento_number or "").strip(): - continue - if lookup_licenses and row_lic not in lookup_licenses: + row_num_raw = scalar_trim(info.get("pedimento_number")) + if not _row_number_looks_like_csv_number(row_num_raw, csv_num_norm): continue + + num_matches += 1 + row_lic = scalar_trim(info.get("license")) + if lookup_license_raw or lookup_license_norm: + # Alta sin patente en catálogo: no excluir por patente del CSV (dato incompleto). + if row_lic: + row_ln = normalize_license_for_match(row_lic) + if row_lic not in lookup_license_raw and row_ln not in lookup_license_norm: + continue pid = info.get("id") if pid not in seen_ids: candidates.append(info) seen_ids.add(pid) if not candidates: - return {"status": "not_found", "pedimento": None, "candidates": []} + # Diagnostic: find why it failed + num_only_matches = [] + for info in (pedimento_rows or []): + rn_raw = scalar_trim(info.get("pedimento_number")) + if _row_number_looks_like_csv_number(rn_raw, csv_num_norm): + num_only_matches.append(info) + + # DEBUG: one line per CSV row on large jobs would flood logs; enable when diagnosing catalog mismatches. + logger.debug( + "Pedimento catalog search results: found 0 candidates for %s-%s-%s " + "(csv_num_norm: %s) among %s rows. Matches by number only: %s", + customs_office, + license_val, + pedimento_number, + csv_num_norm, + total_catalog_rows, + [{"co": c.get("customs_office"), "lic": c.get("license"), "id": c.get("id")} for c in num_only_matches], + ) + if num_only_matches: + return { + "status": "not_found", + "pedimento": None, + "candidates": [], + "not_found_reason": "license_or_patente_mismatch", + } + return { + "status": "not_found", + "pedimento": None, + "candidates": [], + "not_found_reason": "no_catalog_match", + } customs_filtered = [ c for c in candidates - if _same_customs_office(customs_office, (c.get("customs_office") or "")) + if _same_customs_office(customs_office, scalar_trim(c.get("customs_office"))) ] if len(customs_filtered) == 1: - return {"status": "ok", "pedimento": customs_filtered[0], "candidates": customs_filtered} + res = customs_filtered[0] + logger.debug( + "Pedimento found: ID %s for %s-%s-%s", + res.get("id"), + customs_office, + license_val, + pedimento_number, + ) + return {"status": "ok", "pedimento": res, "candidates": customs_filtered} if len(customs_filtered) > 1: + logger.warning(f"Ambiguous pedimento (multiple customs match): {len(customs_filtered)} candidates for {customs_office}-{license_val}-{pedimento_number}") return {"status": "ambiguous", "pedimento": None, "candidates": customs_filtered} if len(candidates) == 1: - return {"status": "ok", "pedimento": candidates[0], "candidates": candidates} + solo = candidates[0] + cand_co = scalar_trim(solo.get("customs_office")) + if not cand_co: + return {"status": "ok", "pedimento": solo, "candidates": candidates} + + logger.warning(f"Pedimento customs mismatch: expected {customs_office}, found {cand_co}") + return { + "status": "not_found", + "pedimento": None, + "candidates": [], + "not_found_reason": "customs_mismatch", + } + logger.warning(f"Ambiguous pedimento (no customs match but multiple candidates): {len(candidates)} candidates") return {"status": "ambiguous", "pedimento": None, "candidates": candidates} diff --git a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py index e291ef5c..93372d6a 100644 --- a/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py +++ b/backend/api/v1/modules/a76/pedmientos/services/pedimentos.py @@ -276,6 +276,8 @@ class PedimentosService: Returns: Created pedimento """ + if company_id is None: + raise ValueError("company_id es obligatorio para crear pedimentos.") try: # Check for existing pedimento with same key (Year, Aduana, Patente, Number) # This avoids IntegrityError in many cases and provides a better error message. @@ -483,6 +485,15 @@ class PedimentosService: # Ensure company_id is set from the existing record company_id = pedimento.company_id + if company_id is None: + logger.error( + "Pedimento %s without company_id detected during update tenant_id=%s", + pedimento_id, + tenant_id, + ) + raise ValueError( + "El pedimento no tiene company_id asignado. Corrige el dato antes de actualizar." + ) try: # Actualizar campos principales del pedimento diff --git a/backend/api/v1/modules/core/tasks_tracking/dispatch.py b/backend/api/v1/modules/core/tasks_tracking/dispatch.py index ac25b8e7..20d1acec 100644 --- a/backend/api/v1/modules/core/tasks_tracking/dispatch.py +++ b/backend/api/v1/modules/core/tasks_tracking/dispatch.py @@ -1,4 +1,5 @@ from typing import Any +import logging from celery import Task from sqlalchemy.orm import Session @@ -7,6 +8,8 @@ from core.database import rls_company_var, rls_tenant_var from .service import TaskTrackerService +logger = logging.getLogger(__name__) + def track_and_dispatch( *, @@ -28,12 +31,26 @@ def track_and_dispatch( headers = {"rls_tenant_id": str(int(tenant_id))} if company_id is not None: headers["rls_company_id"] = str(int(company_id)) + else: + logger.warning( + "Dispatching task without company_id in RLS headers task=%s tenant_id=%s", + getattr(task, "name", ""), + tenant_id, + ) prev_tenant = rls_tenant_var.get() prev_company = rls_company_var.get() rls_tenant_var.set(int(tenant_id)) rls_company_var.set(int(company_id) if company_id is not None else None) try: + logger.info( + "Dispatching Celery task task=%s task_id=%s tenant_id=%s company_id=%s headers=%s", + getattr(task, "name", ""), + task_id, + tenant_id, + company_id, + headers, + ) celery_task = task.apply_async( args=args or [], kwargs=kwargs or {}, diff --git a/backend/api/v1/modules/public/reference_data/incoterms/seed.py b/backend/api/v1/modules/public/reference_data/incoterms/seed.py index 0b9f87ce..a9974e13 100644 --- a/backend/api/v1/modules/public/reference_data/incoterms/seed.py +++ b/backend/api/v1/modules/public/reference_data/incoterms/seed.py @@ -5,6 +5,7 @@ seed = [ ("CIP", "TRANSPORTE Y SEGUROS PAGADOS", "CARRIAGE AND INSURANCE PAID TO"), ("DPU", "ENTREGA EN LUGAR DESCARGADO", "DELIVERED AT PLACE UNLOADED"), ("DAP", "ENTREGA EN LUGAR", "DELIVERED AT PLACE"), + ("DAF", "ENTREGADO EN FRONTERA", "DELIVERED AT FRONTIER"), ("DDP", "ENTREGA DERECHOS PAGADOS", "DELIVERED DUTY PAID"), ("FAS", "PUERTO DE EMBARQUE CONVENIDO", "FREE ALONGSIDE SHIP"), ("FOB", "PUERTO DE EMBARQUE CONVENIDO", "FREE ON BOARD"), diff --git a/backend/core/celery_app.py b/backend/core/celery_app.py index 24c21b6d..1028e4ef 100644 --- a/backend/core/celery_app.py +++ b/backend/core/celery_app.py @@ -1,12 +1,21 @@ import os +import logging from celery import Celery from celery.signals import task_postrun, task_prerun from core.database import reset_rls_context_tokens, rls_company_var, rls_tenant_var +from core.config import settings +logger = logging.getLogger(__name__) valkey_url = os.getenv("VALKEY_URL", "redis://valkey:6379/0") print(f"DEBUG: Celery Broker URL: {valkey_url}") +logger.info( + "Initializing Celery app app_version=%s environment=%s broker=%s", + settings.APP_VERSION, + settings.ENVIRONMENT, + valkey_url, +) # Configurar broker y backend explícitamente en el constructor celery_app = Celery( @@ -46,6 +55,20 @@ def _set_rls_context_from_task(task_id=None, task=None, args=None, kwargs=None, tenant_id = _coerce_int(headers.get("rls_tenant_id")) company_id = _coerce_int(headers.get("rls_company_id")) + if tenant_id is None: + logger.warning( + "Celery task_prerun missing rls_tenant_id task=%s task_id=%s headers=%s", + getattr(task, "name", ""), + task_id, + headers, + ) + logger.info( + "Celery task_prerun RLS context task=%s task_id=%s tenant_id=%s company_id=%s", + getattr(task, "name", ""), + task_id, + tenant_id, + company_id, + ) token_t = rls_tenant_var.set(tenant_id) token_c = rls_company_var.set(company_id) @@ -60,6 +83,13 @@ def _reset_rls_context_from_task(task_id=None, task=None, **_): if tokens is None: return token_t, token_c = tokens + logger.info( + "Celery task_postrun clearing RLS context task=%s task_id=%s tenant_id=%s company_id=%s", + getattr(task, "name", ""), + task_id, + rls_tenant_var.get(), + rls_company_var.get(), + ) reset_rls_context_tokens(token_t, token_c) delattr(task, _RLS_TOKENS_ATTR) diff --git a/backend/core/config.py b/backend/core/config.py index 68755842..f436a36b 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -53,6 +53,9 @@ class Settings(BaseSettings): def strip_quotes(cls, v: str) -> str: if v and isinstance(v, str): v = v.strip().strip('"').strip("'") + # Evitar que solo espacios en .env se conviertan en "/" (rompe httpx: falta protocolo). + if not v: + return "" if not v.endswith("/"): v += "/" return v diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index 85a364f4..295bc5b7 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -117,13 +117,13 @@ } async function handleDownloadScanErrorsCsv() { - const errors = scanResults?.errors; - if (!Array.isArray(errors) || errors.length === 0) return; - const jobId = scanResults?.job_id; + const errorCount = Number(scanResults?.error_count) || 0; + const errors = scanResults?.errors; - // Try backend download (sin límite). If it fails, fallback to preview CSV. - if (jobId) { + // Descarga completa desde el backend (JSONL → CSV) siempre que el scan reportó errores y hay job_id. + // No depende del preview acotado en la respuesta JSON. + if (jobId && errorCount > 0) { try { let blob: Blob; switch (scanErrorsDownloadType) { @@ -178,17 +178,28 @@ document.body.removeChild(a); URL.revokeObjectURL(url); return; - } catch (e) { - // fallback below + } catch { + // Si falla la API, intentar CSV desde preview si existe. } } + if (!Array.isArray(errors) || errors.length === 0) return; + const filename = `errores_${Date.now()}.csv`; + const rows = errors.map((e: Record) => ({ + line: e.line, + col: e.col, + msg: e.msg, + solution: e.solution, + advertencia: e.warning ? 'si' : 'no', + codigo: e.code ?? '', + not_found_reason: e.not_found_reason ?? '' + })); downloadCsv( filename, - ['linea', 'columna', 'mensaje', 'solucion'], - errors, - ['line', 'col', 'msg', 'solution'] + ['linea', 'columna', 'mensaje', 'solucion', 'advertencia', 'codigo', 'not_found_reason'], + rows, + ['line', 'col', 'msg', 'solution', 'advertencia', 'codigo', 'not_found_reason'] ); } @@ -334,18 +345,19 @@

- {#if scanResults.errors && scanResults.errors.length > 0}
{csvMsg('modal.errors_heading')}
- - {csvFmt('modal.errors_badge', { shown: scanErrorsShown, total: scanErrorsTotal })} - + {#if scanResults.errors && scanResults.errors.length > 0} + + {csvFmt('modal.errors_badge', { shown: scanErrorsShown, total: scanErrorsTotal })} + + {/if}
-
- - - - - - - - - - - {#each scanResults.errors as err} - - - - - + {#if scanResults.errors && scanResults.errors.length > 0} +
+
{csvMsg('modal.th_line')}{csvMsg('modal.th_column')}{csvMsg('modal.th_message')}{csvMsg('modal.th_solution')}
{err.line}{err.col || '-'}{err.msg || '-'}{err.solution || '-'}
+ + + + + + - {/each} - -
{csvMsg('modal.th_line')}{csvMsg('modal.th_column')}{csvMsg('modal.th_message')}{csvMsg('modal.th_solution')}
-
- {#if scanErrorsTruncated} -

- {csvMsg('modal.errors_truncated')} + + + {#each scanResults.errors as err} + + {err.line} + {err.col || '-'} + {err.msg || '-'} + {err.solution || '-'} + + {/each} + + +

+ {#if scanErrorsTruncated} +

+ {csvMsg('modal.errors_truncated')} +

+ {/if} + {:else} +

+ {csvFmt('modal.errors_missing_detail', { count: scanResults.error_count })}

{/if} - {:else} -

- {csvFmt('modal.errors_missing_detail', { count: scanResults.error_count })} -

- {/if} {:else}