"""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")