- Added a new route for reverting invoices in the A76 module. - Updated the pre_validators to provide clearer error messages when processing invoices. - Enhanced the PDF progress dialog to support step-by-step progress tracking for both invoice processing and reverting. - Introduced a confirmation dialog for reverting invoices in the dashboard. - Updated frontend components to handle the new revert functionality and display appropriate progress messages.
369 lines
17 KiB
Python
369 lines
17 KiB
Python
"""
|
||
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 sqlalchemy import (
|
||
BigInteger,
|
||
CheckConstraint,
|
||
Date,
|
||
ForeignKey,
|
||
Index,
|
||
Integer,
|
||
Numeric,
|
||
String,
|
||
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 ..balance_movements.models import BalanceMovement
|
||
|
||
|
||
class DischargeType(str, Enum):
|
||
TEMPORARY = "temporary" # SDescargaT — export against temp import
|
||
DEFINITIVE = "definitive" # SDescargaD — export against def import
|
||
REPAIR = "repair" # SDescargaR — repair / return
|
||
DIRECTED = "directed" # SDescargaM — Directed discharge
|
||
CTM = "ctm" # SDescargaCTM — between plants (same group)
|
||
SUBASSEMBLY = "subassembly" # SDescargaSM — to external maquiladora
|
||
WASTE_SCRAP = "waste_scrap" # SDescargaMerDes — merma / desperdicio
|
||
|
||
|
||
class DischargeStatus(str, Enum):
|
||
PENDING = "pending"
|
||
APPLIED = "applied"
|
||
PARTIAL = "partial"
|
||
CANCELLED = "cancelled"
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 2. DischargeHeader
|
||
#
|
||
# One row per discharge event (export, SM batch, CTM transfer…).
|
||
# Groups all DischargeDetail rows for the same business event.
|
||
#
|
||
# source_invoice_id → a76.invoice_header.id (the export/SM/CTM invoice)
|
||
# def_import_invoice_id → a76.invoice_header.id (DEF discharges only)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class DischargeHeader(Base, TenantScopedMixin, TimestampMixin):
|
||
"""
|
||
One discharge per export event.
|
||
Groups DischargeDetail rows (one per import lot consumed).
|
||
"""
|
||
|
||
__tablename__ = "discharge_header"
|
||
__table_args__ = (
|
||
Index("ix_dischdr_source", "source_invoice_id", "status"),
|
||
Index("ix_dischdr_date", "tenant_id", "discharge_date", "discharge_type"),
|
||
{"schema": "a24"},
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||
|
||
# ── Export / SM / CTM document that triggers this discharge ───────────
|
||
source_invoice_id: Mapped[int] = mapped_column(
|
||
ForeignKey("a76.invoice_header.id"),
|
||
comment="Export, SM-out or CTM-send invoice that owns this discharge.",
|
||
)
|
||
|
||
# ── For DEFINITIVE discharges only ────────────────────────────────────
|
||
def_import_invoice_id: Mapped[Optional[int]] = mapped_column(
|
||
ForeignKey("a76.invoice_header.id"),
|
||
comment="Populated only for discharge_type=DEFINITIVE.",
|
||
)
|
||
|
||
discharge_type: Mapped[DischargeType] = mapped_column(String(15))
|
||
status: Mapped[DischargeStatus] = mapped_column(
|
||
String(15), server_default=text("'applied'")
|
||
)
|
||
discharge_date: Mapped[datetime.date] = mapped_column(Date)
|
||
|
||
# ── Control fields from legacy SDescarga* tables ──────────────────────
|
||
reference_invoice: Mapped[Optional[str]] = mapped_column(
|
||
String(19), comment="FACREFERENCIA — for rectifications"
|
||
)
|
||
discharge_subtype: Mapped[Optional[str]] = mapped_column(
|
||
String(10), comment="TIPODESC: NORMAL, PARCIAL, REPARACION, UTILERIA"
|
||
)
|
||
partial_sequence: Mapped[Optional[int]] = mapped_column(
|
||
Integer, comment="CONSECPARCIAL — for partial discharges"
|
||
)
|
||
sales_order: Mapped[Optional[str]] = mapped_column(String(20)) # ORDENVENTA
|
||
ctm_section: Mapped[Optional[str]] = mapped_column(String(3)) # APARTADOCTM
|
||
is_tooling: Mapped[bool] = mapped_column(
|
||
server_default=text("false"), comment="PORUTILERIA"
|
||
)
|
||
discharge_sm: Mapped[Optional[str]] = mapped_column(String(4)) # DESCARGASM
|
||
is_repair_update: Mapped[bool] = mapped_column(
|
||
server_default=text("false"), comment="ACTUALREPARACION"
|
||
)
|
||
material_type_expo: Mapped[Optional[str]] = mapped_column(
|
||
String(10), comment="TIPOMATEXPO"
|
||
)
|
||
|
||
# ── Cancellation trail ────────────────────────────────────────────────
|
||
cancelled_by: Mapped[Optional[str]] = mapped_column(String(20))
|
||
cancellation_reason: Mapped[Optional[str]] = mapped_column(String(300))
|
||
|
||
# ── Relationships ─────────────────────────────────────────────────────
|
||
source_invoice: Mapped["InvoiceHeader"] = relationship(
|
||
foreign_keys=[source_invoice_id],
|
||
)
|
||
def_import_invoice: Mapped[Optional["InvoiceHeader"]] = relationship(
|
||
foreign_keys=[def_import_invoice_id],
|
||
)
|
||
details: Mapped[List["DischargeDetail"]] = relationship(
|
||
back_populates="header", cascade="all, delete-orphan"
|
||
)
|
||
scraps: Mapped[List["DischargeScrap"]] = relationship(
|
||
back_populates="header", cascade="all, delete-orphan"
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 3. DischargeDetail
|
||
#
|
||
# The critical traceability link:
|
||
# "Export line X consumed Y units from import lot Z"
|
||
#
|
||
# One row per (export_line × import_lot) pair.
|
||
# A single export line can span multiple rows when PEPS pulls from
|
||
# more than one import lot.
|
||
#
|
||
# discharge_header_id → a24.discharge_header.id
|
||
# export_item_line_id → a76.item_lines.id (the export line)
|
||
# import_item_line_id → a76.item_lines.id (the import lot consumed)
|
||
# movement_id → a24.balance_movement.id (the ledger row)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class DischargeDetail(Base, TenantScopedMixin, TimestampMixin):
|
||
"""
|
||
One row per (export line × import lot consumed).
|
||
|
||
This is the traceability record the SAT asks for:
|
||
"Show me which import pedimento covered this export line."
|
||
"""
|
||
|
||
__tablename__ = "discharge_detail"
|
||
__table_args__ = (
|
||
CheckConstraint("movement_id IS NOT NULL", name="ck_dischdet_movement_required"),
|
||
Index("ix_dischdet_header", "discharge_header_id"),
|
||
Index("ix_dischdet_import_lot", "import_item_line_id"),
|
||
Index("ix_dischdet_export_line", "export_item_line_id"),
|
||
Index("ix_dischdet_part", "tenant_id", "part_number"),
|
||
{"schema": "a24"},
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||
|
||
discharge_header_id: Mapped[int] = mapped_column(
|
||
ForeignKey("a24.discharge_header.id"),
|
||
)
|
||
|
||
# ── Export side ───────────────────────────────────────────────────────
|
||
export_item_line_id: Mapped[Optional[int]] = mapped_column(
|
||
ForeignKey("a76.item_lines.id"),
|
||
comment="NULL for waste-only discharges.",
|
||
)
|
||
# Denormalized for report performance
|
||
part_number: Mapped[Optional[str]] = mapped_column(
|
||
String(70), comment="NUMPARTE of the export line (denormalized)"
|
||
)
|
||
export_part_number: Mapped[Optional[str]] = mapped_column(
|
||
String(70), comment="NUMPARTEEXPO — as it appears in the pedimento"
|
||
)
|
||
export_line_ref: Mapped[Optional[int]] = mapped_column(
|
||
Integer, comment="LINEAEXPOREF — for rectification references"
|
||
)
|
||
|
||
# ── Import lot side (PEPS lot consumed) ───────────────────────────────
|
||
import_item_line_id: Mapped[int] = mapped_column(
|
||
ForeignKey("a76.item_lines.id"),
|
||
)
|
||
|
||
# ── Ledger row created for this consumption (NOT NULL — design rule 3) ─
|
||
movement_id: Mapped[int] = mapped_column(
|
||
BigInteger,
|
||
ForeignKey("a24.balance_movement.id"),
|
||
comment="The BalanceMovement that records this consumption. Required.",
|
||
)
|
||
|
||
# ── Consumed quantities and values ────────────────────────────────────
|
||
quantity_discharged: Mapped[Decimal] = mapped_column(Numeric(19, 8)) # CANTDESC
|
||
unit_of_measure: Mapped[Optional[str]] = mapped_column(String(5))
|
||
value_mn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
|
||
value_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
|
||
net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
|
||
gross_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
|
||
|
||
# ── Tariff classification of the consumed input ───────────────────────
|
||
tariff_fraction: Mapped[Optional[str]] = mapped_column(String(10)) # FRACCIONIMPO
|
||
fraction_type: Mapped[Optional[str]] = mapped_column(String(7)) # TIPOFRACCION
|
||
ad_valorem: Mapped[Optional[str]] = mapped_column(String(10)) # ADVALOREMIMPO
|
||
country_of_origin: Mapped[Optional[str]] = mapped_column(String(3)) # PAISMERCANCIA
|
||
sector: Mapped[Optional[str]] = mapped_column(String(8)) # SECTOR
|
||
|
||
# ── Repair-specific ───────────────────────────────────────────────────
|
||
original_part: Mapped[Optional[str]] = mapped_column(
|
||
String(70), comment="PARTEORIGINAL"
|
||
)
|
||
equivalent_quantity: Mapped[Optional[Decimal]] = mapped_column(
|
||
Numeric(19, 8), comment="CANTEQUIVALENTE"
|
||
)
|
||
equivalent_unit: Mapped[Optional[str]] = mapped_column(String(5))
|
||
returned_quantity_sm: Mapped[Optional[Decimal]] = mapped_column(
|
||
Numeric(19, 8), comment="CANTRETORNADASAM"
|
||
)
|
||
|
||
# ── Waste / scrap ─────────────────────────────────────────────────────
|
||
waste_type: Mapped[Optional[str]] = mapped_column(
|
||
String(1), comment="M=merma, D=desperdicio, S=scrap"
|
||
)
|
||
take_balance_base_pt: Mapped[Optional[str]] = mapped_column(
|
||
String(2), comment="TOMARSALDOBASEALPT"
|
||
)
|
||
|
||
# ── Tax fields (from SDescargaT) ──────────────────────────────────────
|
||
igi_amount: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # MONTOIGI
|
||
tax_payment: Mapped[Optional[str]] = mapped_column(String(1)) # PAGOIMPUESTO
|
||
has_certificate: Mapped[Optional[str]] = mapped_column(String(1)) # TIENECERT
|
||
iva_mn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORIVAMN
|
||
iva_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8)) # VALORIVAME
|
||
|
||
# ── SM origin traceability ────────────────────────────────────────────
|
||
origin_import_invoice: Mapped[Optional[str]] = mapped_column(
|
||
String(15), comment="FACTURAIMPO original (denorm for SM)"
|
||
)
|
||
procedence: Mapped[Optional[str]] = mapped_column(String(3)) # PROCEDENCIA
|
||
|
||
# ── Relationships ─────────────────────────────────────────────────────
|
||
header: Mapped["DischargeHeader"] = relationship(back_populates="details")
|
||
export_line: Mapped[Optional["LineItem"]] = relationship(
|
||
foreign_keys=[export_item_line_id],
|
||
)
|
||
import_line: Mapped["LineItem"] = relationship(
|
||
foreign_keys=[import_item_line_id],
|
||
)
|
||
movement: Mapped["BalanceMovement"] = relationship(
|
||
foreign_keys=[movement_id],
|
||
back_populates="discharge_detail",
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 4. DischargeScrap
|
||
#
|
||
# Mermas, desperdicios and destrucciones.
|
||
# Can be standalone (no export invoice) or linked to a DischargeHeader.
|
||
# Always generates a BalanceMovement of type WASTE / SCRAP / DESTRUCTION.
|
||
#
|
||
# Replaces: SDescargaS + SDescargaMerDes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
class DischargeScrap(Base, TenantScopedMixin, TimestampMixin):
|
||
"""
|
||
Waste / scrap / destruction records.
|
||
Replaces SDescargaS and the waste portion of SDescargaMerDes.
|
||
"""
|
||
|
||
__tablename__ = "discharge_scrap"
|
||
__table_args__ = (
|
||
Index("ix_dischscrap_header", "discharge_header_id"),
|
||
Index("ix_dischscrap_import_lot", "import_item_line_id"),
|
||
Index("ix_dischscrap_date", "tenant_id", "scrap_date"),
|
||
{"schema": "a24"},
|
||
)
|
||
|
||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||
|
||
# ── Optional parent discharge ─────────────────────────────────────────
|
||
discharge_header_id: Mapped[Optional[int]] = mapped_column(
|
||
ForeignKey("a24.discharge_header.id"),
|
||
comment="NULL when scrap is registered independently (not tied to an export).",
|
||
)
|
||
|
||
# ── The import lot being scrapped ──────────────────────────────────────
|
||
import_item_line_id: Mapped[int] = mapped_column(
|
||
ForeignKey("a76.item_lines.id"),
|
||
)
|
||
|
||
# ── Ledger row recording this scrap ───────────────────────────────────
|
||
movement_id: Mapped[Optional[int]] = mapped_column(
|
||
BigInteger, ForeignKey("a24.balance_movement.id"),
|
||
)
|
||
|
||
# ── Type ──────────────────────────────────────────────────────────────
|
||
scrap_type: Mapped[str] = mapped_column(
|
||
String(1), comment="M=merma, D=desperdicio, S=scrap, X=destrucción"
|
||
)
|
||
|
||
# ── Finished-good export line that generated this scrap ───────────────
|
||
finished_good_line_id: Mapped[Optional[int]] = mapped_column(
|
||
ForeignKey("a76.item_lines.id"),
|
||
comment="Export line of the product whose manufacture created this scrap.",
|
||
)
|
||
finished_good_part: Mapped[Optional[str]] = mapped_column(String(70))
|
||
|
||
# ── Scrap export pedimento (if scrap is exported separately) ──────────
|
||
scrap_export_invoice_id: Mapped[Optional[int]] = mapped_column(
|
||
ForeignKey("a76.invoice_header.id"),
|
||
comment="If desperdicio has its own export pedimento.",
|
||
)
|
||
|
||
# ── The scrapped material ─────────────────────────────────────────────
|
||
part_number: Mapped[str] = mapped_column(String(70))
|
||
item_class: Mapped[Optional[str]] = mapped_column(String(8))
|
||
quantity: Mapped[Decimal] = mapped_column(Numeric(19, 8))
|
||
unit_of_measure: Mapped[str] = mapped_column(String(5))
|
||
value_mn: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
|
||
value_me: Mapped[Optional[Decimal]] = mapped_column(Numeric(23, 8))
|
||
net_weight: Mapped[Optional[Decimal]] = mapped_column(Numeric(19, 8))
|
||
procedence: Mapped[Optional[str]] = mapped_column(String(3))
|
||
scrap_date: Mapped[datetime.date] = mapped_column(Date)
|
||
|
||
# ── Relationships ─────────────────────────────────────────────────────
|
||
header: Mapped[Optional["DischargeHeader"]] = relationship(
|
||
back_populates="scraps",
|
||
)
|
||
import_line: Mapped["LineItem"] = relationship(
|
||
foreign_keys=[import_item_line_id],
|
||
)
|
||
finished_good_line: Mapped[Optional["LineItem"]] = relationship(
|
||
foreign_keys=[finished_good_line_id],
|
||
)
|
||
scrap_export_invoice: Mapped[Optional["InvoiceHeader"]] = relationship(
|
||
foreign_keys=[scrap_export_invoice_id],
|
||
)
|
||
movement: Mapped[Optional["BalanceMovement"]] = relationship(
|
||
foreign_keys=[movement_id],
|
||
) |