From 6d56448f9f8795c6b10db751b2178365c48f2ec2 Mon Sep 17 00:00:00 2001 From: hreyes Date: Tue, 10 Mar 2026 16:21:24 -0600 Subject: [PATCH] feature/partidas-clarion-csv-validaciones-expo --- .../modules/a76/layouts_csv/facturas/tasks.py | 252 +++++++++++++++--- .../facturas/validators/partidas_expo.py | 6 +- .../csv-upload/ProcessingResultModal.svelte | 18 +- 3 files changed, 231 insertions(+), 45 deletions(-) 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 b2c0aea3..8f3990bd 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/tasks.py @@ -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 diff --git a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py index e198d087..a32c6d6e 100644 --- a/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py +++ b/backend/api/v1/modules/a76/layouts_csv/facturas/validators/partidas_expo.py @@ -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") diff --git a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte index 0e2a0b4e..f6cbbd8d 100644 --- a/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte +++ b/frontend/src/lib/components/dashboard/csv-upload/ProcessingResultModal.svelte @@ -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' }); + }); + } + }); @@ -234,12 +245,17 @@ > {totalSkipped} + {#if totalSkipped > 0 && commitResults.skipped_details && commitResults.skipped_details.length > 0} +

+ Revisa el detalle por línea en la tabla inferior. +

+ {/if} {#if commitResults.skipped_details && commitResults.skipped_details.length > 0} -
+
Detalle de Errores