Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/partePais_BOM

This commit is contained in:
2026-03-13 11:13:44 -05:00
84 changed files with 33338 additions and 1104 deletions

View File

@@ -0,0 +1,345 @@
"""
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()

View File

@@ -0,0 +1,370 @@
"""
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,
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],
)

View File

@@ -1,3 +0,0 @@
"""
Módulo de localización
"""

View File

@@ -1,43 +0,0 @@
"""
DTOs (Data Transfer Objects) para módulo de localización
"""
from typing import Optional
from pydantic import BaseModel, Field
class LocationCreateDTO(BaseModel):
"""DTO para crear una localización"""
code: str = Field(..., max_length=5, description="Location code")
description: Optional[str] = Field(
None, max_length=200, description="Location description"
)
class Config:
from_attributes = True
class LocationUpdateDTO(BaseModel):
"""DTO para actualizar una localización"""
code: Optional[str] = Field(
None, max_length=5, description="Location code")
description: Optional[str] = Field(
None, max_length=200, description="Location description"
)
class Config:
from_attributes = True
class LocationResponseDTO(BaseModel):
"""DTO para responder con datos de una localización"""
id: int
code: str
description: Optional[str] = None
class Config:
from_attributes = True

View File

@@ -1,36 +0,0 @@
"""
Modelos ORM para gestión de localización
"""
from typing import Optional
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
from core.database import Base
from sqlalchemy import Integer, PrimaryKeyConstraint, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
class Location(Base, TenantScopedMixin, TimestampMixin):
"""
Modelo para la tabla Location - Localización
"""
__tablename__ = "location" # SLocalizacion
__table_args__ = (
PrimaryKeyConstraint("id", name="location_pkey"),
UniqueConstraint("code", name="location_code_unique"),
{"schema": "a24"},
)
# Primary key
id: Mapped[int] = mapped_column(
Integer, primary_key=True, autoincrement=True)
# Location code (unique)
code: Mapped[str] = mapped_column(String(5), nullable=False, unique=True)
# Location description
description: Mapped[Optional[str]] = mapped_column(String(200))
def __repr__(self):
return f"<Location(id={self.id}, code={self.code}, description={self.description})>"

View File

@@ -1,136 +0,0 @@
"""
Rutas para gestión de localización
"""
from typing import List
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.orm import Session
from core.database import get_core_db
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
from .models import Location
from .service import LocationService
router = APIRouter(prefix="/locations", tags=["locations"])
@router.get(
"",
response_model=dict,
summary="Get all locations",
)
async def get_all_locations(
skip: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=100),
code: str = Query(None),
description: str = Query(None),
db: Session = Depends(get_core_db),
):
"""Get all locations with optional filtering and pagination"""
filters = {}
if code:
filters["code"] = code
if description:
filters["description"] = description
locations, total = LocationService.get_all(db, skip, limit, filters)
return {
"data": [LocationResponseDTO.model_validate(location) for location in locations],
"total": total,
"skip": skip,
"limit": limit,
}
@router.get(
"/{location_id}",
response_model=LocationResponseDTO,
summary="Get location by ID",
)
async def get_location(
location_id: int,
db: Session = Depends(get_core_db),
):
"""Get a location by its ID"""
location = LocationService.get_by_id(db, location_id)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return LocationResponseDTO.model_validate(location)
@router.get(
"/code/{code}",
response_model=LocationResponseDTO,
summary="Get location by code",
)
async def get_location_by_code(
code: str,
db: Session = Depends(get_core_db),
):
"""Get a location by its code"""
location = LocationService.get_by_code(db, code)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return LocationResponseDTO.model_validate(location)
@router.post(
"",
response_model=LocationResponseDTO,
status_code=status.HTTP_201_CREATED,
summary="Create location",
)
async def create_location(
location_data: LocationCreateDTO,
db: Session = Depends(get_core_db),
):
"""Create a new location"""
location = LocationService.create(db, location_data)
return LocationResponseDTO.model_validate(location)
@router.put(
"/{location_id}",
response_model=LocationResponseDTO,
summary="Update location",
)
async def update_location(
location_id: int,
location_data: LocationUpdateDTO,
db: Session = Depends(get_core_db),
):
"""Update a location"""
location = LocationService.update(db, location_id, location_data)
if not location:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return LocationResponseDTO.model_validate(location)
@router.delete(
"/{location_id}",
status_code=status.HTTP_204_NO_CONTENT,
summary="Delete location",
)
async def delete_location(
location_id: int,
db: Session = Depends(get_core_db),
):
"""Delete a location"""
success = LocationService.delete(db, location_id)
if not success:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Location not found",
)
return None

View File

@@ -1,136 +0,0 @@
"""
Capa de servicio para lógica de negocio de localización
"""
import logging
from typing import Any, Dict, List, Optional, Tuple
from fastapi import HTTPException
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .dto import LocationCreateDTO, LocationResponseDTO, LocationUpdateDTO
from .models import Location
logger = logging.getLogger(__name__)
class LocationService:
"""Servicio para gestión de localización"""
def __init__(self, db: Session):
self.db = db
@staticmethod
def get_all(
db: Session,
skip: int = 0,
limit: int = 50,
filters: Optional[Dict[str, Any]] = None,
) -> Tuple[List[Location], int]:
"""Get all locations with pagination"""
query = db.query(Location)
if filters:
if filters.get("code"):
query = query.filter(
Location.code.ilike(f"%{filters['code']}%"))
if filters.get("description"):
query = query.filter(
Location.description.ilike(f"%{filters['description']}%")
)
total = query.count()
locations = query.offset(skip).limit(limit).all()
return locations, total
@staticmethod
def get_by_id(db: Session, location_id: int) -> Optional[Location]:
"""Get location by ID"""
return db.query(Location).filter(Location.id == location_id).first()
@staticmethod
def get_by_code(db: Session, code: str) -> Optional[Location]:
"""Get location by code"""
return db.query(Location).filter(Location.code == code).first()
@staticmethod
def create(db: Session, location_data: LocationCreateDTO) -> Location:
"""Create a new location"""
try:
db_location = Location(
**location_data.model_dump(exclude_unset=True))
db.add(db_location)
db.commit()
db.refresh(db_location)
return db_location
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError creating location: {str(e)}")
raise HTTPException(
status_code=400,
detail="Location code already exists",
)
except Exception as e:
db.rollback()
logger.error(f"Error creating location: {str(e)}")
raise HTTPException(
status_code=500, detail="Error creating location")
@staticmethod
def update(
db: Session, location_id: int, location_data: LocationUpdateDTO
) -> Optional[Location]:
"""Update a location"""
try:
db_location = db.query(Location).filter(
Location.id == location_id).first()
if not db_location:
return None
for key, value in location_data.model_dump(exclude_unset=True).items():
setattr(db_location, key, value)
db.commit()
db.refresh(db_location)
return db_location
except IntegrityError as e:
db.rollback()
logger.error(f"IntegrityError updating location: {str(e)}")
raise HTTPException(
status_code=400,
detail="Error updating location",
)
except Exception as e:
db.rollback()
logger.error(f"Error updating location: {str(e)}")
raise HTTPException(
status_code=500, detail="Error updating location")
@staticmethod
def delete(db: Session, location_id: int) -> bool:
"""Delete a location"""
try:
db_location = db.query(Location).filter(
Location.id == location_id).first()
if not db_location:
return False
db.delete(db_location)
db.commit()
return True
except Exception as e:
db.rollback()
logger.error(f"Error deleting location: {str(e)}")
raise HTTPException(
status_code=500, detail="Error deleting location")