fin.concepts es el catálogo de conceptos facturables de cada empresa, ligado a una clave de producto/servicio del SAT. La relación es 1:1 por empresa: si dos conceptos compartieran la misma clave, al timbrar no habría forma de saber qué descripción corresponde. La unicidad se garantiza por índice único parcial (WHERE deleted_at IS NULL) y se valida además en el service para devolver 409 con mensaje en español en vez de un IntegrityError crudo. La baja lógica libera la clave y el código. Las respuestas traen los objetos del catálogo ya resueltos (selectin) para que el frontend no dispare N+1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
68 lines
3.3 KiB
Python
68 lines
3.3 KiB
Python
"""Catálogo de conceptos de facturación — ``fin.concepts``.
|
|
|
|
A diferencia de los catálogos del SAT, este es **propio de cada empresa**: cada
|
|
concepto que la empresa factura (flete internacional, despacho, almacenaje…) se
|
|
registra una vez y queda amarrado a la clave de producto/servicio del SAT que le
|
|
corresponde.
|
|
|
|
La relación con ``sat.products_services`` es **1:1 por empresa**: si dos conceptos
|
|
compartieran la misma clave ProdServ, al timbrar no habría forma de saber cuál
|
|
descripción corresponde a la clave, así que la unicidad se garantiza por índice y se
|
|
valida además en el service para devolver un 409 con mensaje entendible.
|
|
"""
|
|
|
|
from sqlalchemy import Boolean, ForeignKey, Index, Integer, Numeric, String, Text, text
|
|
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
|
|
|
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
|
from core.database import Base
|
|
|
|
from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones)
|
|
|
|
# Los índices son parciales (``WHERE deleted_at IS NULL``): un concepto dado de baja
|
|
# lógica libera su clave y su código para uno nuevo.
|
|
_ALIVE = text("deleted_at IS NULL")
|
|
|
|
|
|
class Concept(Base, TenantScopedMixin, TimestampMixin):
|
|
"""Concepto facturable de una empresa, ligado a una clave ProdServ del SAT."""
|
|
|
|
__tablename__ = "concepts"
|
|
__table_args__ = (
|
|
Index(
|
|
"uq_fin_concepts_code",
|
|
"tenant_id", "company_id", "code",
|
|
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
|
),
|
|
Index(
|
|
"uq_fin_concepts_product_service",
|
|
"tenant_id", "company_id", "product_service_id",
|
|
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
|
),
|
|
{"schema": "fin"},
|
|
)
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
code: Mapped[str] = mapped_column(String(40), nullable=False) # clave interna del concepto
|
|
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
|
product_service_id: Mapped[int] = mapped_column(
|
|
Integer, ForeignKey("sat.products_services.id"), nullable=False, index=True
|
|
)
|
|
unit_of_measure_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("sat.units_of_measure.id"), nullable=True
|
|
)
|
|
tax_object_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("sat.tax_objects.id"), nullable=True
|
|
)
|
|
unit_price: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
|
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
|
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
# Cargadas con selectinload para que el listado no dispare N+1 consultas.
|
|
product_service: Mapped["ProductService"] = relationship("ProductService", lazy="selectin")
|
|
unit_of_measure: Mapped["UnitOfMeasure | None"] = relationship("UnitOfMeasure", lazy="selectin")
|
|
tax_object: Mapped["TaxObject | None"] = relationship("TaxObject", lazy="selectin")
|