From ee9ac4f64f62233d4513138bad8645bc39e021df Mon Sep 17 00:00:00 2001
From: hreyes
Date: Tue, 12 May 2026 09:00:32 -0600
Subject: [PATCH] feature/alembic-daf
---
.../e4f5a6b7c8d9_add_incoterm_daf_legacy.py | 30 +
.../a76/invoices/common/common_validators.py | 40 +-
.../a76/layouts_csv/common/error_csv.py | 21 +-
.../a76/layouts_csv/common/responses.py | 10 +-
.../a76/layouts_csv/facturas/routes.py | 93 +-
.../modules/a76/layouts_csv/facturas/tasks.py | 890 +++++++++---------
.../layouts_csv/facturas/template_config.py | 13 +-
.../facturas/validators/__init__.py | 2 +
.../validators/consolidated_invoice_dates.py | 107 +++
.../facturas/validators/encabezados_expo.py | 33 +-
.../validators/encabezados_impo_def.py | 63 +-
.../validators/encabezados_impo_temp.py | 221 +++--
.../validators/pedimento_resolution.py | 228 ++++-
.../a76/pedmientos/services/pedimentos.py | 11 +
.../modules/core/tasks_tracking/dispatch.py | 17 +
.../public/reference_data/incoterms/seed.py | 1 +
backend/core/celery_app.py | 30 +
backend/core/config.py | 3 +
.../csv-upload/ProcessingResultModal.svelte | 107 ++-
19 files changed, 1203 insertions(+), 717 deletions(-)
create mode 100644 backend/alembic/versions/e4f5a6b7c8d9_add_incoterm_daf_legacy.py
create mode 100644 backend/api/v1/modules/a76/layouts_csv/facturas/validators/consolidated_invoice_dates.py
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}
-
-
-
-
- | {csvMsg('modal.th_line')} |
- {csvMsg('modal.th_column')} |
- {csvMsg('modal.th_message')} |
- {csvMsg('modal.th_solution')} |
-
-
-
- {#each scanResults.errors as err}
-
- | {err.line} |
- {err.col || '-'} |
- {err.msg || '-'} |
- {err.solution || '-'} |
+ {#if scanResults.errors && scanResults.errors.length > 0}
+
+
+
+
+ | {csvMsg('modal.th_line')} |
+ {csvMsg('modal.th_column')} |
+ {csvMsg('modal.th_message')} |
+ {csvMsg('modal.th_solution')} |
- {/each}
-
-
-
- {#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}