feature/partidas-clarion-csv-validaciones-expo

This commit is contained in:
hreyes
2026-03-10 16:21:24 -06:00
parent 663d419ab8
commit 6d56448f9f
3 changed files with 231 additions and 45 deletions

View File

@@ -232,6 +232,13 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
inv_type_value = "TEM"
if model_target == "invoice_series" and inv_type_value in ("DEF", "MATDE", "EXDEF"):
template_id = "imp_def_series"
if meta.get("operation_type") == "exp" and model_target == "invoice_details":
template_id = "exp_def_partidas"
logger.info(
"Scan job %s template_id=%s model_target=%s job_type_override=%s",
job_id, template_id, model_target, job_type_override,
)
# --- Series de Importación Definitiva: flujo específico (Clarion VALIDA_TODA_SERIES_IMPO_DEF / VALIDA_PARCIAL) ---
DEF_SERIES_TEMPLATE_OR_TYPE = (
@@ -1206,6 +1213,7 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
# --- Partidas Exportación Definitiva: Clarion VALIDA_TODA_PAR_EXPO / VALIDA_PARCIAL_PAR_EXPO / VALIDACIONES_PAR_EXPO ---
if model_target == "invoice_details" and template_id == "exp_def_partidas":
logger.info("Partidas expo scan: running validation for job %s", job_id)
try:
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
@@ -1425,8 +1433,6 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
with open(error_path, "w", encoding="utf-8") as f_err:
for i, row in enumerate(rows_list, start=1):
if i % 1000 == 0:
self.update_state(state="PROGRESS", meta={"current": i, "total": total_rows, "errors": error_count})
row_norm = row_from_template(row, "exp_def_partidas", normalize_header)
err = validate_row_partidas_expo(
row_norm,
@@ -1457,12 +1463,20 @@ def _do_scan_file(job_id: str, model_target: str, config: Optional[str] = None,
error_count += 1
error_lines_list.append(err["line"])
f_err.write(json.dumps({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")}) + "\n")
logger.info(
"Partidas expo scan rechazo línea %s (col %s): %s",
err["line"],
err.get("col", ""),
err.get("msg", ""),
)
if len(errors_detail) < 500:
errors_detail.append({"line": err["line"], "col": err.get("col", ""), "msg": err.get("msg", "")})
processed_rows += 1
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)
return common_responses.scan_result(
job_id, processed_rows, error_count, errors_detail, total_rows_in_file=total_rows
)
except Exception as e:
logger.exception("Partidas exportación definitiva scan failed: %s", e)
return {"status": "failed", "error": str(e)}
@@ -3573,24 +3587,6 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
error_path = common_storage.error_path_for_job(effective_job_type, job_id)
error_lines = common_storage.get_error_lines(effective_job_type, job_id, error_path)
# Partidas exportación definitiva: inserción pendiente (Phase 2); solo cleanup y respuesta
if model_target == "invoice_details" and meta.get("template_id") == "exp_def_partidas":
common_storage.cleanup_import_job(
effective_job_type, job_id,
file_path=file_path,
error_path=error_path,
meta_path=meta_path,
)
return {
"status": "finished",
"inserted": 0,
"skipped_invalid": len(error_lines),
"skipped_missing_fk": 0,
"skipped_duplicate": 0,
"skipped_details": [],
"message": "Validación de partidas de exportación completada. Inserción en BD pendiente de implementación (Phase 2).",
}
# Si el upload fue de series (template_id imp_temp_series o imp_def_series), usar flujo series aunque model_target venga mal
use_series_flow = (
model_target == "invoice_series"
@@ -4160,6 +4156,8 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
_template_id_insert = meta.get("template_id") or (
"imp_temp_header" if model_target == "invoice_header" else "imp_temp_details"
)
if job_type_override == "exp" and model_target == "invoice_details":
_template_id_insert = "exp_def_partidas"
if model_target == "invoice_header" and _template_id_insert == "imp_def_header":
inv_type_value = "DEF"
if model_target == "invoice_header" and _template_id_insert == "cmex_header":
@@ -4179,6 +4177,11 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
inv_type_value = "DEF"
if model_target == "invoice_details" and _template_id_insert == "cmex_details":
inv_type_value = "MEX"
if model_target == "invoice_details" and _template_id_insert == "exp_def_partidas":
op_type_value = OperationType("exp")
inv_type_value = normalize_public_code(
meta.get("tipo_factura") or footer_config.get("tipo_factura") or "AFIJO"
) or "AFIJO"
if _template_id_insert == "cmex_series":
inv_type_value = "MEX"
@@ -4224,6 +4227,23 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
validator = ForeignKeyValidator(session, tenant_id, company_id)
error_msg_by_line: Dict[int, str] = {}
if error_path and os.path.exists(error_path):
try:
with open(error_path, "r", encoding="utf-8") as f_err:
for line in f_err:
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
if "line" in rec and "msg" in rec:
error_msg_by_line[int(rec["line"])] = str(rec["msg"]).strip()
except (json.JSONDecodeError, ValueError, TypeError):
pass
except Exception:
pass
with open(file_path, 'r', encoding='utf-8-sig') as f:
# Detect Delimiter
sample = f.read(2048)
@@ -4235,19 +4255,23 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
reader = csv.DictReader(f, dialect=dialect)
template_id = meta.get("template_id") or (
"imp_temp_header" if model_target == "invoice_header" else "imp_temp_details"
)
template_id = _template_id_insert
for i, row in enumerate(reader, start=1):
row_norm = row_from_template(row, template_id, normalize_header)
if i in error_lines:
skipped_invalid += 1
inv_for_detail = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip() if model_target == 'invoice_header' else ""
if model_target == 'invoice_header':
inv_for_detail = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip()
elif _template_id_insert == "exp_def_partidas":
inv_for_detail = (row_norm.get('NUMERO FACTURA EXPO') or row_norm.get('NUMERO FACTURA EXPO.') or row_norm.get('FACTURA EXPO') or '').strip()
else:
inv_for_detail = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or row_norm.get('FACTURA') or '').strip()
reason = error_msg_by_line.get(i, "Línea marcada con error en el escaneo previo (revisar reporte de validación).")
skipped_fk_details.append({
"line": i,
"invoice": inv_for_detail or "(vacío)",
"reason": "Línea marcada con error en el escaneo previo (revisar reporte de validación).",
"reason": reason,
})
continue
@@ -4664,26 +4688,50 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
headers_to_insert.append(header)
elif model_target == 'invoice_details':
invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip()
if _template_id_insert == "exp_def_partidas":
invoice_number = (
row_norm.get('NUMERO FACTURA EXPO') or row_norm.get('NUMERO FACTURA EXPO.') or row_norm.get('FACTURA EXPO') or ''
).strip()
else:
invoice_number = (row_norm.get('NUMERO FACTURA') or row_norm.get('NUM FACTURA') or '').strip()
if not invoice_number:
skipped_invalid += 1
continue
cache_key = f"{invoice_number}|{inv_type_value}"
if cache_key in invoice_id_cache:
invoice_id = invoice_id_cache[cache_key]
else:
invoice_id = (
session.query(InvoiceHeader.id)
.filter(
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
InvoiceHeader.invoice_number == invoice_number,
InvoiceHeader.invoice_type == inv_type_value,
if _template_id_insert == "exp_def_partidas":
# Expo: lookup by invoice_number + operation_type only (no invoice_type filter, matching scan behavior)
cache_key = f"{invoice_number}|exp"
if cache_key in invoice_id_cache:
invoice_id = invoice_id_cache[cache_key]
else:
invoice_id = (
session.query(InvoiceHeader.id)
.filter(
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
InvoiceHeader.invoice_number == invoice_number,
InvoiceHeader.operation_type == "exp",
)
.scalar()
)
.scalar()
)
invoice_id_cache[cache_key] = invoice_id
invoice_id_cache[cache_key] = invoice_id
else:
cache_key = f"{invoice_number}|{inv_type_value}|{op_type_value.value}"
if cache_key in invoice_id_cache:
invoice_id = invoice_id_cache[cache_key]
else:
invoice_id = (
session.query(InvoiceHeader.id)
.filter(
InvoiceHeader.tenant_id == tenant_id,
InvoiceHeader.company_id == company_id,
InvoiceHeader.invoice_number == invoice_number,
InvoiceHeader.invoice_type == inv_type_value,
InvoiceHeader.operation_type == op_type_value,
)
.scalar()
)
invoice_id_cache[cache_key] = invoice_id
if not invoice_id:
logger.warning(
@@ -4694,6 +4742,128 @@ def _do_insert_valid_rows(job_id: str, model_target: str, job_type_override: Opt
skipped_missing_invoice += 1
continue
# --- Partidas Exportación Definitiva: inserción real ---
if _template_id_insert == "exp_def_partidas":
part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip()
part_id = part_cache.get(part_num) if part_num else None
if part_id is None and part_num:
p = session.query(Part.id).filter(Part.part_number == part_num, Part.tenant_id == tenant_id, Part.company_id == company_id).first()
if p:
part_id = p.id
part_cache[part_num] = part_id
line_num_val = (row_norm.get('LINEA EXPO') or row_norm.get('LINEA EXPO.') or row_norm.get('RENGLON EXPO'))
line_num = parse_int(line_num_val) or (len(details_to_insert) + 1)
uom_code = (row_norm.get('UNIDAD DE MEDIDA') or row_norm.get('U.M.') or row_norm.get('UNIDAD MEDIDA') or '').strip().upper()
uom_id = uom_id_by_code.get(uom_code) if uom_code else None
bulk_key = (row_norm.get('CLAVE BULTOS') or row_norm.get('CLAVEBULTOS') or '').strip()
package_id = package_id_by_key.get(bulk_key) if bulk_key else None
descarga_val = (row_norm.get('GENERA DESCARGA') or row_norm.get('GENERA DESCARGA?') or row_norm.get('DESCARGA') or 'SI').strip().upper()
tipo_impo = (row_norm.get('TIPO DE IMPO') or row_norm.get('TIPO DE IMPO.') or row_norm.get('TIPO IMPO') or row_norm.get('PROCEDENCIA') or '').strip().upper()
factura_impo = (row_norm.get('FACTURA IMPO') or row_norm.get('FACTURA IMPO.') or row_norm.get('FACTURA IMPORTACION') or '').strip()
linea_impo_val = (row_norm.get('LINEA IMPO') or row_norm.get('LINEA IMPO.') or row_norm.get('LINEA IMPORTACION') or '').strip()
se_pago = (row_norm.get('SE PAGO IMPUESTO') or row_norm.get('SE PAGO IMPUESTO? (SI o NO)') or row_norm.get('SEPAGOIMPUESTO') or '').strip().upper()
forma_pago = (row_norm.get('FORMA DE PAGO') or row_norm.get('FORMADEPAGO') or row_norm.get('FORMA PAGO') or '').strip() or None
es_sub_raw = (row_norm.get('ES PARTIDA/SUBPARTIDA') or row_norm.get('ESSUBPARTIDA') or row_norm.get('ES PARTIDA O SUBPARTIDA') or '').strip().upper()
linea_principal_val = (row_norm.get('LINEA PRINCIPAL') or row_norm.get('LINEAPRINCIPAL') or row_norm.get('PARTIDA PRINCIPAL') or '').strip()
is_subitem = (es_sub_raw == 'S')
contains_subitems = (es_sub_raw == 'P')
# Clear existing line items once per invoice
if invoice_id not in cleared_invoices:
logger.info(f"Clearing existing details for Expo Invoice {invoice_number} (ID: {invoice_id})")
session.query(LineItem).filter(LineItem.invoice_id == invoice_id).delete(synchronize_session=False)
session.query(InvoiceSalesDetails).filter(InvoiceSalesDetails.invoice_id == invoice_id).delete(synchronize_session=False)
cleared_invoices.add(invoice_id)
line = LineItem(
invoice_id=invoice_id,
line_number=line_num,
tenant_id=tenant_id,
company_id=company_id,
part_number_id=part_id,
unit_of_measure=uom_id,
order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None),
tax_payment=(se_pago == 'SI'),
payment_method=forma_pago,
)
session.add(line)
session.flush()
# FaLineItem (a24 extension: subpartidas, descarga, factura impo ref)
from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem
fa_line = FaLineItem(
id=line.id,
tenant_id=tenant_id,
company_id=company_id,
search_invoice=factura_impo or None,
search_line=parse_int(linea_impo_val),
search_type=tipo_impo or None,
download=(descarga_val == 'SI'),
is_subitem=is_subitem,
contains_subitems=contains_subitems,
subitem_number=parse_int(linea_principal_val) if is_subitem else None,
)
session.add(fa_line)
price = parse_decimal(row_norm.get('COSTO UNITARIO') or row_norm.get('COSTOUNITARIO'))
qty = parse_decimal(row_norm.get('CANTIDAD EXPORTADA/DESCARGAR') or row_norm.get('CANTIDAD EXPORTADA') or row_norm.get('CANTIDAD'))
commercial_total = (price * qty) if price and qty else None
session.add(LineFinancial(
item_line_id=line.id,
unit_cost_capture=decimal_or_zero(price),
total_commercial_value=decimal_or_zero(commercial_total),
))
net_w = parse_decimal(row_norm.get('PESO NETO') or row_norm.get('PESONETO'))
gross_w = parse_decimal(row_norm.get('PESO BRUTO') or row_norm.get('PESOBRUTO'))
session.add(LineQuantity(
item_line_id=line.id,
quantity=decimal_or_zero(qty),
net_weight=decimal_or_zero(net_w),
gross_weight=decimal_or_zero(gross_w),
package_quantity=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
package_id=package_id,
))
origin = (row_norm.get('PAIS ORIGEN') or row_norm.get('PAISORIGEN') or row_norm.get('PAIS') or '').strip()
fraction = (row_norm.get('FRACCION ARANCELARIA') or row_norm.get('FRACCIONARANCELARIA') or '').strip()
american_fraction = (row_norm.get('FRACCION AMERICANA') or row_norm.get('FRACCIONAMERICANA') or '').strip()
session.add(LineCustom(
item_line_id=line.id,
origin_country=origin or None,
fraction=fraction or None,
american_fraction=american_fraction or None,
))
extra_desc = (row_norm.get('DESCRIPCION EXTRA') or row_norm.get('DESCRIPCIONEXTRA') or '').strip()
additional_info = (row_norm.get('INFORMACION ADICIONAL') or row_norm.get('INFORMACIONADICIONAL') or '').strip()
lot = (row_norm.get('LOTE') or '').strip()
entry_number = (row_norm.get('NUMERO ENTRADA') or row_norm.get('NUM ENTRADA') or '').strip()
session.add(LineDescription(
item_line_id=line.id,
extra_description=extra_desc or None,
additional_info_spanish=additional_info or None,
lot=lot or None,
entry_number=entry_number or None,
))
session.add(InvoiceSalesDetails(
invoice_id=invoice_id,
line_number=line_num,
sales_order=(row_norm.get('ORDEN DE COMPRA') or row_norm.get('ORDENCOMPRA') or row_norm.get('ORDEN DE VENTA') or None),
line_bundles=int_or_zero(row_norm.get('CANTIDAD BULTOS') or row_norm.get('CANTIDADBULTOS')),
tenant_id=tenant_id,
company_id=company_id,
))
details_to_insert.append(line)
continue
part_num = (row_norm.get('NUM. PARTE') or row_norm.get('NUMPARTE') or row_norm.get('NUMERO PARTE') or row_norm.get('NUM PARTE') or '').strip()
if not part_num:
skipped_invalid += 1

View File

@@ -9,8 +9,8 @@ from typing import Dict, Any, Optional, Set, Tuple, List
from .partidas_impo_temp import _clip, _get
# Longitudes Clarion partidas expo
MAX_LEN_FACTURA_EXPO = 15
# Longitudes Clarion partidas expo (Factura EXPO alineado con invoice_number String(100) en InvoiceHeader)
MAX_LEN_FACTURA_EXPO = 100
MAX_LEN_LINEA_EXPO = 5
MAX_LEN_TIPO_IMPO = 3
MAX_LEN_ORDEN_COMPRA = 20
@@ -262,7 +262,7 @@ def _validaciones_par_expo(
line_num,
"NUMERO FACTURA EXPO",
f"Error: (Celda A{line_num}) La Factura de Exportación: {factura_expo} supera la longitud de caracteres. "
"Capturar en la Celda A el campo Factura de Exportación con formato ###############.",
f"Capturar en la Celda A el campo Factura de Exportación con un máximo de {MAX_LEN_FACTURA_EXPO} caracteres.",
)
# Longitud B
linea_expo = _get(row, "LINEA EXPO", "LINEA EXPO.", "RENGLON EXPO")

View File

@@ -9,6 +9,7 @@
FileText,
UploadCloud
} from 'lucide-svelte';
import { tick } from 'svelte';
let {
open = $bindable(false),
@@ -53,6 +54,16 @@
}
open = newOpen;
}
// When modal shows finished state with rejection details, scroll the detail table into view
$effect(() => {
if (open && isFinished && commitResults?.skipped_details?.length > 0) {
tick().then(() => {
const el = document.getElementById('detalle-errores-import');
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
});
}
});
</script>
<Dialog.Root bind:open onOpenChange={handleOpenChange}>
@@ -234,12 +245,17 @@
>
</div>
<span class="text-3xl font-bold text-destructive">{totalSkipped}</span>
{#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0}
<p class="text-xs text-muted-foreground mt-2">
Revisa el detalle por línea en la tabla inferior.
</p>
{/if}
</div>
</div>
<!-- Error Details Table -->
{#if commitResults.skipped_details && commitResults.skipped_details.length > 0}
<div class="border rounded-lg overflow-hidden mt-2 shadow-sm">
<div id="detalle-errores-import" class="border rounded-lg overflow-hidden mt-2 shadow-sm">
<div class="bg-muted/50 px-4 py-2 border-b flex justify-between items-center">
<h5 class="text-xs font-bold text-foreground uppercase tracking-wide">
Detalle de Errores