From 3eacbf18f26b88c9d2397cfcba257847db58ce81 Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Tue, 17 Mar 2026 16:25:23 -0500 Subject: [PATCH 1/4] Refactor 'download' field to 'discharge' across models and services - Renamed the 'download' field to 'discharge' in the FaLineItem model and updated related services and schemas accordingly. - Adjusted frontend components to reflect the new 'discharge' terminology. - Ensured consistency in filtering and validation logic across the application to use the updated field name. --- .../v1/modules/a24/fa/fa_item_lines/models.py | 2 +- .../modules/a24/fa/fa_item_lines/service.py | 6 +- .../invoices/imports/process/main_process.py | 2 +- .../imports/process/pre_validators.py | 2 +- .../process/sub_process/review_rule_octave.py | 47 +------ .../imports/process/sub_process/review_uma.py | 129 ------------------ .../api/v1/modules/a76/invoices/schemas.py | 2 + .../items/exports/validators/calculations.py | 6 +- .../a76/items/exports/validators/common.py | 2 +- .../a76/items/exports/validators/create.py | 6 +- .../a76/items/exports/validators/update.py | 4 +- .../api/v1/modules/a76/items/series/models.py | 6 +- .../invoices/services/query_builders.py | 36 ++--- frontend/src/lib/api/dashboard/a76/items.ts | 2 +- .../edit/items/fa/item-sheet-fa.svelte | 8 +- 15 files changed, 44 insertions(+), 216 deletions(-) delete mode 100644 backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_uma.py diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/models.py b/backend/api/v1/modules/a24/fa/fa_item_lines/models.py index 75496b34..f07b73c8 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/models.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/models.py @@ -70,7 +70,7 @@ class FaLineItem(Base, TenantScopedMixin, TimestampMixin): subitem_number: Mapped[Optional[int]] = mapped_column(Integer) # SUBPARTIDA # Special flags - download: Mapped[Optional[bool]] = mapped_column(Boolean) # DESCARGA + discharge: Mapped[Optional[bool]] = mapped_column(Boolean) # DESCARGA own_equipment: Mapped[Optional[bool]] = mapped_column(Boolean) # EQUIPOPROPIO omit_annex31: Mapped[Optional[bool]] = mapped_column(Boolean) # OMITITENANEXO31 diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/service.py b/backend/api/v1/modules/a24/fa/fa_item_lines/service.py index 890307bf..aaa8e851 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/service.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/service.py @@ -48,8 +48,8 @@ class FaLineItemService: query = query.filter( FaLineItem.own_equipment == filters["own_equipment"] ) - if filters.get("download") is not None: - query = query.filter(FaLineItem.download == filters["download"]) + if filters.get("discharge") is not None: + query = query.filter(FaLineItem.discharge == filters["discharge"]) total = query.count() items = query.offset(skip).limit(limit).all() @@ -146,7 +146,7 @@ class FaLineItemService: search_invoice=fa_line_item_data.search_invoice, search_line=fa_line_item_data.search_line, search_type=fa_line_item_data.search_type, - download=fa_line_item_data.download, + discharge=fa_line_item_data.discharge, own_equipment=fa_line_item_data.own_equipment, omit_annex31=fa_line_item_data.omit_annex31, ) diff --git a/backend/api/v1/modules/a76/invoices/imports/process/main_process.py b/backend/api/v1/modules/a76/invoices/imports/process/main_process.py index cfadf0dd..ea28d7de 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/main_process.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/main_process.py @@ -23,7 +23,7 @@ from .sub_process.review_rule_octave import ( valida_imp_regla_octava, descuenta_cupo_r_octava, ) -from .sub_process.review_uma import revisa_uma +from ...common.process.review_uma import revisa_uma from .sub_process.assing_values import assign_values_lines, assign_values_invoice diff --git a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py index 1e96b84a..c1c47b7f 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/pre_validators.py @@ -30,7 +30,7 @@ def pre_validators(db: Session, invoice: InvoiceHeader, tenant_id: str, company_ errors.add_required_error("compliance_mx.sold_to_id") if not invoice.compliance_mx.shipped_to_id: - errors.add_required_error("compliance_mx.shipped_by_id") + errors.add_required_error("compliance_mx.shipped_to_id") if not invoice.compliance_mx.customs_broker_id: errors.add_required_error("compliance_mx.customs_broker_id") diff --git a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_rule_octave.py b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_rule_octave.py index 3f19cd68..431070f5 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_rule_octave.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_rule_octave.py @@ -6,13 +6,13 @@ from typing import Optional, List from sqlalchemy.orm import Session from api.v1.modules.a76.general_catalogs.fractions.previous_fractions.models import PreviousFraction -from api.v1.modules.a76.general_catalogs.unit_conversions.models import UnitConversion from api.v1.modules.a76.invoices.models import InvoiceHeader from api.v1.modules.a76.items.models import LineItem from api.v1.modules.a76.rule_octave.balances.models import OctaveBalance from api.v1.modules.a76.rule_octave.country.models import CountryRuleOct from api.v1.modules.a76.rule_octave.fractions.models import FractionRuleOctave from api.v1.modules.a76.rule_octave.permissions.models import OctavePermission +from ....common.process.review_equivalence import _get_unit_equivalence from core.exceptions import ErrorCollector # Clarion date 80354 ≈ 2010-05-31 (see previous_fractions/models.py) @@ -50,51 +50,6 @@ class OctavePermitEntry: # Helpers # ───────────────────────────────────────────────────────────────────────────── -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) - - def _previous_fraction_exists( db: Session, tenant_id: str, diff --git a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_uma.py b/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_uma.py deleted file mode 100644 index b4e104b9..00000000 --- a/backend/api/v1/modules/a76/invoices/imports/process/sub_process/review_uma.py +++ /dev/null @@ -1,129 +0,0 @@ -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_rule_octave 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/schemas.py b/backend/api/v1/modules/a76/invoices/schemas.py index f0edde67..e6aee827 100644 --- a/backend/api/v1/modules/a76/invoices/schemas.py +++ b/backend/api/v1/modules/a76/invoices/schemas.py @@ -451,6 +451,8 @@ class InvoiceCollectionsCreate(InvoiceCollectionsBase): class InvoiceHeaderCreate(InvoiceHeaderBase): """Schema for creating Invoice Header with nested relations""" + status: Optional[InvoiceStatus] = Field(InvoiceStatus.PENDING, description="Status: pending, processed, reversed") + compliance_mx: Optional[InvoiceComplianceMxCreate] = None financials: Optional[InvoiceFinancialsCreate] = None logistics: Optional[InvoiceLogisticsCreate] = None diff --git a/backend/api/v1/modules/a76/items/exports/validators/calculations.py b/backend/api/v1/modules/a76/items/exports/validators/calculations.py index 76dfa6a3..26b73c85 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/calculations.py +++ b/backend/api/v1/modules/a76/items/exports/validators/calculations.py @@ -95,7 +95,7 @@ def calculate_values( Traduce el CALCULOS ROUTINE de Clarion: - Busca la factura de importación por fa_data.search_invoice (TEM → DEF como fallback) - - Copia clase, unidad de medida, fracción (si fa_data.download), país, tipo fracción, + - Copia clase, unidad de medida, fracción (si fa_data.discharge), país, tipo fracción, bultos y descripción inglés desde la línea de importación encontrada - Calcula valores en moneda (USD/MXN/MC) según la moneda de la factura """ @@ -168,8 +168,8 @@ def calculate_values( line.class_id = import_line.class_id line.unit_of_measure = import_line.unit_of_measure - # Fracción: copiar solo si fa_data.download == True (≡ ColumnaV != '') - if fa_data and fa_data.download and import_line.customs: + # Fracción: copiar solo si fa_data.discharge == True (≡ ColumnaV != '') + if fa_data and fa_data.discharge and import_line.customs: line.customs.fraction = import_line.customs.fraction if import_line.customs: diff --git a/backend/api/v1/modules/a76/items/exports/validators/common.py b/backend/api/v1/modules/a76/items/exports/validators/common.py index 31ace4a5..b5b36fb4 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/common.py +++ b/backend/api/v1/modules/a76/items/exports/validators/common.py @@ -222,7 +222,7 @@ def validate_common( ) elif ( # Col. H: valida unidad de medida sólo cuando hay descarga - fa_data.download is True + fa_data.discharge is True and line.unit_of_measure and import_line.unit_of_measure and line.unit_of_measure != import_line.unit_of_measure diff --git a/backend/api/v1/modules/a76/items/exports/validators/create.py b/backend/api/v1/modules/a76/items/exports/validators/create.py index 8ab754f8..9c19d6bf 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/create.py +++ b/backend/api/v1/modules/a76/items/exports/validators/create.py @@ -83,11 +83,11 @@ def validate_create( errors.add_required_error(field=f"line[{line_number}].fa_data.movement_type_import") # Col. F: ¿Descarga la línea? (DescargaPartida) — obligatorio - if fa_data.download is None: - errors.add_required_error(field=f"line[{line_number}].fa_data.download") + if fa_data.discharge is None: + errors.add_required_error(field=f"line[{line_number}].fa_data.discharge") # Col. D / E: Factura y Línea de Impo — obligatorios sólo si hay descarga - if fa_data.download is True: + if fa_data.discharge is True: if not fa_data.search_invoice: errors.add_required_error(field=f"line[{line_number}].fa_data.search_invoice") if not fa_data.search_line: diff --git a/backend/api/v1/modules/a76/items/exports/validators/update.py b/backend/api/v1/modules/a76/items/exports/validators/update.py index e4f6c121..4f8e2c9d 100644 --- a/backend/api/v1/modules/a76/items/exports/validators/update.py +++ b/backend/api/v1/modules/a76/items/exports/validators/update.py @@ -207,8 +207,8 @@ def validate_update( fa_data.movement_type_import = existing_fa_data.movement_type_import # Col. F: ¿Descarga la línea? (Descarga) - if fa_data.download is None: - fa_data.download = existing_fa_data.download + if fa_data.discharge is None: + fa_data.discharge = existing_fa_data.discharge # Col. D: Factura de Importación — obligatoria sólo si hay descarga if not fa_data.search_invoice: diff --git a/backend/api/v1/modules/a76/items/series/models.py b/backend/api/v1/modules/a76/items/series/models.py index 6801b43a..a71397ab 100644 --- a/backend/api/v1/modules/a76/items/series/models.py +++ b/backend/api/v1/modules/a76/items/series/models.py @@ -1,5 +1,5 @@ from typing import Optional -from sqlalchemy import ForeignKey, Integer, String +from sqlalchemy import Boolean, ForeignKey, Integer, String from sqlalchemy.orm import Mapped, mapped_column from api.v1.common.base_models import TenantScopedMixin, TimestampMixin from core.database import Base @@ -16,9 +16,9 @@ class Serie(Base, TenantScopedMixin, TimestampMixin): serial_numbers: Mapped[Optional[str]] = mapped_column(String(50)) # SERIEEXPO model: Mapped[Optional[str]] = mapped_column(String(50)) # MODELOEXPO sub_model: Mapped[Optional[str]] = mapped_column(String(50)) # SUBMODELOEXPO - brand: Mapped[Optional[str]] = mapped_column(String(50)) # MARCA - expo_brad: Mapped[Optional[str]] = mapped_column(String(50)) # MARCAEXPO + 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 \ No newline at end of file diff --git a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py index e5d53263..e00286cb 100644 --- a/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py +++ b/backend/api/v1/modules/a76/reports/movements/invoices/services/query_builders.py @@ -433,11 +433,11 @@ class RepairImportQueries: @staticmethod def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: """Build optimized query for NORMAL mode (grouped by invoice with totals).""" - # Use fa_item_lines.download field (true = discharged, false = not discharged) + # Use fa_item_lines.discharge field (true = discharged, false = not discharged) if "SiDes" in discharge_clause: - discharge_filter = "AND fil.download = true" + discharge_filter = "AND fil.discharge = true" elif "NoDes" in discharge_clause: - discharge_filter = "AND fil.download = false" + discharge_filter = "AND fil.discharge = false" else: discharge_filter = "" @@ -514,11 +514,11 @@ class RepairImportQueries: @staticmethod def build_main_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: """Build main SQL query for repair import data.""" - # Use fa_item_lines.download field (true = discharged, false = not discharged) + # Use fa_item_lines.discharge field (true = discharged, false = not discharged) if "SiDes" in discharge_clause: - discharge_filter = "AND fil.download = true" + discharge_filter = "AND fil.discharge = true" elif "NoDes" in discharge_clause: - discharge_filter = "AND fil.download = false" + discharge_filter = "AND fil.discharge = false" else: discharge_filter = "" return f""" @@ -609,11 +609,11 @@ class RepairImportQueries: @staticmethod def build_totals_query(db_name: str, discharge_clause: str = "") -> str: """Build query to get totals for a repair import invoice.""" - # Use fa_item_lines.download field (true = discharged, false = not discharged) + # Use fa_item_lines.discharge field (true = discharged, false = not discharged) if "SiDes" in discharge_clause: - discharge_filter = "AND fil.download = true" + discharge_filter = "AND fil.discharge = true" elif "NoDes" in discharge_clause: - discharge_filter = "AND fil.download = false" + discharge_filter = "AND fil.discharge = false" else: discharge_filter = "" return f""" @@ -818,11 +818,11 @@ class ExportQueries: Only sums partidas where is_subitem is false (main partidas, not sub-items). """ - # Use fa_item_lines.download field (true = discharged, false = not discharged) + # Use fa_item_lines.discharge field (true = discharged, false = not discharged) if "SiDes" in discharge_clause: - discharge_filter = "AND fil.download = true" + discharge_filter = "AND fil.discharge = true" elif "NoDes" in discharge_clause: - discharge_filter = "AND fil.download = false" + discharge_filter = "AND fil.discharge = false" else: discharge_filter = "" return f""" @@ -861,11 +861,11 @@ class ExportRepairQueries: @staticmethod def build_aggregated_query(db_name: str, where_str: str, discharge_clause: str = "") -> str: """Build optimized query for NORMAL mode (grouped by invoice with totals).""" - # Use fa_item_lines.download field (true = discharged, false = not discharged) + # Use fa_item_lines.discharge field (true = discharged, false = not discharged) if "SiDes" in discharge_clause: - discharge_filter = "AND fil.download = true" + discharge_filter = "AND fil.discharge = true" elif "NoDes" in discharge_clause: - discharge_filter = "AND fil.download = false" + discharge_filter = "AND fil.discharge = false" else: discharge_filter = "" @@ -1033,11 +1033,11 @@ class ExportRepairQueries: @staticmethod def build_totals_query(db_name: str, discharge_clause: str = "") -> str: """Build query to get totals for an export repair invoice.""" - # Use fa_item_lines.download field (true = discharged, false = not discharged) + # Use fa_item_lines.discharge field (true = discharged, false = not discharged) if "SiDes" in discharge_clause: - discharge_filter = "AND fil.download = true" + discharge_filter = "AND fil.discharge = true" elif "NoDes" in discharge_clause: - discharge_filter = "AND fil.download = false" + discharge_filter = "AND fil.discharge = false" else: discharge_filter = "" return f""" diff --git a/frontend/src/lib/api/dashboard/a76/items.ts b/frontend/src/lib/api/dashboard/a76/items.ts index 1c7f2a75..d13beac4 100644 --- a/frontend/src/lib/api/dashboard/a76/items.ts +++ b/frontend/src/lib/api/dashboard/a76/items.ts @@ -127,7 +127,7 @@ export interface FaLineItem { subitem_number?: number; // Special flags - download?: boolean; + discharge?: boolean; own_equipment?: boolean; omit_annex31?: boolean; diff --git a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte index 2ed9af1f..6a0026c2 100644 --- a/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte +++ b/frontend/src/lib/components/dashboard/invoices/edit/items/fa/item-sheet-fa.svelte @@ -263,10 +263,10 @@
{ editingItem.fa_data = editingItem.fa_data || {}; - editingItem.fa_data.download = v === 'si'; + editingItem.fa_data.discharge = v === 'si'; }} class="flex gap-4" > @@ -355,10 +355,10 @@
{ editingItem.fa_data = editingItem.fa_data || {}; - editingItem.fa_data.download = v === 'si'; + editingItem.fa_data.discharge = v === 'si'; }} class="flex gap-4" > From 73462fcec3643cea05f3e55bf63c98a16a44f1ca Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Tue, 17 Mar 2026 19:46:09 -0500 Subject: [PATCH 2/4] Add ENTRY_VOID movement type and integrate balance entry creation in invoice processing - Introduced a new movement type 'ENTRY_VOID' to handle invoice reversals, ensuring net balance remains zero. - Updated the FaLineItem DTOs to replace 'download' with 'discharge' for consistency. - Implemented balance entry creation in the invoice processing and reverting workflows, enhancing inventory management. - Adjusted progress tracking messages to reflect the new balance entry generation steps. --- backend/api/v1/modules/a24/balance_movements/models.py | 7 +++++++ backend/api/v1/modules/a24/fa/fa_item_lines/dto.py | 6 +++--- .../modules/a76/invoices/imports/process/main_process.py | 4 ++++ .../api/v1/modules/a76/invoices/imports/process/task.py | 6 ++++++ .../modules/a76/invoices/imports/revert/main_process.py | 8 ++++++++ .../api/v1/modules/a76/invoices/imports/revert/task.py | 2 +- backend/api/v1/modules/a76/items/service.py | 2 +- 7 files changed, 30 insertions(+), 5 deletions(-) diff --git a/backend/api/v1/modules/a24/balance_movements/models.py b/backend/api/v1/modules/a24/balance_movements/models.py index a0d5d71c..2fd7fe84 100644 --- a/backend/api/v1/modules/a24/balance_movements/models.py +++ b/backend/api/v1/modules/a24/balance_movements/models.py @@ -74,6 +74,12 @@ class MovementType(str, Enum): EXPIRATION = "expiration" # Balance cancelled due to deadline REGIME_CHANGE_OUT = "regime_chg_out" # Eg. temporary → definitive (exit side) + # ── Reversal (annuls a prior ENTRY — used when un-processing an invoice) ─ + # Inserting ENTRY_VOID with the same quantity as the original ENTRY leaves + # the net balance at zero, preventing any further discharges against that + # lot. A fresh ENTRY is created when the invoice is re-processed. + ENTRY_VOID = "entry_void" + # Which movement types reduce the balance (sign = -1) NEGATIVE_MOVEMENTS = { @@ -85,6 +91,7 @@ NEGATIVE_MOVEMENTS = { MovementType.TRANSFER_OUT, MovementType.EXPIRATION, MovementType.REGIME_CHANGE_OUT, + MovementType.ENTRY_VOID, } # Which types count toward "used" (CANTUSADA in Anexo 24 report) diff --git a/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py b/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py index 6b4cdc52..9c464513 100644 --- a/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py +++ b/backend/api/v1/modules/a24/fa/fa_item_lines/dto.py @@ -56,7 +56,7 @@ class FaLineItemCreateDTO(BaseModel): subitem_number: Optional[int] = Field(0, description="Número de subpartida") # Special flags - download: Optional[bool] = Field(None, description="Indicador de descarga") + discharge: Optional[bool] = Field(None, description="Indicador de descarga") own_equipment: Optional[bool] = Field(None, description="Equipo propio") omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31") @@ -108,7 +108,7 @@ class FaLineItemUpdateDTO(BaseModel): subitem_number: Optional[int] = Field(None, description="Número de subpartida") # Special flags - download: Optional[bool] = Field(None, description="Indicador de descarga") + discharge: Optional[bool] = Field(None, description="Indicador de descarga") own_equipment: Optional[bool] = Field(None, description="Equipo propio") omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31") @@ -156,7 +156,7 @@ class FaLineItemResponseDTO(BaseModel): subitem_number: Optional[int] = Field(None, description="Número de subpartida") # Special flags - download: Optional[bool] = Field(None, description="Indicador de descarga") + discharge: Optional[bool] = Field(None, description="Indicador de descarga") own_equipment: Optional[bool] = Field(None, description="Equipo propio") omit_annex31: Optional[bool] = Field(None, description="Omitir en Anexo 31") diff --git a/backend/api/v1/modules/a76/invoices/imports/process/main_process.py b/backend/api/v1/modules/a76/invoices/imports/process/main_process.py index ea28d7de..df03b322 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/main_process.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/main_process.py @@ -25,6 +25,7 @@ from .sub_process.review_rule_octave import ( ) from ...common.process.review_uma import revisa_uma from .sub_process.assing_values import assign_values_lines, assign_values_invoice +from ..balance.create_balance_entries import create_balance_entries @@ -282,4 +283,7 @@ def main_process(db: Session, invoice: InvoiceHeader, tenant_id: str, company_id # Paso 7: Actualizar totales, IVA e incrementables y marcar como procesada _update_invoice_totals(invoice) + # Paso 8: Generar saldos en a24.balance_movement (una entrada por partida) + create_balance_entries(db, invoice, lines) + db.flush() \ No newline at end of file diff --git a/backend/api/v1/modules/a76/invoices/imports/process/task.py b/backend/api/v1/modules/a76/invoices/imports/process/task.py index 67c58de8..b269e129 100644 --- a/backend/api/v1/modules/a76/invoices/imports/process/task.py +++ b/backend/api/v1/modules/a76/invoices/imports/process/task.py @@ -12,6 +12,7 @@ from .sub_process.review_exchange_rate import review_exchange_rate from .sub_process.review_weights import review_weights_kgs, review_weights_lbs from .sub_process.review_rule_octave import valida_imp_regla_octava, descuenta_cupo_r_octava from .sub_process.assing_values import assign_values_lines, assign_values_invoice +from ..balance.create_balance_entries import create_balance_entries from .main_process import _validate_sisimp_limits, _update_invoice_totals, _validate_lines @@ -99,6 +100,11 @@ def process_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id sql_errors=sql_errors, ) _update_invoice_totals(invoice) + + # ── Paso 8: Generar saldos en a24.balance_movement ─────────────────── + _progress(self, 98, "Generando saldos de inventario...") + create_balance_entries(db, invoice, lines) + db.flush() db.commit() diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py b/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py index 8f71e2b2..f39e51ef 100644 --- a/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py +++ b/backend/api/v1/modules/a76/invoices/imports/revert/main_process.py @@ -9,6 +9,7 @@ from api.v1.modules.a76.items.models import LineItem from core.exceptions import ErrorCollector from .sub_process.review_rule_octave import borra_saldos_regla_octava +from ..balance.void_balance_entries import void_balance_entries # ───────────────────────────────────────────────────────────────────────────── @@ -231,4 +232,11 @@ def revert_process( # ── Paso 2c: UPDATE QEqiMaq ─────────────────────────────────────────────── _reset_line_quantities(lines) + # ── Paso 2d: Anular saldos en a24.balance_movement ─────────────────────── + # Inserta ENTRY_VOID por cada ENTRY abierto de esta factura, dejando el + # balance neto en 0 para que las descargas de exportación no puedan + # consumir esos lotes. El guard interno confirma que no haya consumos + # activos (ya validado arriba, pero se mantiene como doble seguro). + void_balance_entries(db, invoice) + return sql_errors diff --git a/backend/api/v1/modules/a76/invoices/imports/revert/task.py b/backend/api/v1/modules/a76/invoices/imports/revert/task.py index 039baba7..fbf2b056 100644 --- a/backend/api/v1/modules/a76/invoices/imports/revert/task.py +++ b/backend/api/v1/modules/a76/invoices/imports/revert/task.py @@ -58,7 +58,7 @@ def revert_invoice_task(self: Task, invoice_id: int, tenant_id: str, company_id: ) # ── Paso 4: Confirmar transacción ───────────────────────────────────── - _progress(self, 95, "Confirmando cambios...") + _progress(self, 95, "Anulando saldos de inventario y confirmando...") db.flush() db.commit() diff --git a/backend/api/v1/modules/a76/items/service.py b/backend/api/v1/modules/a76/items/service.py index 4f3cb2d3..fa42b169 100644 --- a/backend/api/v1/modules/a76/items/service.py +++ b/backend/api/v1/modules/a76/items/service.py @@ -166,7 +166,7 @@ class ItemService: # FA data uses line.id as primary key if line_data.fa_data: fa_dict = line_data.fa_data.model_dump( - exclude_unset=True, exclude={"line_item_id"} + exclude_unset=True, exclude={"line_item_id", "includes_subitems"} ) fa_dict.update( {"id": line.id, "tenant_id": tenant_id, "company_id": company_id} From 5d9f4041e44755eb46fd51ba40be09c9fea198de Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Tue, 17 Mar 2026 21:09:01 -0500 Subject: [PATCH 3/4] Add balance entry creation and void functionality for import invoices - Introduced `create_balance_entries` function to generate `BalanceMovement` entries for each line item of a processed import invoice, ensuring accurate inventory balance tracking. - Implemented `void_balance_entries` function to cancel open ENTRY movements by inserting corresponding ENTRY_VOID movements, with safeguards against already consumed lots. - Enhanced transaction handling to maintain data integrity during invoice processing and reverting operations. --- .../imports/balance/create_balance_entries.py | 120 +++++++++++++ .../imports/balance/void_balance_entries.py | 160 ++++++++++++++++++ 2 files changed, 280 insertions(+) create mode 100644 backend/api/v1/modules/a76/invoices/imports/balance/create_balance_entries.py create mode 100644 backend/api/v1/modules/a76/invoices/imports/balance/void_balance_entries.py diff --git a/backend/api/v1/modules/a76/invoices/imports/balance/create_balance_entries.py b/backend/api/v1/modules/a76/invoices/imports/balance/create_balance_entries.py new file mode 100644 index 00000000..e19d9a9a --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/balance/create_balance_entries.py @@ -0,0 +1,120 @@ +""" +create_balance_entries +Generates one ``BalanceMovement`` (type ENTRY) for every line item of a +processed import invoice, writing the initial inventory balance for each lot. + +Design rules (from a24.balance_movement): + 1. NEVER update existing rows — only INSERT. + 2. Balance = SUM of movements. No cached balance columns. + 3. order_peps is set to the new movement's id (globally monotonic) via a + post-flush assignment — SQLAlchemy fills autoincrement ids after flush. + +This function is called AFTER all validations pass and BEFORE db.flush() at +the end of the import main_process, so all inserts are part of the same +transaction. +""" + +from decimal import Decimal +from typing import List + +from sqlalchemy.orm import Session + +from api.v1.modules.a24.balance_movements.models import BalanceMovement, MovementType +from api.v1.modules.a76.invoices.models import InvoiceHeader +from api.v1.modules.a76.items.models import LineItem + + +def create_balance_entries( + db: Session, + invoice: InvoiceHeader, + lines: List[LineItem], +) -> List[BalanceMovement]: + """ + Inserts one ``BalanceMovement(type=ENTRY)`` for every import line item. + + Parameters + ---------- + db : active SQLAlchemy session (inside the process transaction) + invoice : the import invoice that has just been validated and totalled + lines : all LineItem rows of the invoice + + Returns + ------- + List of the newly created BalanceMovement objects (already added to the + session, ids available after the next flush). + + Notes + ----- + - ``order_peps`` is set equal to ``movement.id`` right after flush so that + the PEPS index is globally monotonic — older imports always have a lower + value and are consumed first on export. + - Sub-items (fa_data.is_subitem = True) are skipped; only principal lines + contribute to inventory. + - Lines with quantity = 0 are skipped to keep the ledger clean. + """ + movements: List[BalanceMovement] = [] + + operation_date = ( + invoice.invoice_date.date() + if hasattr(invoice.invoice_date, "date") + else invoice.invoice_date + ) + + for line in lines: + # Skip sub-items — they have no independent balance + is_subitem = line.fa_data.is_subitem if line.fa_data else False + if is_subitem: + continue + + qty = ( + Decimal(str(line.quantity.quantity or 0)) + if line.quantity + else Decimal(0) + ) + if qty <= 0: + continue + + value_me = ( + Decimal(str(line.financial.value_usd or 0)) + if line.financial + else Decimal(0) + ) + value_mn = ( + Decimal(str(line.financial.value_mxn or 0)) + if line.financial + else Decimal(0) + ) + net_weight = ( + Decimal(str(line.quantity.net_weight or 0)) + if line.quantity + else Decimal(0) + ) + + movement = BalanceMovement( + tenant_id=invoice.tenant_id, + company_id=invoice.company_id, + import_invoice_id=invoice.id, + import_item_line_id=line.id, + part_number_id=line.part_number_id, + movement_type=MovementType.ENTRY, + quantity=qty, + value_me=value_me if value_me > 0 else None, + value_mn=value_mn if value_mn > 0 else None, + net_weight=net_weight if net_weight > 0 else None, + source_invoice_id=None, + source_item_line_id=None, + order_peps=0, # placeholder — set after flush (see below) + operation_date=operation_date, + notes=f"Entrada por factura de importación {invoice.invoice_number}", + ) + db.add(movement) + movements.append(movement) + + if movements: + # Flush to get autoincrement ids, then set order_peps = id so that + # the PEPS index is monotonic and requires no separate sequence. + db.flush() + for mov in movements: + mov.order_peps = mov.id + + return movements diff --git a/backend/api/v1/modules/a76/invoices/imports/balance/void_balance_entries.py b/backend/api/v1/modules/a76/invoices/imports/balance/void_balance_entries.py new file mode 100644 index 00000000..45bb113c --- /dev/null +++ b/backend/api/v1/modules/a76/invoices/imports/balance/void_balance_entries.py @@ -0,0 +1,160 @@ +""" +void_balance_entries +Cancels every open ENTRY balance of an import invoice by inserting a matching +ENTRY_VOID movement for each one. + +Design rules preserved: + 1. NEVER update or delete balance_movement rows — only INSERT. + 2. Net balance after void = SUM(ENTRY qty) - SUM(ENTRY_VOID qty) = 0. + 3. order_peps is set to the new movement's id (post-flush, globally monotonic). + +Called when an import invoice is un-processed (reverted) so that the lots can +no longer be consumed by export discharges. A subsequent re-process will +insert fresh ENTRY rows with up-to-date values. + +Guard: + If any ENTRY has already been partially or fully consumed (i.e. there exist + CONSUMPTION/WASTE/SCRAP/DESTRUCTION movements against it), the void is + blocked and a ``ValueError`` is raised — you cannot un-process an invoice + whose materials are already in use. +""" + +from decimal import Decimal +from typing import List + +from sqlalchemy import select, func, case +from sqlalchemy.orm import Session + +from api.v1.modules.a24.balance_movements.models import ( + BalanceMovement, + MovementType, + NEGATIVE_MOVEMENTS, + USED_MOVEMENTS, +) +from api.v1.modules.a76.invoices.models import InvoiceHeader + + +def void_balance_entries( + db: Session, + invoice: InvoiceHeader, +) -> List[BalanceMovement]: + """ + Inserts ``ENTRY_VOID`` movements that cancel every open ENTRY for the + given import invoice. + + Parameters + ---------- + db : active SQLAlchemy session (inside the revert transaction) + invoice : the import invoice being un-processed + + Returns + ------- + List of the newly created ENTRY_VOID BalanceMovement objects. + + Raises + ------ + ValueError + If any lot of the invoice has already been (partially) consumed by + an export, waste, scrap or destruction. In that case the invoice + cannot be un-processed without first cancelling those discharges. + """ + # ── 1. Fetch all ENTRY movements for this invoice ──────────────────────── + entries: List[BalanceMovement] = ( + db.execute( + select(BalanceMovement).where( + BalanceMovement.import_invoice_id == invoice.id, + BalanceMovement.movement_type == MovementType.ENTRY, + ) + ) + .scalars() + .all() + ) + + if not entries: + return [] + + import_line_ids = [e.import_item_line_id for e in entries] + + # ── 2. Guard: check no lot has been consumed ───────────────────────────── + sign_expr = case( + (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), -1), + else_=1, + ) + used_expr = case( + (BalanceMovement.movement_type.in_(USED_MOVEMENTS), BalanceMovement.quantity), + else_=Decimal(0), + ) + + lot_summary = ( + db.execute( + select( + BalanceMovement.import_item_line_id, + func.sum(sign_expr * BalanceMovement.quantity).label("balance"), + func.sum(used_expr).label("used"), + ) + .where( + BalanceMovement.import_item_line_id.in_(import_line_ids), + ) + .group_by(BalanceMovement.import_item_line_id) + ) + .all() + ) + + consumed_lots = [row for row in lot_summary if (row.used or 0) > 0] + if consumed_lots: + lot_ids = ", ".join(str(r.import_item_line_id) for r in consumed_lots) + raise ValueError( + f"No se puede des-procesar la factura '{invoice.invoice_number}': " + f"los siguientes lotes ya tienen consumos registrados y deben " + f"cancelarse primero (item_line ids: {lot_ids})." + ) + + # ── 3. Build the ENTRY_VOID map: one void per ENTRY ────────────────────── + # Map lot_id → open balance (should equal the original ENTRY qty since no + # consumptions exist, but we use the actual net balance to be safe). + balance_map: dict[int, Decimal] = { + row.import_item_line_id: Decimal(str(row.balance or 0)) + for row in lot_summary + } + + operation_date = ( + invoice.invoice_date.date() + if hasattr(invoice.invoice_date, "date") + else invoice.invoice_date + ) + + voids: List[BalanceMovement] = [] + for entry in entries: + open_qty = balance_map.get(entry.import_item_line_id, Decimal(0)) + if open_qty <= 0: + continue + + void_mov = BalanceMovement( + tenant_id=invoice.tenant_id, + company_id=invoice.company_id, + import_invoice_id=invoice.id, + import_item_line_id=entry.import_item_line_id, + part_number_id=entry.part_number_id, + movement_type=MovementType.ENTRY_VOID, + quantity=open_qty, + value_me=entry.value_me, + value_mn=entry.value_mn, + net_weight=entry.net_weight, + source_invoice_id=None, + source_item_line_id=None, + order_peps=0, # set after flush + operation_date=operation_date, + notes=( + f"Anulación de entrada por des-procesamiento de " + f"factura {invoice.invoice_number} (entry id={entry.id})" + ), + ) + db.add(void_mov) + voids.append(void_mov) + + if voids: + db.flush() + for mov in voids: + mov.order_peps = mov.id + + return voids From 196525c6841f522c98d60a39b87e835f4f5daeeb Mon Sep 17 00:00:00 2001 From: AlexeerCT Date: Tue, 17 Mar 2026 22:43:53 -0500 Subject: [PATCH 4/4] 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