From ee9ac4f64f62233d4513138bad8645bc39e021df Mon Sep 17 00:00:00 2001
From: hreyes
Date: Tue, 12 May 2026 09:00:32 -0600
Subject: [PATCH 1/5] 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}
From 831da7b31cd0ae95135699ebe5d6438d744ed97a Mon Sep 17 00:00:00 2001
From: Galindo97
Date: Tue, 12 May 2026 10:04:35 -0500
Subject: [PATCH 2/5] refactor: remove unused base_url parameter from
create_invite and update fallback URL logic
---
backend/api/v1/modules/core/invites/routes.py | 3 ---
backend/api/v1/modules/core/invites/service.py | 6 +++---
backend/core/middleware.py | 2 +-
3 files changed, 4 insertions(+), 7 deletions(-)
diff --git a/backend/api/v1/modules/core/invites/routes.py b/backend/api/v1/modules/core/invites/routes.py
index 15912707..9a9225fd 100644
--- a/backend/api/v1/modules/core/invites/routes.py
+++ b/backend/api/v1/modules/core/invites/routes.py
@@ -4,7 +4,6 @@ import logging
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
-from core.config import settings
from fastapi import APIRouter, Depends, Query, Request
from sqlalchemy.orm import Session
@@ -37,12 +36,10 @@ async def create_invite(
tenant_slug: str = current_user.get("tenant_slug") or ""
created_by: str = current_user.get("sub") or ""
- base_url = settings.APP_PUBLIC_URL.rstrip("/")
service = InviteService(db)
return await service.create_invite(
data=data,
created_by=created_by,
tenant_slug=tenant_slug,
- base_url=base_url,
)
diff --git a/backend/api/v1/modules/core/invites/service.py b/backend/api/v1/modules/core/invites/service.py
index 4170a31a..447ad72d 100644
--- a/backend/api/v1/modules/core/invites/service.py
+++ b/backend/api/v1/modules/core/invites/service.py
@@ -45,7 +45,6 @@ class InviteService:
data: CreateInviteDTO,
created_by: str,
tenant_slug: str,
- base_url: str,
) -> InviteResponseDTO:
import httpx
from api.v1.modules.core.tenants.models import Tenant
@@ -99,10 +98,11 @@ class InviteService:
except Exception as exc:
logger.warning("Hub invite creation failed (non-blocking): %s", exc)
- # Fallback: URL local si el Hub falló
+ # Fallback: URL del workspace (Hub) si la creación de invitación en Hub falló
if not invite_url:
+ hub_base = settings.HUB_URL.rstrip("/")
invite_url = (
- f"{base_url}/register"
+ f"{hub_base}/register"
f"?invite_token={token_plain}"
f"&tenant={tenant_slug}"
f"&email={data.email}"
diff --git a/backend/core/middleware.py b/backend/core/middleware.py
index 1bb5836d..8c827fc2 100644
--- a/backend/core/middleware.py
+++ b/backend/core/middleware.py
@@ -34,7 +34,7 @@ def _is_token_issue_message(*values: str | None) -> bool:
has_invalid_marker = any(marker in text for marker in invalid_markers)
has_expired_marker = any(marker in text for marker in expired_markers)
- return has_expired_marker or (has_token_context and has_invalid_marker)
+ return (has_expired_marker and has_token_context) or (has_token_context and has_invalid_marker)
def _extract_company_id(request: Request) -> Optional[int]:
From e3cbb738e0ae8a075a343c0efb34aa72063d0bdd Mon Sep 17 00:00:00 2001
From: hreyes
Date: Tue, 12 May 2026 09:22:30 -0600
Subject: [PATCH 3/5] feature/csv-correccion-clientes
---
.../a76/clients_and_providers/routes.py | 9 +-
.../common/common_validators.py | 6 +
.../clients_and_providers/routes.py | 5 +
.../clients_and_providers/tasks.py | 182 ++++++------------
.../validators/common.py | 21 +-
frontend/src/lib/api.ts | 14 +-
.../api/dashboard/a76/clients-providers.ts | 14 +-
.../routes/dashboard/csv-upload/+page.svelte | 3 +
8 files changed, 105 insertions(+), 149 deletions(-)
diff --git a/backend/api/v1/modules/a76/clients_and_providers/routes.py b/backend/api/v1/modules/a76/clients_and_providers/routes.py
index 7176d9c0..f595e6dc 100644
--- a/backend/api/v1/modules/a76/clients_and_providers/routes.py
+++ b/backend/api/v1/modules/a76/clients_and_providers/routes.py
@@ -7,7 +7,7 @@ from typing import List, Optional
from core.database import get_core_db
from core.security import get_current_user, validate_access_to_resource
from fastapi import APIRouter, Depends, HTTPException, Query
-from sqlalchemy import or_
+from sqlalchemy import func, or_
from sqlalchemy.orm import Session, joinedload
from api.v1.common.tenant_crud_routes import TenantCRUDRoutes
from .models import ClientOrProviderEnum
@@ -35,6 +35,9 @@ async def get_clients_and_providers(
company_id: int = Query(..., description="Company ID"),
name: Optional[str] = Query(None, description="Filter by name (contains)"),
rfc: Optional[str] = Query(None, description="Filter by RFC/TAX-ID (contains)"),
+ short_name: Optional[str] = Query(
+ None, description="Filter by short name / clave (exact match, case-insensitive)"
+ ),
type: Optional[ClientOrProviderEnum] = Query(
None, description="Type of entity (client or provider)"
),
@@ -68,6 +71,10 @@ async def get_clients_and_providers(
if rfc:
query = query.filter(ClientProvider.rfc.ilike(f"%{rfc.strip()}%"))
+ if short_name:
+ sn = short_name.strip().upper()
+ query = query.filter(func.upper(ClientProvider.short_name) == sn)
+
if type is not None:
# Include 'both' type when filtering by client or provider
query = query.filter(
diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py
index 38a1415d..aff20495 100644
--- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py
+++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/common/common_validators.py
@@ -8,6 +8,12 @@ from typing import Dict, Any, Optional
from api.v1.modules.a76.clients_and_providers.models import ClientOrProviderEnum
+
+def short_name_import_key(raw: Optional[str]) -> str:
+ """Clave estable para upsert CSV y validación ACT (strip + upper)."""
+ return (raw or "").strip().upper()
+
+
RFC_MAX = 30
# Formato RFC México: 3-4 letras, 6 dígitos, 3 homoclave. TAX-ID: 2 dígitos, guión, resto. Ej: 12-3456789.
diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py
index 8cbf361a..2fbf06d1 100644
--- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py
+++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/routes.py
@@ -91,6 +91,10 @@ def _get_redis():
async def upload_import_file(
file: UploadFile = File(...),
company_id: int = Query(..., description="Company ID"),
+ actualizar: bool = Query(
+ False,
+ description="Modo Agregar/Actualizar (ACT); si True, validación parcial para claves existentes en catálogo",
+ ),
db: Session = Depends(get_core_db),
current_user: Dict[str, Any] = Depends(get_current_user),
):
@@ -114,6 +118,7 @@ async def upload_import_file(
"company_id": company_id,
"user_id": current_user.get("id"),
"template_id": "client_providers",
+ "actualizar": actualizar,
}
try:
diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py
index 411bcc42..09d39d0c 100644
--- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py
+++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/tasks.py
@@ -19,6 +19,7 @@ from ..common import csv_reader as common_csv_reader
from .template_config import row_from_template
from .validators import validate_row_client_provider
from .common.mappers import row_to_client_provider_data
+from .common.common_validators import short_name_import_key
logger = logging.getLogger(__name__)
@@ -64,8 +65,9 @@ def _do_scan(job_id: str, progress_callback: Optional[Any] = None) -> Dict[str,
)
.all()
):
- if (cp.short_name or "").strip():
- existing_short_names.add((cp.short_name or "").strip())
+ sn = short_name_import_key(cp.short_name)
+ if sn:
+ existing_short_names.add(sn)
except Exception as e:
logger.warning("CP import: could not load existing short_names for ACT: %s", e)
@@ -159,8 +161,9 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
)
.all()
):
- if (cp.short_name or "").strip():
- existing_short_names.add((cp.short_name or "").strip())
+ sn = short_name_import_key(cp.short_name)
+ if sn:
+ existing_short_names.add(sn)
except Exception as e:
logger.warning("CP commit: could not load existing short_names for ACT: %s", e)
@@ -178,7 +181,6 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
try:
with CoreSessionLocal() as session:
- existing_by_rfc: Dict[str, ClientProvider] = {}
existing_by_short_name: Dict[str, ClientProvider] = {}
for cp in (
session.query(ClientProvider)
@@ -188,10 +190,7 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
)
.all()
):
- rfc_key = (cp.rfc or "").strip()
- if rfc_key:
- existing_by_rfc[rfc_key] = cp
- sn_key = (cp.short_name or "").strip()
+ sn_key = short_name_import_key(cp.short_name)
if sn_key:
existing_by_short_name[sn_key] = cp
@@ -227,30 +226,22 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
}
)
continue
- if actualizar:
- short_name_key = (cp_data.get("short_name") or "").strip()
- if not short_name_key:
- skipped_invalid += 1
- skipped_details.append(
- {
- "line": i,
- "reason": "Clave (SHORT_NAME) requerida en modo Actualizar",
- "solution": "Capturar en la columna SHORT_NAME la clave requerida para modo Actualizar.",
- }
- )
- continue
- existing = existing_by_short_name.get(short_name_key)
- if not existing:
- skipped_invalid += 1
- skipped_details.append(
- {
- "line": i,
- "reason": "Clave no existe en catálogo",
- "solution": "Capturar en la columna SHORT_NAME una clave que exista en el catálogo.",
- }
- )
- continue
- # Merge: fill from existing when csv value is empty
+
+ sn_key = short_name_import_key(cp_data.get("short_name"))
+ if not sn_key:
+ skipped_invalid += 1
+ skipped_details.append(
+ {
+ "line": i,
+ "reason": "Clave (SHORT_NAME) requerida",
+ "solution": "Capturar en la columna SHORT_NAME la clave del cliente/proveedor.",
+ }
+ )
+ continue
+ cp_data["short_name"] = sn_key
+
+ existing = existing_by_short_name.get(sn_key)
+ if existing:
for k, v in cp_data.items():
if k in ("tenant_id", "company_id"):
continue
@@ -263,7 +254,6 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
setattr(existing, k, v)
session.add(existing)
updated_count += 1
- # Update address if present
if address_data and existing.address:
addr = existing.address
for k, v in address_data.items():
@@ -288,7 +278,6 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
fax_number=address_data.get("fax_number"),
)
session.add(addr)
- # Update or create programs
if programs_data:
prog = session.query(ClientProviderPrograms).filter(
ClientProviderPrograms.client_id == existing.id,
@@ -307,100 +296,37 @@ def _do_commit(job_id: str) -> Dict[str, Any]:
)
session.add(prog)
else:
- # Alta / Reemplazar: key por RFC
- if not cp_data.get("rfc"):
- skipped_invalid += 1
- skipped_details.append(
- {
- "line": i,
- "reason": "RFC requerido",
- "solution": "Capturar en la columna RFC el RFC requerido.",
- }
+ new_cp = ClientProvider(**cp_data)
+ session.add(new_cp)
+ session.flush()
+ existing_by_short_name[sn_key] = new_cp
+ inserted_count += 1
+ if address_data:
+ addr = ClientProviderAddress(
+ client_id=new_cp.id,
+ tenant_id=tenant_id,
+ company_id=company_id,
+ streets=address_data.get("streets"),
+ postal_code=address_data.get("postal_code"),
+ city=address_data.get("city"),
+ state=address_data.get("state"),
+ country=address_data.get("country"),
+ phone=address_data.get("phone"),
+ email=address_data.get("email"),
+ contact=address_data.get("contact"),
+ exterior_number=address_data.get("exterior_number"),
+ neighborhood=address_data.get("neighborhood"),
+ fax_number=address_data.get("fax_number"),
)
- continue
- rfc = cp_data["rfc"]
- existing = existing_by_rfc.get(rfc)
- if existing:
- for k, v in cp_data.items():
- if k not in ("tenant_id", "company_id", "rfc"):
- setattr(existing, k, v)
- session.add(existing)
- updated_count += 1
- if address_data and existing.address:
- addr = existing.address
- for k, v in address_data.items():
- if k not in ("tenant_id", "company_id") and v is not None:
- setattr(addr, k, v)
- session.add(addr)
- elif address_data:
- addr = ClientProviderAddress(
- client_id=existing.id,
- tenant_id=tenant_id,
- company_id=company_id,
- streets=address_data.get("streets"),
- postal_code=address_data.get("postal_code"),
- city=address_data.get("city"),
- state=address_data.get("state"),
- country=address_data.get("country"),
- phone=address_data.get("phone"),
- email=address_data.get("email"),
- contact=address_data.get("contact"),
- exterior_number=address_data.get("exterior_number"),
- neighborhood=address_data.get("neighborhood"),
- fax_number=address_data.get("fax_number"),
- )
- session.add(addr)
- if programs_data:
- prog = session.query(ClientProviderPrograms).filter(
- ClientProviderPrograms.client_id == existing.id,
- ).first()
- if prog:
- for k, v in programs_data.items():
- if v is not None:
- setattr(prog, k, v)
- session.add(prog)
- else:
- prog = ClientProviderPrograms(
- client_id=existing.id,
- tenant_id=tenant_id,
- company_id=company_id,
- **{k: v for k, v in programs_data.items() if v is not None},
- )
- session.add(prog)
- else:
- new_cp = ClientProvider(**cp_data)
- session.add(new_cp)
- session.flush()
- existing_by_rfc[rfc] = new_cp
- if (new_cp.short_name or "").strip():
- existing_by_short_name[(new_cp.short_name or "").strip()] = new_cp
- inserted_count += 1
- if address_data:
- addr = ClientProviderAddress(
- client_id=new_cp.id,
- tenant_id=tenant_id,
- company_id=company_id,
- streets=address_data.get("streets"),
- postal_code=address_data.get("postal_code"),
- city=address_data.get("city"),
- state=address_data.get("state"),
- country=address_data.get("country"),
- phone=address_data.get("phone"),
- email=address_data.get("email"),
- contact=address_data.get("contact"),
- exterior_number=address_data.get("exterior_number"),
- neighborhood=address_data.get("neighborhood"),
- fax_number=address_data.get("fax_number"),
- )
- session.add(addr)
- if programs_data:
- prog = ClientProviderPrograms(
- client_id=new_cp.id,
- tenant_id=tenant_id,
- company_id=company_id,
- **{k: v for k, v in programs_data.items() if v is not None},
- )
- session.add(prog)
+ session.add(addr)
+ if programs_data:
+ prog = ClientProviderPrograms(
+ client_id=new_cp.id,
+ tenant_id=tenant_id,
+ company_id=company_id,
+ **{k: v for k, v in programs_data.items() if v is not None},
+ )
+ session.add(prog)
try:
session.commit()
diff --git a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py
index dacf458a..fc17aa29 100644
--- a/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py
+++ b/backend/api/v1/modules/a76/layouts_csv/clients_and_providers/validators/common.py
@@ -24,6 +24,7 @@ from ..common.common_validators import (
check_es_empresa_certificada_registro,
check_transformador_submaq,
check_desfase,
+ short_name_import_key,
)
@@ -36,8 +37,6 @@ MSG_CLAVE_VACIA = (
MSG_CLAVE_VACIA_SOLUCION = (
"Capturar una Clave de Cliente/Proveedor nueva o existente a la cual desee agregar, remplazar o actualizar campos."
)
-MSG_CLAVE_NO_EXISTE = "Error: (Col.C) Clave de Proveedor/Cliente no existe."
-MSG_CLAVE_NO_EXISTE_SOLUCION = "La clave debe existir en el catálogo cuando el modo es Actualizar."
def validate_row_desfase(row: Dict[str, Any], line_num: int) -> Optional[Dict[str, Any]]:
@@ -91,7 +90,6 @@ def valida_toda_cliente_o_prov(
) -> List[Dict[str, Any]]:
"""
VALIDA_TODA: obligatorios Col A (Procedencia), Col D (Nombre) cuando no es ACT.
- Si modo ACT y clave no existe → error se devuelve antes (en validate_row_client_provider).
Luego ejecuta VALIDACIONES_CLIENTE_O_PROV.
"""
campos_oblig = []
@@ -129,9 +127,8 @@ def validate_row_client_provider(
Valida una fila de CSV de clientes y proveedores.
- Desfase (COL_EXTRA) primero.
- Clave vacía → error.
- - Si actualizar y clave no existe en existing_short_names → error "Clave no existe".
- - Si actualizar y clave existe → VALIDA_PARCIAL (solo validaciones comunes).
- - Si no actualizar o clave no existe → VALIDA_TODA (A y D obligatorios cuando aplique, luego comunes).
+ - Si actualizar y clave existe en catálogo → VALIDA_PARCIAL (solo validaciones comunes).
+ - Si no actualizar, o actualizar con clave nueva → VALIDA_TODA (A y D obligatorios cuando aplique, luego comunes).
Además se validan RFC (requerido max 30), longitudes NOMBRE/SHORT_NAME/CURP para compatibilidad.
"""
errors: List[Dict[str, Any]] = []
@@ -143,17 +140,9 @@ def validate_row_client_provider(
if err:
errors.append(err)
- short_name = (row.get("SHORT_NAME") or "").strip()
+ sn_key = short_name_import_key(row.get("SHORT_NAME"))
existing = existing_short_names or set()
- use_partial = actualizar and short_name in existing
-
- if actualizar and short_name and short_name not in existing:
- errors.append({
- "line": line_num,
- "col": "SHORT_NAME",
- "msg": MSG_CLAVE_NO_EXISTE,
- "solution": MSG_CLAVE_NO_EXISTE_SOLUCION,
- })
+ use_partial = actualizar and sn_key in existing
if use_partial:
errors.extend(valida_parcial_cliente_o_prov(row, line_num))
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index d34ecb93..aca125c8 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -845,13 +845,21 @@ export const api = {
// CSV import for Clientes y Proveedores (flujo propio en clients_and_providers/imports)
clientProviderImports: {
- upload: (file: File, companyId: number, uploadOptions?: CsvFormDataUploadOptions) => {
+ upload: (
+ file: File,
+ companyId: number,
+ options?: { actualizar?: boolean; onUploadProgress?: CsvFormDataUploadOptions['onUploadProgress'] }
+ ) => {
const formData = new FormData();
formData.append('file', file);
+ const params = new URLSearchParams({ company_id: String(companyId) });
+ if (options?.actualizar !== undefined) params.set('actualizar', String(options.actualizar));
+ const uploadOpts =
+ options?.onUploadProgress != null ? { onUploadProgress: options.onUploadProgress } : undefined;
return fetchApiFormDataPost(
- `/v1/a76/clients-providers/imports/upload?company_id=${companyId}`,
+ `/v1/a76/clients-providers/imports/upload?${params.toString()}`,
formData,
- uploadOptions
+ uploadOpts
);
},
status: (jobId: string) => api.get(`/v1/a76/clients-providers/imports/${jobId}/status`),
diff --git a/frontend/src/lib/api/dashboard/a76/clients-providers.ts b/frontend/src/lib/api/dashboard/a76/clients-providers.ts
index ea7c2026..d7ad1566 100644
--- a/frontend/src/lib/api/dashboard/a76/clients-providers.ts
+++ b/frontend/src/lib/api/dashboard/a76/clients-providers.ts
@@ -67,7 +67,19 @@ export interface CreateClientProviderData extends Omit {}
export const clientsProvidersApi = {
- list: (companyId: number, page = 1, pageSize = 50, filters?: Record) => {
+ list: (
+ companyId: number,
+ page = 1,
+ pageSize = 50,
+ filters?: Partial<{
+ name: string;
+ rfc: string;
+ /** Clave corta; el backend filtra por coincidencia exacta sin distinguir mayúsculas/minúsculas */
+ short_name: string;
+ type: string;
+ active: string | boolean;
+ }>
+ ) => {
const params = new URLSearchParams({
company_id: companyId.toString(),
page: page.toString(),
diff --git a/frontend/src/routes/dashboard/csv-upload/+page.svelte b/frontend/src/routes/dashboard/csv-upload/+page.svelte
index 7c05bf78..49fb57a8 100644
--- a/frontend/src/routes/dashboard/csv-upload/+page.svelte
+++ b/frontend/src/routes/dashboard/csv-upload/+page.svelte
@@ -649,7 +649,10 @@
if (useClientProviderImport) {
try {
+ const catalogosSettings = allSettings['catalogos'] || {};
+ const actualizar = catalogosSettings['mode'] === 'update';
const res = await api.clientProviderImports.upload(file, companyId, {
+ actualizar,
onUploadProgress: onCsvFileUploadProgress
});
if (res.data?.job_id) {
From 99a6daa1601260006bd60a4f42988c27037a448f Mon Sep 17 00:00:00 2001
From: hreyes
Date: Tue, 12 May 2026 09:31:37 -0600
Subject: [PATCH 4/5] fix/head-alembic
---
...3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py | 2 +-
.../alembic/versions/e4f5a6b7c8d9_add_incoterm_daf_legacy.py | 4 ++--
2 files changed, 3 insertions(+), 3 deletions(-)
diff --git a/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py b/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py
index 446e9e31..e9141079 100644
--- a/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py
+++ b/backend/alembic/versions/c3d4e5f6a7b_add_workspace_profile_fields_to_user_tenants.py
@@ -1,7 +1,7 @@
"""add workspace profile fields to user_tenants
Revision ID: c3d4e5f6a7b
-Revises: b2c3d4e5f6a7
+Revises: ca7d3c4e8b2a
Create Date: 2026-05-08 00:00:00.000000
"""
diff --git a/backend/alembic/versions/e4f5a6b7c8d9_add_incoterm_daf_legacy.py b/backend/alembic/versions/e4f5a6b7c8d9_add_incoterm_daf_legacy.py
index b0c99c61..cde28814 100644
--- a/backend/alembic/versions/e4f5a6b7c8d9_add_incoterm_daf_legacy.py
+++ b/backend/alembic/versions/e4f5a6b7c8d9_add_incoterm_daf_legacy.py
@@ -1,7 +1,7 @@
"""add incoterm DAF legacy catalog row
Revision ID: e4f5a6b7c8d9
-Revises: ca7d3c4e8b2a
+Revises: c3d4e5f6a7b
Create Date: 2026-05-08
Incoterm histórico DAF (Delivered At Frontier) para CSV y referencias legacy.
@@ -11,7 +11,7 @@ from typing import Sequence, Union
from alembic import op
revision: str = "e4f5a6b7c8d9"
-down_revision: Union[str, None] = "ca7d3c4e8b2a"
+down_revision: Union[str, None] = "c3d4e5f6a7b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
From 51d29950c868eed747614d875aaf3b38f97a92f5 Mon Sep 17 00:00:00 2001
From: Galindo97
Date: Tue, 12 May 2026 10:35:52 -0500
Subject: [PATCH 5/5] fix: implement backfill for UserCompanyRole during user
login and invite flow
---
backend/api/v1/modules/core/auth/routes.py | 5 +
backend/api/v1/modules/core/auth/service.py | 190 +++++++++++++++++++-
2 files changed, 187 insertions(+), 8 deletions(-)
diff --git a/backend/api/v1/modules/core/auth/routes.py b/backend/api/v1/modules/core/auth/routes.py
index 65d35fa8..059d03dc 100644
--- a/backend/api/v1/modules/core/auth/routes.py
+++ b/backend/api/v1/modules/core/auth/routes.py
@@ -220,6 +220,11 @@ async def lazy_link(
)
except Exception:
pass
+ try:
+ claims = service._decode_kc_user_from_token(credentials.credentials)
+ service._backfill_company_roles(claims.get("sub", ""))
+ except Exception:
+ pass
return {"ok": True}
diff --git a/backend/api/v1/modules/core/auth/service.py b/backend/api/v1/modules/core/auth/service.py
index b64f2eca..6a1ea384 100644
--- a/backend/api/v1/modules/core/auth/service.py
+++ b/backend/api/v1/modules/core/auth/service.py
@@ -146,6 +146,16 @@ class AuthService:
except Exception as exc:
logger.warning("Lazy-link invite check failed (non-blocking): %s", exc)
+ # Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
+ try:
+ login_sub = data.get("sub") or data.get("user_id")
+ if not login_sub:
+ claims = self._decode_kc_user_from_token(data.get("access_token", ""))
+ login_sub = claims.get("sub")
+ self._backfill_company_roles(login_sub)
+ except Exception as exc:
+ logger.warning("backfill_company_roles failed on login (non-blocking): %s", exc)
+
# Sync de perfil/avatar desde Workspace usando el mismo bearer.
# No bloquea login si Workspace no responde.
access_token = data.get("access_token")
@@ -470,7 +480,7 @@ class AuthService:
logger.error("Hub admin create error during invite flow: %s", exc)
raise HTTPException(status_code=503, detail="Error al crear usuario en el sistema de autenticación")
- # 4. Crear fila UserTenant local
+ # 4. Crear fila UserTenant y UserCompanyRole local
if hub_user_id and invite_result.company_id:
try:
ut = UserTenant(
@@ -483,11 +493,47 @@ class AuthService:
last_name=register_data.last_name,
)
self.db.add(ut)
- self.db.commit()
+ self.db.flush()
except Exception as exc:
logger.warning("Could not create UserTenant (may already exist): %s", exc)
self.db.rollback()
+ # Asignar UserCompanyRole para que el usuario tenga permisos resueltos
+ try:
+ from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
+ company_role_obj = (
+ self.db.query(CompanyRole)
+ .filter(
+ CompanyRole.code == invite_result.role,
+ CompanyRole.company_id == invite_result.company_id,
+ CompanyRole.is_active == True,
+ )
+ .first()
+ )
+ if company_role_obj:
+ existing_ucr = (
+ self.db.query(UserCompanyRole)
+ .filter(
+ UserCompanyRole.user_id == hub_user_id,
+ UserCompanyRole.company_role_id == company_role_obj.id,
+ UserCompanyRole.company_id == invite_result.company_id,
+ )
+ .first()
+ )
+ if not existing_ucr:
+ ucr = UserCompanyRole(
+ user_id=hub_user_id,
+ company_role_id=company_role_obj.id,
+ company_id=invite_result.company_id,
+ tenant_id=tenant.id,
+ is_active=True,
+ )
+ self.db.add(ucr)
+ self.db.commit()
+ except Exception as exc:
+ logger.warning("Could not create UserCompanyRole for invited user: %s", exc)
+ self.db.rollback()
+
# 5. Consumir invite token
invite_service.consume_by_id(invite_result.invite_id)
@@ -509,7 +555,19 @@ class AuthService:
json=exchange_data.model_dump()
)
if response.status_code == 200:
- return TokenResponseDTO(**response.json())
+ data = response.json()
+ # Lazy-link: crear UserTenant si hay invite pendiente
+ try:
+ await self._link_pending_invite("", access_token=data.get("access_token", ""))
+ except Exception as exc:
+ logger.warning("exchange_code lazy-link failed (non-blocking): %s", exc)
+ # Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
+ try:
+ ec_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
+ self._backfill_company_roles(ec_claims.get("sub") or data.get("sub"))
+ except Exception as exc:
+ logger.warning("backfill_company_roles failed on exchange_code (non-blocking): %s", exc)
+ return TokenResponseDTO(**data)
raise HTTPException(status_code=response.status_code, detail="Code exchange failed")
except Exception as e:
logger.error(f"Exchange code error: {str(e)}")
@@ -545,6 +603,17 @@ class AuthService:
)
if response.status_code == 200:
data = response.json()
+ # Lazy-link: crear UserTenant si hay invite pendiente (usuario registrado vía workspace)
+ try:
+ await self._link_pending_invite("", access_token=data.get("access_token", ""))
+ except Exception as exc:
+ logger.warning("sso_exchange lazy-link failed (non-blocking): %s", exc)
+ # Backfill: crear UserCompanyRole faltantes para usuarios ya registrados
+ try:
+ sso_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
+ self._backfill_company_roles(sso_claims.get("sub") or data.get("sub"))
+ except Exception as exc:
+ logger.warning("backfill_company_roles failed on sso_exchange (non-blocking): %s", exc)
return TokenResponseDTO(
access_token=data["access_token"],
refresh_token=data["refresh_token"],
@@ -580,11 +649,10 @@ class AuthService:
hub_user_id = None
user_email = username_or_email
- # Si tenemos el access_token, extraer info del JWT directamente
+ # Si tenemos el access_token, extraer info del JWT directamente (sin red)
if access_token:
try:
- from core.security import verify_token
- claims = await verify_token(access_token)
+ claims = self._decode_kc_user_from_token(access_token)
hub_user_id = claims.get("sub")
user_email = claims.get("email") or username_or_email
except Exception as exc:
@@ -592,6 +660,8 @@ class AuthService:
# Sin access_token: buscar usuario en el Hub vía service account
if not hub_user_id:
+ if not user_email:
+ return # Sin email ni hub_user_id no podemos buscar el invite
try:
async with httpx.AsyncClient(timeout=10.0) as client:
login_resp = await client.post(
@@ -669,8 +739,7 @@ class AuthService:
is_active=True,
)
self.db.add(ut)
- pending.used_at = now
- self.db.commit()
+ self.db.flush()
logger.info(
"Lazy-link: UserTenant created for user=%s tenant=%s role=%s",
hub_user_id,
@@ -681,3 +750,108 @@ class AuthService:
logger.warning("_link_pending_invite: could not create UserTenant: %s", exc)
self.db.rollback()
+ # Asignar UserCompanyRole para que el usuario tenga permisos resueltos
+ if pending.company_id:
+ try:
+ from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
+ company_role_obj = (
+ self.db.query(CompanyRole)
+ .filter(
+ CompanyRole.code == pending.role,
+ CompanyRole.company_id == pending.company_id,
+ CompanyRole.is_active == True,
+ )
+ .first()
+ )
+ if company_role_obj:
+ existing_ucr = (
+ self.db.query(UserCompanyRole)
+ .filter(
+ UserCompanyRole.user_id == hub_user_id,
+ UserCompanyRole.company_role_id == company_role_obj.id,
+ UserCompanyRole.company_id == pending.company_id,
+ )
+ .first()
+ )
+ if not existing_ucr:
+ ucr = UserCompanyRole(
+ user_id=hub_user_id,
+ company_role_id=company_role_obj.id,
+ company_id=pending.company_id,
+ tenant_id=tenant.id,
+ is_active=True,
+ )
+ self.db.add(ucr)
+ except Exception as exc:
+ logger.warning("_link_pending_invite: could not create UserCompanyRole: %s", exc)
+
+ pending.used_at = now
+ self.db.commit()
+
+ def _backfill_company_roles(self, hub_user_id: str) -> None:
+ """
+ Self-healing: para usuarios ya registrados vía invitación que tienen UserTenant
+ pero no UserCompanyRole (creados antes del fix del flujo de invitación).
+ Por cada UserTenant activo con role y company_id busca el CompanyRole y crea
+ el UserCompanyRole si no existe. Non-blocking.
+ """
+ if not hub_user_id:
+ return
+ try:
+ from api.v1.modules.core.user_tenant.models import UserTenant
+ from api.v1.modules.core.permissions.models import CompanyRole, UserCompanyRole
+
+ user_tenants = (
+ self.db.query(UserTenant)
+ .filter(
+ UserTenant.keycloak_user_id == hub_user_id,
+ UserTenant.is_active == True,
+ UserTenant.company_id.isnot(None),
+ UserTenant.role.isnot(None),
+ )
+ .all()
+ )
+
+ changed = False
+ for ut in user_tenants:
+ company_role_obj = (
+ self.db.query(CompanyRole)
+ .filter(
+ CompanyRole.code == ut.role,
+ CompanyRole.company_id == ut.company_id,
+ CompanyRole.is_active == True,
+ )
+ .first()
+ )
+ if not company_role_obj:
+ continue
+
+ existing = (
+ self.db.query(UserCompanyRole)
+ .filter(
+ UserCompanyRole.user_id == hub_user_id,
+ UserCompanyRole.company_role_id == company_role_obj.id,
+ UserCompanyRole.company_id == ut.company_id,
+ )
+ .first()
+ )
+ if not existing:
+ self.db.add(UserCompanyRole(
+ user_id=hub_user_id,
+ company_role_id=company_role_obj.id,
+ company_id=ut.company_id,
+ tenant_id=ut.tenant_id,
+ is_active=True,
+ ))
+ changed = True
+ logger.info(
+ "backfill: UserCompanyRole created for user=%s company=%s role=%s",
+ hub_user_id, ut.company_id, ut.role,
+ )
+
+ if changed:
+ self.db.commit()
+ except Exception as exc:
+ logger.warning("_backfill_company_roles failed (non-blocking): %s", exc)
+ self.db.rollback()
+