Files
plantillas-proyectos/backend/api/v1/modules/a24/balance_movements/models.py
AlexeerCT ccd81d743e Add invoice processing functionality and enhance models and routes
- Introduced new routes for processing invoices in the router.
- Added `InvoiceStatus` enum to manage invoice states.
- Enhanced `InvoiceHeader` and `InvoiceFinancials` models with new fields for status tracking and total packages.
- Updated schemas to include new fields for invoice processing.
- Implemented API methods for processing invoices and checking process status in the frontend.
- Removed outdated validation files related to invoice processing.
2026-03-13 11:02:49 -05:00

346 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Annex 24 - Balance Management Core
SQLAlchemy v2
Plugs into the existing schema:
invoice_header (a76.invoice_header) ← already exists
item_lines (a76.item_lines) ← already exists
These 4 tables are ALL you need for balances:
a24.balance_movement ← the ledger (append-only, never UPDATE)
a24.discharge_header ← one discharge per export/SM/CTM event
a24.discharge_detail ← one row per (export line × import lot consumed)
a24.discharge_scrap ← mermas, desperdicios, destrucciones
Design rules:
1. NEVER update balance_movement rows — only INSERT
2. Balance = SUM of movements. No cached balance columns.
3. Every discharge_detail row MUST reference a balance_movement row
4. PEPS order is enforced via order_peps (monotonic, set on INSERT)
"""
from __future__ import annotations
import datetime
from decimal import Decimal
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
from ..discharges.models import DischargeDetail
from sqlalchemy import (
BigInteger,
CheckConstraint,
Date,
ForeignKey,
Index,
Integer,
Numeric,
String,
UniqueConstraint,
text,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
if TYPE_CHECKING:
from api.v1.modules.a76.invoices.models import InvoiceHeader
from api.v1.modules.a76.items.models import LineItem
from api.v1.modules.a76.parts.models import Part
# ---------------------------------------------------------------------------
# Enums
# ---------------------------------------------------------------------------
class MovementType(str, Enum):
# ── Positive (add to balance) ──────────────────────────────────────────
ENTRY = "entry" # Normal import entry
RETURN = "return" # Material returned to balance
POSITIVE_ADJUSTMENT = "pos_adj" # Physical inventory adjustment (+)
TRANSFER_IN = "transfer_in" # CTM/SM received (the receiving side)
REGIME_CHANGE_IN = "regime_chg_in" # Eg. temporary → definitive (entry side)
# ── Negative (consume from balance) ───────────────────────────────────
CONSUMPTION = "consumption" # Consumed in export (most common)
WASTE = "waste" # Merma de proceso
SCRAP = "scrap" # Desperdicio / scrap
DESTRUCTION = "destruction" # Destrucción oficial ante aduana
NEGATIVE_ADJUSTMENT = "neg_adj" # Physical inventory adjustment (-)
TRANSFER_OUT = "transfer_out" # CTM/SM sent (the sending side)
EXPIRATION = "expiration" # Balance cancelled due to deadline
REGIME_CHANGE_OUT = "regime_chg_out" # Eg. temporary → definitive (exit side)
# Which movement types reduce the balance (sign = -1)
NEGATIVE_MOVEMENTS = {
MovementType.CONSUMPTION,
MovementType.WASTE,
MovementType.SCRAP,
MovementType.DESTRUCTION,
MovementType.NEGATIVE_ADJUSTMENT,
MovementType.TRANSFER_OUT,
MovementType.EXPIRATION,
MovementType.REGIME_CHANGE_OUT,
}
# Which types count toward "used" (CANTUSADA in Anexo 24 report)
USED_MOVEMENTS = {
MovementType.CONSUMPTION,
MovementType.WASTE,
MovementType.SCRAP,
MovementType.DESTRUCTION,
}
# ---------------------------------------------------------------------------
# 1. BalanceMovement (the ledger — APPEND ONLY)
#
# One row per atomic change to a specific import lot.
# Current balance of any lot = SUM of (signed quantity) over its rows.
#
# import_item_line_id → a76.item_lines.id (the import line = the "lot")
# import_invoice_id → a76.invoice_header.id (the import invoice)
# part_number_id → a76.parts.id (denormalized for PEPS index)
# source_item_line_id → a76.item_lines.id (export/SM/CTM line, if any)
# source_invoice_id → a76.invoice_header.id (export/SM/CTM invoice, if any)
#
# NOTE: No discharge_detail_id column. Navigate the other direction via
# DischargeDetail.movement_id to avoid a circular FK and keep this
# table truly append-only (no UPDATE ever needed).
# ---------------------------------------------------------------------------
class BalanceMovement(Base, TenantScopedMixin, TimestampMixin):
"""
Core ledger table. NEVER update existing rows — only INSERT.
Balance of a lot = SUM(quantity) WHERE sign=+1 (entries)
- SUM(quantity) WHERE sign=-1 (exits)
Append-only rule is enforced at the application layer.
"""
__tablename__ = "balance_movement"
__table_args__ = (
UniqueConstraint(
"import_item_line_id", "order_peps",
name="uq_balance_movement_lot_peps",
),
CheckConstraint("quantity > 0", name="ck_balance_movement_qty_positive"),
# PEPS lookup: "give me available lots for this part+regime, oldest first"
# part_number_id is denormalized here so this index is self-contained.
Index(
"ix_balmov_peps_lookup",
"tenant_id", "part_number_id", "movement_type", "order_peps",
postgresql_include=["import_item_line_id", "quantity", "value_me", "value_mn"],
),
# Balance calculation per lot
Index("ix_balmov_lot", "import_item_line_id"),
# "What did this export consume?"
Index("ix_balmov_source", "source_invoice_id", "source_item_line_id"),
# Anexo 24 period reports
Index("ix_balmov_operation_date", "tenant_id", "operation_date", "movement_type"),
{"schema": "a24"},
)
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
# ── The import lot this movement belongs to ────────────────────────────
import_invoice_id: Mapped[int] = mapped_column(
ForeignKey("a76.invoice_header.id"),
comment="Import invoice (cabecera de importación)",
)
import_item_line_id: Mapped[int] = mapped_column(
ForeignKey("a76.item_lines.id"),
comment="Import line item = the PEPS lot",
)
# ── Denormalized part reference — enables efficient PEPS index ─────────
# Must equal import_line.part_number_id. Set on INSERT, never changed.
part_number_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.parts.id"),
comment="Denormalized from item_lines.part_number_id. Enables PEPS index without joins.",
)
# ── What kind of movement ─────────────────────────────────────────────
movement_type: Mapped[MovementType] = mapped_column(
String(20),
comment="See MovementType enum. Determines sign and whether qty counts as used.",
)
# ── Quantity and value (always stored positive) ───────────────────────
quantity: Mapped[Decimal] = mapped_column(
Numeric(19, 8),
comment="Always positive. Sign is inferred from movement_type via NEGATIVE_MOVEMENTS.",
)
value_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8), comment="USD")
value_mn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8), comment="MXN")
net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
# ── Document that caused this movement ────────────────────────────────
# NULL for ENTRY movements (the import invoice itself is the cause)
source_invoice_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.invoice_header.id"),
comment="Export / SM / CTM invoice. NULL for entries.",
)
source_item_line_id: Mapped[Optional[int]] = mapped_column(
ForeignKey("a76.item_lines.id"),
comment="Specific line in the export / SM / CTM invoice.",
)
# ── PEPS ordering ─────────────────────────────────────────────────────
# Simplest approach: set order_peps = id (globally monotonic).
# Finer approach: use a per-part sequence or epoch-based value.
order_peps: Mapped[int] = mapped_column(
BigInteger,
comment="PEPS order within this lot. Lower = older = consumed first.",
)
# ── Business date of the operation ───────────────────────────────────
operation_date: Mapped[datetime.date] = mapped_column(
Date, comment="Date of the actual business event, not DB insert."
)
notes: Mapped[Optional[str]] = mapped_column(String(300))
# ── Relationships ─────────────────────────────────────────────────────
import_invoice: Mapped["InvoiceHeader"] = relationship(
foreign_keys=[import_invoice_id],
)
import_line: Mapped["LineItem"] = relationship(
foreign_keys=[import_item_line_id],
)
part: Mapped[Optional["Part"]] = relationship(
foreign_keys=[part_number_id],
)
source_invoice: Mapped[Optional["InvoiceHeader"]] = relationship(
foreign_keys=[source_invoice_id],
)
source_line: Mapped[Optional["LineItem"]] = relationship(
foreign_keys=[source_item_line_id],
)
# Back-reference: navigate to the detail that consumed this movement.
# Use viewonly=True — ownership lives on DischargeDetail.movement_id.
discharge_detail: Mapped[Optional["DischargeDetail"]] = relationship(
back_populates="movement",
foreign_keys="[DischargeDetail.movement_id]",
primaryjoin="BalanceMovement.id == DischargeDetail.movement_id",
viewonly=True,
)
# ---------------------------------------------------------------------------
# Repository helpers (copy to your service/repository layer)
# ---------------------------------------------------------------------------
#
#
# ── Current balance of a lot ─────────────────────────────────────────────
#
# from sqlalchemy import case, func, select
#
# def current_balance(session, import_item_line_id: int):
# sign = case(
# (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), -1),
# else_=1,
# )
# affects_used = BalanceMovement.movement_type.in_(USED_MOVEMENTS)
# return session.execute(
# select(
# func.sum(sign * BalanceMovement.quantity).label("current_balance"),
# func.sum(
# case((affects_used, BalanceMovement.quantity), else_=0)
# ).label("quantity_used"),
# func.sum(
# case((affects_used, BalanceMovement.value_me), else_=0)
# ).label("value_used_me"),
# ).where(BalanceMovement.import_item_line_id == import_item_line_id)
# ).one()
#
#
# ── PEPS resolver — call BEFORE inserting a CONSUMPTION movement ─────────
#
# def peps_lots_for(session, tenant_id, part_number_id, operation_type, qty_needed):
# """
# Returns import lots in FIFO order with their available balance.
# Walk the list and consume until qty_needed is satisfied.
# """
# sign = case(
# (BalanceMovement.movement_type.in_(NEGATIVE_MOVEMENTS), -1),
# else_=1,
# )
# lot_balances = (
# select(
# BalanceMovement.import_item_line_id,
# func.sum(sign * BalanceMovement.quantity).label("available"),
# func.min(BalanceMovement.order_peps).label("oldest_peps"),
# )
# .where(
# BalanceMovement.tenant_id == tenant_id,
# BalanceMovement.part_number_id == part_number_id,
# )
# .group_by(BalanceMovement.import_item_line_id)
# .having(func.sum(sign * BalanceMovement.quantity) > 0)
# .order_by("oldest_peps")
# .subquery()
# )
# return session.execute(select(lot_balances)).all()
#
#
# ── Transaction flow for a new discharge ─────────────────────────────────
#
# def apply_discharge(session, export_invoice_id, lines):
# """
# lines = [{"export_line_id": X, "part_number_id": Y, "quantity": Z}, ...]
#
# Two-step INSERT: movement first, then detail referencing it.
# No UPDATE on balance_movement — design rule 1 is preserved.
# """
# header = DischargeHeader(source_invoice_id=export_invoice_id, ...)
# session.add(header)
# session.flush() # get header.id
#
# for line in lines:
# lots = peps_lots_for(session, ..., line["part_number_id"], qty_needed=line["quantity"])
# remaining = line["quantity"]
#
# for lot in lots:
# consume = min(lot.available, remaining)
#
# # Step 1: insert movement
# mov = BalanceMovement(
# import_item_line_id = lot.import_item_line_id,
# part_number_id = line["part_number_id"],
# movement_type = MovementType.CONSUMPTION,
# quantity = consume,
# source_invoice_id = export_invoice_id,
# source_item_line_id = line["export_line_id"],
# order_peps = <next_sequence>,
# operation_date = datetime.date.today(),
# )
# session.add(mov)
# session.flush() # get mov.id
#
# # Step 2: insert detail referencing the movement
# det = DischargeDetail(
# discharge_header_id = header.id,
# export_item_line_id = line["export_line_id"],
# import_item_line_id = lot.import_item_line_id,
# movement_id = mov.id, # NOT NULL — set immediately
# quantity_discharged = consume,
# )
# session.add(det)
#
# remaining -= consume
# if remaining <= 0:
# break
#
# session.commit()