Merge branch 'development' of https://git.aduanasoft.com/ADUANASOFT/anexo76 into feature/partePais_BOM
This commit is contained in:
345
backend/api/v1/modules/a24/balance_movements/models.py
Normal file
345
backend/api/v1/modules/a24/balance_movements/models.py
Normal 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()
|
||||
370
backend/api/v1/modules/a24/discharges/models.py
Normal file
370
backend/api/v1/modules/a24/discharges/models.py
Normal 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],
|
||||
)
|
||||
@@ -1,3 +0,0 @@
|
||||
"""
|
||||
Módulo de localización
|
||||
"""
|
||||
@@ -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
|
||||
@@ -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})>"
|
||||
@@ -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
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user