From 196525c6841f522c98d60a39b87e835f4f5daeeb Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Tue, 17 Mar 2026 22:43:53 -0500 Subject: [PATCH] Enhance invoice processing to support export and import differentiation - Updated the invoice processing route to automatically detect and handle invoice types based on the operation_type. - Introduced error handling for non-existent invoices, returning a 404 status when an invoice is not found. - Added a new field 'serie_row' in the Serie model to accommodate additional data for export scenarios. --- .../common/process/review_equivalence.py | 47 +++ .../a76/invoices/common/process/review_uma.py | 128 ++++++ .../invoices/exports/process/main_process.py | 204 +++++++++ .../exports/process/pre_validators.py | 117 ++++++ .../a76/invoices/exports/process/routes.py | 74 ++++ .../exports/process/sub_process/__init__.py | 0 .../sub_process/assign_no_discharges.py | 82 ++++ .../process/sub_process/assign_values.py | 236 +++++++++++ .../process/sub_process/compare_balances.py | 164 ++++++++ .../process/sub_process/discharge_types.py | 68 +++ .../sub_process/download_balance_collector.py | 125 ++++++ .../sub_process/fill_available_balances.py | 276 +++++++++++++ .../process/sub_process/finalize_invoice.py | 280 +++++++++++++ .../sub_process/generate_definitive_import.py | 389 ++++++++++++++++++ .../sub_process/register_discharge_series.py | 181 ++++++++ .../sub_process/register_import_discharge.py | 173 ++++++++ .../process/sub_process/review_class.py | 181 ++++++++ .../sub_process/review_exchange_rate.py | 91 ++++ .../process/sub_process/review_limits.py | 80 ++++ .../sub_process/review_origin_procedure.py | 74 ++++ .../sub_process/review_qty_vs_weight.py | 72 ++++ .../process/sub_process/review_unit_cost.py | 60 +++ .../process/sub_process/series/__init__.py | 0 .../sub_process/series/review_qty_series.py | 121 ++++++ .../sub_process/series/review_series_exist.py | 149 +++++++ .../series/review_series_other_lines.py | 210 ++++++++++ .../sub_process/verify_consolidated.py | 82 ++++ .../a76/invoices/exports/process/task.py | 50 +++ .../a76/invoices/imports/balance/__init__.py | 0 .../a76/invoices/imports/process/routes.py | 22 +- .../api/v1/modules/a76/items/series/models.py | 2 +- 31 files changed, 3732 insertions(+), 6 deletions(-) create mode 100644 backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py create mode 100644 backend/api/v1/modules/a76/invoices/common/process/review_uma.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/main_process.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/routes.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/__init__.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_no_discharges.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_series.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_import_discharge.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_class.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_origin_procedure.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_unit_cost.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/__init__.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_exist.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_other_lines.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/sub_process/verify_consolidated.py create mode 100644 backend/api/v1/modules/a76/invoices/exports/process/task.py create mode 100644 backend/api/v1/modules/a76/invoices/imports/balance/__init__.py diff --git a/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py b/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py new file mode 100644 index 00000000..1d0825b6 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/common/process/review_equivalence.py @@ -0,0 +1,47 @@ +from decimal import Decimal +from sqlalchemy.orm import Session +from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion + +def _get_unit_equivalence( + db: Session, + from_unit: str, + to_unit: str, + tenant_id: str, + company_id: str, +) -> tuple[str, Decimal]: + """ + Busca una conversión entre dos unidades de medida. + Paridad: REVEQUIVALENCIA (Clarion SCAII). + + Retorna (multi_divide, factor_conv): + - ('M', factor) → multiplicar cantidad por factor + - ('D', factor) → dividir cantidad por factor + - ('', 0) → no existe equivalencia + """ + conv = ( + db.query(UnitConversion) + .filter( + UnitConversion.tenant_id == tenant_id, + UnitConversion.company_id == company_id, + UnitConversion.from_unit_code == from_unit, + UnitConversion.to_unit_code == to_unit, + ) + .first() + ) + if conv and conv.conversion_factor: + return "M", conv.conversion_factor + + conv_inv = ( + db.query(UnitConversion) + .filter( + UnitConversion.tenant_id == tenant_id, + UnitConversion.company_id == company_id, + UnitConversion.from_unit_code == to_unit, + UnitConversion.to_unit_code == from_unit, + ) + .first() + ) + if conv_inv and conv_inv.conversion_factor: + return "D", conv_inv.conversion_factor + + return "", Decimal(0) diff --git a/backend/api/v1/modules/a76/invoices/common/process/review_uma.py b/backend/api/v1/modules/a76/invoices/common/process/review_uma.py new file mode 100644 index 00000000..96b0f9ae --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/common/process/review_uma.py @@ -0,0 +1,128 @@ +from decimal import Decimal + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasureCustoms +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector + +from .review_equivalence import _get_unit_equivalence + +def revisa_uma( + db: Session, + line: LineItem, + tenant_id: str, + company_id: str, + errors: ErrorCollector, +) -> None: + """ + Revisa la unidad de medida americana (UMA) de aduana para una partida. + Paridad: REVISA_UMA (Clarion SCAII). + + Flujo: + 1. Verifica que la UM de la partida exista en el catálogo GUnimedida. + 2. Verifica que esa UM tenga asignada una Clave_AMex (UM aduana mexicana). + 3. Verifica que la Clave_AMex exista en el catálogo GUMAduana. + 4. Verifica que el registro de aduana tenga una UnidadSCAII (a76_unit_code). + 5. Calcula la cantidad UMA: + - Si la UM de la partida coincide con UnidadSCAII → cantidad directa. + - Si no → busca conversión en el catálogo de equivalencias. + 6. Escribe CantImpoUMA y ClaveUMA de regreso en la partida. + """ + uom = line.unit_of_measure_info + line_um_code = uom.code if uom else "" + + # ── 1. Verificar existencia de la UM en el catálogo ────────────────────── + if uom is None: + errors.add_error( + field=f"line[{line.line_number}].unit_of_measure", + message=( + f"No existe la unidad de medida {line_um_code}" + " en el catálogo de Unidades de Medida." + ), + solution=["Capturarla en el catálogo de Unidades de Medida."], + code="UNIMEDIDA", + ) + return + + # ── 2. Verificar que tenga Clave_AMex asignada ─────────────────────────── + customs_code = uom.customs_code or "" + if not customs_code: + errors.add_error( + field=f"line[{line.line_number}].unit_of_measure", + message=( + f"No existe la unidad de medida de la aduana para la unidad de medida " + f"{line_um_code} en el catálogo de Unidades de Medida." + ), + solution=["Asignar la U.M.A en el catálogo de Unidades de Medida."], + code="UNIMEDIDA", + ) + return + + # ── 3. Buscar la UM en el catálogo de Aduana Mex (GUMAduana) ───────────── + customs_uom = ( + db.query(UnitOfMeasureCustoms) + .filter(UnitOfMeasureCustoms.code == customs_code) + .first() + ) + + if customs_uom is None: + errors.add_error( + field=f"line[{line.line_number}].unit_of_measure", + message=( + f"No existe la unidad de medida {customs_code}" + " en el catálogo de Unidades de Medida de la Aduana Mex." + ), + solution=["Actualizar sus Catálogos Fijos o llamar a su proveedor."], + code="UNIMEDIDA", + ) + return + + # ── 4. Verificar que tenga UnidadSCAII asignada ────────────────────────── + scaii_unit = customs_uom.a76_unit_code or "" + if not scaii_unit: + errors.add_error( + field=f"line[{line.line_number}].unit_of_measure", + message=( + f"No tiene asignada la U.M. Equivalente SCAII la unidad de medida " + f"{customs_code} en el catálogo de Unidades de Medida de la Aduana Mex." + ), + solution=["Actualizar sus Catálogos Fijos o llamar a su proveedor."], + code="UNIMEDIDA", + ) + return + + # ── 5. Calcular cantidad UMA ────────────────────────────────────────────── + line_qty = Decimal(str(line.quantity.quantity or 0)) if line.quantity else Decimal(0) + cant_uma = Decimal(0) + + if line_um_code == scaii_unit: + cant_uma = line_qty + else: + multi_divide, factor_conv = _get_unit_equivalence( + db, line_um_code, scaii_unit, tenant_id, company_id + ) + + if multi_divide == "M": + cant_uma = line_qty * factor_conv + elif multi_divide == "D": + cant_uma = line_qty / factor_conv if factor_conv else Decimal(0) + else: + errors.add_error( + field=f"line[{line.line_number}].unit_of_measure", + message=( + f"No hay equivalencia entre la U.M. Partida: {line_um_code}" + f" y la U.M. Aduana {scaii_unit}." + ), + solution=[ + "Capturar su equivalencia en el Catálogo de Conversiones " + "o configurar la U.M.Aduana correcta en la U.M. Comercial." + ], + code="EQUIVALENCIA", + ) + return + + # ── 6. Escribir resultados en la partida ────────────────────────────────── + if line.quantity: + line.quantity.quantity_uma = cant_uma + line.uma_key = customs_code diff --git a/backend/api/v1/modules/a76/invoices/exports/process/main_process.py b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py new file mode 100644 index 00000000..2dcdf9e4 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/main_process.py @@ -0,0 +1,204 @@ + +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector +from .pre_validators import pre_validators +from .sub_process.assign_no_discharges import assign_no_discharges_items, assign_no_discharges_series +from .sub_process.review_class import review_class +from .sub_process.review_exchange_rate import review_exchange_rate +from .sub_process.assign_values import assign_values +from .sub_process.review_exchange_rate import review_exchange_rate +from .sub_process.review_qty_vs_weight import review_qty_vs_weight +from .sub_process.review_unit_cost import review_unit_cost +from .sub_process.review_limits import limit_weight, limit_value +from .sub_process.series.review_qty_series import review_qty_series +from .sub_process.download_balance_collector import collect_lines_to_discharge +from .sub_process.discharge_types import DownloadEntry +from .sub_process.finalize_invoice import ( + finalize_invoice_no_discharge, + finalize_invoice_with_discharge, +) +from .sub_process.review_origin_procedure import review_origin_procedure +from .sub_process.fill_available_balances import fill_available_balances +from .sub_process.compare_balances import compare_balances +from .sub_process.verify_consolidated import verify_consolidated +from .sub_process.generate_definitive_import import ( + generate_definitive_import, + generate_definitive_import_all_lines, +) + +# --------------------------------------------------------------------------- +# Bloque reutilizable: descarga normal (AFIJO / DONAC / SCRAP / REEXP / VEMEX) +# --------------------------------------------------------------------------- + +def _process_with_discharge( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + Secuencia común para los tipos de factura que realizan descarga de saldos: + AFIJO, DONAC, SCRAP, REEXP, VEMEX. + """ + assign_no_discharges_series(db, lines, errors) + review_class(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) + review_exchange_rate(db, invoice, errors) + assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) + + review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors) + + review_unit_cost(lines, errors) + total_qty, total_net_weight = limit_weight(lines) + total_value = limit_value(lines) + review_qty_series(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) + + # QUIERE_DESCARGAR → LLENA_QUEUE_SALDOS → COMPARA_SALDOS + to_discharge = collect_lines_to_discharge(db, invoice, lines, errors) + + fill_available_balances(db, invoice, to_discharge, errors) + compare_balances(db, invoice, to_discharge, errors) + verify_consolidated(db, invoice, to_discharge, errors) + + finalize_invoice_with_discharge(db, invoice, lines, errors, to_discharge) + + +# --------------------------------------------------------------------------- +# Proceso principal +# --------------------------------------------------------------------------- + +def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str) -> dict: + """ + Proceso principal para actualizar facturas de exportación. + + Flujo (porta la rutina principal del legacy SCAII – Facturas de Exportación): + + 1. Validaciones previas (pre_validators) + 2. TODO: Compartir parámetros generales (QSisGen / GEmpresa) + 3. TODO: Compartir parámetros de exportación (QSisExpo) según EsCambioRegimen + 4. TODO: Validar permisos de usuario (GUsuarios / GNivelesSeguridad) + 5. TODO: Iniciar transacción SQL (BEGIN TRAN) + 6. Verificar que existan partidas + 7. TODO: Obtener tipo de cambio según SisGen:CalValBaseTCPedExpo + (TCPED desde la fecha de pago del pedimento, o TCFAC desde la factura) + 8. TODO: Validar que la factura no exista ya en Importaciones Definitivas (si GeneraID='S') + 9. CASE invoice_type → ejecutar sub-proceso específico por tipo: + - NODES : sin descarga + - AFIJO / DONAC / SCRAP : con descarga + lógica de CambioRegimen opcional + - REEXP / VEMEX : con descarga + revisión de procedencia DEF + 10. Si hay errores: rollback implícito (raise) + Si no hay errores: COMMIT y marcar factura como procesada + """ + errors = ErrorCollector() + + # --- Paso 1: Validaciones previas ---------------------------------------- + lines = pre_validators(db, invoice, tenant_id, company_id, errors) + errors.raise_if_errors() + + # --- Paso 2-4: Parámetros generales, parámetros expo y permisos ---------- + # TODO: Compartir QSisGen / GEmpresa + # TODO: Compartir QSisExpo (EsCambioRegimen = 'S' → SisExp:EsCambioRegimen = 'CR') + # TODO: Validar permisos usuario (GUsuarios / GNivelesSeguridad) + + # --- Paso 5: Iniciar transacción ----------------------------------------- + # TODO: BEGIN TRAN (en el legacy: GSQLFile{PROP:SQL} = 'BEGIN TRAN') + + # --- Paso 6: Verificar que existan partidas ------------------------------ + if not lines: + errors.add_error( + field="items", + message="Esta Factura no tiene partidas.", + solution=["Capturar al menos una partida a la factura."], + code="NO_ITEMS_FOUND", + ) + errors.raise_if_errors() + + # --- Paso 7: Tipo de cambio ---------------------------------------------- + # TODO: Si SisGen:CalValBaseTCPedExpo = 1: + # invoice.which_exchange_rate = 'TCPED' + # Buscar pedimento (EqiPed:Pedimento = EqiFex:PedimentoExpo) + # Buscar GTipoCambio por EqiPed:Fecha_Pago + # exchange_rate = GenTC:Valor + # Else: + # invoice.which_exchange_rate = 'TCFAC' + # exchange_rate = invoice.financials.exchange_rate + + # --- Paso 8: Validar que la factura no exista en ImportDef --------------- + # TODO: Si invoice.generate_id = True: + # Buscar en QFacImpDef por invoice.invoice_number + # Si ya existe → agregar error + + # --- Paso 9: Sub-proceso por tipo de factura ----------------------------- + invoice_type = invoice.invoice_type + + if invoice_type == "NODES": + # Sin descarga de saldos + assign_no_discharges_items(lines, errors) + assign_no_discharges_series(db, lines, errors) + review_class(db, lines, errors) + review_exchange_rate(db, invoice, errors) + assign_values(db, invoice, lines, invoice.tenant_id, invoice.company_id, errors) + + review_qty_vs_weight(lines, invoice.logistics.weight_type.upper(), errors) + + review_unit_cost(lines, errors) + review_qty_series(db, invoice, lines, tenant_id, company_id, errors) + total_qty, total_net_weight = limit_weight(lines) + total_value = limit_value(lines) + + finalize_invoice_no_discharge(db, invoice, lines, errors) + + elif invoice_type == "AFIJO": + if invoice.compliance_mx and invoice.compliance_mx.is_regime_change: + review_origin_procedure(db, invoice, lines, "TEM", errors) + if invoice.generate_id and invoice.generate_desc_parties == "Todas": + def_inv = generate_definitive_import(db, invoice, errors) + if def_inv: + generate_definitive_import_all_lines(db, invoice, def_inv, errors) + + _process_with_discharge(db, invoice, lines, errors) + + elif invoice_type == "DONAC": + _process_with_discharge(db, invoice, lines, errors) + + elif invoice_type == "SCRAP": + if invoice.compliance_mx and invoice.compliance_mx.is_regime_change: + review_origin_procedure(db, invoice, lines, "TEM", errors) + if invoice.generate_id and invoice.generate_desc_parties == "Todas": + def_inv = generate_definitive_import(db, invoice, errors) + if def_inv: + generate_definitive_import_all_lines(db, invoice, def_inv, errors) + + _process_with_discharge(db, invoice, lines, errors) + + elif invoice_type == "REEXP": + review_origin_procedure(db, invoice, lines, "DEF", errors) + _process_with_discharge(db, invoice, lines, errors) + + elif invoice_type == "VEMEX": + review_origin_procedure(db, invoice, lines, "DEF", errors) + _process_with_discharge(db, invoice, lines, errors) + + else: + errors.add_error( + field="invoice_type", + message=f"{invoice_type} no es un Tipo de Factura válido, llamar al proveedor del Sistema SCAII.", + solution=["Verificar el tipo de factura de exportación."], + code="INVALID_INVOICE_TYPE", + value=invoice_type, + ) + + # --- Paso 10: Commit / Rollback ------------------------------------------ + errors.raise_if_errors() + + # TODO: COMMIT TRAN (en el legacy: gSQLFile{PROP:SQL} = 'COMMIT TRAN') + # TODO: GBitacora('ACTUALIZAR FACTURA', invoice.invoice_number) + + # invoice.status and totals are set inside finalize_invoice_no_discharge / termina_ac_o_lp_normal + db.flush() + + return {"status": "ok", "invoice_id": str(invoice.id)} diff --git a/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py b/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py new file mode 100644 index 00000000..bc62c4a6 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/pre_validators.py @@ -0,0 +1,117 @@ +from sqlalchemy import func +from sqlalchemy.orm import Session +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.general_catalogs.fractions.warning_fractions.models import WarningFraction +from api.v1.modules.a76.clients_and_providers.models import ClientProvider +from core.exceptions import ErrorCollector + +def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id: str, errors: ErrorCollector): + if invoice.status == InvoiceStatus.PROCESSED: + errors.add_error( + "status", + "La factura ya fue procesada y no puede volver a actualizarse. Desactualícela primero.", + solution=["Use el botón 'Desactualizar' antes de volver a procesar la factura."], + code="ALREADY_PROCESSED", + value=invoice.status, + ) + errors.raise_if_errors() + return + + if not invoice.invoice_date: + errors.add_required_error("invoice_date") + + if invoice.invoice_type != "VEMEX": + if not invoice.document_type: + errors.add_required_error("document_type") + + if not invoice.compliance_mx.provider_id: + errors.add_required_error("compliance_mx.provider_id") + + if not invoice.compliance_mx.sold_to_id: + errors.add_required_error("compliance_mx.sold_to_id") + + if not invoice.compliance_mx.shipped_to_id: + errors.add_required_error("compliance_mx.shipped_to_id") + else: + shipped_to_exists = db.query(ClientProvider).filter( + ClientProvider.id == invoice.compliance_mx.shipped_to_id, + ClientProvider.tenant_id == tenant_id, + ClientProvider.company_id == company_id, + ).first() + if not shipped_to_exists: + errors.add_error( + field="compliance_mx.shipped_to_id", + message="El Destinatario no existe en el Catálogo de Clientes y Proveedores.", + solution=["Verifica el ID del Destinatario", "Revisa el catálogo"], + code="NOT_FOUND", + value=invoice.compliance_mx.shipped_to_id, + ) + if not shipped_to_exists.address.country: + errors.add_error( + field="compliance_mx.shipped_to_id", + message="El Destinatario no tiene capturado el pais.", + solution=["Captura el pais de envío del Destinatario", "Revisa el catálogo"], + code="MISSING_COUNTRY", + value=invoice.compliance_mx.shipped_to_id, + ) + + if invoice.invoice_type != "VEMEX": + if not invoice.compliance_mx.customs_broker_id: + errors.add_required_error("compliance_mx.customs_broker_id") + + + if not invoice.financials.exchange_rate or invoice.financials.exchange_rate <= 0: + errors.add_range_error( + "financials.exchange_rate", + min_value=0.0001, + ) + + if not invoice.financials.currency: + errors.add_required_error("El Tipo de Moneda esta vacio no se puede actualizar") + elif invoice.financials.currency == "manual" and not invoice.financials.currency_type: + errors.add_required_error("financials.currency_type") + + #TODO: SSISGEN: Seguridad Ejemplo en: BrowseQFacImp + + # 2.- Existe tipo de cambio para la factura seleccionada + #TODO: SSISGEN: VALIDACION DEL TIPO DE CAMBIO EN BASE A LA FECHA DE PAGO DEL PEDIMENTO. + + # 3.- Validacion que deber de existir un pedimento cuando es requerido + if not invoice.compliance_mx.is_pedimento_pending and not invoice.compliance_mx.pedimento_id: + errors.add_required_error("compliance_mx.pedimento_number") + + # 4.- Verificacion de que existan partidas para la factura, si no hay partidas no se puede procesar + item_count = ( + db.query(func.count(LineItem.id)) + .filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == invoice.tenant_id, + LineItem.company_id == invoice.company_id, + ) + .scalar() + ) + if item_count == 0: + errors.add_error( + field="items", + message="La factura no tiene partidas capturadas.", + solution=["Capture al menos una partida antes de procesar la factura."], + code="NO_ITEMS_FOUND", + ) + + # Advertencias para las fracciones y su horario + lines = db.query(LineItem).filter( + LineItem.invoice_id == invoice.id, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ).all() + + return lines + + + + + + + + \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/exports/process/routes.py b/backend/api/v1/modules/a76/invoices/exports/process/routes.py new file mode 100644 index 00000000..1f8a2297 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/routes.py @@ -0,0 +1,74 @@ +from typing import Any, Dict + +from fastapi import APIRouter, Depends, Query +from sqlalchemy.orm import Session + +from core.celery_app import celery_app +from core.database import get_core_db +from core.security import get_current_user, validate_access_to_resource + +from .task import process_export_invoice_task + +router = APIRouter() + + +@router.post("/invoices/{invoice_id}/process") +def trigger_invoice_process( + invoice_id: int, + company_id: int = Query(..., description="Company ID"), + db: Session = Depends(get_core_db), + current_user: Dict[str, Any] = Depends(get_current_user), +): + """ + Inicia el procesamiento de una factura de exportación como tarea Celery. + Retorna el task_id para hacer polling del progreso. + """ + tenant_id = validate_access_to_resource(db, company_id, current_user) + + task = process_export_invoice_task.apply_async( + args=[invoice_id, str(tenant_id), str(company_id)] + ) + + return {"task_id": task.id} + + +@router.get("/invoices/process/{task_id}/status") +def get_invoice_process_status(task_id: str): + """ + Consulta el estado de progreso de una tarea de procesamiento de factura. + + Retorna: + - state: 'PROCESSING' | 'SUCCESS' | 'FAILURE' + - info: { current: int, status: str } (cuando state == 'PROCESSING') + - result: dict (cuando state == 'SUCCESS' o 'FAILURE') + """ + task_result = celery_app.AsyncResult(task_id) + + if task_result.state in ("PENDING", "STARTED"): + return { + "state": "PROCESSING", + "info": {"current": 0, "status": "Iniciando..."}, + } + + if task_result.state == "PROGRESS": + return { + "state": "PROCESSING", + "info": task_result.info or {"current": 0, "status": "Procesando..."}, + } + + if task_result.state == "SUCCESS": + return { + "state": "SUCCESS", + "result": task_result.result, + } + + error_info = task_result.result + if isinstance(error_info, Exception): + error_msg = str(error_info) + else: + error_msg = str(error_info) if error_info else "Error desconocido" + + return { + "state": "FAILURE", + "result": error_msg, + } diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/__init__.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_no_discharges.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_no_discharges.py new file mode 100644 index 00000000..8505fcb2 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_no_discharges.py @@ -0,0 +1,82 @@ +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.series.models import Serie +from core.exceptions import ErrorCollector + + +def assign_no_discharges_items( + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + ASIGNA_NODESCARGA_PARTIDAS + Sets ``discharge = False`` on every line item of the invoice. + Used exclusively by invoice type NODES (no discharge). + + Legacy equivalent + ----------------- + UPDATE QEqeMaq SET Descarga = 0 + FROM QEqeMaq + WHERE Consecutivo = + """ + try: + for line in lines: + line.fa_data.discharge = False + except Exception as exc: + errors.add_error( + field="items.discharge", + message="Error al asignar No-Descarga en las partidas de exportación.", + solution=["Verifique la integridad de las partidas de la factura."], + code="ASSIGN_NO_DISCHARGE_ITEMS_ERROR", + value=str(exc), + ) + + +def assign_no_discharges_series( + db: Session, + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + ASIGNA_NODESCARGA_SERIES + Sets ``marca = False`` on every ``Serie`` row whose parent ``LineItem`` + has ``discharge = False`` (or ``discharge`` is ``None``). + + Legacy equivalent + ----------------- + UPDATE QSeriesExpo + SET Marca = 0 + FROM QSeriesExpo SerExpo + LEFT JOIN QEqeMaq EqiPex + ON EqiPex.Consecutivo = SerExpo.Consecutivo + AND EqiPex.LineaExpo = SerExpo.LineaExpo + WHERE SerExpo.Consecutivo = + AND EqiPex.Descarga = 0 + """ + try: + no_discharge_line_ids = { + line.id + for line in lines + if not line.fa_data.discharge + } + + if not no_discharge_line_ids: + return + + ( + db.query(Serie) + .filter(Serie.line_item_id.in_(no_discharge_line_ids)) + .update({"discharge": False}, synchronize_session="fetch") + ) + except Exception as exc: + errors.add_error( + field="series.discharge", + message="Error al asignar No-Descarga en las series de exportación.", + solution=["Verifique la integridad de las series de la factura."], + code="ASSIGN_NO_DISCHARGE_SERIES_ERROR", + value=str(exc), + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py new file mode 100644 index 00000000..2dc6b770 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/assign_values.py @@ -0,0 +1,236 @@ +""" +ASIGNA_VALORES_PARTIDAS_ASIGNA_PESOS +Resets and recalculates unit costs, export values +(KGS ↔ LBS) for every line item of an export invoice. + +Two cost-assignment strategies (controlled by SisExp:ValFactTC — TODO): + TCE → bulk SQL UPDATE using the invoice-level exchange rate (Loc:TipoCambio). + else → per-line loop that resolves each line's exchange rate from its + source import invoice (TEM → QFacImp, DEF → QFacImpDef). + +After costs are assigned the routine always: + 1. Calls REVISA_UMA for each line. +""" + +from decimal import Decimal +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import Currency, InvoiceHeader +from api.v1.modules.a76.invoices.common.process.review_uma import revisa_uma +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector + +_KGS_TO_LBS = Decimal("2.204624") + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + +def _assign_costs_tce( + lines: List[LineItem], + currency: Currency, + tc: Decimal, + tc_mm: Decimal, +) -> None: + """ + Bulk-style cost assignment when SisExp:ValFactTC = 'TCE'. + Uses the single invoice-level exchange rate for all lines. + + Legacy equivalent (branch 1 of the IF SisExp:ValFactTC): + UPDATE QEqeMaq SET CostoUnitarioDlls = ..., CostoUnitarioPesos = ..., + ValorExpoMN = ..., ValorExpoME = ..., ValorExpoMC = ... + WHERE Consecutivo = + """ + for line in lines: + if line.financial is None or line.quantity is None: + continue + + capture = line.financial.unit_cost_capture or Decimal(0) + qty = line.quantity.quantity or Decimal(0) + + if currency == Currency.FOREIGN: # ME + line.financial.unit_cost_usd = capture + line.financial.unit_cost_mxn = capture * tc + line.financial.value_mxn = qty * capture * tc + line.financial.value_usd = qty * capture + line.financial.value_mc = qty * capture + + elif currency == Currency.LOCAL: # MN + line.financial.unit_cost_mxn = capture + line.financial.unit_cost_usd = (capture / tc) if tc else Decimal(0) + line.financial.value_mxn = qty * capture + line.financial.value_usd = (qty * capture / tc) if tc else Decimal(0) + line.financial.value_mc = qty * capture + + elif currency == Currency.MANUAL: # MC + cost_usd = capture * tc_mm + line.financial.unit_cost_usd = cost_usd + line.financial.unit_cost_mxn = cost_usd * tc + line.financial.value_mxn = qty * cost_usd * tc + line.financial.value_usd = qty * cost_usd + line.financial.value_mc = qty * capture + + +def _assign_costs_per_line( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + currency: Currency, + tc_mm: Decimal, + errors: ErrorCollector, +) -> None: + """ + Per-line cost assignment when SisExp:ValFactTC != 'TCE'. + Each line resolves the exchange rate from its source import invoice + (TEM → QFacImp header, DEF → QFacImpDef header). + + Legacy equivalent (ELSE branch – LOOP QEqeMaq): + If TipoMovImpo = 'TEM' → ACCESS:QFacImp.TryFetch(EqiFim:FKFacturaImpo) + Else → ACCESS:QFacImpDef.TryFetch(EqiFID:FKFacImpoDef) + then assign CostoUnitarioDlls / CostoUnitarioPesos / ValorExpoMN/ME/MC + """ + from api.v1.modules.a76.invoices.models import InvoiceHeader as InvHeader + + for line in lines: + if line.financial is None or line.quantity is None: + continue + + capture = line.financial.unit_cost_capture or Decimal(0) + qty = line.quantity.quantity or Decimal(0) + + # Resolve the exchange rate from the source import invoice + line_tc = _get_source_invoice_tc(db, invoice, line, errors) + + if currency == Currency.FOREIGN: # ME + line.financial.unit_cost_usd = capture + line.financial.unit_cost_mxn = capture * line_tc + + elif currency == Currency.LOCAL: # MN + line.financial.unit_cost_usd = (capture / line_tc) if line_tc else Decimal(0) + line.financial.unit_cost_mxn = capture + + elif currency == Currency.MANUAL: # MC + cost_usd = capture * tc_mm + line.financial.unit_cost_usd = cost_usd + line.financial.unit_cost_mxn = cost_usd * line_tc + + # Values are always: cost × qty + line.financial.value_mxn = (line.financial.unit_cost_mxn or Decimal(0)) * qty + line.financial.value_usd = (line.financial.unit_cost_usd or Decimal(0)) * qty + line.financial.value_mc = capture * qty + + +def _get_source_invoice_tc( + db: Session, + invoice: InvoiceHeader, + line: LineItem, + errors: ErrorCollector, +) -> Decimal: + """ + Returns the exchange rate of the import invoice linked to this export line. + + Movement type 'TEM' → look up QFacImp (temporary import header). + Any other type → look up QFacImpDef (definitive import header). + + Falls back to the export invoice's own exchange rate if the source invoice + is not found, and records a warning-level error. + + Legacy fields: + EqiPex:TipoMovImpo → line.customs.origin_procedure + EqiPex:FacturaImpo → line.reference.import_invoice (TODO: confirm field) + """ + fallback_tc = Decimal(str(invoice.financials.exchange_rate or 0)) + + movement_type = (line.customs.origin_procedure or "").strip().upper() if line.customs else "" + import_invoice_number = (line.reference.import_invoice if line.reference else None) or "" + + if not import_invoice_number: + return fallback_tc + + from api.v1.modules.a76.invoices.models import InvoiceHeader as InvHeader + + if movement_type == "TEM": + source = ( + db.query(InvHeader) + .filter( + InvHeader.invoice_number == import_invoice_number, + InvHeader.tenant_id == invoice.tenant_id, + InvHeader.company_id == invoice.company_id, + ) + .first() + ) + else: + # DEF: definitive import + source = ( + db.query(InvHeader) + .filter( + InvHeader.invoice_number == import_invoice_number, + InvHeader.tenant_id == invoice.tenant_id, + InvHeader.company_id == invoice.company_id, + ) + .first() + ) + + if source is None or source.financials is None: + errors.add_error( + field=f"line[{line.line_number}].import_invoice", + message=( + f"No se encontró la factura de importación '{import_invoice_number}' " + f"referenciada en la partida {line.line_number}." + ), + solution=[ + "Verificar el número de factura de importación en la partida.", + ], + code="SOURCE_INVOICE_NOT_FOUND", + ) + return fallback_tc + + return Decimal(str(source.financials.exchange_rate or 0)) or fallback_tc + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def assign_values( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + tenant_id: str, + company_id: str, + errors: ErrorCollector, +) -> None: + """ + ASIGNA_VALORES_PARTIDAS_ASIGNA_PESOS + + 1. Assigns unit costs and export values to every line (ME / MN / MC). + Strategy A (TCE): bulk assignment using the invoice exchange rate. + Strategy B (per-line): resolves exchange rate per source import invoice. + 2. Calls REVISA_UMA for each line. + """ + currency = invoice.financials.currency + tc = Decimal(str(invoice.financials.exchange_rate or 0)) + tc_mm = Decimal(str(invoice.financials.exchange_rate_mm or 0)) + + + # --- Step 1: Assign costs / values --------------------------------------- + # TODO: Read SisExp:ValFactTC from the export system parameters model. + # When ValFactTC = 'TCE' use _assign_costs_tce (single TC for all lines). + # Otherwise use _assign_costs_per_line (TC from each source import invoice). + # For now the per-line strategy is always used as the safe default. + val_fact_tc = "PER_LINE" # TODO: replace with SisExp.val_fact_tc + + if val_fact_tc == "TCE": + _assign_costs_tce(lines, currency, tc, tc_mm) + else: + _assign_costs_per_line(db, invoice, lines, currency, tc_mm, errors) + + # --- Step 2: REVISA_UMA -------------------------------------------------- + for line in lines: + revisa_uma(db=db, line=line, tenant_id=tenant_id, company_id=company_id, errors=errors) + + # --- Step 3: Assign weights ---------------------------------------------- + # In anexo76 will be calculated in realtime based in weight type by conversion factor + diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py new file mode 100644 index 00000000..940011b9 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/compare_balances.py @@ -0,0 +1,164 @@ +""" +COMPARA_SALDOS_POR_FACTURA +Compares the discharge queue (QueADescargar) against the available PEPS lots +(QSaldoActual) and distributes the quantity to discharge across the available +lots, updating ``entry.quantity_used`` and ``lot.available_qty`` accordingly. + +Also validates that the unit of measure on the export line matches the one +on the import lot. + +If after consuming all available lots a discharge entry still has remaining +quantity, no explicit error is raised here — the caller (compare_balances) +detects this and reports an insufficient-balance error. + +Legacy mapping +-------------- +QADesc:Cantidad → entry.quantity +QADesc:CantUsada → entry.quantity_used +QSaldo:Cantidad → lot.available_qty (net balance from ledger) +QSaldo:CantUsada → lot_used (tracked locally; lots are mutated in-place) +QADesc:UniMed → entry.unit_of_measure +QSaldo:UniMed → resolved from import line (stored on AvailableLot via uom) +""" + +from decimal import Decimal +from typing import List, Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector +from .discharge_types import AvailableLot, DownloadEntry + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _resolve_import_uom(db: Session, import_item_line_id: int) -> Optional[str]: + """ + Returns the unit-of-measure code of the import line (QSaldo:UniMed). + Equivalent to EqiPim:UnidadMedida resolved via the import LineItem. + """ + from api.v1.modules.a76.general_catalogs.units_of_measure.models import UnitOfMeasure + + row = db.execute( + select(LineItem.unit_of_measure).where(LineItem.id == import_item_line_id) + ).scalar_one_or_none() + + if row is None: + return None + + uom = db.get(UnitOfMeasure, row) + return uom.code if uom else None + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def compare_balances( + db: Session, + export_invoice: InvoiceHeader, + to_discharge: List[DownloadEntry], + errors: ErrorCollector, +) -> None: + """ + COMPARA_SALDOS_POR_FACTURA + For each discharge entry distributes the quantity to discharge across the + available PEPS lots attached to the entry by ``fill_available_balances``. + + Mutates ``entry.quantity_used`` and ``lot.available_qty`` in-place. + After this call, ``entry.quantity_used`` should equal ``entry.quantity`` + for every entry; if not, there is insufficient balance. + + Parameters + ---------- + db : active SQLAlchemy session + export_invoice : the export invoice being processed + to_discharge : list of DownloadEntry objects populated by + fill_available_balances (entry.available_lots must be set) + errors : shared error collector + """ + # Sort mirrors Clarion: + # Sort(QueADescargar, -Procedencia, FacturaImpo, LineaImpo) + # Sort(QSaldoActual, -Procedencia, FacturaImpo, LineaImpo) + sorted_entries = sorted( + to_discharge, + key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line), + reverse=True, + ) + + for entry in sorted_entries: + if not entry.available_lots: + # No lots were found for this entry — balance check will catch it + continue + + # ── Validate unit of measure matches the import lot ─────────────────── + first_lot = entry.available_lots[0] + import_uom = _resolve_import_uom(db, first_lot.import_item_line_id) + + if import_uom and entry.unit_of_measure and import_uom != entry.unit_of_measure: + errors.add_error( + field=f"line[{entry.export_line}].unit_of_measure", + message=( + f"La U.M.: '{entry.unit_of_measure}' de la partida: {entry.export_line} " + f"es diferente a la U.M: '{import_uom}' registrada en importación." + ), + solution=["Revisar la Partida de Exportación y cambiar la Unidad de Medida."], + code="UOM_MISMATCH", + value={ + "export_line": entry.export_line, + "export_uom": entry.unit_of_measure, + "import_uom": import_uom, + }, + ) + continue + + # ── Distribute quantity across available lots (PEPS order) ──────────── + # Lots are already ordered by order_peps (oldest first) from + # fill_available_balances; sort defensively here too. + sorted_lots: List[AvailableLot] = sorted( + entry.available_lots, key=lambda lot: lot.order_peps + ) + + for lot in sorted_lots: + remaining_entry = entry.quantity - entry.quantity_used + remaining_lot = lot.available_qty + + if remaining_entry <= 0: + break # Entry fully satisfied + + if remaining_lot <= 0: + continue # Lot exhausted — try next + + # Consume as much as possible from this lot + consume = min(remaining_entry, remaining_lot) + + entry.quantity_used += consume + lot.available_qty -= consume + + # ── Check if the entry was fully satisfied ──────────────────────────── + if entry.quantity_used < entry.quantity: + shortage = entry.quantity - entry.quantity_used + errors.add_error( + field=f"line[{entry.export_line}].quantity", + message=( + f"Saldo insuficiente para la partida: {entry.export_line}. " + f"Se requieren {entry.quantity} y solo hay {entry.quantity_used} disponibles " + f"(faltan {shortage})." + ), + solution=[ + "Verificar el saldo disponible de la Factura de Importación.", + "Reducir la cantidad a descargar.", + ], + code="INSUFFICIENT_BALANCE", + value={ + "export_line": entry.export_line, + "required": str(entry.quantity), + "available": str(entry.quantity_used), + "shortage": str(shortage), + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py new file mode 100644 index 00000000..f27140bf --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/discharge_types.py @@ -0,0 +1,68 @@ +""" +Shared dataclasses for the export invoice discharge process. + +Kept in a standalone module (no local imports) so that +download_balance_collector, review_series_exist, review_series_other_lines +and fill_available_balances can all import from here without circular deps. +""" + +from dataclasses import dataclass, field +from decimal import Decimal +from typing import List, Optional + + +@dataclass +class AvailableLot: + """ + One PEPS lot available for discharge — equivalent to one QSaldoActual record. + + Fields + ------ + import_item_line_id : a76.item_lines.id of the import line (the lot) + import_invoice_id : a76.invoice_header.id of the import invoice + part_number_id : denormalized from the import line + available_qty : net balance available (QSaldo:Cantidad) + value_me : USD value of the full lot (for proportional calc) + value_mn : MXN value of the full lot (for proportional calc) + order_peps : PEPS ordering key — lower = older = consumed first + """ + import_item_line_id: int + import_invoice_id: int + part_number_id: Optional[int] + available_qty: Decimal + value_me: Optional[Decimal] + value_mn: Optional[Decimal] + order_peps: int + + +@dataclass +class DownloadEntry: + """ + Represents one export line that will be discharged from inventory. + Equivalent to the QADesc (QueADescargar) record in the legacy system. + + Fields + ------ + origin_procedure : TipoMovImpo – 'TEM' (temporal) | 'DEF' (definitiva) + export_line : LineaExpo – line number on the export invoice + part_number : NumParte – part number code + class_code : Clase – class code + quantity : CantExpo – quantity to discharge + quantity_used : CantUsada – amount already consumed (starts at 0) + unit_of_measure : UniMed – unit of measure code + import_invoice : FacturaImpo – source import invoice number + import_line : LineaImpo – source import line number + line_item_id : internal DB id of the LineItem (for series lookups) + available_lots : PEPS lots attached by fill_available_balances + """ + origin_procedure: str + export_line: int + part_number: str + class_code: str + quantity: Decimal + unit_of_measure: str + import_invoice: str + import_line: int + line_item_id: int + quantity_used: Decimal = field(default_factory=Decimal) + available_lots: List[AvailableLot] = field(default_factory=list) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py new file mode 100644 index 00000000..ba9955f4 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/download_balance_collector.py @@ -0,0 +1,125 @@ +""" +QUIERE_DESCARGAR +Collects every export line item that has discharge = True and builds the +"QueADescargar" list used by the balance-verification steps that follow +(LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA, COMPARA_SALDOS_POR_FACTURA, +VERIFICAQCONSOLIDADO). + +For each discharge line the routine also triggers two series sub-validations: + · REVISA_SERIES_EXISTA → verifies the series to be discharged exist + · REVISA_SERIES_OTRAS_PAR → verifies the series are not already discharged + on another line (REVISA_SERIES_DESC was commented-out in the legacy) + +Legacy equivalent +----------------- +SELECT EqiPex.TipoMovImpo, EqiPex.LineaExpo, EqiPex.NumParte, + EqiPex.Clase, EqiPex.CantExpo, EqiPex.UnidadMedida, + EqiPex.FacturaImpo, EqiPex.LineaImpo +FROM QEqeMaq EqiPex +WHERE Consecutivo = + AND EqiPex.Descarga = 1 +""" + +from decimal import Decimal +from typing import List, Set, Tuple + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector +from .discharge_types import AvailableLot, DownloadEntry # re-exported for callers +from .series.review_series_exist import review_series_exist +from .series.review_series_other_lines import review_series_other_lines + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def collect_lines_to_discharge( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + errors: ErrorCollector, +) -> List[DownloadEntry]: + """ + QUIERE_DESCARGAR + Builds and returns the list of ``DownloadEntry`` records for every line + that has ``discharge = True``. If no lines have discharge enabled the + list is empty and the subsequent balance steps are skipped. + + For each collected line the function also runs: + · _revisa_series_exista + · _revisa_series_otras_par + + Parameters + ---------- + db : active SQLAlchemy session + invoice : the export invoice being processed + lines : all line items of the invoice (already loaded by pre_validators) + errors : shared error collector + + Returns + ------- + List[DownloadEntry] — the "QueADescargar" equivalent + """ + to_discharge: List[DownloadEntry] = [] + + discharge_lines = [line for line in lines if line.discharge] + + if not discharge_lines: + return to_discharge + + # Shared across all lines — accumulates series keys to detect duplicates + # between lines (equivalent to QueueSeries in the Clarion) + seen_series: Set[Tuple] = set() + + for line in discharge_lines: + origin_procedure = ( + line.customs.origin_procedure + if line.customs and line.customs.origin_procedure + else "" + ) + part_number = "" + if line.part_info: + part_number = line.part_info.part_number or "" + + class_code = "" + if line.class_info: + class_code = line.class_info.class_code or "" + + quantity = ( + line.quantity.quantity or Decimal(0) + if line.quantity + else Decimal(0) + ) + + uom_code = "" + if line.unit_of_measure_info: + uom_code = line.unit_of_measure_info.code or "" + + import_invoice_number = "" + import_line_number = 0 + if line.fa_data: + import_invoice_number = line.fa_data.search_invoice or "" + import_line_number = line.fa_data.search_line or 0 + + entry = DownloadEntry( + origin_procedure=origin_procedure, + export_line=line.line_number, + part_number=part_number, + class_code=class_code, + quantity=quantity, + quantity_used=Decimal(0), + unit_of_measure=uom_code, + import_invoice=import_invoice_number, + import_line=import_line_number, + line_item_id=line.id, + ) + to_discharge.append(entry) + + review_series_exist(db, invoice, line, entry, errors) + review_series_other_lines(db, invoice, line, entry, seen_series, errors) + + return to_discharge diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py new file mode 100644 index 00000000..51e3d176 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/fill_available_balances.py @@ -0,0 +1,276 @@ +""" +LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA +For each entry in ``to_discharge`` (QueADescargar), validates the source +import invoice/line and computes the net available balance from +``a24.balance_movement`` using the PEPS ledger. + +New logic vs. legacy +-------------------- +The legacy Clarion routine read ``CantImpo - CantRetornadaTemp - CantRetornada`` +directly from the import line record. Since we migrated to an append-only +ledger (a24.balance_movement), the available balance is now computed as: + + SUM(signed_qty) per import_item_line_id + +where sign = +1 for positive movement types and -1 for negative ones +(see NEGATIVE_MOVEMENTS set in the BalanceMovement model). + +Validations preserved from legacy +---------------------------------- +1. Import invoice must exist. +2. Import invoice must be processed (status != 'NA' / not 'unprocessed'). +3. Import invoice date must not be later than the export invoice date. +4. Import line must exist. +5. Net available balance must be > 0 (otherwise the lot is skipped). + +Skipped items (equivalent to legacy CYCLE) +------------------------------------------ +- Entries already seen in the same call (duplicate import_invoice + import_line + combination) — handled naturally since each entry is unique in QueADescargar. +- Lots with net balance <= 0. + +Output +------ +On success, ``entry.available_lots`` is populated with one ``AvailableLot`` +per lot that has available balance. Errors are added to ``errors``. +""" + +import datetime +from decimal import Decimal +from typing import List, Optional + +from sqlalchemy import case, func, select +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a24.balance_movements.models import BalanceMovement, NEGATIVE_MOVEMENTS +from core.exceptions import ErrorCollector +from .discharge_types import AvailableLot, DownloadEntry + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _fetch_import_invoice( + db: Session, + invoice_number: str, + export_invoice: InvoiceHeader, +) -> Optional[InvoiceHeader]: + return ( + db.query(InvoiceHeader) + .filter( + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.tenant_id == export_invoice.tenant_id, + InvoiceHeader.company_id == export_invoice.company_id, + ) + .first() + ) + + +def _fetch_import_line( + db: Session, + invoice_id: int, + line_number: int, + tenant_id: int, + company_id: int, +) -> Optional[LineItem]: + return ( + db.query(LineItem) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.line_number == line_number, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .first() + ) + + +def _net_balance_for_lot( + db: Session, + import_item_line_id: int, + as_of_date: datetime.date, +) -> Decimal: + """ + Computes the net available balance for one import lot as of ``as_of_date``. + + Equivalent to the legacy two-step calculation: + 1. Base check: CantImpo - CantRetornadaTemp - CantRetornada (general balance) + 2. CALCULA_SALDO_FECHA_EXPO: only count exits with operation_date <= export date + + In the new ledger, ENTRY movements have no operation_date restriction (the + lot exists from its import date). EXIT movements (CONSUMPTION, WASTE, etc.) + are only counted if their operation_date <= as_of_date, mirroring the + Clarion "FechaFactura > EqiFex:FechaFactura → CYCLE" guard. + + balance = SUM(+qty for ENTRY-type movements) + - SUM( qty for EXIT-type movements WHERE operation_date <= as_of_date) + """ + sign_expr = case( + (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), Decimal(-1)), + else_=Decimal(1), + ) + # Positive movements: always count (entries, returns, adjustments in) + # Negative movements: only count if they occurred on or before the export date + date_filter = case( + ( + BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), + BalanceMovement.operation_date <= as_of_date, + ), + else_=True, + ) + result = db.execute( + select(func.sum(sign_expr * BalanceMovement.quantity)).where( + BalanceMovement.import_item_line_id == import_item_line_id, + date_filter, + ) + ).scalar() + return Decimal(str(result or 0)) + + +def _peps_order_for_lot(db: Session, import_item_line_id: int) -> int: + """Returns the minimum (oldest) order_peps for this lot.""" + result = db.execute( + select(func.min(BalanceMovement.order_peps)).where( + BalanceMovement.import_item_line_id == import_item_line_id, + ) + ).scalar() + return int(result or 0) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def fill_available_balances( + db: Session, + export_invoice: InvoiceHeader, + to_discharge: List[DownloadEntry], + errors: ErrorCollector, +) -> None: + """ + LLENA_QUEUE_SALDOS_DISPONIBLES_FACTURA + Validates each discharge entry and populates ``entry.available_lots`` + with the net balance available from the PEPS ledger. + + Parameters + ---------- + db : active SQLAlchemy session + export_invoice : the export invoice being processed + to_discharge : list of DownloadEntry objects (QueADescargar) + errors : shared error collector + """ + export_date: datetime.date = ( + export_invoice.invoice_date.date() + if hasattr(export_invoice.invoice_date, "date") + else export_invoice.invoice_date + ) + + # Sort mirrors Clarion: Sort(QueADescargar, FacturaImpo, LineaImpo) + sorted_entries = sorted( + to_discharge, + key=lambda e: (e.import_invoice, e.import_line), + ) + + # Track already-resolved (invoice, line) pairs to skip duplicates + seen: set = set() + + for entry in sorted_entries: + key = (entry.import_invoice, entry.import_line) + if key in seen: + continue + seen.add(key) + + if not entry.import_invoice or entry.import_line == 0: + continue + + # ── 1. Validate import invoice ──────────────────────────────────────── + import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice) + + if import_invoice is None: + errors.add_error( + field=f"line[{entry.export_line}].import_invoice", + message=f"La Factura de Importación: '{entry.import_invoice}' no existe.", + solution=["Seleccionar otra factura de Importación."], + code="IMPORT_INVOICE_NOT_FOUND", + value=entry.import_invoice, + ) + continue + + # Status 'NA' == not processed (Clarion: Estatus = 'NA') + if import_invoice.status == InvoiceStatus.UNPROCESSED: + errors.add_error( + field=f"line[{entry.export_line}].import_invoice", + message=f"La Factura de Importación: '{entry.import_invoice}' está Desactualizada.", + solution=["Actualizar la factura de Importación."], + code="IMPORT_INVOICE_UNPROCESSED", + value=entry.import_invoice, + ) + continue + + # Import date must not be later than export date + imp_date: datetime.date = ( + import_invoice.invoice_date.date() + if hasattr(import_invoice.invoice_date, "date") + else import_invoice.invoice_date + ) + if imp_date > export_date: + errors.add_error( + field=f"line[{entry.export_line}].import_invoice", + message=( + f"La Factura de Importación: '{entry.import_invoice}' tiene una Fecha Mayor " + f"a la Fecha de Descarga." + ), + solution=[ + f"Seleccionar otra factura de Importación con Fecha Anterior al " + f"{export_date.strftime('%d/%m/%Y')}." + ], + code="IMPORT_INVOICE_DATE_AFTER_EXPORT", + value={"import_date": str(imp_date), "export_date": str(export_date)}, + ) + continue + + # ── 2. Validate import line ─────────────────────────────────────────── + import_line = _fetch_import_line( + db, + import_invoice.id, + entry.import_line, + export_invoice.tenant_id, + export_invoice.company_id, + ) + + if import_line is None: + errors.add_error( + field=f"line[{entry.export_line}].import_line", + message=( + f"La Factura de Importación: '{entry.import_invoice}' " + f"con Línea: {entry.import_line} no existe." + ), + solution=["Seleccionar otra Línea de Importación a Descargar."], + code="IMPORT_LINE_NOT_FOUND", + value={"import_invoice": entry.import_invoice, "import_line": entry.import_line}, + ) + continue + + # ── 3. Compute net available balance from ledger (as of export date) ─── + # Equivalent to: CantImpo - CantRetornadaTemp - CantRetornada (general) + # then CALCULA_SALDO_FECHA_EXPO (only exits on or before export_date). + available = _net_balance_for_lot(db, import_line.id, export_date) + if available <= 0: + # No balance — skip this lot (equivalent to Clarion CYCLE) + continue + + # ── 4. Build AvailableLot and attach to entry ───────────────────────── + fin = import_line.financial + lot = AvailableLot( + import_item_line_id=import_line.id, + import_invoice_id=import_invoice.id, + part_number_id=import_line.part_number_id, + available_qty=available, + value_me=Decimal(str(fin.value_usd or 0)) if fin else None, + value_mn=Decimal(str(fin.value_mxn or 0)) if fin else None, + order_peps=_peps_order_for_lot(db, import_line.id), + ) + entry.available_lots.append(lot) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py new file mode 100644 index 00000000..bb1bab97 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/finalize_invoice.py @@ -0,0 +1,280 @@ +""" +finalize_invoice_no_discharge / finalize_invoice_with_discharge +(TERMINA_AC_O_LP_NODES / TERMINA_AC_O_LP_NORMAL) + +Last step of export invoice processing. Both variants: + 1. TODO: DO REVISACLASESHABILITADAS + 2. Validate SisExp quantity / weight / value limits (min and max). + 3. If no errors: assign invoice-level totals and mark as PROCESSED. + +The "with_discharge" variant additionally: + 4. DO GENERAIMPODEFINITIVA (if is_regime_change and generate_id) + 5. DO REGISTRA_DESCARGA_IMPORTACION (update returned qty/value on import lines) + 6. DO REGISTRA_DESCARGA_SERIES (flag import series as exported) + +The legacy 'Of LP' branch (print-preview / progress-bar UI) is not ported. +""" + +import datetime +from decimal import Decimal +from typing import TYPE_CHECKING, List + +from sqlalchemy.orm import Session + +from .register_import_discharge import register_import_discharge +from .register_discharge_series import register_discharge_series + +if TYPE_CHECKING: + from .discharge_types import DownloadEntry + +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector +from .review_limits import limit_weight, limit_value +from .generate_definitive_import import generate_definitive_import + + +# --------------------------------------------------------------------------- +# REVISACLASESHABILITADAS +# --------------------------------------------------------------------------- + +def _review_enabled_classes( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + REVISACLASESHABILITADAS + Verifies that every line item's class is active (not disabled). + + Clarion: loops QEqeMaq for the invoice, fetches QClaAct by class code, + errors if HabilitaDeshabilitaClase = 1 → Python: Class.is_active = False. + """ + for line in lines: + if not line.class_id: + continue + cls: Class | None = db.get(Class, line.class_id) + if cls is not None and cls.is_active is False: + errors.add_error( + field=f"line[{line.line_number}].class", + message=( + f"La Clase: '{cls.class_code}' esta desactivada, " + "no se pueden hacer movimientos." + ), + solution=["Seleccionar una clase activa."], + code="CLASS_DISABLED", + ) + + +# --------------------------------------------------------------------------- +# SisExp limit checks (shared by both public functions) +# --------------------------------------------------------------------------- + +def _validate_sisexp_limits( + invoice: InvoiceHeader, + total_qty: Decimal, + total_net_weight: Decimal, + total_value: Decimal, + errors: ErrorCollector, +) -> None: + """ + Validates invoice totals against the SisExp min/max limit parameters. + + TODO: Read actual SisExp parameters from the tenant system-config model. + Until then all limits default to 0 (= disabled) so no checks fire. + + Clarion names → Python (TODO): + SisExp:CantLimiteMin / SisExp:CantLimite → qty min / max + SisExp:PesoLimiteMin / SisExp:PesoLimite → weight min / max + SisExp:ValorLimiteMin / SisExp:ValorLimite → value min / max + """ + # TODO: load from SisExp tenant config + cant_limite_min: Decimal = Decimal(0) + cant_limite: Decimal = Decimal(0) + peso_limite_min: Decimal = Decimal(0) + peso_limite: Decimal = Decimal(0) + valor_limite_min: Decimal = Decimal(0) + valor_limite: Decimal = Decimal(0) + + solution = ["Consulte a su Administrador de sistema para parametrizar la factura."] + code = "PAR.EXPO" + + if cant_limite_min != 0 and cant_limite_min > total_qty: + errors.add_error( + field="invoice.total_quantity", + message=( + f"La cantidad total de la factura: {total_qty} " + f"no supera a la cantidad mínima parametrizada: {cant_limite_min}." + ), + solution=solution, code=code, + ) + if cant_limite != 0 and cant_limite < total_qty: + errors.add_error( + field="invoice.total_quantity", + message=( + f"La cantidad total de la factura: {total_qty} " + f"excede a la cantidad máxima parametrizada: {cant_limite}." + ), + solution=solution, code=code, + ) + if peso_limite_min != 0 and peso_limite_min > total_net_weight: + errors.add_error( + field="invoice.net_weight", + message=( + f"El Peso Neto total de la factura: {total_net_weight} " + f"no supera el Peso mínimo parametrizado: {peso_limite_min}." + ), + solution=solution, code=code, + ) + if peso_limite != 0 and peso_limite < total_net_weight: + errors.add_error( + field="invoice.net_weight", + message=( + f"El Peso Neto total de la factura: {total_net_weight} " + f"excede el Peso máximo parametrizado: {peso_limite}." + ), + solution=solution, code=code, + ) + if valor_limite_min != 0 and valor_limite_min > total_value: + errors.add_error( + field="invoice.total_value", + message=( + f"El Valor total de la factura: {total_value} " + f"no supera el Valor mínimo parametrizado: {valor_limite_min}." + ), + solution=solution, code=code, + ) + if valor_limite != 0 and valor_limite < total_value: + errors.add_error( + field="invoice.total_value", + message=( + f"El Valor total de la factura: {total_value} " + f"excede el Valor máximo parametrizado: {valor_limite}." + ), + solution=solution, code=code, + ) + + +# --------------------------------------------------------------------------- +# DO ASIGNA_VALORES_FACTURA +# --------------------------------------------------------------------------- + +def _assign_invoice_totals( + invoice: InvoiceHeader, + lines: List[LineItem], +) -> None: + """ + DO ASIGNA_VALORES_FACTURA + Aggregates line-level values (MN, ME, qty, packages, net/gross weight) + and writes the totals to the invoice header, then marks it as PROCESSED. + + Clarion equivalent: + SELECT SUM(ValorExpoMN), SUM(ValorExpoME), SUM(CantExpo), + SUM(CantBultos), SUM(PesoNeto), SUM(PesoBruto) + FROM QEqeMaq WHERE Consecutivo = + + TODO: SisGen:CalValBaseTCPedExpo = 1 → invoice.financials.exchange_rate = Loc:TipoCambio + TODO: SisGen:CalValBaseTCPedExpo = 1 → + invoice.process_log = 'Se Actualizó con el Tipo de Cambio de la Fecha de Pago de Pedimento.' + TODO: SisGen:ActSeguridad = 1 → invoice.updated_by = current_user + """ + total_value_mn = Decimal(0) + total_value_me = Decimal(0) + total_qty = Decimal(0) + total_packages = 0 + total_net_weight = Decimal(0) + total_gross_weight = Decimal(0) + + for line in lines: + if line.financial: + total_value_mn += Decimal(str(line.financial.value_mxn or 0)) + total_value_me += Decimal(str(line.financial.value_usd or 0)) + if line.quantity: + total_qty += line.quantity.quantity or Decimal(0) + total_packages += line.quantity.package_quantity or 0 + total_net_weight += line.quantity.net_weight or Decimal(0) + total_gross_weight += line.quantity.gross_weight or Decimal(0) + + if invoice.financials is not None: + invoice.financials.value_mn = float(total_value_mn) + invoice.financials.value_me = float(total_value_me) + invoice.financials.total_quantity = float(total_qty) + invoice.financials.total_packages = total_packages + invoice.financials.net_weight = float(total_net_weight) + invoice.financials.gross_weight = float(total_gross_weight) + + invoice.party_count = len([l for l in lines if not (l.fa_data and l.fa_data.is_subitem)]) + invoice.updated_date = datetime.date.today() + invoice.status = InvoiceStatus.PROCESSED + + +# --------------------------------------------------------------------------- +# Public entry points +# --------------------------------------------------------------------------- + +def finalize_invoice_no_discharge( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + TERMINA_AC_O_LP_NODES + Finalizes a NODES-type export invoice (no inventory discharge). + + Flow: + 1. Verify all line classes are active (REVISACLASESHABILITADAS). + 2. Validate SisExp limits (qty / weight / value). + 3. If no errors: write invoice totals and set status = PROCESSED. + """ + _review_enabled_classes(db, invoice, lines, errors) + + total_qty, total_net_weight = limit_weight(lines) + total_value = limit_value(lines) + + _validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors) + + if not errors.has_errors(): + _assign_invoice_totals(invoice, lines) + + +def finalize_invoice_with_discharge( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + errors: ErrorCollector, + to_discharge: List["DownloadEntry"] | None = None, +) -> None: + """ + TERMINA_AC_O_LP_NORMAL + Finalizes a discharge-type export invoice (AFIJO / DONAC / SCRAP / REEXP / VEMEX). + + Flow: + 1. Verify all line classes are active (REVISACLASESHABILITADAS). + 2. Validate SisExp limits (qty / weight / value). + 3. If no errors: + a. DO GENERAIMPODEFINITIVA (only if is_regime_change and generate_id) + b. TODO: DO REGISTRA_DESCARGA_IMPORTACION (write a24 discharge movements) + c. TODO: DO REGISTRA_DESCARGA_SERIES (write series discharge records) + d. Write invoice totals and set status = PROCESSED. + + Note: the legacy 'Of LP' branch (print-preview UI) is not ported. + """ + _review_enabled_classes(db, invoice, lines, errors) + + total_qty, total_net_weight = limit_weight(lines) + total_value = limit_value(lines) + + _validate_sisexp_limits(invoice, total_qty, total_net_weight, total_value, errors) + + if not errors.has_errors(): + if invoice.compliance_mx and invoice.compliance_mx.is_regime_change and invoice.generate_id: + generate_definitive_import(db, invoice, errors) + + if to_discharge: + register_import_discharge(db, invoice, to_discharge) + register_discharge_series(db, invoice, to_discharge) + + _assign_invoice_totals(invoice, lines) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py new file mode 100644 index 00000000..221a7e86 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/generate_definitive_import.py @@ -0,0 +1,389 @@ +""" +GENERAIMPODEFINITIVA +Generates a definitive import invoice header from the export invoice when +processing a regime-change (cambio de régimen) export. + +If an invoice with the same number already exists as a definitive import, +the step is skipped (idempotent). After creating the header the function +calls the appropriate lines sub-routine based on ``generate_desc_parties``: + + 'Todas' → generate_definitive_import_all_lines + other → generate_definitive_import_discharged_lines + +Legacy equivalent +----------------- +GENERAIMPODEFINITIVA Routine + Access:QFacImpDef.TryFetch(EqiFID:FKFacImpoDef) + If ErrorCode() = 35 Then ← not found → create + INSERT INTO QFacImpDef (...) + End + IF EqiFex:GenPartidas = 'Todas' THEN + DO GENERAIMPODEFINITIVA_PARTIDAS_TODAS + ELSE + DO GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA + END +""" + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader, InvoiceStatus, OperationType +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.line_quantities.models import LineQuantity +from api.v1.modules.a76.items.line_customs.models import LineCustom +from api.v1.modules.a76.items.line_descriptions.models import LineDescription +from api.v1.modules.a76.items.series.models import Serie +from core.exceptions import ErrorCollector +from .discharge_types import DownloadEntry + + +# --------------------------------------------------------------------------- +# Header creation +# --------------------------------------------------------------------------- + +def _create_definitive_import_header( + db: Session, + export_invoice: InvoiceHeader, +) -> InvoiceHeader: + """ + Inserts a new InvoiceHeader of type 'IMD' (Importación Definitiva) cloning + the relevant fields from the export invoice. + + Clarion field mapping (EqiFex → EqiFID): + FacturaExpo → invoice_number + FechaFactura → invoice_date / updated_date + TipoCambio → financials.exchange_rate + TipoPeso → logistics.weight_type + Proveedor → provider_id + VendidoConsignado → sold_to_header ('Vendido a:') + VendidoA → sold_to_id + EnviadoTransferido → shipped_to_header ('Enviado a:') + EnviadoA → shipped_to_id + AAduanal → customs_broker_id + AAduanalAme → customs_broker_us_id + Aduana_Cruce → compliance_mx.aduana + Cant_Partidas → party_count + Transportista → logistics.carrier_id (approx) + Incoterm → logistics.incoterm + Precinto → logistics.precinto (approx) + SubEmpresa → sub_company (approx) + Flete / Seguros / etc. → financials.* + Observaciones → notes + TipoMoneda → financials.currency_type + ClaveMoneda → financials.currency (approx) + ModTrans → logistics.transport_mode (approx) + Ped_Pendiente_Asignar → compliance_mx.is_pedimento_pending + PedimentoExpo → compliance_mx.pedimento_id (approx) + Remesa → compliance_mx.remesa + Estatus → status = PENDING ('NA') + TipoDoc → invoice_type = 'IMD' + ProvImpoDefCR → 'C' (fixed — always definitiva por cambio de régimen) + Sujecion → 'MaqEquipo' (fixed) + """ + exp = export_invoice + exp_fin = exp.financials + exp_log = exp.logistics + exp_comp = exp.compliance_mx + + def_invoice = InvoiceHeader( + tenant_id=exp.tenant_id, + company_id=exp.company_id, + system=exp.system, + operation_type=OperationType.IMPORT, + invoice_type="IMD", + invoice_number=exp.invoice_number, + invoice_date=exp.invoice_date, + updated_date=exp.invoice_date, + party_count=exp.party_count, + generate_id=False, + status=InvoiceStatus.PENDING, + + # Clients / providers + provider_id=exp.provider_id, + sold_to_header="Vendido a:", + sold_to_id=exp.sold_to_id, + shipped_to_header="Enviado a:", + shipped_to_id=exp.shipped_to_id, + customs_broker_id=exp.customs_broker_id, + customs_broker_us_id=exp.customs_broker_us_id, + + # Notes + notes=exp.notes, + notes_english=exp.notes_english, + ) + db.add(def_invoice) + db.flush() # get def_invoice.id before creating child records + + # ── Financials ──────────────────────────────────────────────────────── + if exp_fin is not None: + from api.v1.modules.a76.invoices.models import InvoiceFinancials + def_fin = InvoiceFinancials( + tenant_id=exp.tenant_id, + company_id=exp.company_id, + invoice_id=def_invoice.id, + currency=exp_fin.currency, + currency_type=exp_fin.currency_type, + exchange_rate=exp_fin.exchange_rate, + freight=exp_fin.freight, + insurance=exp_fin.insurance, + insurance_value=exp_fin.insurance_value, + packaging=exp_fin.packaging, + other_increments=exp_fin.other_increments, + ) + db.add(def_fin) + + # ── Compliance / pedimento ──────────────────────────────────────────── + if exp_comp is not None: + from api.v1.modules.a76.invoices.models import InvoiceComplianceMX + def_comp = InvoiceComplianceMX( + tenant_id=exp.tenant_id, + company_id=exp.company_id, + invoice_id=def_invoice.id, + aduana=exp_comp.aduana, + remesa=exp_comp.remesa, + pedimento_id=exp_comp.pedimento_id, + is_pedimento_pending=exp_comp.is_pedimento_pending, + ) + db.add(def_comp) + + # ── Logistics ───────────────────────────────────────────────────────── + if exp_log is not None: + from api.v1.modules.a76.invoices.models import InvoiceLogistics + def_log = InvoiceLogistics( + tenant_id=exp.tenant_id, + company_id=exp.company_id, + invoice_id=def_invoice.id, + weight_type=exp_log.weight_type, + incoterm=exp_log.incoterm, + ) + db.add(def_log) + + db.flush() + return def_invoice + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def generate_definitive_import( + db: Session, + invoice: InvoiceHeader, + errors: ErrorCollector, +) -> InvoiceHeader | None: + """ + GENERAIMPODEFINITIVA + Creates a definitive import invoice header from ``invoice`` (export) when + processing a regime-change export. + + If a definitive import with the same ``invoice_number`` already exists, + the function is a no-op and returns the existing record. + + After creating (or finding) the header, delegates to the lines sub-routine: + generate_desc_parties == 'Todas' → TODO: GENERAIMPODEFINITIVA_PARTIDAS_TODAS + otherwise → TODO: GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA + + Parameters + ---------- + db : active SQLAlchemy session + invoice : the export invoice being processed (regime-change type) + errors : shared error collector + + Returns + ------- + The existing or newly created definitive import InvoiceHeader, or None if + a non-blocking SQL error occurred. + """ + # ── 1. Check whether the definitive import already exists ──────────────── + existing: InvoiceHeader | None = db.execute( + select(InvoiceHeader).where( + InvoiceHeader.tenant_id == invoice.tenant_id, + InvoiceHeader.company_id == invoice.company_id, + InvoiceHeader.invoice_number == invoice.invoice_number, + InvoiceHeader.invoice_type == "IMD", + ) + ).scalar_one_or_none() + + if existing is not None: + def_invoice = existing + else: + # ── 2. Create the definitiva header ────────────────────────────────── + def_invoice = _create_definitive_import_header(db, invoice) + + # ── 3. Generate lines ──────────────────────────────────────────────────── + # to_discharge / all_lines must be passed by the caller after this returns. + # See generate_definitive_import_all_lines() and + # generate_definitive_import_discharged_lines() below. + + return def_invoice + + +# --------------------------------------------------------------------------- +# Shared helper — copies one export line into the definitive import invoice +# (EqiPex → EqiPdf, identical body in both PARTIDAS_CON_DESCARGA and +# PARTIDAS_TODAS Clarion routines) +# --------------------------------------------------------------------------- + +def _copy_line_to_definitive( + db: Session, + export_line: LineItem, + def_invoice: InvoiceHeader, + def_line_number: int, +) -> None: + """ + Copies a single export LineItem (and its series) into a new definitive + import LineItem under ``def_invoice``. + + Clarion fixed values: + EsSubPartida = 'P' → is_subitem = False + ContieneSubP = 'N' → contains_subitems = False + SubPartida = 0 → subitem_number = 0 + EsReparacion = 0 → (no repair flag needed) + """ + from api.v1.modules.a76.items.line_financials.models import LineFinancial + from api.v1.modules.a24.fa.fa_item_lines.models import FaLineItem + + def_line = LineItem( + tenant_id=def_invoice.tenant_id, + company_id=def_invoice.company_id, + invoice_id=def_invoice.id, + line_number=def_line_number, + part_number_id=export_line.part_number_id, + class_id=export_line.class_id, + unit_of_measure=export_line.unit_of_measure, + ) + db.add(def_line) + db.flush() # get def_line.id + + if export_line.quantity: + src_q = export_line.quantity + db.add(LineQuantity( + item_line_id=def_line.id, + quantity=src_q.quantity, + net_weight=src_q.net_weight, + gross_weight=src_q.gross_weight, + package_quantity=src_q.package_quantity, + package_id=src_q.package_id, + )) + + if export_line.financial: + db.add(LineFinancial( + item_line_id=def_line.id, + unit_cost_capture=export_line.financial.unit_cost_capture, + )) + + if export_line.customs: + src_c = export_line.customs + db.add(LineCustom( + item_line_id=def_line.id, + fraction=src_c.fraction, + fraction_type=src_c.fraction_type, + rate=src_c.rate, + sector=src_c.sector, + origin_country=src_c.origin_country, + )) + + if export_line.description: + src_d = export_line.description + db.add(LineDescription( + item_line_id=def_line.id, + description_spanish=src_d.description_spanish, + extra_description=src_d.extra_description, + description_english=src_d.description_english, + package_description=src_d.package_description, + brand=src_d.brand, + model=src_d.model, + has_serial=src_d.has_serial, + )) + + db.add(FaLineItem( + item_line_id=def_line.id, + is_subitem=False, + contains_subitems=False, + subitem_number=0, + )) + + # Series: QSeriesExpo → QSeriesDef + export_series: list[Serie] = ( + db.execute(select(Serie).where(Serie.line_item_id == export_line.id)) + .scalars() + .all() + ) + for serie in export_series: + db.add(Serie( + tenant_id=def_invoice.tenant_id, + company_id=def_invoice.company_id, + line_item_id=def_line.id, + row=serie.row, + serial_numbers=serie.serial_numbers, # SerieImpo ← SerieExpo + model=serie.model, # ModeloImpo ← ModeloExpo + brand=serie.brand, # ParteImpo ← ParteExpo + )) + + +# --------------------------------------------------------------------------- +# GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA +# --------------------------------------------------------------------------- + +def generate_definitive_import_discharged_lines( + db: Session, + export_invoice: InvoiceHeader, + def_invoice: InvoiceHeader, + to_discharge: list[DownloadEntry], + errors: ErrorCollector, +) -> None: + """ + GENERAIMPODEFINITIVA_PARTIDAS_CON_DESCARGA + Creates definitive import lines only for the lines in the discharge list, + sorted by (import_invoice, import_line). + + Clarion: Sort(QueADescargar, FacturaImpo, LineaImpo) → loop + """ + sorted_entries = sorted( + to_discharge, + key=lambda e: (e.import_invoice, e.import_line), + ) + def_line_number = 0 + for entry in sorted_entries: + export_line: LineItem | None = db.get(LineItem, entry.line_item_id) + if export_line is None: + continue + def_line_number += 1 + _copy_line_to_definitive(db, export_line, def_invoice, def_line_number) + + db.flush() + + +# --------------------------------------------------------------------------- +# GENERAIMPODEFINITIVA_PARTIDAS_TODAS +# --------------------------------------------------------------------------- + +def generate_definitive_import_all_lines( + db: Session, + export_invoice: InvoiceHeader, + def_invoice: InvoiceHeader, + errors: ErrorCollector, +) -> None: + """ + GENERAIMPODEFINITIVA_PARTIDAS_TODAS + Creates definitive import lines for ALL lines of the export invoice, + sorted by line_number (QueuePartidaID sorted by LineaExpo). + + Clarion: Sort(QueuePartidaID, LineaExpo) → loop over all export lines + """ + export_lines: list[LineItem] = ( + db.execute( + select(LineItem) + .where(LineItem.invoice_id == export_invoice.id) + .order_by(LineItem.line_number) + ) + .scalars() + .all() + ) + + def_line_number = 0 + for export_line in export_lines: + def_line_number += 1 + _copy_line_to_definitive(db, export_line, def_invoice, def_line_number) + + db.flush() diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_series.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_series.py new file mode 100644 index 00000000..c6000ee4 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_discharge_series.py @@ -0,0 +1,181 @@ +""" +REGISTRA_DESCARGA_SERIES +Marks each import serie that is being discharged by this export invoice by +setting ``discharge = True`` (Clarion: ``SerImp:SerieExpo = 1``). + +The routine iterates over every export serie with ``discharge = True``, +resolves the corresponding import serie (via ``import_serie_row`` or serial +number lookup), and flags it as exported so it cannot be discharged again. + +Clarion mapping +--------------- +QueueSeries records (SerDes) → export Serie rows with discharge=True, + grouped by DownloadEntry +SerDes:ConsectivoImpo → import InvoiceHeader.id (via invoice_number) +SerDes:LineaImpo → import LineItem.line_number +SerDes:Renglon → Serie.row on the import side +SerDes:Procedencia → entry.origin_procedure ('TEM' | 'DEF') +SerImp/SerDef:SerieExpo = 1 → import_serie.discharge = True + +Legacy equivalent +----------------- +Sort(QueueSeries, -Procedencia, ConsectivoImpo, LineaImpo) +Loop: GET import serie by (Consecutivo, LineaImpo, Renglon) → set SerieExpo=1 +""" + +from typing import List, Optional + +from sqlalchemy import select +from sqlalchemy.orm import Session, joinedload + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.series.models import Serie +from .discharge_types import DownloadEntry + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _fetch_import_invoice( + db: Session, + invoice_number: str, + export_invoice: InvoiceHeader, +) -> Optional[InvoiceHeader]: + return ( + db.query(InvoiceHeader) + .filter( + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.tenant_id == export_invoice.tenant_id, + InvoiceHeader.company_id == export_invoice.company_id, + ) + .first() + ) + + +def _fetch_import_line_id( + db: Session, + invoice_id: int, + line_number: int, + tenant_id: int, + company_id: int, +) -> Optional[int]: + return db.execute( + select(LineItem.id).where( + LineItem.invoice_id == invoice_id, + LineItem.line_number == line_number, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + ).scalar_one_or_none() + + +def _fetch_import_serie( + db: Session, + import_line_id: int, + row: int, +) -> Optional[Serie]: + """Fetch the import serie by (line_item_id, row) — equiv. TryFetch PKConsec_Lin_Ren.""" + return db.execute( + select(Serie).where( + Serie.line_item_id == import_line_id, + Serie.row == row, + ) + ).scalar_one_or_none() + + +def _resolve_import_serie_row( + db: Session, + import_line_id: int, + serial_number: str, +) -> Optional[int]: + """ + Fallback: find the import serie row by matching serial_number when + export_serie.serie_row is not set. + """ + return db.execute( + select(Serie.row).where( + Serie.line_item_id == import_line_id, + Serie.serial_numbers == serial_number, + ) + ).scalar_one_or_none() + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def register_discharge_series( + db: Session, + export_invoice: InvoiceHeader, + to_discharge: List[DownloadEntry], +) -> None: + """ + REGISTRA_DESCARGA_SERIES + For every export serie with ``discharge = True`` on each entry in + ``to_discharge``, locates the matching import serie and marks it as + discharged (``discharge = True``). + + Parameters + ---------- + db : active SQLAlchemy session + export_invoice : the export invoice being processed + to_discharge : list of DownloadEntry records (QueADescargar) + """ + # Sort mirrors Clarion: Sort(QueueSeries, -Procedencia, ConsectivoImpo, LineaImpo) + # Descending procedencia puts 'TEM' before 'DEF' (T > D alphabetically) + sorted_entries = sorted( + to_discharge, + key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line), + reverse=True, + ) + + for entry in sorted_entries: + if not entry.import_invoice or entry.import_line == 0: + continue + + # ── Resolve import invoice and line ─────────────────────────────────── + import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice) + if import_invoice is None: + continue + + import_line_id = _fetch_import_line_id( + db, + import_invoice.id, + entry.import_line, + export_invoice.tenant_id, + export_invoice.company_id, + ) + if import_line_id is None: + continue + + # ── Fetch all export series for this discharge line ─────────────────── + export_series: List[Serie] = ( + db.execute( + select(Serie).where( + Serie.line_item_id == entry.line_item_id, + Serie.discharge == True, # noqa: E712 + ) + ) + .scalars() + .all() + ) + + for export_serie in export_series: + # Resolve which row in the import series table this corresponds to + import_row = export_serie.serie_row + if import_row is None: + import_row = _resolve_import_serie_row( + db, import_line_id, export_serie.serial_numbers or "" + ) + + if import_row is None: + continue + + import_serie = _fetch_import_serie(db, import_line_id, import_row) + if import_serie is None: + continue + + # SerImp:SerieExpo = 1 (or SerDef:SerieExpo = 1) + import_serie.discharge = True diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_import_discharge.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_import_discharge.py new file mode 100644 index 00000000..d9409bd1 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/register_import_discharge.py @@ -0,0 +1,173 @@ +""" +REGISTRA_DESCARGA_IMPORTACION +Updates the import line items (temporary or definitive) with the returned +quantities and values consumed by this export invoice. + +For each entry in ``to_discharge`` (QSaldoActual in the legacy) the routine: + · Looks up the source import invoice header (TEM → QFacImp, DEF → QFacImpDef). + · Looks up the corresponding import line item. + · Increments quantity_returned, value_returned_mxn, value_returned_usd on the + import line's quantity/financial sub-records. + · For TEM invoices, also calculates vat_used_mxn / vat_used_usd when the + import invoice date is on or after 2014-12-31 (Clarion date 78165). + +Legacy equivalent +----------------- +Loop QSaldoActual: + If TEM → fetch QFacImp + QEqiMaq, update CantRetornada, ValorRetornadoMN/ME, + ValorIVAMNUsado / ValorIVAMEUsado + Else → fetch QFacImpDef + QEqiDef, update CantRetornada, ValorRetornadoMN/ME +""" + +import datetime +from decimal import Decimal +from typing import List, Optional + +from sqlalchemy.orm import Session, joinedload + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from .discharge_types import DownloadEntry + +# Cutoff date: Clarion day 78165 ≈ 2014-12-31 +_VAT_CUTOFF = datetime.date(2014, 12, 31) + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _fetch_import_invoice( + db: Session, + invoice_number: str, + export_invoice: InvoiceHeader, +) -> Optional[InvoiceHeader]: + """Return the import InvoiceHeader that matches *invoice_number*.""" + return ( + db.query(InvoiceHeader) + .filter( + InvoiceHeader.invoice_number == invoice_number, + InvoiceHeader.tenant_id == export_invoice.tenant_id, + InvoiceHeader.company_id == export_invoice.company_id, + ) + .first() + ) + + +def _fetch_import_line( + db: Session, + invoice_id: int, + line_number: int, + tenant_id: int, + company_id: int, +) -> Optional[LineItem]: + """Return the LineItem for *invoice_id* / *line_number*, with financial and quantity loaded.""" + return ( + db.query(LineItem) + .options( + joinedload(LineItem.quantity), + joinedload(LineItem.financial), + ) + .filter( + LineItem.invoice_id == invoice_id, + LineItem.line_number == line_number, + LineItem.tenant_id == tenant_id, + LineItem.company_id == company_id, + ) + .first() + ) + + +# --------------------------------------------------------------------------- +# Public entry point +# --------------------------------------------------------------------------- + +def register_import_discharge( + db: Session, + export_invoice: InvoiceHeader, + to_discharge: List[DownloadEntry], +) -> None: + """ + REGISTRA_DESCARGA_IMPORTACION + Accumulates discharged quantities and values back onto the source import + line items (temporal or definitive). + + Parameters + ---------- + db : active SQLAlchemy session + export_invoice : the export invoice being processed + to_discharge : list of DownloadEntry records (QSaldoActual equivalent); + each entry's ``quantity`` holds the amount consumed + (QSaldo:CantUsada in the legacy). + """ + # Sort mirrors the Clarion: Sort(QSaldoActual, -Procedencia, FacturaImpo, LineaImpo) + # (descending procedencia puts 'TEM' before 'DEF' alphabetically reversed) + sorted_entries = sorted( + to_discharge, + key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line), + reverse=False, + ) + + for entry in sorted_entries: + qty_used = entry.quantity # CantUsada — full entry qty consumed + + if not entry.import_invoice or entry.import_line == 0: + continue + + # ── Fetch source import invoice header ─────────────────────────────── + import_invoice = _fetch_import_invoice(db, entry.import_invoice, export_invoice) + if import_invoice is None: + continue + + # ── Fetch source import line item ───────────────────────────────────── + import_line = _fetch_import_line( + db, + import_invoice.id, + entry.import_line, + export_invoice.tenant_id, + export_invoice.company_id, + ) + if import_line is None: + continue + + # ── Calculate proportional values ───────────────────────────────────── + # value_returned = qty_used * (line_value / line_qty) + fin = import_line.financial + qty_rec = import_line.quantity + + if fin is None or qty_rec is None: + continue + + original_qty = qty_rec.quantity or Decimal(0) + if original_qty == 0: + continue + + value_mn = Decimal(str(fin.value_mxn or 0)) + value_usd = Decimal(str(fin.value_usd or 0)) + + returned_mn = qty_used * value_mn / original_qty + returned_usd = qty_used * value_usd / original_qty + + # ── Accumulate returned qty and value ───────────────────────────────── + qty_rec.quantity_returned = (qty_rec.quantity_returned or Decimal(0)) + qty_used + + fin.value_returned_mxn = (fin.value_returned_mxn or Decimal(0)) + returned_mn + fin.value_returned_usd = (fin.value_returned_usd or Decimal(0)) + returned_usd + + # ── VAT used (TEM only, and only for invoices on/after cutoff date) ─── + # Clarion: IF EqiFim:FechaFactura > 78165 (≈ 2014-12-31) + if entry.origin_procedure == "TEM": + inv_date = import_invoice.invoice_date + if isinstance(inv_date, datetime.datetime): + inv_date = inv_date.date() + + if inv_date and inv_date >= _VAT_CUTOFF: + iva_factor = Decimal(0) + if import_invoice.financials and import_invoice.financials.iva_factor: + iva_factor = Decimal(str(import_invoice.financials.iva_factor)) + + fin.vat_used_mxn = (returned_mn * iva_factor) / 100 + fin.vat_used_usd = (returned_usd * iva_factor) / 100 + else: + fin.vat_used_mxn = Decimal(0) + fin.vat_used_usd = Decimal(0) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_class.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_class.py new file mode 100644 index 00000000..4857f926 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_class.py @@ -0,0 +1,181 @@ +""" +REVISA_CLASE +Validates that every line item of an export invoice has a valid class in the +catalog (QClaAct) and that the associated tariff fraction exists in either +the active fractions catalog (SFracciones) or the historical catalog +(GFraccionesHistorico). + +Two-pass logic (ported from legacy SCAII): + Pass A – no class errors: + Iterate all lines and validate their fractions. + Pass B – class errors detected: + Report each missing class and also validate its fraction. +""" + +from typing import List + +from sqlalchemy import func, or_ +from sqlalchemy.orm import Session + +from api.v1.modules.a76.classes.models import Class +from api.v1.modules.a76.general_catalogs.fractions.historical_tariff_fractions.models import ( + HistoricalTariffFraction, +) +from api.v1.modules.a76.general_catalogs.fractions.tariff_fractions.models import TariffFraction +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector + + +# --------------------------------------------------------------------------- +# Fraction helpers (shared with imports, same catalog sources) +# --------------------------------------------------------------------------- + +def _fraction_exists_via_sitar(fraction_code: str) -> bool: + """ + Returns True when the fraction exists in SITAR (active or anterior). + Falls back to False when SITAR is not configured or unreachable. + + Format: first 8 chars = base fraction, chars 9-10 = NICO/country (optional). + """ + if not fraction_code: + return True + + base_frac = fraction_code[:8].strip() + nico = fraction_code[8:10].strip() if len(fraction_code) > 8 else "" + + try: + from api.v1.modules.sitar.fracciones.service import FraccionesService + from api.v1.modules.sitar.fracciones_anteriores.service import ( + FraccionesAnterioresService, + ) + + results = FraccionesService.search_sync( + fraccion=base_frac, + nico=nico if nico else None, + limit=1, + ) + if results: + return True + + hist_results = FraccionesAnterioresService.search_sync( + fraccion_anterior=base_frac, + limit=1, + ) + return len(hist_results) > 0 + + except Exception: + return False + + +def _fraction_exists_in_local_db(db: Session, fraction_code: str) -> bool: + """Fallback: validates against local TariffFraction and HistoricalTariffFraction tables.""" + if not fraction_code: + return True + + base_frac = fraction_code[:8] + nico = fraction_code[8:10] if len(fraction_code) > 8 else "" + + tariff_q = db.query(TariffFraction).filter( + func.left(TariffFraction.code, 8) == base_frac + ) + if nico: + tariff_q = tariff_q.filter(TariffFraction.nico == nico) + else: + tariff_q = tariff_q.filter( + or_(TariffFraction.nico.is_(None), TariffFraction.nico == "") + ) + if tariff_q.first() is not None: + return True + + hist_q = db.query(HistoricalTariffFraction).filter( + HistoricalTariffFraction.historical_fraction == base_frac + ) + if nico: + hist_q = hist_q.filter(HistoricalTariffFraction.country == nico) + else: + hist_q = hist_q.filter( + or_( + HistoricalTariffFraction.country.is_(None), + HistoricalTariffFraction.country == "", + ) + ) + return hist_q.first() is not None + + +def _fraction_exists_in_catalog(db: Session, fraction_code: str) -> bool: + """SITAR first, local DB as fallback.""" + if _fraction_exists_via_sitar(fraction_code): + return True + return _fraction_exists_in_local_db(db, fraction_code) + + +def _validate_line_fraction( + db: Session, + line: LineItem, + errors: ErrorCollector, +) -> None: + """Adds a FRACCION error when the line's export fraction is not in any catalog.""" + fraction = line.customs.fraction if line.customs else None + if not fraction: + return + + if _fraction_exists_in_catalog(db, fraction): + return + + class_code = line.class_info.class_code if line.class_info else "" + errors.add_error( + field=f"line[{line.line_number}].fraction", + message=( + f"La Factura contiene la fraccion: {fraction} asociada al Clase {class_code} " + "que no existe en el catálogo de fracciones" + ), + solution=["Agregar la fracción a fracciones históricas."], + code="FRACCION", + ) + + +# --------------------------------------------------------------------------- +# Main entry point +# --------------------------------------------------------------------------- + +def review_class( + db: Session, + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + Validates class and fraction integrity for all line items of an export invoice. + + Logic ported from legacy REVISA_CLASE: + + 1. Count line items whose class_id is not present in the active classes catalog. + 2a. No class errors (TotalReg = 0): + Iterate every line and validate its tariff fraction. + 2b. Class errors found (TotalReg > 0): + For each invalid line: report a CLASE error, then validate its fraction. + """ + invalid_class_lines = [ + line for line in lines + if line.class_id is None or db.get(Class, line.class_id) is None + ] + has_class_errors = bool(invalid_class_lines) + + if not has_class_errors: + # Pass A: all classes exist — validate fractions for every line + for line in lines: + _validate_line_fraction(db, line, errors) + else: + # Pass B: report missing classes and validate their fractions + for line in invalid_class_lines: + class_code = line.class_info.class_code if line.class_info else "" + errors.add_error( + field=f"line[{line.line_number}].class", + message=f"La clase: {class_code or '(vacía)'} no existe en catálogo de clases", + solution=[ + f"Borrar la partida: {line.line_number}, " + f"o dar de alta la clase: {class_code or '(vacía)'} en el catálogo de Clases" + ], + code="CLASE", + ) + _validate_line_fraction(db, line, errors) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py new file mode 100644 index 00000000..9e3142e1 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_exchange_rate.py @@ -0,0 +1,91 @@ +""" +REVISA_TIPOCAMBIO +Validates that the exchange rate captured on the export invoice matches the +rate registered in the exchange-rate catalogue for the invoice date. + +Only runs when SisGen:CalValBaseTCPedExpo = 0 (use invoice-date TC, not +pedimento-payment-date TC). When the flag is 1 the TC is taken from the +pedimento and this check is skipped — that branch is handled in the TODO +for step 7 of main_process. + +Clarion mapping +--------------- +gtipocambio → a76.exchange_rate (ExchangeRate model) +FECHA → ExchangeRate.date (cast to DATE for comparison) +VALOR → ExchangeRate.value +EqiFex:FechaFactura → invoice.invoice_date +EqiFex:TipoCambio → invoice.financials.exchange_rate +""" + +import datetime +from decimal import Decimal +from typing import Optional + +from sqlalchemy import cast, Date, select +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.general_catalogs.exchange_rate.models import ExchangeRate +from core.exceptions import ErrorCollector + + +def review_exchange_rate( + db: Session, + invoice: InvoiceHeader, + errors: ErrorCollector, +) -> None: + """ + REVISA_TIPOCAMBIO + Checks that the invoice's exchange rate matches the catalogue value for + the invoice date. + + Parameters + ---------- + db : active SQLAlchemy session + invoice : the export invoice being processed + errors : shared error collector + """ + # TODO: skip when SisGen:CalValBaseTCPedExpo = 1 + # (TC is taken from pedimento payment date, validated elsewhere) + + if not invoice.financials: + return + + invoice_date: datetime.date = ( + invoice.invoice_date.date() + if hasattr(invoice.invoice_date, "date") + else invoice.invoice_date + ) + + # Look up the catalogue rate for the invoice date + catalogue_rate: Optional[ExchangeRate] = db.execute( + select(ExchangeRate).where( + ExchangeRate.tenant_id == invoice.tenant_id, + ExchangeRate.company_id == invoice.company_id, + cast(ExchangeRate.date, Date) == invoice_date, + ) + ).scalar_one_or_none() + + if catalogue_rate is None: + # No rate registered for this date — cannot validate, skip + # (the Clarion loop simply finds no rows and exits cleanly) + return + + invoice_tc = Decimal(str(invoice.financials.exchange_rate or 0)) + catalogue_tc = Decimal(str(catalogue_rate.value or 0)) + + if invoice_tc != catalogue_tc: + errors.add_error( + field="financials.exchange_rate", + message="No está capturado correctamente el Tipo de Cambio.", + solution=[ + "Capture o modifique el tipo de cambio que corresponda a la " + "factura en el catálogo de Tipo de Cambio." + ], + code="EXCHANGE_RATE_MISMATCH", + value={ + "invoice_date": str(invoice_date), + "invoice_tc": str(invoice_tc), + "catalogue_tc": str(catalogue_tc), + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py new file mode 100644 index 00000000..b62759f8 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_limits.py @@ -0,0 +1,80 @@ +""" +TOT_PAR_LIM_CANT_PESO / TOT_PAR_LIM_VALOR +Computes invoice-level totals (quantity, net weight, capture value) from all +line items and writes them back to the invoice financials. + +These totals are used downstream to enforce the SisExp limit parameters +(CantLimite, PesoLimite, ValorLimite — TODO when SisExp model is available). + +Legacy equivalents +------------------ +TOT_PAR_LIM_CANT_PESO: + SELECT SUM(CantExpo), SUM(PesoNeto) + FROM QEqeMaq + WHERE Consecutivo = + → stored in Loc:CantExpoLim, Loc:PesoNetoLim + +TOT_PAR_LIM_VALOR: + SELECT SUM(CostoUnitarioCaptura * CantExpo) + FROM QEqeMaq + WHERE Consecutivo = + → stored in Loc:ValorExpoLim +""" + +from decimal import Decimal +from typing import List + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector + + +def limit_weight( + lines: List[LineItem], +) -> tuple[Decimal, Decimal]: + """ + TOT_PAR_LIM_CANT_PESO + Sums exported quantity and net weight across all line items and Returns the totals. + + Returns + ------- + (total_quantity, total_net_weight) + after the call. + """ + total_qty = Decimal(0) + total_net_weight = Decimal(0) + + for line in lines: + if line.quantity is None: + continue + total_qty += line.quantity.quantity or Decimal(0) + total_net_weight += line.quantity.net_weight or Decimal(0) + + return total_qty, total_net_weight + + +def limit_value( + lines: List[LineItem], +) -> Decimal: + """ + TOT_PAR_LIM_VALOR + Sums (unit_cost_capture × quantity) across all line items and writes the + result to ``invoice.financials.value_mn`` as the capture-based total value. + + Returns + ------- + total_capture_value — also available on invoice.financials after the call. + + Note: the legacy field Loc:ValorExpoLim is only used to compare against + SisExp limit parameters (TODO when SisExp model is available). + """ + total_value = Decimal(0) + + for line in lines: + if line.financial is None or line.quantity is None: + continue + capture = line.financial.unit_cost_capture or Decimal(0) + qty = line.quantity.quantity or Decimal(0) + total_value += capture * qty + + return total_value diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_origin_procedure.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_origin_procedure.py new file mode 100644 index 00000000..5e3783fd --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_origin_procedure.py @@ -0,0 +1,74 @@ +""" +REVISA_PROCEDENCIA_PAR +Validates that every line item of the export invoice has an import origin +procedure (TipoMovImpo) that matches what the invoice type requires: + + · Regime-change (AFIJO / SCRAP with EsCambioRegimen='S') → all lines must be 'TEM' + · REEXP / VEMEX → all lines must be 'DEF' + +Clarion mapping +--------------- +Loc:Procedencia → expected_procedure parameter ('TEM' | 'DEF') +GSQLFile2.SQL2:C2 → line.customs.origin_procedure +GSQLFile2.SQL2:C1 → line.line_number +EqiFex:TipoFactura → invoice.invoice_type +""" + +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector + + +def review_origin_procedure( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + expected_procedure: str, + errors: ErrorCollector, +) -> None: + """ + REVISA_PROCEDENCIA_PAR + Verifies that every export line's import origin procedure matches + ``expected_procedure``. + + Parameters + ---------- + db : active SQLAlchemy session + invoice : the export invoice being processed + lines : all line items of the invoice + expected_procedure : 'TEM' (regime-change) or 'DEF' (REEXP / VEMEX) + errors : shared error collector + """ + expected = expected_procedure.strip().upper() + + for line in lines: + line_procedure = ( + (line.customs.origin_procedure or "").strip().upper() + if line.customs + else "" + ) + + if line_procedure != expected: + errors.add_error( + field=f"line[{line.line_number}].origin_procedure", + message=( + f"La partida: {line.line_number} tiene una factura de importación " + f"de procedencia: '{line_procedure}', diferente a la que acepta el " + f"Tipo de Factura: '{invoice.invoice_type}'." + ), + solution=[ + "Para Cambio de Régimen todo debe ser procedencia TEM, " + "para Ventas y Reexpediciones debe ser procedencia DEF." + ], + code="INVALID_ORIGIN_PROCEDURE", + value={ + "line_number": line.line_number, + "found": line_procedure, + "expected": expected, + "invoice_type": invoice.invoice_type, + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py new file mode 100644 index 00000000..7dad03d8 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_qty_vs_weight.py @@ -0,0 +1,72 @@ +""" +REVISA_CANT_vs_PESONETO_KGS / REVISA_CANT_vs_PESONETO_LBS +Validates that the net weight of each export line matches its quantity +when the line's unit of measure is weight-based (KGS or LBS). + +Rule (identical for both variants, only the unit differs): + - KGS: if UnitOfMeasure = 'KGS' → net_weight_kgs must equal quantity + - LBS: if UnitOfMeasure = 'LBS' → net_weight_lbs must equal quantity + +Legacy equivalents +------------------ +KGS: + SELECT COUNT(*) FROM QEqeMaq + WHERE Consecutivo = AND UnidadMedida = 'KGS' AND PesoNetoKGS <> CantExpo + +LBS: + SELECT COUNT(*) FROM QEqeMaq + WHERE Consecutivo = AND UnidadMedida = 'LBS' AND PesoNetoLBS <> CantExpo +""" + +from decimal import Decimal +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector + +_UNIT_KGS = "KGS" +_UNIT_LBS = "LBS" + + +def review_qty_vs_weight( + lines: List[LineItem], + unit_code: str, + errors: ErrorCollector, +) -> None: + """ + Generic validator used by both KGS and LBS variants. + + For every line whose unit of measure code matches ``unit_code``, + checks that the exported quantity equals the exported quantity. Adds a PESO_NETO error for each mismatch. + + Parameters + ---------- + lines : all LineItem rows for the invoice + unit_code : 'KGS' or 'LBS' — only lines with this UOM are evaluated + errors : collector for validation errors + """ + for line in lines: + uom = line.unit_of_measure_info + if uom is None: + continue + + line_uom_code = (uom.code or "").strip().upper() + if line_uom_code != unit_code: + continue + + if line.quantity is None: + continue + + qty = line.quantity.quantity or Decimal(0) + net_weight = getattr(line.quantity.quantity, None) or Decimal(0) + + if net_weight != qty: + errors.add_error( + field=f"line[{line.line_number}].quantity", + message=f"La cantidad es de {qty} {unit_code} y el Peso Neto es de {net_weight} {unit_code}.", + solution=["Igualar el Peso Neto con la cantidad a Exportar."], + code="PESO_NETO", + ) \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_unit_cost.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_unit_cost.py new file mode 100644 index 00000000..91a065d8 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/review_unit_cost.py @@ -0,0 +1,60 @@ +""" +REVISA_COSTOS_0 +Validates that every principal line item (non sub-item) of an export invoice +has a unit cost greater than zero. + +A zero unit cost on a principal line is an error because values and customs +declarations cannot be computed without it. + +Legacy equivalent +----------------- +SELECT COUNT(*) FROM QEqeMaq EqiPex +WHERE EqiPex.Consecutivo = + AND EqiPex.CostoUnitarioCaptura = 0 + AND EqiPex.EsSubPartida = 'P' -- 'P' = Principal (not a sub-item) +""" + +from decimal import Decimal +from typing import List + +from api.v1.modules.a76.items.models import LineItem +from core.exceptions import ErrorCollector + + +def review_unit_cost( + lines: List[LineItem], + errors: ErrorCollector, +) -> None: + """ + REVISA_COSTOS_0 + + For every principal line (``fa_data.is_subitem`` is False or None) checks + that ``financial.unit_cost_capture`` is not zero. Adds a COSTO_CERO error + for each offending line. + + Sub-items are skipped because their cost derives from the principal line + and may legitimately be zero at this stage. + """ + for line in lines: + # Skip sub-items — EsSubPartida = 'P' means is_subitem is False/None + is_subitem = line.fa_data.is_subitem if line.fa_data else False + if is_subitem: + continue + + unit_cost = ( + line.financial.unit_cost_capture + if line.financial + else None + ) + if unit_cost is not None and unit_cost != Decimal(0): + continue + + errors.add_error( + field=f"line[{line.line_number}].unit_cost_capture", + message=f"La partida: {line.line_number} no tiene capturado el costo unitario", + solution=[ + f"Asignar el costo unitario a la partida: {line.line_number}, " + "o desactivar el parámetro de En Base al Costo de Captura." + ], + code="COSTO_CERO", + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/__init__.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py new file mode 100644 index 00000000..5d4a3200 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_qty_series.py @@ -0,0 +1,121 @@ +""" +REVISA_CANT_SERIES +Validates series count against export quantity for each line that carries +serial numbers (LlevaSerie = 1 / has_serial = True). + +Rules (ported from legacy SCAII REVISA_CANT_SERIES): + 1. If the line carries series but no series records exist → error SERIES_VACIAS. + 2. If SisGen:CantvsCantSeries = 1: + a. RFC-exception companies (hardcoded set): + - If invoice is a cambio de régimen (is_regime_change): only validate + when the line's unit of measure is 'PZA'. + - Otherwise: always validate count vs quantity. + b. All other companies: always validate count vs quantity. + +Note: the GNiv:CantSerievsCant = 0 block (series > quantity warning) was +commented-out in the original Clarion and is therefore not ported. +""" + +from decimal import Decimal +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.general_catalogs.company.models import Company +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.series.models import Serie +from core.exceptions import ErrorCollector + +# TODO: Read SisGen:CantvsCantSeries from the tenant system-config model +_SISGEN_CANT_VS_CANT_SERIES: int = 0 # 0 = disabled + +# RFCs where qty-vs-series validation is conditional on UOM = PZA when is_regime_change +_RFC_EXCEPCION_PZA = { + "IMS030409FZ0", + "TOP140430PB6", + "AMA7504258K2", + "BZG111091T9", +} + + +def _validate_line_series( + db: Session, + invoice: InvoiceHeader, + line: LineItem, + company_rfc: str, + errors: ErrorCollector, +) -> None: + """Validates series count for a single line that has has_serial = True.""" + series_count = ( + db.query(Serie) + .filter(Serie.line_item_id == line.id) + .count() + ) + + # Rule 1: series flag active but no series records exist + if series_count == 0: + errors.add_error( + field=f"line[{line.line_number}].series", + message="La opción de contiene series esta activada y no existen registros de Series", + solution=[ + "Desactivar la opción de Lleva series o registrar las series a esta partida." + ], + code="SERIES_VACIAS", + ) + return + + # Rule 2: quantity vs series count check (controlled by SisGen flag) + # TODO: Replace _SISGEN_CANT_VS_CANT_SERIES with the real config value + if _SISGEN_CANT_VS_CANT_SERIES != 1: + return + + qty = line.quantity.quantity if line.quantity else None + if qty is None: + return + + is_regime_change = bool( + invoice.compliance_mx and invoice.compliance_mx.is_regime_change + ) + + uom_code = "" + if line.unit_of_measure_info: + uom_code = (line.unit_of_measure_info.code or "").strip().upper() + + if company_rfc in _RFC_EXCEPCION_PZA: + # RFC-exception: when cambio de régimen only validate for PZA lines + if is_regime_change and uom_code != "PZA": + return + # For all other companies (and exception RFCs without cambio de régimen), + # always compare count vs quantity + + if series_count != qty: + errors.add_error( + field=f"line[{line.line_number}].series", + message="La Cantidad de Series No Coincide con la Cantidad de la Partida.", + solution=[f"Nivelar las series de la Partida {line.line_number}."], + code="SERIES_VS_CANT", + ) + + +def review_qty_series( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], + tenant_id: str, + company_id: str, + errors: ErrorCollector, +) -> None: + """ + REVISA_CANT_SERIES + Iterates all line items and validates series count for those that carry + serial numbers (has_serial = True / LlevaSerie = 1). + """ + company = db.get(Company, company_id) + company_rfc = (company.rfc or "").strip().upper() if company else "" + + for line in lines: + if not (line.description and line.description.has_serial): + continue + + _validate_line_series(db, invoice, line, company_rfc, errors) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_exist.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_exist.py new file mode 100644 index 00000000..014ef8d9 --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_exist.py @@ -0,0 +1,149 @@ +""" +REVISA_SERIES_EXISTA +Verifies that every export series marked for discharge (discharge=True / Marca=1) +references a row that actually exists in the corresponding import invoice line. + +Clarion mapping +--------------- +QSeriesExpo → Serie (line_item_id = export LineItem.id) +QSeriesImpo → Serie (line_item_id = import LineItem.id, for TEM invoices) +QSeriesDef → Serie (line_item_id = import LineItem.id, for DEF invoices) + +SerExpo.Marca = 1 → Serie.discharge = True +SerExpo.LineaSerieImpo → Serie.serie_row +SerImp.Renglon / SerDef.Renglon → Serie.row (on the import side) + +Logic +----- +For each export serie with discharge=True on this line, check that +``serie_row`` exists as a ``row`` in the series of the referenced +import invoice line. If it does not → error. + +The check differs by origin_procedure: + TEM → look in import invoice (InvoiceType='TEM') + DEF → look in import invoice (InvoiceType='DEF') +""" + +from typing import List, Set + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.series.models import Serie +from api.v1.modules.a76.invoices.exports.process.sub_process.discharge_types import ( + DownloadEntry, +) +from core.exceptions import ErrorCollector + + +def review_series_exist( + db: Session, + invoice: InvoiceHeader, + line: LineItem, + entry: DownloadEntry, + errors: ErrorCollector, +) -> None: + """ + REVISA_SERIES_EXISTA + Checks that every export serie marked for discharge on ``line`` references + an import serie row that actually exists in the import invoice line. + + Parameters + ---------- + db : active SQLAlchemy session + invoice : the export invoice being processed + line : the export LineItem whose series are being validated + entry : the DownloadEntry for this line (provides import_invoice / import_line) + errors : shared error collector + """ + # ── 1. Export series with discharge=True on this line ──────────────────── + export_series: List[Serie] = ( + db.execute( + select(Serie).where( + Serie.line_item_id == line.id, + Serie.discharge == True, # noqa: E712 — SQLAlchemy requires == + ) + ) + .scalars() + .all() + ) + + if not export_series: + return + + # ── 2. Resolve the import LineItem ─────────────────────────────────────── + invoice_type_filter = entry.origin_procedure.upper() # 'TEM' or 'DEF' + + import_line_id: int | None = ( + db.execute( + select(LineItem.id) + .join(InvoiceHeader, LineItem.invoice_id == InvoiceHeader.id) + .where( + InvoiceHeader.tenant_id == invoice.tenant_id, + InvoiceHeader.invoice_number == entry.import_invoice, + InvoiceHeader.invoice_type == invoice_type_filter, + LineItem.line_number == entry.import_line, + ) + ) + .scalar_one_or_none() + ) + + if import_line_id is None: + # The import line itself was not found — already caught by fill_available_balances, + # but add a targeted error here as well. + errors.add_error( + field=f"line[{line.line_number}].series", + message=( + f"No se encontró la línea {entry.import_line} de la factura de " + f"importación '{entry.import_invoice}' para validar las series." + ), + solution=["Verificar que la factura y línea de importación existen y están procesadas."], + code="IMPORT_LINE_NOT_FOUND_FOR_SERIES", + ) + return + + # ── 3. Fetch the set of valid import serie rows ─────────────────────────── + valid_rows: Set[int] = set( + db.execute( + select(Serie.row).where( + Serie.line_item_id == import_line_id, + ) + ) + .scalars() + .all() + ) + + # ── 4. Validate each export serie ──────────────────────────────────────── + invoice_type_label = ( + "Impo. Tem." if invoice_type_filter == "TEM" else "Impo. Def." + ) + invoice_type_code = ( + "FAC_IMPO_TEM" if invoice_type_filter == "TEM" else "FAC_IMPO_DEF" + ) + + for serie in export_series: + ref_row = serie.serie_row + + if ref_row is None or ref_row not in valid_rows: + errors.add_error( + field=f"line[{line.line_number}].series[{serie.row}]", + message=( + f"La Línea: {serie.serie_row} " + f"(Serie: {serie.serial_numbers or ''}) " + f"no existe en la Factura de {invoice_type_label}: " + f"'{entry.import_invoice}' con Línea: {entry.import_line}." + ), + solution=[ + "Capturar un número de Serie que exista en la Factura " + "y Línea a Descargar de Importación." + ], + code=invoice_type_code, + value={ + "export_serie_row": serie.row, + "serie_row": ref_row, + "import_invoice": entry.import_invoice, + "import_line": entry.import_line, + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_other_lines.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_other_lines.py new file mode 100644 index 00000000..076c37dc --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/series/review_series_other_lines.py @@ -0,0 +1,210 @@ +""" +REVISA_SERIES_OTRAS_PAR +Verifies that no export serie marked for discharge is already assigned for +discharge on a different line of the same export invoice. + +Clarion mapping +--------------- +QueueSeries → ``seen_series: set[tuple]`` passed in from the caller. + The set accumulates across all discharge lines so that a serie + registered on line 1 is detected as duplicate when line 2 is + processed. + +SerExpo (QSeriesExpo) → Serie (line_item_id = export LineItem.id) +SerImp (QSeriesImpo) → Serie (line_item_id = import LineItem.id, TEM) +SerDef (QSeriesDef) → Serie (line_item_id = import LineItem.id, DEF) + +Key tuple (equivalent to QueueSeries record used for GET/ADD): + (export_invoice_number, import_line, serial_number, + origin_procedure, serie_row, import_invoice_number) + +Logic +----- +For each export serie with discharge=True on this line: + 1. Build the key tuple. + 2. Resolve ``serie_row`` if blank: + TEM → look up the matching row in QSeriesImpo by serial_number + DEF → look up the matching row in QSeriesDef by serial_number + 3. If the key is already in ``seen_series`` → duplicate error. + 4. Otherwise → add to ``seen_series`` (mark as seen for subsequent lines). +""" + +from typing import Optional, Set, Tuple + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem +from api.v1.modules.a76.items.series.models import Serie +from api.v1.modules.a76.invoices.exports.process.sub_process.discharge_types import ( + DownloadEntry, +) +from core.exceptions import ErrorCollector + +# Type alias for the duplicate-detection key (equiv. to QueueSeries record) +_SeriesKey = Tuple[str, int, str, str, Optional[int], str] + + +def review_series_other_lines( + db: Session, + invoice: InvoiceHeader, + line: LineItem, + entry: DownloadEntry, + seen_series: Set[_SeriesKey], + errors: ErrorCollector, +) -> None: + """ + REVISA_SERIES_OTRAS_PAR + Checks that no export serie on ``line`` (with discharge=True) is already + registered for discharge on another line of the same invoice. + + Parameters + ---------- + db : active SQLAlchemy session + invoice : the export invoice being processed + line : the export LineItem whose series are being validated + entry : the DownloadEntry for this line + seen_series : mutable set shared across all calls within one invoice + processing run — accumulates keys as lines are processed + errors : shared error collector + """ + export_series = ( + db.execute( + select(Serie).where( + Serie.line_item_id == line.id, + Serie.discharge == True, # noqa: E712 + ) + ) + .scalars() + .all() + ) + + if not export_series: + return + + # Resolve the import line id once (needed for serie row lookup) + import_line_id = _resolve_import_line_id(db, invoice, entry) + + for serie in export_series: + serial = serie.serial_numbers or "" + import_serie_row = serie.serie_row + + # If import_serie_row is not set on the export serie, resolve it from + # the import series table by matching serial_number + if import_serie_row is None and import_line_id is not None: + import_serie_row = _resolve_import_serie_row( + db, import_line_id, serial + ) + + key: _SeriesKey = ( + invoice.invoice_number or "", + entry.import_line, + serial, + entry.origin_procedure, + import_serie_row, + entry.import_invoice, + ) + + if key in seen_series: + # Find which export line already claimed this serie + existing_line = _find_existing_export_line( + db, invoice, line.id, serial, entry + ) + errors.add_error( + field=f"line[{entry.export_line}].series[{serie.row}]", + message=( + f"La Serie: '{serial}' ya fue descargada y está capturada " + f"para ser Descargada en la Partida: {existing_line}." + ), + solution=[ + "Capturar otro número de Serie o capturar el Renglón " + "de la Serie de Importación." + ], + code="SERIE_DUPLICATE_DISCHARGE", + value={ + "serial": serial, + "export_line": entry.export_line, + "conflicting_line": existing_line, + }, + ) + else: + seen_series.add(key) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _resolve_import_line_id( + db: Session, + invoice: InvoiceHeader, + entry: DownloadEntry, +) -> Optional[int]: + """Returns the import LineItem.id for the invoice/line referenced by the entry.""" + from api.v1.modules.a76.invoices.models import InvoiceHeader as IH + from api.v1.modules.a76.items.models import LineItem as LI + + return db.execute( + select(LI.id) + .join(IH, LI.invoice_id == IH.id) + .where( + IH.tenant_id == invoice.tenant_id, + IH.invoice_number == entry.import_invoice, + LI.line_number == entry.import_line, + ) + ).scalar_one_or_none() + + +def _resolve_import_serie_row( + db: Session, + import_line_id: int, + serial_number: str, +) -> Optional[int]: + """ + Looks up the ``row`` of an import serie by serial_number on the given + import line — equivalent to the SQL3 query in the Clarion for both TEM + and DEF cases (both use the same Serie model now). + """ + return db.execute( + select(Serie.row).where( + Serie.line_item_id == import_line_id, + Serie.serial_numbers == serial_number, + ) + ).scalar_one_or_none() + + +def _find_existing_export_line( + db: Session, + invoice: InvoiceHeader, + current_line_id: int, + serial_number: str, + entry: DownloadEntry, +) -> int: + """ + Returns the export_line number of another line on the same invoice that + already has this serial registered for discharge. + Falls back to entry.export_line if not found (shouldn't happen in practice). + """ + from api.v1.modules.a76.items.models import LineItem as LI + + # Find all export lines on this invoice that are not the current one + other_line_ids = db.execute( + select(LI.id, LI.line_number).where( + LI.invoice_id == invoice.id, + LI.id != current_line_id, + ) + ).all() + + for row in other_line_ids: + match = db.execute( + select(Serie.id).where( + Serie.line_item_id == row.id, + Serie.serial_numbers == serial_number, + Serie.discharge == True, # noqa: E712 + ) + ).scalar_one_or_none() + if match is not None: + return row.line_number + + return entry.export_line diff --git a/backend/api/v1/modules/a76/invoices/exports/process/sub_process/verify_consolidated.py b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/verify_consolidated.py new file mode 100644 index 00000000..2a00a41c --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/sub_process/verify_consolidated.py @@ -0,0 +1,82 @@ +""" +VERIFICAQCONSOLIDADO +Second-pass check after COMPARA_SALDOS_POR_FACTURA: iterates every discharge +entry and reports an error for any line that still has unmet quantity +(QADesc:Cantidad - QADesc:CantUsada <> 0). + +The Clarion routine distinguishes TEM vs DEF in the error message; this +translation preserves that distinction. + +Note: compare_balances already raises INSUFFICIENT_BALANCE errors per entry. +This routine acts as a final consolidation gate — if compare_balances is +called with raise_if_errors() afterwards, this function may be redundant in +practice. It is kept as a faithful port and can serve as the sole +insufficient-balance check if compare_balances is ever made non-raising. +""" + +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from core.exceptions import ErrorCollector +from .discharge_types import DownloadEntry + + +def verify_consolidated( + db: Session, + export_invoice: InvoiceHeader, + to_discharge: List[DownloadEntry], + errors: ErrorCollector, +) -> None: + """ + VERIFICAQCONSOLIDADO + Reports an error for every discharge entry whose quantity was not fully + satisfied by ``compare_balances``. + + Parameters + ---------- + db : active SQLAlchemy session + export_invoice : the export invoice being processed + to_discharge : list of DownloadEntry objects after compare_balances ran + errors : shared error collector + """ + # Sort mirrors Clarion: Sort(QueADescargar, -Procedencia, FacturaImpo, LineaImpo) + sorted_entries = sorted( + to_discharge, + key=lambda e: (e.origin_procedure, e.import_invoice, e.import_line), + reverse=True, + ) + + for entry in sorted_entries: + remaining = entry.quantity - entry.quantity_used + if remaining == 0: + continue + + uom = entry.unit_of_measure or "" + procedure = (entry.origin_procedure or "").strip().upper() + + if procedure == "TEM": + message = ( + f"Insuficiencia TEM: La Linea: {entry.export_line} se quiere " + f"descargar: {entry.quantity} {uom} y hay: {entry.quantity_used} {uom}." + ) + else: # DEF or any other + message = ( + f"Insuficiencia DEF.: La Linea: {entry.export_line} se quiere " + f"descargar: {entry.quantity} {uom} y hay: {entry.quantity_used} {uom}." + ) + + errors.add_error( + field=f"line[{entry.export_line}].quantity", + message=message, + solution=["Asignar Facturas con Saldos Disponibles."], + code="INSUFFICIENT_BALANCE_CONSOLIDATED", + value={ + "export_line": entry.export_line, + "required": str(entry.quantity), + "available": str(entry.quantity_used), + "shortage": str(remaining), + "origin_procedure": procedure, + }, + ) diff --git a/backend/api/v1/modules/a76/invoices/exports/process/task.py b/backend/api/v1/modules/a76/invoices/exports/process/task.py new file mode 100644 index 00000000..65bda45a --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/exports/process/task.py @@ -0,0 +1,50 @@ +from celery import Task + +from core.celery_app import celery_app +from core.database import CoreSessionLocal +from core.exceptions import ValidationException + +from api.v1.modules.a76.invoices.models import InvoiceHeader +from .main_process import main_process + + +def _progress(task: Task, current: int, status: str) -> None: + task.update_state(state="PROGRESS", meta={"current": current, "status": status}) + + +@celery_app.task(bind=True, name="process_export_invoice_task") +def process_export_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: str) -> dict: + """ + Procesa una factura de exportación ejecutando todas las validaciones y + actualizaciones del proceso principal de exportación con reporte de progreso. + """ + db = CoreSessionLocal() + try: + _progress(self, 5, "Cargando factura...") + invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id) + if invoice is None: + return { + "status": "error", + "message": f"Factura con id {invoice_id} no encontrada.", + "errors": [], + } + + _progress(self, 10, "Procesando factura de exportación...") + result = main_process(db, invoice, tenant_id, company_id) + + db.commit() + _progress(self, 100, "Proceso completado.") + return {**result, "invoice_id": invoice_id} + + except ValidationException as exc: + db.rollback() + return { + "status": "validation_error", + "message": exc.message, + "errors": exc.errors, + } + except Exception as exc: + db.rollback() + raise exc + finally: + db.close() diff --git a/backend/api/v1/modules/a76/invoices/imports/balance/__init__.py b/backend/api/v1/modules/a76/invoices/imports/balance/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/backend/api/v1/modules/a76/invoices/imports/process/routes.py b/backend/api/v1/modules/a76/invoices/imports/process/routes.py index cc65bc06..66a00121 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/routes.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/routes.py @@ -7,7 +7,9 @@ from core.celery_app import celery_app from core.database import get_core_db from core.security import get_current_user, validate_access_to_resource +from api.v1.modules.a76.invoices.models import InvoiceHeader, OperationType from .task import process_invoice_task +from ...exports.process.task import process_export_invoice_task router = APIRouter() @@ -20,14 +22,25 @@ def trigger_invoice_process( current_user: Dict[str, Any] = Depends(get_current_user), ): """ - Inicia el procesamiento de una factura de importación como tarea Celery. + Inicia el procesamiento de una factura como tarea Celery. + Detecta automáticamente si es importación o exportación por el + operation_type de la factura y despacha al proceso correspondiente. Retorna el task_id para hacer polling del progreso. """ tenant_id = validate_access_to_resource(db, company_id, current_user) - task = process_invoice_task.apply_async( - args=[invoice_id, str(tenant_id), str(company_id)] - ) + invoice: InvoiceHeader | None = db.get(InvoiceHeader, invoice_id) + if invoice is None: + raise HTTPException(status_code=404, detail=f"Factura {invoice_id} no encontrada.") + + if invoice.operation_type == OperationType.EXP: + task = process_export_invoice_task.apply_async( + args=[invoice_id, str(tenant_id), str(company_id)] + ) + else: + task = process_invoice_task.apply_async( + args=[invoice_id, str(tenant_id), str(company_id)] + ) return {"task_id": task.id} @@ -62,7 +75,6 @@ def get_invoice_process_status(task_id: str): "result": task_result.result, } - # FAILURE u otro estado de error error_info = task_result.result if isinstance(error_info, Exception): error_msg = str(error_info) diff --git a/backend/api/v1/modules/a76/items/series/models.py b/backend/api/v1/modules/a76/items/series/models.py index a71397ab..991656b6 100644 --- a/backend/api/v1/modules/a76/items/series/models.py +++ b/backend/api/v1/modules/a76/items/series/models.py @@ -19,6 +19,6 @@ class Serie(Base, TenantScopedMixin, TimestampMixin): brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO number_id: Mapped[Optional[str]] = mapped_column(String(25)) # NUMIDEXPO discharge: Mapped[Optional[bool]] = mapped_column(Boolean) # MARCA - + serie_row: Mapped[Optional[int]] = mapped_column(Integer) # LINEASERIEIMPO <-- IN CASE OF EXPO \ No newline at end of file