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.
This commit is contained in:
2026-03-17 21:09:01 -05:00
parent 73462fcec3
commit 5d9f4041e4
2 changed files with 280 additions and 0 deletions

View File

@@ -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

View File

@@ -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