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