Compare commits
10 Commits
c6f18013b3
...
f4ef6a037d
| Author | SHA1 | Date | |
|---|---|---|---|
| f4ef6a037d | |||
| 15717314fd | |||
| ce09e0d30a | |||
| ae0664e987 | |||
| cb2acb11fc | |||
| 5a112a0171 | |||
| 8d9db3505d | |||
| b8b8311ece | |||
| 9cf142add6 | |||
| e24435c74b |
@@ -0,0 +1,269 @@
|
|||||||
|
"""Catálogos SAT (schema sat), conceptos de facturación, datos fiscales del emisor
|
||||||
|
y amarre de facturas y partidas a los catálogos.
|
||||||
|
|
||||||
|
Revision ID: e6f7a8b9c0d1
|
||||||
|
Revises: d5e6f7a8b9c0
|
||||||
|
Create Date: 2026-08-07 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs
|
||||||
|
|
||||||
|
revision: str = "e6f7a8b9c0d1"
|
||||||
|
down_revision: Union[str, None] = "d5e6f7a8b9c0"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
# Índices únicos parciales: la baja lógica (deleted_at) libera la clave.
|
||||||
|
_ALIVE = "deleted_at IS NULL"
|
||||||
|
|
||||||
|
# Catálogos del SAT: (tabla, longitud de code, columnas propias del catálogo).
|
||||||
|
_SAT_CATALOGS: list[tuple[str, int, list[sa.Column]]] = [
|
||||||
|
("tax_regimes", 3, [
|
||||||
|
sa.Column("applies_to_individual", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("applies_to_legal_entity", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
]),
|
||||||
|
("taxes", 3, [
|
||||||
|
sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("is_transferred", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("is_local", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
]),
|
||||||
|
("payment_forms", 2, []),
|
||||||
|
("units_of_measure", 20, [
|
||||||
|
sa.Column("name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("symbol", sa.String(length=20), nullable=True),
|
||||||
|
]),
|
||||||
|
("products_services", 8, []),
|
||||||
|
("voucher_types", 1, []),
|
||||||
|
("payment_methods", 3, []),
|
||||||
|
("tax_objects", 2, []),
|
||||||
|
]
|
||||||
|
|
||||||
|
# units_of_measure guarda el nombre corto aparte, así que su description es opcional.
|
||||||
|
_NULLABLE_DESCRIPTION = {"units_of_measure"}
|
||||||
|
|
||||||
|
|
||||||
|
def _timestamp_columns(with_soft_delete: bool) -> list[sa.Column]:
|
||||||
|
columns = [
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||||
|
]
|
||||||
|
if with_soft_delete:
|
||||||
|
columns.append(sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||||
|
return columns
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ---------- Schema y catálogos globales del SAT ----------
|
||||||
|
op.execute("CREATE SCHEMA IF NOT EXISTS sat")
|
||||||
|
|
||||||
|
for table, code_length, extra_columns in _SAT_CATALOGS:
|
||||||
|
op.create_table(
|
||||||
|
table,
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=code_length), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"description",
|
||||||
|
sa.String(length=500),
|
||||||
|
nullable=table in _NULLABLE_DESCRIPTION,
|
||||||
|
),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
*extra_columns,
|
||||||
|
*_timestamp_columns(with_soft_delete=False),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
schema="sat",
|
||||||
|
)
|
||||||
|
op.create_index(f"ix_sat_{table}_id", table, ["id"], schema="sat")
|
||||||
|
# La clave oficial del SAT es única dentro de su catálogo.
|
||||||
|
op.create_index(f"ix_sat_{table}_code", table, ["code"], unique=True, schema="sat")
|
||||||
|
|
||||||
|
# Semillas de los catálogos (idempotente: puede volver a correrse sin duplicar).
|
||||||
|
sync_catalogs(op.get_bind())
|
||||||
|
|
||||||
|
# ---------- fin.concepts ----------
|
||||||
|
op.create_table(
|
||||||
|
"concepts",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=40), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("product_service_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("unit_of_measure_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("tax_object_id", sa.Integer(), nullable=True),
|
||||||
|
sa.Column("unit_price", sa.Numeric(precision=14, scale=2), nullable=True),
|
||||||
|
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("notes", sa.Text(), nullable=True),
|
||||||
|
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||||
|
*_timestamp_columns(with_soft_delete=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_concepts_tenant_id"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["product_service_id"], ["sat.products_services.id"], name="fk_fin_concepts_product_service_id"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["unit_of_measure_id"], ["sat.units_of_measure.id"], name="fk_fin_concepts_unit_of_measure_id"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tax_object_id"], ["sat.tax_objects.id"], name="fk_fin_concepts_tax_object_id"
|
||||||
|
),
|
||||||
|
schema="fin",
|
||||||
|
)
|
||||||
|
op.create_index("ix_fin_concepts_id", "concepts", ["id"], schema="fin")
|
||||||
|
op.create_index("ix_fin_concepts_tenant_id", "concepts", ["tenant_id"], schema="fin")
|
||||||
|
op.create_index("ix_fin_concepts_company_id", "concepts", ["company_id"], schema="fin")
|
||||||
|
op.create_index("ix_fin_concepts_product_service_id", "concepts", ["product_service_id"], schema="fin")
|
||||||
|
# La clave interna del concepto es única por empresa.
|
||||||
|
op.create_index(
|
||||||
|
"uq_fin_concepts_code", "concepts", ["tenant_id", "company_id", "code"],
|
||||||
|
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||||
|
)
|
||||||
|
# Relación 1:1 con c_ClaveProdServ: una clave del SAT no puede repetirse entre
|
||||||
|
# los conceptos vigentes de la misma empresa.
|
||||||
|
op.create_index(
|
||||||
|
"uq_fin_concepts_product_service", "concepts", ["tenant_id", "company_id", "product_service_id"],
|
||||||
|
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- fin.issuer_settings ----------
|
||||||
|
op.create_table(
|
||||||
|
"issuer_settings",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("legal_name", sa.String(length=255), nullable=False),
|
||||||
|
sa.Column("rfc", sa.String(length=13), nullable=False),
|
||||||
|
sa.Column("tax_regime_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("zip_code", sa.String(length=5), nullable=True),
|
||||||
|
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||||
|
*_timestamp_columns(with_soft_delete=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_issuer_settings_tenant_id"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["tax_regime_id"], ["sat.tax_regimes.id"], name="fk_fin_issuer_settings_tax_regime_id"
|
||||||
|
),
|
||||||
|
schema="fin",
|
||||||
|
)
|
||||||
|
op.create_index("ix_fin_issuer_settings_id", "issuer_settings", ["id"], schema="fin")
|
||||||
|
op.create_index("ix_fin_issuer_settings_tenant_id", "issuer_settings", ["tenant_id"], schema="fin")
|
||||||
|
op.create_index("ix_fin_issuer_settings_company_id", "issuer_settings", ["company_id"], schema="fin")
|
||||||
|
op.create_index("ix_fin_issuer_settings_tax_regime_id", "issuer_settings", ["tax_regime_id"], schema="fin")
|
||||||
|
# Una sola configuración fiscal vigente por empresa.
|
||||||
|
op.create_index(
|
||||||
|
"uq_fin_issuer_settings_company", "issuer_settings", ["tenant_id", "company_id"],
|
||||||
|
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- fin.invoice_item_taxes ----------
|
||||||
|
op.create_table(
|
||||||
|
"invoice_item_taxes",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("invoice_item_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tax_id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||||
|
sa.Column("rate", sa.Numeric(precision=8, scale=6), nullable=True),
|
||||||
|
sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||||
|
*_timestamp_columns(with_soft_delete=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_invoice_item_taxes_tenant_id"),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["invoice_item_id"], ["fin.invoice_items.id"], name="fk_fin_invoice_item_taxes_invoice_item_id"
|
||||||
|
),
|
||||||
|
sa.ForeignKeyConstraint(["tax_id"], ["sat.taxes.id"], name="fk_fin_invoice_item_taxes_tax_id"),
|
||||||
|
schema="fin",
|
||||||
|
)
|
||||||
|
op.create_index("ix_fin_invoice_item_taxes_id", "invoice_item_taxes", ["id"], schema="fin")
|
||||||
|
op.create_index("ix_fin_invoice_item_taxes_tenant_id", "invoice_item_taxes", ["tenant_id"], schema="fin")
|
||||||
|
op.create_index("ix_fin_invoice_item_taxes_company_id", "invoice_item_taxes", ["company_id"], schema="fin")
|
||||||
|
op.create_index(
|
||||||
|
"ix_fin_invoice_item_taxes_invoice_item_id", "invoice_item_taxes", ["invoice_item_id"], schema="fin"
|
||||||
|
)
|
||||||
|
# Un mismo impuesto no puede declararse dos veces con el mismo rol en la partida.
|
||||||
|
op.create_index(
|
||||||
|
"uq_fin_invoice_item_taxes", "invoice_item_taxes", ["invoice_item_id", "tax_id", "is_withholding"],
|
||||||
|
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- fin.invoices: claves fiscales del comprobante ----------
|
||||||
|
# Todas nullable: las facturas ya emitidas no tienen estos datos.
|
||||||
|
op.add_column("invoices", sa.Column("voucher_type_id", sa.Integer(), nullable=True), schema="fin")
|
||||||
|
op.add_column("invoices", sa.Column("payment_form_id", sa.Integer(), nullable=True), schema="fin")
|
||||||
|
op.add_column("invoices", sa.Column("payment_method_id", sa.Integer(), nullable=True), schema="fin")
|
||||||
|
op.add_column("invoices", sa.Column("expedition_zip_code", sa.String(length=5), nullable=True), schema="fin")
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_fin_invoices_voucher_type_id", "invoices", "voucher_types",
|
||||||
|
["voucher_type_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_fin_invoices_payment_form_id", "invoices", "payment_forms",
|
||||||
|
["payment_form_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_fin_invoices_payment_method_id", "invoices", "payment_methods",
|
||||||
|
["payment_method_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------- fin.invoice_items: claves fiscales de la partida ----------
|
||||||
|
# La columna de texto libre `concept` se conserva intacta y obligatoria: la usa el
|
||||||
|
# PDF actual de la factura.
|
||||||
|
op.add_column("invoice_items", sa.Column("concept_id", sa.Integer(), nullable=True), schema="fin")
|
||||||
|
op.add_column("invoice_items", sa.Column("product_service_id", sa.Integer(), nullable=True), schema="fin")
|
||||||
|
op.add_column("invoice_items", sa.Column("unit_of_measure_id", sa.Integer(), nullable=True), schema="fin")
|
||||||
|
op.add_column("invoice_items", sa.Column("tax_object_id", sa.Integer(), nullable=True), schema="fin")
|
||||||
|
op.create_index("ix_fin_invoice_items_concept_id", "invoice_items", ["concept_id"], schema="fin")
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_fin_invoice_items_concept_id", "invoice_items", "concepts",
|
||||||
|
["concept_id"], ["id"], source_schema="fin", referent_schema="fin",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_fin_invoice_items_product_service_id", "invoice_items", "products_services",
|
||||||
|
["product_service_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_fin_invoice_items_unit_of_measure_id", "invoice_items", "units_of_measure",
|
||||||
|
["unit_of_measure_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_fin_invoice_items_tax_object_id", "invoice_items", "tax_objects",
|
||||||
|
["tax_object_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# fin.invoice_items
|
||||||
|
for constraint in (
|
||||||
|
"fk_fin_invoice_items_tax_object_id",
|
||||||
|
"fk_fin_invoice_items_unit_of_measure_id",
|
||||||
|
"fk_fin_invoice_items_product_service_id",
|
||||||
|
"fk_fin_invoice_items_concept_id",
|
||||||
|
):
|
||||||
|
op.drop_constraint(constraint, "invoice_items", schema="fin", type_="foreignkey")
|
||||||
|
op.drop_index("ix_fin_invoice_items_concept_id", table_name="invoice_items", schema="fin")
|
||||||
|
for column in ("tax_object_id", "unit_of_measure_id", "product_service_id", "concept_id"):
|
||||||
|
op.drop_column("invoice_items", column, schema="fin")
|
||||||
|
|
||||||
|
# fin.invoices
|
||||||
|
for constraint in (
|
||||||
|
"fk_fin_invoices_payment_method_id",
|
||||||
|
"fk_fin_invoices_payment_form_id",
|
||||||
|
"fk_fin_invoices_voucher_type_id",
|
||||||
|
):
|
||||||
|
op.drop_constraint(constraint, "invoices", schema="fin", type_="foreignkey")
|
||||||
|
for column in ("expedition_zip_code", "payment_method_id", "payment_form_id", "voucher_type_id"):
|
||||||
|
op.drop_column("invoices", column, schema="fin")
|
||||||
|
|
||||||
|
# Tablas nuevas (los índices caen con la tabla).
|
||||||
|
op.drop_table("invoice_item_taxes", schema="fin")
|
||||||
|
op.drop_table("issuer_settings", schema="fin")
|
||||||
|
op.drop_table("concepts", schema="fin")
|
||||||
|
|
||||||
|
# Catálogos del SAT: se va el schema completo.
|
||||||
|
op.execute("DROP SCHEMA IF EXISTS sat CASCADE")
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""Catálogo c_UsoCFDI y claves fiscales del receptor en crm.accounts.
|
||||||
|
|
||||||
|
Cierra las decisiones pendientes 1 y 5 del ticket de catálogos SAT: agrega
|
||||||
|
``sat.cfdi_uses`` y amarra el régimen fiscal y el uso de CFDI de la cuenta a los
|
||||||
|
catálogos, conservando las columnas de texto libre que ya existían.
|
||||||
|
|
||||||
|
Revision ID: f7a8b9c0d1e2
|
||||||
|
Revises: e6f7a8b9c0d1
|
||||||
|
Create Date: 2026-08-07 00:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs
|
||||||
|
|
||||||
|
revision: str = "f7a8b9c0d1e2"
|
||||||
|
down_revision: Union[str, None] = "e6f7a8b9c0d1"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# ---------- sat.cfdi_uses ----------
|
||||||
|
op.create_table(
|
||||||
|
"cfdi_uses",
|
||||||
|
sa.Column("id", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("code", sa.String(length=4), nullable=False),
|
||||||
|
sa.Column("description", sa.String(length=500), nullable=False),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||||
|
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
schema="sat",
|
||||||
|
)
|
||||||
|
op.create_index("ix_sat_cfdi_uses_id", "cfdi_uses", ["id"], schema="sat")
|
||||||
|
op.create_index("ix_sat_cfdi_uses_code", "cfdi_uses", ["code"], unique=True, schema="sat")
|
||||||
|
|
||||||
|
# sync_catalogs es idempotente: siembra c_UsoCFDI y deja intactos los catálogos
|
||||||
|
# que ya sembró la migración anterior.
|
||||||
|
sync_catalogs(op.get_bind())
|
||||||
|
|
||||||
|
# ---------- crm.accounts: claves fiscales del receptor ----------
|
||||||
|
# Nullables: las cuentas existentes solo tienen el texto libre.
|
||||||
|
op.add_column("accounts", sa.Column("tax_regime_id", sa.Integer(), nullable=True), schema="crm")
|
||||||
|
op.add_column("accounts", sa.Column("cfdi_use_id", sa.Integer(), nullable=True), schema="crm")
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_crm_accounts_tax_regime_id", "accounts", "tax_regimes",
|
||||||
|
["tax_regime_id"], ["id"], source_schema="crm", referent_schema="sat",
|
||||||
|
)
|
||||||
|
op.create_foreign_key(
|
||||||
|
"fk_crm_accounts_cfdi_use_id", "accounts", "cfdi_uses",
|
||||||
|
["cfdi_use_id"], ["id"], source_schema="crm", referent_schema="sat",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Backfill conservador: solo resuelve lo inequívoco. Se compara el texto libre
|
||||||
|
# contra la clave del catálogo (p. ej. "601", "G03") y contra la descripción
|
||||||
|
# exacta, sin distinguir mayúsculas ni espacios sobrantes. Lo que no case así se
|
||||||
|
# queda en NULL para que lo revise el usuario: adivinar el régimen de un receptor
|
||||||
|
# a partir de texto libre provoca CFDI rechazados.
|
||||||
|
for column, catalog in [("tax_regime", "tax_regimes"), ("cfdi_use", "cfdi_uses")]:
|
||||||
|
op.execute(
|
||||||
|
f"""
|
||||||
|
UPDATE crm.accounts AS a
|
||||||
|
SET {column}_id = c.id
|
||||||
|
FROM sat.{catalog} AS c
|
||||||
|
WHERE a.{column}_id IS NULL
|
||||||
|
AND a.{column} IS NOT NULL
|
||||||
|
AND (
|
||||||
|
upper(btrim(a.{column})) = upper(c.code)
|
||||||
|
OR upper(btrim(a.{column})) = upper(c.description)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_constraint("fk_crm_accounts_cfdi_use_id", "accounts", schema="crm", type_="foreignkey")
|
||||||
|
op.drop_constraint("fk_crm_accounts_tax_regime_id", "accounts", schema="crm", type_="foreignkey")
|
||||||
|
op.drop_column("accounts", "cfdi_use_id", schema="crm")
|
||||||
|
op.drop_column("accounts", "tax_regime_id", schema="crm")
|
||||||
|
op.drop_table("cfdi_uses", schema="sat")
|
||||||
@@ -25,6 +25,9 @@ class AccountBase(BaseModel):
|
|||||||
# Fiscal
|
# Fiscal
|
||||||
tax_regime: str | None = Field(None, max_length=120)
|
tax_regime: str | None = Field(None, max_length=120)
|
||||||
cfdi_use: str | None = Field(None, max_length=60)
|
cfdi_use: str | None = Field(None, max_length=60)
|
||||||
|
# Claves contra los catálogos del SAT; sustituyen al texto libre de arriba al timbrar.
|
||||||
|
tax_regime_id: int | None = Field(None, description="c_RegimenFiscal del receptor")
|
||||||
|
cfdi_use_id: int | None = Field(None, description="c_UsoCFDI del receptor")
|
||||||
payment_method: str | None = Field(None, max_length=60)
|
payment_method: str | None = Field(None, max_length=60)
|
||||||
payment_form: str | None = Field(None, max_length=60)
|
payment_form: str | None = Field(None, max_length=60)
|
||||||
currency: str | None = Field(None, max_length=3)
|
currency: str | None = Field(None, max_length=3)
|
||||||
@@ -65,6 +68,8 @@ class AccountUpdate(BaseModel):
|
|||||||
website: str | None = Field(None, max_length=255)
|
website: str | None = Field(None, max_length=255)
|
||||||
tax_regime: str | None = Field(None, max_length=120)
|
tax_regime: str | None = Field(None, max_length=120)
|
||||||
cfdi_use: str | None = Field(None, max_length=60)
|
cfdi_use: str | None = Field(None, max_length=60)
|
||||||
|
tax_regime_id: int | None = None
|
||||||
|
cfdi_use_id: int | None = None
|
||||||
payment_method: str | None = Field(None, max_length=60)
|
payment_method: str | None = Field(None, max_length=60)
|
||||||
payment_form: str | None = Field(None, max_length=60)
|
payment_form: str | None = Field(None, max_length=60)
|
||||||
currency: str | None = Field(None, max_length=3)
|
currency: str | None = Field(None, max_length=3)
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from sqlalchemy import Integer, Numeric, String, Text, text
|
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||||
|
from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime # noqa: F401 (resuelve las FK)
|
||||||
from core.database import Base
|
from core.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -47,8 +48,17 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
|||||||
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
||||||
# ----- Información fiscal -----
|
# ----- Información fiscal -----
|
||||||
|
# Régimen fiscal y uso de CFDI en texto libre: se conservan como capturó el usuario
|
||||||
|
# para no perder lo ya registrado, pero lo que vale al timbrar son las FK de abajo.
|
||||||
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal
|
tax_regime: Mapped[str | None] = mapped_column(String(120), nullable=True) # régimen fiscal
|
||||||
cfdi_use: Mapped[str | None] = mapped_column(String(60), nullable=True) # uso de CFDI
|
cfdi_use: Mapped[str | None] = mapped_column(String(60), nullable=True) # uso de CFDI
|
||||||
|
# Claves del receptor contra los catálogos del SAT (c_RegimenFiscal y c_UsoCFDI).
|
||||||
|
tax_regime_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("sat.tax_regimes.id"), nullable=True
|
||||||
|
)
|
||||||
|
cfdi_use_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("sat.cfdi_uses.id"), nullable=True
|
||||||
|
)
|
||||||
payment_method: Mapped[str | None] = mapped_column(String(60), nullable=True) # método de pago
|
payment_method: Mapped[str | None] = mapped_column(String(60), nullable=True) # método de pago
|
||||||
payment_form: Mapped[str | None] = mapped_column(String(60), nullable=True) # forma de pago
|
payment_form: Mapped[str | None] = mapped_column(String(60), nullable=True) # forma de pago
|
||||||
currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # moneda
|
currency: Mapped[str | None] = mapped_column(String(3), nullable=True) # moneda
|
||||||
|
|||||||
@@ -3,10 +3,24 @@ from datetime import datetime, timezone
|
|||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime
|
||||||
|
|
||||||
from .dto import AccountCreate, AccountUpdate
|
from .dto import AccountCreate, AccountUpdate
|
||||||
from .models import Account
|
from .models import Account
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_sat_refs(db: Session, data: dict) -> None:
|
||||||
|
"""Verifica las claves del SAT del receptor antes de guardar la cuenta."""
|
||||||
|
for field, model, msg in [
|
||||||
|
("tax_regime_id", TaxRegime, "El régimen fiscal indicado no existe en el catálogo del SAT"),
|
||||||
|
("cfdi_use_id", CfdiUse, "El uso de CFDI indicado no existe en el catálogo del SAT"),
|
||||||
|
]:
|
||||||
|
value = data.get(field)
|
||||||
|
if field in data and value is not None:
|
||||||
|
if db.query(model.id).filter(model.id == value).first() is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
||||||
|
|
||||||
|
|
||||||
def get_accounts(
|
def get_accounts(
|
||||||
db: Session,
|
db: Session,
|
||||||
tenant_id: int,
|
tenant_id: int,
|
||||||
@@ -53,8 +67,10 @@ def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) -
|
|||||||
def create_account(
|
def create_account(
|
||||||
db: Session, payload: AccountCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
db: Session, payload: AccountCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||||
) -> Account:
|
) -> Account:
|
||||||
|
data = payload.model_dump()
|
||||||
|
_validate_sat_refs(db, data)
|
||||||
account = Account(
|
account = Account(
|
||||||
**payload.model_dump(),
|
**data,
|
||||||
tenant_id=tenant_id,
|
tenant_id=tenant_id,
|
||||||
company_id=company_id,
|
company_id=company_id,
|
||||||
created_by=user_id,
|
created_by=user_id,
|
||||||
@@ -75,7 +91,9 @@ def update_account(
|
|||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
) -> Account:
|
) -> Account:
|
||||||
account = get_account(db, account_id, tenant_id, company_id)
|
account = get_account(db, account_id, tenant_id, company_id)
|
||||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
_validate_sat_refs(db, data)
|
||||||
|
for field, value in data.items():
|
||||||
setattr(account, field, value)
|
setattr(account, field, value)
|
||||||
account.updated_by = user_id
|
account.updated_by = user_id
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|||||||
1
backend/api/v1/modules/fin/catalogs/__init__.py
Normal file
1
backend/api/v1/modules/fin/catalogs/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Catálogos oficiales del SAT (schema ``sat``): globales y de solo lectura."""
|
||||||
61
backend/api/v1/modules/fin/catalogs/dto.py
Normal file
61
backend/api/v1/modules/fin/catalogs/dto.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
"""Esquemas de respuesta de los catálogos del SAT (solo lectura)."""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class SatCatalogItem(BaseModel):
|
||||||
|
"""Forma común de todo catálogo del SAT: clave + descripción."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
code: str
|
||||||
|
description: str
|
||||||
|
is_active: bool
|
||||||
|
|
||||||
|
|
||||||
|
class TaxRegimeResponse(SatCatalogItem):
|
||||||
|
"""``c_RegimenFiscal``: incluye a qué tipo de persona aplica el régimen."""
|
||||||
|
|
||||||
|
applies_to_individual: bool # persona física
|
||||||
|
applies_to_legal_entity: bool # persona moral
|
||||||
|
|
||||||
|
|
||||||
|
class TaxResponse(SatCatalogItem):
|
||||||
|
"""``c_Impuesto``: indica si el impuesto puede retenerse o trasladarse."""
|
||||||
|
|
||||||
|
is_withholding: bool
|
||||||
|
is_transferred: bool
|
||||||
|
is_local: bool
|
||||||
|
|
||||||
|
|
||||||
|
class UnitOfMeasureResponse(SatCatalogItem):
|
||||||
|
"""``c_ClaveUnidad``: nombre corto, símbolo y nota larga del catálogo."""
|
||||||
|
|
||||||
|
description: str | None = None
|
||||||
|
name: str
|
||||||
|
symbol: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentFormResponse(SatCatalogItem):
|
||||||
|
"""``c_FormaPago``."""
|
||||||
|
|
||||||
|
|
||||||
|
class ProductServiceResponse(SatCatalogItem):
|
||||||
|
"""``c_ClaveProdServ``."""
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherTypeResponse(SatCatalogItem):
|
||||||
|
"""``c_TipoDeComprobante``."""
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMethodResponse(SatCatalogItem):
|
||||||
|
"""``c_MetodoPago``."""
|
||||||
|
|
||||||
|
|
||||||
|
class TaxObjectResponse(SatCatalogItem):
|
||||||
|
"""``c_ObjetoImp``."""
|
||||||
|
|
||||||
|
|
||||||
|
class CfdiUseResponse(SatCatalogItem):
|
||||||
|
"""``c_UsoCFDI``."""
|
||||||
143
backend/api/v1/modules/fin/catalogs/models.py
Normal file
143
backend/api/v1/modules/fin/catalogs/models.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
"""Modelos de los catálogos oficiales del SAT — schema ``sat``.
|
||||||
|
|
||||||
|
Son catálogos **globales**: los publica el SAT, valen igual para cualquier tenant y
|
||||||
|
compañía, por eso no heredan ``TenantScopedMixin``. Tampoco se borran: cuando el SAT
|
||||||
|
retira una clave, el registro se marca ``is_active = false`` para que las facturas
|
||||||
|
históricas que la usan sigan resolviendo su descripción (de ahí que se use
|
||||||
|
``BaseTimestampMixin``, sin ``deleted_at``).
|
||||||
|
|
||||||
|
La API los expone únicamente en modo lectura; el alta y la actualización pasan por
|
||||||
|
``seed_data.sync_catalogs()``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Integer, String, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from api.v1.common.base_models import BaseTimestampMixin
|
||||||
|
from core.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class SatCatalogMixin(BaseTimestampMixin):
|
||||||
|
"""Campos comunes a todo catálogo del SAT.
|
||||||
|
|
||||||
|
``code`` (la clave oficial) se declara en cada modelo porque su longitud
|
||||||
|
cambia de catálogo en catálogo.
|
||||||
|
"""
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||||
|
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||||
|
|
||||||
|
|
||||||
|
class TaxRegime(Base, SatCatalogMixin):
|
||||||
|
"""``c_RegimenFiscal`` — régimen fiscal del emisor y del receptor del CFDI.
|
||||||
|
|
||||||
|
Las banderas indican a qué tipo de persona aplica el régimen: una persona física
|
||||||
|
no puede declararse en el 601 (General de Ley Personas Morales) y viceversa.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "tax_regimes"
|
||||||
|
__table_args__ = {"schema": "sat"}
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
|
||||||
|
applies_to_individual: Mapped[bool] = mapped_column( # persona física
|
||||||
|
Boolean, nullable=False, server_default=text("false")
|
||||||
|
)
|
||||||
|
applies_to_legal_entity: Mapped[bool] = mapped_column( # persona moral
|
||||||
|
Boolean, nullable=False, server_default=text("false")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Tax(Base, SatCatalogMixin):
|
||||||
|
"""``c_Impuesto`` — impuestos federales que pueden trasladarse o retenerse."""
|
||||||
|
|
||||||
|
__tablename__ = "taxes"
|
||||||
|
__table_args__ = {"schema": "sat"}
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
|
||||||
|
is_withholding: Mapped[bool] = mapped_column( # puede retenerse
|
||||||
|
Boolean, nullable=False, server_default=text("false")
|
||||||
|
)
|
||||||
|
is_transferred: Mapped[bool] = mapped_column( # puede trasladarse
|
||||||
|
Boolean, nullable=False, server_default=text("false")
|
||||||
|
)
|
||||||
|
# Los impuestos locales (ISH y similares) viajan en el complemento "Impuestos
|
||||||
|
# Locales" con claves ajenas a c_Impuesto; la bandera queda disponible para
|
||||||
|
# cuando el negocio defina ese catálogo.
|
||||||
|
is_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentForm(Base, SatCatalogMixin):
|
||||||
|
"""``c_FormaPago`` — con qué se pagó (efectivo, transferencia, tarjeta…)."""
|
||||||
|
|
||||||
|
__tablename__ = "payment_forms"
|
||||||
|
__table_args__ = {"schema": "sat"}
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class UnitOfMeasure(Base, SatCatalogMixin):
|
||||||
|
"""``c_ClaveUnidad`` — unidad de medida de la partida.
|
||||||
|
|
||||||
|
Único catálogo que separa nombre corto y definición: ``name`` es lo que se
|
||||||
|
muestra al capturar y ``description`` la nota larga del SAT, que puede venir
|
||||||
|
vacía.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "units_of_measure"
|
||||||
|
__table_args__ = {"schema": "sat"}
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(20), nullable=False, unique=True, index=True)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
symbol: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
|
# Se redeclara para permitir NULL: aquí la descripción es la nota del catálogo.
|
||||||
|
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ProductService(Base, SatCatalogMixin):
|
||||||
|
"""``c_ClaveProdServ`` — clave de producto o servicio de la partida."""
|
||||||
|
|
||||||
|
__tablename__ = "products_services"
|
||||||
|
__table_args__ = {"schema": "sat"}
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(8), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class VoucherType(Base, SatCatalogMixin):
|
||||||
|
"""``c_TipoDeComprobante`` — I ingreso, E egreso, T traslado, N nómina, P pago."""
|
||||||
|
|
||||||
|
__tablename__ = "voucher_types"
|
||||||
|
__table_args__ = {"schema": "sat"}
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(1), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentMethod(Base, SatCatalogMixin):
|
||||||
|
"""``c_MetodoPago`` — PUE (una sola exhibición) o PPD (parcialidades/diferido)."""
|
||||||
|
|
||||||
|
__tablename__ = "payment_methods"
|
||||||
|
__table_args__ = {"schema": "sat"}
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TaxObject(Base, SatCatalogMixin):
|
||||||
|
"""``c_ObjetoImp`` — si la partida es o no objeto de impuesto."""
|
||||||
|
|
||||||
|
__tablename__ = "tax_objects"
|
||||||
|
__table_args__ = {"schema": "sat"}
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True)
|
||||||
|
|
||||||
|
|
||||||
|
class CfdiUse(Base, SatCatalogMixin):
|
||||||
|
"""``c_UsoCFDI`` — uso que el receptor le dará al comprobante.
|
||||||
|
|
||||||
|
Lo declara el receptor, no el emisor, y el SAT lo valida contra su régimen
|
||||||
|
fiscal: por eso vive en la ficha del cliente (``crm.accounts.cfdi_use_id``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "cfdi_uses"
|
||||||
|
__table_args__ = {"schema": "sat"}
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(4), nullable=False, unique=True, index=True)
|
||||||
138
backend/api/v1/modules/fin/catalogs/routes.py
Normal file
138
backend/api/v1/modules/fin/catalogs/routes.py
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
"""Endpoints de los catálogos del SAT — **solo lectura**.
|
||||||
|
|
||||||
|
No se exponen POST/PUT/PATCH/DELETE a propósito: son catálogos fijos publicados por
|
||||||
|
el SAT y se mantienen con ``seed_data.sync_catalogs()``, no por API.
|
||||||
|
|
||||||
|
Nota: aunque los catálogos son globales, el router del módulo exige ``fin.access``,
|
||||||
|
permiso que se resuelve sobre una compañía; por eso las peticiones siguen llevando
|
||||||
|
``company_id`` en la query string.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user
|
||||||
|
|
||||||
|
from . import service
|
||||||
|
from .dto import (
|
||||||
|
CfdiUseResponse,
|
||||||
|
PaymentFormResponse,
|
||||||
|
PaymentMethodResponse,
|
||||||
|
ProductServiceResponse,
|
||||||
|
TaxObjectResponse,
|
||||||
|
TaxRegimeResponse,
|
||||||
|
TaxResponse,
|
||||||
|
UnitOfMeasureResponse,
|
||||||
|
VoucherTypeResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_SEARCH = Query(None, description="Búsqueda por clave o descripción")
|
||||||
|
_ACTIVE_ONLY = Query(True, description="Solo claves vigentes")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogs/tax-regimes", response_model=list[TaxRegimeResponse])
|
||||||
|
def list_tax_regimes(
|
||||||
|
search: str | None = _SEARCH,
|
||||||
|
active_only: bool = _ACTIVE_ONLY,
|
||||||
|
person_type: Literal["fisica", "moral"] | None = Query(
|
||||||
|
None, description="Acota al régimen de persona física o moral"
|
||||||
|
),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""``c_RegimenFiscal`` — régimen fiscal del emisor/receptor del CFDI."""
|
||||||
|
return service.get_tax_regimes(db, search, active_only, person_type)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogs/taxes", response_model=list[TaxResponse])
|
||||||
|
def list_taxes(
|
||||||
|
search: str | None = _SEARCH,
|
||||||
|
active_only: bool = _ACTIVE_ONLY,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""``c_Impuesto`` — impuestos federales trasladados y retenidos."""
|
||||||
|
return service.get_taxes(db, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogs/payment-forms", response_model=list[PaymentFormResponse])
|
||||||
|
def list_payment_forms(
|
||||||
|
search: str | None = _SEARCH,
|
||||||
|
active_only: bool = _ACTIVE_ONLY,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""``c_FormaPago`` — medio con el que se liquidó el comprobante."""
|
||||||
|
return service.get_payment_forms(db, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogs/units-of-measure", response_model=list[UnitOfMeasureResponse])
|
||||||
|
def list_units_of_measure(
|
||||||
|
search: str | None = _SEARCH,
|
||||||
|
active_only: bool = _ACTIVE_ONLY,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""``c_ClaveUnidad`` — unidad de medida de la partida."""
|
||||||
|
return service.get_units_of_measure(db, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogs/products-services", response_model=list[ProductServiceResponse])
|
||||||
|
def list_products_services(
|
||||||
|
search: str | None = _SEARCH,
|
||||||
|
active_only: bool = _ACTIVE_ONLY,
|
||||||
|
limit: int = Query(50, ge=1, le=200, description="Máximo de claves devueltas"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""``c_ClaveProdServ`` — clave de producto/servicio; pensado para autocompletado."""
|
||||||
|
return service.get_products_services(db, search, active_only, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogs/voucher-types", response_model=list[VoucherTypeResponse])
|
||||||
|
def list_voucher_types(
|
||||||
|
search: str | None = _SEARCH,
|
||||||
|
active_only: bool = _ACTIVE_ONLY,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""``c_TipoDeComprobante`` — ingreso, egreso, traslado, nómina o pago."""
|
||||||
|
return service.get_voucher_types(db, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogs/payment-methods", response_model=list[PaymentMethodResponse])
|
||||||
|
def list_payment_methods(
|
||||||
|
search: str | None = _SEARCH,
|
||||||
|
active_only: bool = _ACTIVE_ONLY,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""``c_MetodoPago`` — PUE o PPD."""
|
||||||
|
return service.get_payment_methods(db, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogs/tax-objects", response_model=list[TaxObjectResponse])
|
||||||
|
def list_tax_objects(
|
||||||
|
search: str | None = _SEARCH,
|
||||||
|
active_only: bool = _ACTIVE_ONLY,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""``c_ObjetoImp`` — si la partida es objeto de impuesto."""
|
||||||
|
return service.get_tax_objects(db, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalogs/cfdi-uses", response_model=list[CfdiUseResponse])
|
||||||
|
def list_cfdi_uses(
|
||||||
|
search: str | None = _SEARCH,
|
||||||
|
active_only: bool = _ACTIVE_ONLY,
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""``c_UsoCFDI`` — uso que el receptor le dará al comprobante."""
|
||||||
|
return service.get_cfdi_uses(db, search, active_only)
|
||||||
336
backend/api/v1/modules/fin/catalogs/seed_data.py
Normal file
336
backend/api/v1/modules/fin/catalogs/seed_data.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""Datos semilla de los catálogos del SAT y su sincronización idempotente.
|
||||||
|
|
||||||
|
Los catálogos viven aquí y no dentro de una migración concreta a propósito: cuando el
|
||||||
|
SAT corrige una descripción o publica una clave nueva, basta editar estas listas y
|
||||||
|
volver a correr :func:`sync_catalogs`, sin escribir una migración de esquema.
|
||||||
|
|
||||||
|
Las tablas se describen con ``sa.Table`` ligeros sobre un ``MetaData`` propio (no con
|
||||||
|
los modelos ORM) para que la migración pueda importar este módulo sin acoplarse a la
|
||||||
|
definición ORM, que sigue evolucionando.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
_metadata = sa.MetaData()
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_table(name: str, *extra_columns: sa.Column) -> sa.Table:
|
||||||
|
"""Tabla mínima de catálogo: las columnas que toca el upsert, nada más."""
|
||||||
|
return sa.Table(
|
||||||
|
name,
|
||||||
|
_metadata,
|
||||||
|
sa.Column("id", sa.Integer, primary_key=True),
|
||||||
|
sa.Column("code", sa.String, nullable=False),
|
||||||
|
sa.Column("description", sa.String),
|
||||||
|
sa.Column("is_active", sa.Boolean),
|
||||||
|
*extra_columns,
|
||||||
|
schema="sat",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
tax_regimes_table = _catalog_table(
|
||||||
|
"tax_regimes",
|
||||||
|
sa.Column("applies_to_individual", sa.Boolean),
|
||||||
|
sa.Column("applies_to_legal_entity", sa.Boolean),
|
||||||
|
)
|
||||||
|
taxes_table = _catalog_table(
|
||||||
|
"taxes",
|
||||||
|
sa.Column("is_withholding", sa.Boolean),
|
||||||
|
sa.Column("is_transferred", sa.Boolean),
|
||||||
|
sa.Column("is_local", sa.Boolean),
|
||||||
|
)
|
||||||
|
payment_forms_table = _catalog_table("payment_forms")
|
||||||
|
units_of_measure_table = _catalog_table(
|
||||||
|
"units_of_measure",
|
||||||
|
sa.Column("name", sa.String),
|
||||||
|
sa.Column("symbol", sa.String),
|
||||||
|
)
|
||||||
|
products_services_table = _catalog_table("products_services")
|
||||||
|
voucher_types_table = _catalog_table("voucher_types")
|
||||||
|
payment_methods_table = _catalog_table("payment_methods")
|
||||||
|
tax_objects_table = _catalog_table("tax_objects")
|
||||||
|
cfdi_uses_table = _catalog_table("cfdi_uses")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# c_RegimenFiscal (CFDI 4.0)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _regime(code: str, description: str, individual: bool, legal_entity: bool) -> dict:
|
||||||
|
return {
|
||||||
|
"code": code,
|
||||||
|
"description": description,
|
||||||
|
"applies_to_individual": individual,
|
||||||
|
"applies_to_legal_entity": legal_entity,
|
||||||
|
"is_active": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
TAX_REGIMES: list[dict] = [
|
||||||
|
_regime("601", "General de Ley Personas Morales", False, True),
|
||||||
|
_regime("603", "Personas Morales con Fines no Lucrativos", False, True),
|
||||||
|
_regime("605", "Sueldos y Salarios e Ingresos Asimilados a Salarios", True, False),
|
||||||
|
_regime("606", "Arrendamiento", True, False),
|
||||||
|
_regime("607", "Régimen de Enajenación o Adquisición de Bienes", True, False),
|
||||||
|
_regime("608", "Demás ingresos", True, False),
|
||||||
|
_regime("610", "Residentes en el Extranjero sin Establecimiento Permanente en México", True, True),
|
||||||
|
_regime("611", "Ingresos por Dividendos (socios y accionistas)", True, False),
|
||||||
|
_regime("612", "Personas Físicas con Actividades Empresariales y Profesionales", True, False),
|
||||||
|
_regime("614", "Ingresos por intereses", True, False),
|
||||||
|
_regime("615", "Régimen de los ingresos por obtención de premios", True, False),
|
||||||
|
_regime("616", "Sin obligaciones fiscales", True, False),
|
||||||
|
_regime("620", "Sociedades Cooperativas de Producción que optan por diferir sus ingresos", False, True),
|
||||||
|
_regime("621", "Incorporación Fiscal", True, False),
|
||||||
|
_regime("622", "Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras", False, True),
|
||||||
|
_regime("623", "Opcional para Grupos de Sociedades", False, True),
|
||||||
|
_regime("624", "Coordinados", False, True),
|
||||||
|
_regime("625", "Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas", True, False),
|
||||||
|
_regime("626", "Régimen Simplificado de Confianza", True, True),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# c_Impuesto
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# is_local queda en false para los tres: los impuestos locales (ISH y similares)
|
||||||
|
# se declaran en el complemento "Impuestos Locales" con claves que no pertenecen
|
||||||
|
# a c_Impuesto. No se siembran registros locales inventados.
|
||||||
|
|
||||||
|
TAXES: list[dict] = [
|
||||||
|
{"code": "001", "description": "ISR", "is_withholding": True, "is_transferred": False, "is_local": False, "is_active": True},
|
||||||
|
{"code": "002", "description": "IVA", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True},
|
||||||
|
{"code": "003", "description": "IEPS", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# c_FormaPago
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PAYMENT_FORMS: list[dict] = [
|
||||||
|
{"code": code, "description": description, "is_active": True}
|
||||||
|
for code, description in [
|
||||||
|
("01", "Efectivo"),
|
||||||
|
("02", "Cheque nominativo"),
|
||||||
|
("03", "Transferencia electrónica de fondos"),
|
||||||
|
("04", "Tarjeta de crédito"),
|
||||||
|
("05", "Monedero electrónico"),
|
||||||
|
("06", "Dinero electrónico"),
|
||||||
|
("08", "Vales de despensa"),
|
||||||
|
("12", "Dación en pago"),
|
||||||
|
("13", "Pago por subrogación"),
|
||||||
|
("14", "Pago por consignación"),
|
||||||
|
("15", "Condonación"),
|
||||||
|
("17", "Compensación"),
|
||||||
|
("23", "Novación"),
|
||||||
|
("24", "Confusión"),
|
||||||
|
("25", "Remisión de deuda"),
|
||||||
|
("26", "Prescripción o caducidad"),
|
||||||
|
("27", "A satisfacción del acreedor"),
|
||||||
|
("28", "Tarjeta de débito"),
|
||||||
|
("29", "Tarjeta de servicios"),
|
||||||
|
("30", "Aplicación de anticipos"),
|
||||||
|
("31", "Intermediario pagos"),
|
||||||
|
("99", "Por definir"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# c_TipoDeComprobante
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
VOUCHER_TYPES: list[dict] = [
|
||||||
|
{"code": code, "description": description, "is_active": True}
|
||||||
|
for code, description in [
|
||||||
|
("I", "Ingreso"),
|
||||||
|
("E", "Egreso"),
|
||||||
|
("T", "Traslado"),
|
||||||
|
("N", "Nómina"),
|
||||||
|
("P", "Pago"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# c_MetodoPago
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PAYMENT_METHODS: list[dict] = [
|
||||||
|
{"code": "PUE", "description": "Pago en una sola exhibición", "is_active": True},
|
||||||
|
{"code": "PPD", "description": "Pago en parcialidades o diferido", "is_active": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# c_ObjetoImp
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Versiones posteriores del catálogo incorporan las claves 05–07; no se siembran
|
||||||
|
# hasta que el área Fiscal confirme la versión vigente (ver PENDIENTE DECISIÓN).
|
||||||
|
|
||||||
|
TAX_OBJECTS: list[dict] = [
|
||||||
|
{"code": "01", "description": "No objeto de impuesto", "is_active": True},
|
||||||
|
{"code": "02", "description": "Sí objeto de impuesto", "is_active": True},
|
||||||
|
{"code": "03", "description": "Sí objeto del impuesto y no obligado al desglose", "is_active": True},
|
||||||
|
{"code": "04", "description": "Sí objeto del impuesto y no causa impuesto", "is_active": True},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# c_ClaveUnidad — subset operativo
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# description queda en NULL: es la nota larga del catálogo, que aquí no aporta.
|
||||||
|
|
||||||
|
UNITS_OF_MEASURE: list[dict] = [
|
||||||
|
{"code": code, "name": name, "symbol": symbol, "description": None, "is_active": True}
|
||||||
|
for code, name, symbol in [
|
||||||
|
("H87", "Pieza", "pz"),
|
||||||
|
("E48", "Unidad de servicio", None),
|
||||||
|
("ACT", "Actividad", None),
|
||||||
|
("C62", "Uno", None),
|
||||||
|
("KGM", "Kilogramo", "kg"),
|
||||||
|
("TNE", "Tonelada métrica", "t"),
|
||||||
|
("GRM", "Gramo", "g"),
|
||||||
|
("LTR", "Litro", "l"),
|
||||||
|
("MTR", "Metro", "m"),
|
||||||
|
("MTK", "Metro cuadrado", "m²"),
|
||||||
|
("MTQ", "Metro cúbico", "m³"),
|
||||||
|
("KMT", "Kilómetro", "km"),
|
||||||
|
("CMT", "Centímetro", "cm"),
|
||||||
|
("DAY", "Día", "d"),
|
||||||
|
("HUR", "Hora", "h"),
|
||||||
|
("MON", "Mes", None),
|
||||||
|
("XBX", "Caja", None),
|
||||||
|
("XPK", "Paquete", None),
|
||||||
|
("XPX", "Paleta / tarima", None),
|
||||||
|
("XLT", "Lote", None),
|
||||||
|
("E51", "Trabajo", None),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# c_ClaveProdServ — subset de logística
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Subset inicial de c_ClaveProdServ para agente de carga — pendiente validación con
|
||||||
|
# área Fiscal antes de producción. El catálogo completo son ~52,000 claves; aquí solo
|
||||||
|
# se siembran las del giro. Si falta una clave para un caso de uso, se documenta como
|
||||||
|
# PENDIENTE DECISIÓN: no se deduce ni se inventa.
|
||||||
|
|
||||||
|
PRODUCTS_SERVICES: list[dict] = [
|
||||||
|
{"code": code, "description": description, "is_active": True}
|
||||||
|
for code, description in [
|
||||||
|
("78101500", "Transporte de carga por carretera"),
|
||||||
|
("78101600", "Transporte de carga marítimo"),
|
||||||
|
("78101700", "Transporte de carga por ferrocarril"),
|
||||||
|
("78101800", "Transporte de carga aérea"),
|
||||||
|
("78102200", "Servicios postales de paqueteo y courrier"),
|
||||||
|
("78121600", "Embalaje"),
|
||||||
|
("78131600", "Almacenaje"),
|
||||||
|
("78141500", "Servicios de planificación logística"),
|
||||||
|
("78141600", "Servicios de expedición de fletes"),
|
||||||
|
("84131500", "Seguros de vida, salud y accidentes / seguros de carga"),
|
||||||
|
("80101500", "Servicios de consultoría de negocios y administración corporativa"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# c_UsoCFDI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Catálogo del uso que el receptor da al comprobante. Se siembran clave y
|
||||||
|
# descripción; **no** se cargan las banderas de persona física/moral ni la
|
||||||
|
# compatibilidad por régimen fiscal, porque esa matriz cambia entre versiones del
|
||||||
|
# catálogo y equivocarla provoca rechazos al timbrar.
|
||||||
|
#
|
||||||
|
# Pendiente validación con área Fiscal antes de producción, igual que el subset de
|
||||||
|
# c_ClaveProdServ.
|
||||||
|
|
||||||
|
CFDI_USES: list[dict] = [
|
||||||
|
{"code": code, "description": description, "is_active": True}
|
||||||
|
for code, description in [
|
||||||
|
("G01", "Adquisición de mercancías"),
|
||||||
|
("G02", "Devoluciones, descuentos o bonificaciones"),
|
||||||
|
("G03", "Gastos en general"),
|
||||||
|
("I01", "Construcciones"),
|
||||||
|
("I02", "Mobiliario y equipo de oficina por inversiones"),
|
||||||
|
("I03", "Equipo de transporte"),
|
||||||
|
("I04", "Equipo de cómputo y accesorios"),
|
||||||
|
("I05", "Dados, troqueles, moldes, matrices y herramental"),
|
||||||
|
("I06", "Comunicaciones telefónicas"),
|
||||||
|
("I07", "Comunicaciones satelitales"),
|
||||||
|
("I08", "Otra maquinaria y equipo"),
|
||||||
|
("D01", "Honorarios médicos, dentales y gastos hospitalarios"),
|
||||||
|
("D02", "Gastos médicos por incapacidad o discapacidad"),
|
||||||
|
("D03", "Gastos funerales"),
|
||||||
|
("D04", "Donativos"),
|
||||||
|
("D05", "Intereses reales efectivamente pagados por créditos hipotecarios (casa habitación)"),
|
||||||
|
("D06", "Aportaciones voluntarias al SAR"),
|
||||||
|
("D07", "Primas por seguros de gastos médicos"),
|
||||||
|
("D08", "Gastos de transportación escolar obligatoria"),
|
||||||
|
("D09", "Depósitos en cuentas para el ahorro, primas que tengan como base planes de pensiones"),
|
||||||
|
("D10", "Pagos por servicios educativos (colegiaturas)"),
|
||||||
|
("S01", "Sin efectos fiscales"),
|
||||||
|
("CP01", "Pagos"),
|
||||||
|
("CN01", "Nómina"),
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Orden estable de sincronización: (tabla, filas).
|
||||||
|
CATALOGS: list[tuple[sa.Table, list[dict]]] = [
|
||||||
|
(tax_regimes_table, TAX_REGIMES),
|
||||||
|
(taxes_table, TAXES),
|
||||||
|
(payment_forms_table, PAYMENT_FORMS),
|
||||||
|
(units_of_measure_table, UNITS_OF_MEASURE),
|
||||||
|
(products_services_table, PRODUCTS_SERVICES),
|
||||||
|
(voucher_types_table, VOUCHER_TYPES),
|
||||||
|
(payment_methods_table, PAYMENT_METHODS),
|
||||||
|
(tax_objects_table, TAX_OBJECTS),
|
||||||
|
(cfdi_uses_table, CFDI_USES),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sync_catalogs(connection) -> dict[str, int]:
|
||||||
|
"""Sincroniza los catálogos del SAT contra la base, de forma idempotente.
|
||||||
|
|
||||||
|
Inserta las claves que faltan y actualiza descripción y banderas de las que ya
|
||||||
|
existen. **Nunca borra**: una clave retirada por el SAT se desactiva a mano para
|
||||||
|
no romper los CFDI históricos que la referencian.
|
||||||
|
|
||||||
|
Devuelve un resumen ``{"sat.tabla": filas_insertadas}`` útil para la bitácora de
|
||||||
|
la migración.
|
||||||
|
|
||||||
|
Los catálogos cuya tabla todavía no existe se omiten: al correr el historial de
|
||||||
|
migraciones desde cero, una migración antigua invoca esta misma función cuando los
|
||||||
|
catálogos agregados después aún no se han creado. Cada uno se siembra en la
|
||||||
|
migración que lo crea.
|
||||||
|
|
||||||
|
Se usa contra el ``connection`` que da ``op.get_bind()`` en Alembic, o contra la
|
||||||
|
conexión de una sesión en pruebas.
|
||||||
|
"""
|
||||||
|
inspector = sa.inspect(connection)
|
||||||
|
# La inspección no aplica el schema_translate_map (las pruebas mapean sat -> None
|
||||||
|
# sobre SQLite), así que se resuelve el schema efectivo a mano.
|
||||||
|
schema_map = connection.get_execution_options().get("schema_translate_map") or {}
|
||||||
|
|
||||||
|
inserted: dict[str, int] = {}
|
||||||
|
for table, rows in CATALOGS:
|
||||||
|
effective_schema = schema_map.get(table.schema, table.schema)
|
||||||
|
if not inspector.has_table(table.name, schema=effective_schema):
|
||||||
|
continue
|
||||||
|
key = f"sat.{table.name}"
|
||||||
|
inserted[key] = 0
|
||||||
|
for row in rows:
|
||||||
|
existing = connection.execute(
|
||||||
|
sa.select(table.c.id).where(table.c.code == row["code"])
|
||||||
|
).scalar()
|
||||||
|
values = {k: v for k, v in row.items() if k != "code"}
|
||||||
|
if existing is None:
|
||||||
|
connection.execute(table.insert().values(code=row["code"], **values))
|
||||||
|
inserted[key] += 1
|
||||||
|
else:
|
||||||
|
connection.execute(
|
||||||
|
table.update().where(table.c.id == existing).values(**values)
|
||||||
|
)
|
||||||
|
return inserted
|
||||||
106
backend/api/v1/modules/fin/catalogs/service.py
Normal file
106
backend/api/v1/modules/fin/catalogs/service.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Consultas de los catálogos del SAT.
|
||||||
|
|
||||||
|
Son globales (sin tenant_id / company_id) y de solo lectura: aquí no hay altas,
|
||||||
|
cambios ni bajas, únicamente búsqueda para llenar los selectores de captura.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from .models import (
|
||||||
|
CfdiUse,
|
||||||
|
PaymentForm,
|
||||||
|
PaymentMethod,
|
||||||
|
ProductService,
|
||||||
|
Tax,
|
||||||
|
TaxObject,
|
||||||
|
TaxRegime,
|
||||||
|
UnitOfMeasure,
|
||||||
|
VoucherType,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Catálogos que además del código y la descripción buscan por nombre corto.
|
||||||
|
_SEARCHABLE_EXTRA_FIELDS = {UnitOfMeasure: ("name",)}
|
||||||
|
|
||||||
|
|
||||||
|
def search_catalog(
|
||||||
|
db: Session,
|
||||||
|
model,
|
||||||
|
search: str | None = None,
|
||||||
|
active_only: bool = True,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> list:
|
||||||
|
"""Devuelve las claves de un catálogo, filtradas por texto libre.
|
||||||
|
|
||||||
|
``search`` compara contra la clave o la descripción sin distinguir mayúsculas.
|
||||||
|
"""
|
||||||
|
q = db.query(model)
|
||||||
|
if active_only:
|
||||||
|
q = q.filter(model.is_active.is_(True))
|
||||||
|
if search:
|
||||||
|
term = f"%{search.strip()}%"
|
||||||
|
fields = [model.code, model.description]
|
||||||
|
for extra in _SEARCHABLE_EXTRA_FIELDS.get(model, ()):
|
||||||
|
fields.append(getattr(model, extra))
|
||||||
|
q = q.filter(or_(*[f.ilike(term) for f in fields]))
|
||||||
|
q = q.order_by(model.code.asc())
|
||||||
|
if limit is not None:
|
||||||
|
q = q.limit(limit)
|
||||||
|
return q.all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_tax_regimes(
|
||||||
|
db: Session,
|
||||||
|
search: str | None = None,
|
||||||
|
active_only: bool = True,
|
||||||
|
person_type: str | None = None,
|
||||||
|
) -> list[TaxRegime]:
|
||||||
|
"""``c_RegimenFiscal``, opcionalmente acotado al tipo de persona.
|
||||||
|
|
||||||
|
``person_type='fisica'`` deja solo los regímenes que puede usar una persona
|
||||||
|
física; ``'moral'``, los de persona moral.
|
||||||
|
"""
|
||||||
|
q = db.query(TaxRegime)
|
||||||
|
if active_only:
|
||||||
|
q = q.filter(TaxRegime.is_active.is_(True))
|
||||||
|
if search:
|
||||||
|
term = f"%{search.strip()}%"
|
||||||
|
q = q.filter(or_(TaxRegime.code.ilike(term), TaxRegime.description.ilike(term)))
|
||||||
|
if person_type == "fisica":
|
||||||
|
q = q.filter(TaxRegime.applies_to_individual.is_(True))
|
||||||
|
elif person_type == "moral":
|
||||||
|
q = q.filter(TaxRegime.applies_to_legal_entity.is_(True))
|
||||||
|
return q.order_by(TaxRegime.code.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_taxes(db: Session, search=None, active_only=True) -> list[Tax]:
|
||||||
|
return search_catalog(db, Tax, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
def get_payment_forms(db: Session, search=None, active_only=True) -> list[PaymentForm]:
|
||||||
|
return search_catalog(db, PaymentForm, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
def get_units_of_measure(db: Session, search=None, active_only=True) -> list[UnitOfMeasure]:
|
||||||
|
return search_catalog(db, UnitOfMeasure, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
def get_products_services(db: Session, search=None, active_only=True, limit=50) -> list[ProductService]:
|
||||||
|
"""``c_ClaveProdServ``. Va paginado porque alimenta un autocompletado."""
|
||||||
|
return search_catalog(db, ProductService, search, active_only, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
def get_voucher_types(db: Session, search=None, active_only=True) -> list[VoucherType]:
|
||||||
|
return search_catalog(db, VoucherType, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
def get_payment_methods(db: Session, search=None, active_only=True) -> list[PaymentMethod]:
|
||||||
|
return search_catalog(db, PaymentMethod, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
def get_tax_objects(db: Session, search=None, active_only=True) -> list[TaxObject]:
|
||||||
|
return search_catalog(db, TaxObject, search, active_only)
|
||||||
|
|
||||||
|
|
||||||
|
def get_cfdi_uses(db: Session, search=None, active_only=True) -> list[CfdiUse]:
|
||||||
|
return search_catalog(db, CfdiUse, search, active_only)
|
||||||
1
backend/api/v1/modules/fin/concepts/__init__.py
Normal file
1
backend/api/v1/modules/fin/concepts/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Catálogo de conceptos de facturación por empresa."""
|
||||||
55
backend/api/v1/modules/fin/concepts/dto.py
Normal file
55
backend/api/v1/modules/fin/concepts/dto.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
"""Esquemas del catálogo de conceptos de facturación."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from ..catalogs.dto import ProductServiceResponse, TaxObjectResponse, UnitOfMeasureResponse
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptBase(BaseModel):
|
||||||
|
code: str = Field(..., min_length=1, max_length=40, description="Clave interna del concepto")
|
||||||
|
description: str = Field(..., min_length=1, max_length=500)
|
||||||
|
product_service_id: int = Field(..., description="Clave ProdServ del SAT (1:1 por empresa)")
|
||||||
|
unit_of_measure_id: int | None = None
|
||||||
|
tax_object_id: int | None = None
|
||||||
|
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||||
|
currency: str = Field("MXN", min_length=3, max_length=3)
|
||||||
|
is_active: bool = True
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptCreate(ConceptBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptUpdate(BaseModel):
|
||||||
|
"""Actualización parcial: solo se tocan los campos enviados."""
|
||||||
|
|
||||||
|
code: str | None = Field(None, min_length=1, max_length=40)
|
||||||
|
description: str | None = Field(None, min_length=1, max_length=500)
|
||||||
|
product_service_id: int | None = None
|
||||||
|
unit_of_measure_id: int | None = None
|
||||||
|
tax_object_id: int | None = None
|
||||||
|
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||||
|
currency: str | None = Field(None, min_length=3, max_length=3)
|
||||||
|
is_active: bool | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ConceptResponse(ConceptBase):
|
||||||
|
"""Incluye los objetos del catálogo del SAT ya resueltos, para evitar N+1 en la UI."""
|
||||||
|
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
tenant_id: int
|
||||||
|
company_id: int
|
||||||
|
product_service: ProductServiceResponse | None = None
|
||||||
|
unit_of_measure: UnitOfMeasureResponse | None = None
|
||||||
|
tax_object: TaxObjectResponse | None = None
|
||||||
|
created_by: str | None = None
|
||||||
|
updated_by: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
67
backend/api/v1/modules/fin/concepts/models.py
Normal file
67
backend/api/v1/modules/fin/concepts/models.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""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")
|
||||||
96
backend/api/v1/modules/fin/concepts/routes.py
Normal file
96
backend/api/v1/modules/fin/concepts/routes.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Endpoints del catálogo de conceptos de facturación (CRUD por empresa)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user
|
||||||
|
|
||||||
|
from . import service
|
||||||
|
from .dto import ConceptCreate, ConceptResponse, ConceptUpdate
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _uid(current_user: dict) -> str | None:
|
||||||
|
return current_user.get("sub") or current_user.get("id")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/concepts",
|
||||||
|
response_model=list[ConceptResponse],
|
||||||
|
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
|
||||||
|
)
|
||||||
|
def list_concepts(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
search: str | None = Query(None, description="Búsqueda por clave o descripción"),
|
||||||
|
active_only: bool | None = Query(None, description="Filtra por conceptos activos o inactivos"),
|
||||||
|
product_service_id: int | None = Query(None, description="Filtra por clave ProdServ del SAT"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
return service.get_concepts(
|
||||||
|
db, current_user["tenant_id"], company_id, search, active_only, product_service_id
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/concepts/{concept_id}",
|
||||||
|
response_model=ConceptResponse,
|
||||||
|
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
|
||||||
|
)
|
||||||
|
def get_concept(
|
||||||
|
concept_id: int,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
return service.get_concept(db, concept_id, current_user["tenant_id"], company_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/concepts",
|
||||||
|
response_model=ConceptResponse,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
dependencies=[Depends(PermissionChecker(["fin.concept.create"]))],
|
||||||
|
)
|
||||||
|
def create_concept(
|
||||||
|
payload: ConceptCreate,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
return service.create_concept(db, payload, current_user["tenant_id"], company_id, _uid(current_user))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/concepts/{concept_id}",
|
||||||
|
response_model=ConceptResponse,
|
||||||
|
dependencies=[Depends(PermissionChecker(["fin.concept.edit"]))],
|
||||||
|
)
|
||||||
|
def update_concept(
|
||||||
|
concept_id: int,
|
||||||
|
payload: ConceptUpdate,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
return service.update_concept(
|
||||||
|
db, concept_id, payload, current_user["tenant_id"], company_id, _uid(current_user)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/concepts/{concept_id}",
|
||||||
|
status_code=status.HTTP_204_NO_CONTENT,
|
||||||
|
dependencies=[Depends(PermissionChecker(["fin.concept.delete"]))],
|
||||||
|
)
|
||||||
|
def delete_concept(
|
||||||
|
concept_id: int,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""Baja lógica del concepto (``deleted_at``)."""
|
||||||
|
service.delete_concept(db, concept_id, current_user["tenant_id"], company_id)
|
||||||
146
backend/api/v1/modules/fin/concepts/service.py
Normal file
146
backend/api/v1/modules/fin/concepts/service.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Lógica del catálogo de conceptos de facturación.
|
||||||
|
|
||||||
|
Todas las consultas filtran por ``tenant_id``, ``company_id`` y ``deleted_at IS NULL``:
|
||||||
|
el catálogo es privado de cada empresa dentro de cada tenant.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure
|
||||||
|
from .dto import ConceptCreate, ConceptUpdate
|
||||||
|
from .models import Concept
|
||||||
|
|
||||||
|
|
||||||
|
def _check_sat_refs(db: Session, data: dict) -> None:
|
||||||
|
"""Verifica que las claves del SAT referidas existan antes de guardar."""
|
||||||
|
for field, model, msg in [
|
||||||
|
("product_service_id", ProductService, "La clave de producto/servicio del SAT no existe"),
|
||||||
|
("unit_of_measure_id", UnitOfMeasure, "La unidad de medida del SAT no existe"),
|
||||||
|
("tax_object_id", TaxObject, "El objeto de impuesto del SAT no existe"),
|
||||||
|
]:
|
||||||
|
value = data.get(field)
|
||||||
|
if field in data and value is not None:
|
||||||
|
if db.query(model.id).filter(model.id == value).first() is None:
|
||||||
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
||||||
|
|
||||||
|
|
||||||
|
def _check_unique(
|
||||||
|
db: Session,
|
||||||
|
tenant_id: int,
|
||||||
|
company_id: int,
|
||||||
|
code: str | None,
|
||||||
|
product_service_id: int | None,
|
||||||
|
exclude_id: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Aplica en el service las mismas reglas que los índices únicos parciales.
|
||||||
|
|
||||||
|
Sin esto el conflicto llegaría al cliente como un IntegrityError crudo; aquí se
|
||||||
|
traduce a un 409 con mensaje en español.
|
||||||
|
"""
|
||||||
|
base = db.query(Concept).filter(
|
||||||
|
Concept.tenant_id == tenant_id,
|
||||||
|
Concept.company_id == company_id,
|
||||||
|
Concept.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if exclude_id is not None:
|
||||||
|
base = base.filter(Concept.id != exclude_id)
|
||||||
|
|
||||||
|
if code is not None and base.filter(Concept.code == code).first() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Ya existe un concepto con la clave '{code}' en esta empresa",
|
||||||
|
)
|
||||||
|
# Regla 1:1 — una clave ProdServ no puede repetirse entre conceptos de la empresa.
|
||||||
|
if product_service_id is not None and base.filter(
|
||||||
|
Concept.product_service_id == product_service_id
|
||||||
|
).first() is not None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail="La clave de producto/servicio del SAT ya está asignada a otro concepto de esta empresa",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_concepts(
|
||||||
|
db: Session,
|
||||||
|
tenant_id: int,
|
||||||
|
company_id: int,
|
||||||
|
search: str | None = None,
|
||||||
|
active_only: bool | None = None,
|
||||||
|
product_service_id: int | None = None,
|
||||||
|
) -> list[Concept]:
|
||||||
|
q = db.query(Concept).filter(
|
||||||
|
Concept.tenant_id == tenant_id,
|
||||||
|
Concept.company_id == company_id,
|
||||||
|
Concept.deleted_at.is_(None),
|
||||||
|
)
|
||||||
|
if active_only is not None:
|
||||||
|
q = q.filter(Concept.is_active.is_(active_only))
|
||||||
|
if product_service_id is not None:
|
||||||
|
q = q.filter(Concept.product_service_id == product_service_id)
|
||||||
|
if search:
|
||||||
|
term = f"%{search.strip()}%"
|
||||||
|
q = q.filter(or_(Concept.code.ilike(term), Concept.description.ilike(term)))
|
||||||
|
return q.order_by(Concept.code.asc()).all()
|
||||||
|
|
||||||
|
|
||||||
|
def get_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> Concept:
|
||||||
|
obj = db.query(Concept).filter(
|
||||||
|
Concept.id == concept_id,
|
||||||
|
Concept.tenant_id == tenant_id,
|
||||||
|
Concept.company_id == company_id,
|
||||||
|
Concept.deleted_at.is_(None),
|
||||||
|
).first()
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def create_concept(
|
||||||
|
db: Session, payload: ConceptCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||||
|
) -> Concept:
|
||||||
|
data = payload.model_dump()
|
||||||
|
_check_sat_refs(db, data)
|
||||||
|
_check_unique(db, tenant_id, company_id, data["code"], data["product_service_id"])
|
||||||
|
obj = Concept(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||||
|
db.add(obj)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def update_concept(
|
||||||
|
db: Session,
|
||||||
|
concept_id: int,
|
||||||
|
payload: ConceptUpdate,
|
||||||
|
tenant_id: int,
|
||||||
|
company_id: int,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> Concept:
|
||||||
|
obj = get_concept(db, concept_id, tenant_id, company_id)
|
||||||
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
_check_sat_refs(db, data)
|
||||||
|
_check_unique(
|
||||||
|
db,
|
||||||
|
tenant_id,
|
||||||
|
company_id,
|
||||||
|
data.get("code"),
|
||||||
|
data.get("product_service_id"),
|
||||||
|
exclude_id=obj.id,
|
||||||
|
)
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(obj, field, value)
|
||||||
|
obj.updated_by = user_id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def delete_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> None:
|
||||||
|
"""Baja lógica: libera la clave ProdServ y el código para un concepto nuevo."""
|
||||||
|
obj = get_concept(db, concept_id, tenant_id, company_id)
|
||||||
|
obj.deleted_at = datetime.now(timezone.utc)
|
||||||
|
db.commit()
|
||||||
@@ -10,7 +10,16 @@ class InvoiceClientReviewInput(BaseModel):
|
|||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class InvoiceItemBase(BaseModel):
|
class InvoiceItemSatFields(BaseModel):
|
||||||
|
"""Claves fiscales de la partida. Opcionales: las facturas previas no las tienen."""
|
||||||
|
|
||||||
|
concept_id: int | None = None
|
||||||
|
product_service_id: int | None = None
|
||||||
|
unit_of_measure_id: int | None = None
|
||||||
|
tax_object_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceItemBase(InvoiceItemSatFields):
|
||||||
concept: str = Field(..., max_length=60)
|
concept: str = Field(..., max_length=60)
|
||||||
description: str | None = Field(None, max_length=255)
|
description: str | None = Field(None, max_length=255)
|
||||||
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
|
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
|
||||||
@@ -19,9 +28,11 @@ class InvoiceItemBase(BaseModel):
|
|||||||
|
|
||||||
class InvoiceItemCreate(InvoiceItemBase):
|
class InvoiceItemCreate(InvoiceItemBase):
|
||||||
invoice_id: int
|
invoice_id: int
|
||||||
|
# Opcional solo si viene concept_id: el service copia la descripción del concepto.
|
||||||
|
concept: str | None = Field(None, max_length=60)
|
||||||
|
|
||||||
|
|
||||||
class InvoiceItemUpdate(BaseModel):
|
class InvoiceItemUpdate(InvoiceItemSatFields):
|
||||||
concept: str | None = Field(None, max_length=60)
|
concept: str | None = Field(None, max_length=60)
|
||||||
description: str | None = Field(None, max_length=255)
|
description: str | None = Field(None, max_length=255)
|
||||||
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
|
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
|
||||||
@@ -76,6 +87,11 @@ class InvoiceBase(BaseModel):
|
|||||||
bank_info: str | None = None
|
bank_info: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
owner_user_id: str | None = Field(None, max_length=64)
|
owner_user_id: str | None = Field(None, max_length=64)
|
||||||
|
# ----- Claves fiscales del CFDI (opcionales mientras no se timbre) -----
|
||||||
|
voucher_type_id: int | None = None
|
||||||
|
payment_form_id: int | None = None
|
||||||
|
payment_method_id: int | None = None
|
||||||
|
expedition_zip_code: str | None = Field(None, max_length=5)
|
||||||
|
|
||||||
|
|
||||||
class InvoiceCreate(InvoiceBase):
|
class InvoiceCreate(InvoiceBase):
|
||||||
@@ -94,6 +110,10 @@ class InvoiceUpdate(BaseModel):
|
|||||||
bank_info: str | None = None
|
bank_info: str | None = None
|
||||||
notes: str | None = None
|
notes: str | None = None
|
||||||
owner_user_id: str | None = Field(None, max_length=64)
|
owner_user_id: str | None = Field(None, max_length=64)
|
||||||
|
voucher_type_id: int | None = None
|
||||||
|
payment_form_id: int | None = None
|
||||||
|
payment_method_id: int | None = None
|
||||||
|
expedition_zip_code: str | None = Field(None, max_length=5)
|
||||||
|
|
||||||
|
|
||||||
class InvoiceResponse(InvoiceBase):
|
class InvoiceResponse(InvoiceBase):
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||||
from core.database import Base
|
from core.database import Base
|
||||||
|
|
||||||
|
from ..catalogs.models import ( # noqa: F401 (registra los catálogos SAT referidos por las FK)
|
||||||
|
PaymentForm,
|
||||||
|
PaymentMethod,
|
||||||
|
ProductService,
|
||||||
|
Tax,
|
||||||
|
TaxObject,
|
||||||
|
UnitOfMeasure,
|
||||||
|
VoucherType,
|
||||||
|
)
|
||||||
|
from ..concepts.models import Concept # noqa: F401
|
||||||
|
|
||||||
|
|
||||||
class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||||
"""Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente."""
|
"""Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente."""
|
||||||
@@ -50,6 +61,18 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
|||||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
# ----- Datos fiscales del CFDI (catálogos SAT) -----
|
||||||
|
# Nullables: las facturas emitidas antes de existir los catálogos no los tienen.
|
||||||
|
voucher_type_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("sat.voucher_types.id"), nullable=True
|
||||||
|
)
|
||||||
|
payment_form_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("sat.payment_forms.id"), nullable=True
|
||||||
|
)
|
||||||
|
payment_method_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("sat.payment_methods.id"), nullable=True
|
||||||
|
)
|
||||||
|
expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
||||||
@@ -62,10 +85,54 @@ class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
|||||||
invoice_id: Mapped[int] = mapped_column(
|
invoice_id: Mapped[int] = mapped_column(
|
||||||
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
|
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
|
||||||
)
|
)
|
||||||
|
# Texto libre histórico: lo consume el PDF actual y se conserva obligatorio.
|
||||||
concept: Mapped[str] = mapped_column(String(60), nullable=False)
|
concept: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
|
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
|
||||||
unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||||
|
# ----- Datos fiscales de la partida (catálogos SAT) -----
|
||||||
|
concept_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("fin.concepts.id"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
product_service_id: Mapped[int | None] = mapped_column(
|
||||||
|
Integer, ForeignKey("sat.products_services.id"), nullable=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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
"""Impuesto trasladado o retenido de una partida de la factura.
|
||||||
|
|
||||||
|
Es captura de detalle fiscal para el futuro CFDI: **no** interviene en el cálculo
|
||||||
|
de subtotal/IVA/total de la factura, que sigue saliendo de ``invoices.tax_rate``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "invoice_item_taxes"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_fin_invoice_item_taxes",
|
||||||
|
"invoice_item_id", "tax_id", "is_withholding",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("deleted_at IS NULL"),
|
||||||
|
sqlite_where=text("deleted_at IS NULL"),
|
||||||
|
),
|
||||||
|
{"schema": "fin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
invoice_item_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("fin.invoice_items.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
tax_id: Mapped[int] = mapped_column(Integer, ForeignKey("sat.taxes.id"), nullable=False)
|
||||||
|
# false = trasladado (se cobra al cliente); true = retenido
|
||||||
|
is_withholding: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||||
|
rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True) # p. ej. 0.160000
|
||||||
|
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||||
|
|
||||||
|
|
||||||
class Payment(Base, TenantScopedMixin, TimestampMixin):
|
class Payment(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from api.v1.modules.crm.accounts.models import Account
|
|||||||
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
|
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
|
||||||
from api.v1.modules.ops.shipments.models import Shipment
|
from api.v1.modules.ops.shipments.models import Shipment
|
||||||
|
|
||||||
|
from ..concepts.models import Concept
|
||||||
from .dto import (
|
from .dto import (
|
||||||
InvoiceClientReviewInput,
|
InvoiceClientReviewInput,
|
||||||
InvoiceCreate,
|
InvoiceCreate,
|
||||||
@@ -331,9 +332,50 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
|
|||||||
return obj
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
# Claves del SAT que la partida hereda del concepto del catálogo cuando no se envían.
|
||||||
|
_CONCEPT_INHERITED_FIELDS = ("product_service_id", "unit_of_measure_id", "tax_object_id")
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
|
||||||
|
"""Completa la partida a partir del concepto del catálogo.
|
||||||
|
|
||||||
|
Hereda dos cosas cuando el cliente no las manda:
|
||||||
|
|
||||||
|
- ``concept``: el PDF de la factura sigue leyendo esa columna de texto libre, así
|
||||||
|
que ahí va la descripción del concepto (recortada al largo de la columna).
|
||||||
|
- Las claves fiscales (``product_service_id``, ``unit_of_measure_id``,
|
||||||
|
``tax_object_id``): sin ellas la partida capturada por catálogo quedaría
|
||||||
|
incompleta para el CFDI. Lo que el cliente sí envía manda sobre el catálogo,
|
||||||
|
para poder facturar una partida con una unidad distinta a la del concepto.
|
||||||
|
"""
|
||||||
|
concept_id = data.get("concept_id")
|
||||||
|
if concept_id is not None:
|
||||||
|
catalog_concept = db.query(Concept).filter(
|
||||||
|
Concept.id == concept_id, Concept.tenant_id == tenant_id,
|
||||||
|
Concept.company_id == company_id, Concept.deleted_at.is_(None),
|
||||||
|
).first()
|
||||||
|
if not catalog_concept:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="El concepto del catálogo no existe en esta empresa",
|
||||||
|
)
|
||||||
|
if not data.get("concept"):
|
||||||
|
data["concept"] = catalog_concept.description[:60]
|
||||||
|
for field in _CONCEPT_INHERITED_FIELDS:
|
||||||
|
if data.get(field) is None:
|
||||||
|
data[field] = getattr(catalog_concept, field)
|
||||||
|
if not data.get("concept"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="La partida requiere un concepto o una referencia al catálogo de conceptos",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem:
|
def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem:
|
||||||
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
|
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
|
||||||
item = InvoiceItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
data = payload.model_dump()
|
||||||
|
_resolve_item_concept(db, data, tenant_id, company_id)
|
||||||
|
item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id)
|
||||||
db.add(item)
|
db.add(item)
|
||||||
db.flush()
|
db.flush()
|
||||||
_recompute(db, invoice)
|
_recompute(db, invoice)
|
||||||
@@ -344,7 +386,12 @@ def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> Invoic
|
|||||||
|
|
||||||
def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem:
|
def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem:
|
||||||
item = _get_item(db, item_id, tenant_id, company_id)
|
item = _get_item(db, item_id, tenant_id, company_id)
|
||||||
for f, v in payload.model_dump(exclude_unset=True).items():
|
data = payload.model_dump(exclude_unset=True)
|
||||||
|
# Cambiar el concepto del catálogo revalida la referencia y vuelve a heredar
|
||||||
|
# descripción y claves fiscales del concepto nuevo.
|
||||||
|
if data.get("concept_id") is not None:
|
||||||
|
_resolve_item_concept(db, data, tenant_id, company_id)
|
||||||
|
for f, v in data.items():
|
||||||
setattr(item, f, v)
|
setattr(item, f, v)
|
||||||
db.flush()
|
db.flush()
|
||||||
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
|
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
|
||||||
|
|||||||
1
backend/api/v1/modules/fin/issuer/__init__.py
Normal file
1
backend/api/v1/modules/fin/issuer/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""Datos fiscales del emisor por empresa."""
|
||||||
60
backend/api/v1/modules/fin/issuer/dto.py
Normal file
60
backend/api/v1/modules/fin/issuer/dto.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Esquemas de los datos fiscales del emisor."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
|
|
||||||
|
from ..catalogs.dto import TaxRegimeResponse
|
||||||
|
|
||||||
|
# RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave.
|
||||||
|
RFC_PATTERN = re.compile(r"^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$")
|
||||||
|
ZIP_PATTERN = re.compile(r"^\d{5}$")
|
||||||
|
|
||||||
|
|
||||||
|
class IssuerSettingsInput(BaseModel):
|
||||||
|
"""Alta o actualización de los datos fiscales del emisor."""
|
||||||
|
|
||||||
|
legal_name: str = Field(..., min_length=1, max_length=255, description="Razón social")
|
||||||
|
rfc: str = Field(..., max_length=13, description="RFC del emisor")
|
||||||
|
tax_regime_id: int = Field(..., description="Régimen fiscal (c_RegimenFiscal)")
|
||||||
|
zip_code: str | None = Field(None, max_length=5, description="CP del lugar de expedición")
|
||||||
|
|
||||||
|
# mode="before": la normalización corre antes que el max_length del campo, para que
|
||||||
|
# un RFC con espacios de sobra no se rechace por longitud antes de limpiarlo.
|
||||||
|
@field_validator("rfc", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _validate_rfc(cls, value: str) -> str:
|
||||||
|
"""Normaliza a mayúsculas sin espacios y valida el formato oficial del RFC."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ValueError("El RFC debe ser texto")
|
||||||
|
normalized = value.replace(" ", "").replace("-", "").upper()
|
||||||
|
if not RFC_PATTERN.match(normalized):
|
||||||
|
raise ValueError("El RFC no tiene un formato válido (ej. XAXX010101000)")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
@field_validator("zip_code")
|
||||||
|
@classmethod
|
||||||
|
def _validate_zip(cls, value: str | None) -> str | None:
|
||||||
|
if value is None or value == "":
|
||||||
|
return None
|
||||||
|
normalized = value.strip()
|
||||||
|
if not ZIP_PATTERN.match(normalized):
|
||||||
|
raise ValueError("El código postal debe tener 5 dígitos")
|
||||||
|
return normalized
|
||||||
|
|
||||||
|
|
||||||
|
class IssuerSettingsResponse(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
tenant_id: int
|
||||||
|
company_id: int
|
||||||
|
legal_name: str
|
||||||
|
rfc: str
|
||||||
|
tax_regime_id: int
|
||||||
|
tax_regime: TaxRegimeResponse | None = None
|
||||||
|
zip_code: str | None = None
|
||||||
|
updated_by: str | None = None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
42
backend/api/v1/modules/fin/issuer/models.py
Normal file
42
backend/api/v1/modules/fin/issuer/models.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Datos fiscales del emisor — ``fin.issuer_settings``.
|
||||||
|
|
||||||
|
Es la identidad fiscal con la que la empresa emite CFDI: razón social, RFC, régimen
|
||||||
|
fiscal y código postal del lugar de expedición. Hay **una sola configuración vigente
|
||||||
|
por empresa**, garantizada con un índice único parcial.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import ForeignKey, Index, Integer, String, 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 TaxRegime # noqa: F401 (resuelve la relación)
|
||||||
|
|
||||||
|
_ALIVE = text("deleted_at IS NULL")
|
||||||
|
|
||||||
|
|
||||||
|
class IssuerSettings(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
"""Configuración fiscal del emisor de la empresa."""
|
||||||
|
|
||||||
|
__tablename__ = "issuer_settings"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"uq_fin_issuer_settings_company",
|
||||||
|
"tenant_id", "company_id",
|
||||||
|
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
||||||
|
),
|
||||||
|
{"schema": "fin"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||||
|
legal_name: Mapped[str] = mapped_column(String(255), nullable=False) # razón social
|
||||||
|
rfc: Mapped[str] = mapped_column(String(13), nullable=False)
|
||||||
|
tax_regime_id: Mapped[int] = mapped_column(
|
||||||
|
Integer, ForeignKey("sat.tax_regimes.id"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
# CP del lugar de expedición del comprobante
|
||||||
|
zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
|
||||||
|
tax_regime: Mapped["TaxRegime"] = relationship("TaxRegime", lazy="selectin")
|
||||||
48
backend/api/v1/modules/fin/issuer/routes.py
Normal file
48
backend/api/v1/modules/fin/issuer/routes.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""Endpoints de los datos fiscales del emisor (una configuración por empresa)."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user
|
||||||
|
|
||||||
|
from . import service
|
||||||
|
from .dto import IssuerSettingsInput, IssuerSettingsResponse
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/settings/issuer",
|
||||||
|
response_model=IssuerSettingsResponse,
|
||||||
|
dependencies=[Depends(PermissionChecker(["fin.settings.view"]))],
|
||||||
|
)
|
||||||
|
def get_issuer_settings(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""Devuelve 404 mientras la empresa no haya capturado sus datos fiscales."""
|
||||||
|
return service.get_issuer_settings(db, current_user["tenant_id"], company_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/settings/issuer",
|
||||||
|
response_model=IssuerSettingsResponse,
|
||||||
|
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
|
||||||
|
)
|
||||||
|
def save_issuer_settings(
|
||||||
|
payload: IssuerSettingsInput,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""Alta o actualización (upsert) de los datos fiscales del emisor."""
|
||||||
|
return service.save_issuer_settings(
|
||||||
|
db,
|
||||||
|
payload,
|
||||||
|
current_user["tenant_id"],
|
||||||
|
company_id,
|
||||||
|
current_user.get("sub") or current_user.get("id"),
|
||||||
|
)
|
||||||
58
backend/api/v1/modules/fin/issuer/service.py
Normal file
58
backend/api/v1/modules/fin/issuer/service.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Lógica de los datos fiscales del emisor.
|
||||||
|
|
||||||
|
Una empresa tiene, a lo más, una configuración vigente: el guardado es un upsert, no
|
||||||
|
un alta que pueda duplicar filas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import HTTPException, status
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from ..catalogs.models import TaxRegime
|
||||||
|
from .dto import IssuerSettingsInput
|
||||||
|
from .models import IssuerSettings
|
||||||
|
|
||||||
|
|
||||||
|
def _find(db: Session, tenant_id: int, company_id: int) -> IssuerSettings | None:
|
||||||
|
return db.query(IssuerSettings).filter(
|
||||||
|
IssuerSettings.tenant_id == tenant_id,
|
||||||
|
IssuerSettings.company_id == company_id,
|
||||||
|
IssuerSettings.deleted_at.is_(None),
|
||||||
|
).first()
|
||||||
|
|
||||||
|
|
||||||
|
def get_issuer_settings(db: Session, tenant_id: int, company_id: int) -> IssuerSettings:
|
||||||
|
obj = _find(db, tenant_id, company_id)
|
||||||
|
if not obj:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND,
|
||||||
|
detail="La empresa aún no tiene datos fiscales del emisor configurados",
|
||||||
|
)
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def save_issuer_settings(
|
||||||
|
db: Session,
|
||||||
|
payload: IssuerSettingsInput,
|
||||||
|
tenant_id: int,
|
||||||
|
company_id: int,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> IssuerSettings:
|
||||||
|
"""Crea la configuración la primera vez y la actualiza en adelante."""
|
||||||
|
if db.query(TaxRegime.id).filter(TaxRegime.id == payload.tax_regime_id).first() is None:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||||
|
detail="El régimen fiscal indicado no existe en el catálogo del SAT",
|
||||||
|
)
|
||||||
|
|
||||||
|
obj = _find(db, tenant_id, company_id)
|
||||||
|
data = payload.model_dump()
|
||||||
|
if obj is None:
|
||||||
|
obj = IssuerSettings(**data, tenant_id=tenant_id, company_id=company_id, updated_by=user_id)
|
||||||
|
db.add(obj)
|
||||||
|
else:
|
||||||
|
for field, value in data.items():
|
||||||
|
setattr(obj, field, value)
|
||||||
|
obj.updated_by = user_id
|
||||||
|
db.commit()
|
||||||
|
db.refresh(obj)
|
||||||
|
return obj
|
||||||
@@ -3,7 +3,7 @@
|
|||||||
from api.v1.modules.core.permissions.registry import registry
|
from api.v1.modules.core.permissions.registry import registry
|
||||||
|
|
||||||
MODULE = "fin"
|
MODULE = "fin"
|
||||||
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")]
|
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos"), ("concept", "conceptos")]
|
||||||
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
|
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
|
||||||
|
|
||||||
|
|
||||||
@@ -12,6 +12,11 @@ def register_permissions() -> None:
|
|||||||
for entity, label in _ENTITIES:
|
for entity, label in _ENTITIES:
|
||||||
for action, verb in _ACTIONS:
|
for action, verb in _ACTIONS:
|
||||||
registry.register(code=f"{MODULE}.{entity}.{action}", description=f"{verb} {label}", module=MODULE, action=action)
|
registry.register(code=f"{MODULE}.{entity}.{action}", description=f"{verb} {label}", module=MODULE, action=action)
|
||||||
|
# Datos fiscales del emisor: es configuración de la empresa, no una entidad con CRUD,
|
||||||
|
# así que solo tiene ver/editar. Los catálogos del SAT no llevan permiso propio:
|
||||||
|
# son globales y de solo lectura, basta con fin.access.
|
||||||
|
registry.register(code=f"{MODULE}.settings.view", description="Ver datos fiscales del emisor", module=MODULE, action="view")
|
||||||
|
registry.register(code=f"{MODULE}.settings.edit", description="Editar datos fiscales del emisor", module=MODULE, action="edit")
|
||||||
|
|
||||||
|
|
||||||
register_permissions()
|
register_permissions()
|
||||||
|
|||||||
@@ -5,8 +5,14 @@ from fastapi import APIRouter, Depends
|
|||||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||||
|
|
||||||
from . import permissions # noqa: F401 (side-effect: registra permisos)
|
from . import permissions # noqa: F401 (side-effect: registra permisos)
|
||||||
|
from .catalogs.routes import router as catalogs_router
|
||||||
|
from .concepts.routes import router as concepts_router
|
||||||
from .invoices.routes import router as invoices_router
|
from .invoices.routes import router as invoices_router
|
||||||
|
from .issuer.routes import router as issuer_router
|
||||||
|
|
||||||
# Enforcement por área/carril (R-T-07): se exige fin.access para el módulo.
|
# Enforcement por área/carril (R-T-07): se exige fin.access para el módulo.
|
||||||
router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))])
|
router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))])
|
||||||
|
router.include_router(catalogs_router)
|
||||||
|
router.include_router(concepts_router)
|
||||||
|
router.include_router(issuer_router)
|
||||||
router.include_router(invoices_router)
|
router.include_router(invoices_router)
|
||||||
|
|||||||
@@ -37,9 +37,13 @@ import api.v1.modules.crm.quotes.models # noqa: E402,F401
|
|||||||
import api.v1.modules.crm.service_requests.models # noqa: E402,F401
|
import api.v1.modules.crm.service_requests.models # noqa: E402,F401
|
||||||
import api.v1.modules.crm.suppliers.models # noqa: E402,F401
|
import api.v1.modules.crm.suppliers.models # noqa: E402,F401
|
||||||
import api.v1.modules.ops.shipments.models # noqa: E402,F401
|
import api.v1.modules.ops.shipments.models # noqa: E402,F401
|
||||||
|
import api.v1.modules.fin.catalogs.models # noqa: E402,F401
|
||||||
|
import api.v1.modules.fin.concepts.models # noqa: E402,F401
|
||||||
|
import api.v1.modules.fin.issuer.models # noqa: E402,F401
|
||||||
import api.v1.modules.fin.invoices.models # noqa: E402,F401
|
import api.v1.modules.fin.invoices.models # noqa: E402,F401
|
||||||
|
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs # noqa: E402
|
||||||
|
|
||||||
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None}
|
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None, "sat": None}
|
||||||
|
|
||||||
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
|
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
|
||||||
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
|
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
|
||||||
@@ -75,6 +79,10 @@ def db():
|
|||||||
Base.metadata.create_all(engine)
|
Base.metadata.create_all(engine)
|
||||||
session_factory = sessionmaker(bind=engine, future=True)
|
session_factory = sessionmaker(bind=engine, future=True)
|
||||||
session = session_factory()
|
session = session_factory()
|
||||||
|
# Los catálogos del SAT los siembra la migración en PostgreSQL; aquí se replica
|
||||||
|
# con la misma función para que conceptos y emisor tengan claves que referenciar.
|
||||||
|
sync_catalogs(session.connection())
|
||||||
|
session.commit()
|
||||||
try:
|
try:
|
||||||
yield session
|
yield session
|
||||||
finally:
|
finally:
|
||||||
|
|||||||
444
backend/tests/test_fin_sat_catalogs.py
Normal file
444
backend/tests/test_fin_sat_catalogs.py
Normal file
@@ -0,0 +1,444 @@
|
|||||||
|
"""Pruebas de los catálogos del SAT, el catálogo de conceptos y los datos fiscales
|
||||||
|
del emisor (módulo fin).
|
||||||
|
|
||||||
|
Cubren: lectura de los 8 catálogos y su filtrado, que no acepten escritura, el CRUD de
|
||||||
|
conceptos con la relación 1:1 contra c_ClaveProdServ, el aislamiento multi-tenant, el
|
||||||
|
upsert del emisor y el amarre de las partidas de factura al catálogo de conceptos.
|
||||||
|
|
||||||
|
Los RFC de las pruebas son dummies (XAXX010101000): nunca datos reales.
|
||||||
|
"""
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from pydantic import ValidationError
|
||||||
|
|
||||||
|
from api.v1.modules.crm.accounts import service as accounts_service
|
||||||
|
from api.v1.modules.crm.accounts.dto import AccountCreate, AccountUpdate
|
||||||
|
from api.v1.modules.fin.catalogs.models import CfdiUse, ProductService, TaxObject, TaxRegime, UnitOfMeasure
|
||||||
|
from api.v1.modules.fin.catalogs.routes import router as catalogs_router
|
||||||
|
from api.v1.modules.fin.catalogs.seed_data import CATALOGS, sync_catalogs
|
||||||
|
from api.v1.modules.fin.concepts import service as concepts_service
|
||||||
|
from api.v1.modules.fin.concepts.dto import ConceptCreate, ConceptUpdate
|
||||||
|
from api.v1.modules.fin.invoices import service as invoices_service
|
||||||
|
from api.v1.modules.fin.invoices.dto import (
|
||||||
|
InvoiceCreate,
|
||||||
|
InvoiceItemCreate,
|
||||||
|
InvoiceItemResponse,
|
||||||
|
InvoiceItemUpdate,
|
||||||
|
)
|
||||||
|
from api.v1.modules.fin.issuer import service as issuer_service
|
||||||
|
from api.v1.modules.fin.issuer.dto import IssuerSettingsInput
|
||||||
|
from api.v1.modules.fin.issuer.models import IssuerSettings
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user
|
||||||
|
|
||||||
|
T, C = 1, 1
|
||||||
|
OTHER_TENANT, OTHER_COMPANY = 2, 2
|
||||||
|
RFC_DUMMY = "XAXX010101000"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def client(db):
|
||||||
|
"""App mínima con solo el router de catálogos: evita levantar auth y permisos."""
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(catalogs_router, prefix="/fin")
|
||||||
|
app.dependency_overrides[get_core_db] = lambda: db
|
||||||
|
app.dependency_overrides[get_current_user] = lambda: {"sub": "tester", "tenant_id": T}
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
def _product_service(db, code: str = "78101600") -> ProductService:
|
||||||
|
return db.query(ProductService).filter(ProductService.code == code).one()
|
||||||
|
|
||||||
|
|
||||||
|
def _concept_payload(db, code: str = "FLETE-MAR", ps_code: str = "78101600") -> ConceptCreate:
|
||||||
|
return ConceptCreate(
|
||||||
|
code=code,
|
||||||
|
description="Flete marítimo internacional",
|
||||||
|
product_service_id=_product_service(db, ps_code).id,
|
||||||
|
unit_of_measure_id=db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "E48").one().id,
|
||||||
|
tax_object_id=db.query(TaxObject).filter(TaxObject.code == "02").one().id,
|
||||||
|
unit_price=Decimal("1500.00"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Catálogos del SAT: lectura ----------
|
||||||
|
|
||||||
|
CATALOG_EXPECTATIONS = [
|
||||||
|
("tax-regimes", 19, "601"),
|
||||||
|
("taxes", 3, "002"),
|
||||||
|
("payment-forms", 22, "03"),
|
||||||
|
("units-of-measure", 21, "H87"),
|
||||||
|
("products-services", 11, "78101500"),
|
||||||
|
("voucher-types", 5, "I"),
|
||||||
|
("payment-methods", 2, "PUE"),
|
||||||
|
("tax-objects", 4, "02"),
|
||||||
|
("cfdi-uses", 24, "G03"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("path,expected_count,sample_code", CATALOG_EXPECTATIONS)
|
||||||
|
def test_catalog_endpoints_return_seeded_rows(client, path, expected_count, sample_code):
|
||||||
|
res = client.get(f"/fin/catalogs/{path}")
|
||||||
|
assert res.status_code == 200
|
||||||
|
rows = res.json()
|
||||||
|
assert len(rows) == expected_count
|
||||||
|
assert sample_code in [r["code"] for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalog_search_filters_by_code_or_description(client):
|
||||||
|
by_code = client.get("/fin/catalogs/payment-forms", params={"search": "03"}).json()
|
||||||
|
assert [r["code"] for r in by_code] == ["03"]
|
||||||
|
|
||||||
|
by_description = client.get("/fin/catalogs/payment-forms", params={"search": "transferencia"}).json()
|
||||||
|
assert [r["code"] for r in by_description] == ["03"]
|
||||||
|
|
||||||
|
prodserv = client.get("/fin/catalogs/products-services", params={"search": "marítimo"}).json()
|
||||||
|
assert [r["code"] for r in prodserv] == ["78101600"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_tax_regimes_person_type_excludes_individual_only(client):
|
||||||
|
moral = client.get("/fin/catalogs/tax-regimes", params={"person_type": "moral"}).json()
|
||||||
|
codes = [r["code"] for r in moral]
|
||||||
|
assert "601" in codes # General de Ley Personas Morales
|
||||||
|
assert "605" not in codes # Sueldos y Salarios: solo persona física
|
||||||
|
assert all(r["applies_to_legal_entity"] for r in moral)
|
||||||
|
|
||||||
|
fisica = client.get("/fin/catalogs/tax-regimes", params={"person_type": "fisica"}).json()
|
||||||
|
fisica_codes = [r["code"] for r in fisica]
|
||||||
|
assert "605" in fisica_codes and "601" not in fisica_codes
|
||||||
|
|
||||||
|
|
||||||
|
def test_products_services_limit_caps_results(client):
|
||||||
|
assert len(client.get("/fin/catalogs/products-services", params={"limit": 3}).json()) == 3
|
||||||
|
assert client.get("/fin/catalogs/products-services", params={"limit": 500}).status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_catalogs_are_read_only(client):
|
||||||
|
"""Los catálogos del SAT no exponen métodos de escritura."""
|
||||||
|
for method, path in [
|
||||||
|
("post", "/fin/catalogs/payment-forms"),
|
||||||
|
("put", "/fin/catalogs/tax-regimes"),
|
||||||
|
("patch", "/fin/catalogs/units-of-measure"),
|
||||||
|
("delete", "/fin/catalogs/products-services"),
|
||||||
|
]:
|
||||||
|
res = client.request(method.upper(), path, json={"code": "XX", "description": "Inventado"})
|
||||||
|
assert res.status_code == 405, f"{method.upper()} {path} no debería aceptarse"
|
||||||
|
|
||||||
|
|
||||||
|
def _catalog_counts(db) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
table.name: db.execute(sa.select(sa.func.count()).select_from(table)).scalar()
|
||||||
|
for table, _ in CATALOGS
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sync_catalogs_is_idempotent(db):
|
||||||
|
"""Volver a correrla no duplica ni borra filas."""
|
||||||
|
before = _catalog_counts(db)
|
||||||
|
inserted = sync_catalogs(db.connection()) # el fixture ya sembró los catálogos
|
||||||
|
db.commit()
|
||||||
|
assert sum(inserted.values()) == 0
|
||||||
|
assert _catalog_counts(db) == before
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Conceptos ----------
|
||||||
|
|
||||||
|
def test_concept_crud(db):
|
||||||
|
created = concepts_service.create_concept(db, _concept_payload(db), T, C, "tester")
|
||||||
|
assert created.code == "FLETE-MAR" and created.currency == "MXN" and created.is_active
|
||||||
|
|
||||||
|
fetched = concepts_service.get_concept(db, created.id, T, C)
|
||||||
|
assert fetched.product_service.code == "78101600" # catálogo resuelto sin N+1
|
||||||
|
|
||||||
|
updated = concepts_service.update_concept(
|
||||||
|
db, created.id, ConceptUpdate(description="Flete marítimo FCL", is_active=False), T, C, "tester"
|
||||||
|
)
|
||||||
|
assert updated.description == "Flete marítimo FCL" and updated.is_active is False
|
||||||
|
|
||||||
|
assert concepts_service.get_concepts(db, T, C, active_only=False) == [updated]
|
||||||
|
assert concepts_service.get_concepts(db, T, C, active_only=True) == []
|
||||||
|
|
||||||
|
concepts_service.delete_concept(db, created.id, T, C)
|
||||||
|
assert concepts_service.get_concepts(db, T, C) == []
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
concepts_service.get_concept(db, created.id, T, C)
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_product_service_in_same_company_conflicts(db):
|
||||||
|
concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
concepts_service.create_concept(db, _concept_payload(db, code="OTRO-CODIGO"), T, C)
|
||||||
|
assert exc.value.status_code == 409
|
||||||
|
assert "producto/servicio" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_concept_code_in_same_company_conflicts(db):
|
||||||
|
concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
concepts_service.create_concept(db, _concept_payload(db, ps_code="78101500"), T, C)
|
||||||
|
assert exc.value.status_code == 409
|
||||||
|
assert "clave 'FLETE-MAR'" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
def test_same_product_service_allowed_in_another_company(db):
|
||||||
|
concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
other = concepts_service.create_concept(db, _concept_payload(db), T, OTHER_COMPANY)
|
||||||
|
assert other.company_id == OTHER_COMPANY
|
||||||
|
assert other.product_service_id == _product_service(db).id
|
||||||
|
|
||||||
|
|
||||||
|
def test_soft_deleted_concept_frees_its_product_service(db):
|
||||||
|
first = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
concepts_service.delete_concept(db, first.id, T, C)
|
||||||
|
reused = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
assert reused.id != first.id
|
||||||
|
assert reused.product_service_id == first.product_service_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_concept_is_isolated_by_tenant(db):
|
||||||
|
other_tenant_concept = concepts_service.create_concept(db, _concept_payload(db), OTHER_TENANT, C)
|
||||||
|
assert concepts_service.get_concepts(db, T, C) == []
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
concepts_service.get_concept(db, other_tenant_concept.id, T, C)
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
concepts_service.update_concept(
|
||||||
|
db, other_tenant_concept.id, ConceptUpdate(description="Ajeno"), T, C
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_concept_rejects_unknown_sat_key(db):
|
||||||
|
payload = _concept_payload(db)
|
||||||
|
payload.product_service_id = 999999
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
concepts_service.create_concept(db, payload, T, C)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Datos fiscales del emisor ----------
|
||||||
|
|
||||||
|
def _issuer_payload(db, legal_name: str = "Empresa Demo SA de CV") -> IssuerSettingsInput:
|
||||||
|
regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one()
|
||||||
|
return IssuerSettingsInput(
|
||||||
|
legal_name=legal_name, rfc=RFC_DUMMY, tax_regime_id=regime.id, zip_code="64000"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_issuer_settings_upsert_keeps_one_row_per_company(db):
|
||||||
|
created = issuer_service.save_issuer_settings(db, _issuer_payload(db), T, C, "tester")
|
||||||
|
assert created.rfc == RFC_DUMMY
|
||||||
|
|
||||||
|
updated = issuer_service.save_issuer_settings(
|
||||||
|
db, _issuer_payload(db, legal_name="Empresa Demo Renombrada SA de CV"), T, C, "tester"
|
||||||
|
)
|
||||||
|
assert updated.id == created.id
|
||||||
|
assert updated.legal_name == "Empresa Demo Renombrada SA de CV"
|
||||||
|
|
||||||
|
rows = db.query(IssuerSettings).filter(
|
||||||
|
IssuerSettings.tenant_id == T, IssuerSettings.company_id == C, IssuerSettings.deleted_at.is_(None)
|
||||||
|
).all()
|
||||||
|
assert len(rows) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_issuer_settings_missing_returns_404(db):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
issuer_service.get_issuer_settings(db, T, C)
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_issuer_rfc_is_validated_and_normalized(db):
|
||||||
|
regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one()
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
IssuerSettingsInput(legal_name="Demo", rfc="RFC-INVALIDO", tax_regime_id=regime.id)
|
||||||
|
with pytest.raises(ValidationError):
|
||||||
|
IssuerSettingsInput(legal_name="Demo", rfc=RFC_DUMMY, tax_regime_id=regime.id, zip_code="123")
|
||||||
|
|
||||||
|
normalized = IssuerSettingsInput(
|
||||||
|
legal_name="Demo", rfc=" xaxx010101000 ", tax_regime_id=regime.id
|
||||||
|
)
|
||||||
|
assert normalized.rfc == RFC_DUMMY
|
||||||
|
|
||||||
|
|
||||||
|
def test_issuer_rejects_unknown_tax_regime(db):
|
||||||
|
payload = _issuer_payload(db)
|
||||||
|
payload.tax_regime_id = 999999
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
issuer_service.save_issuer_settings(db, payload, T, C)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Amarre con las facturas ----------
|
||||||
|
|
||||||
|
def test_invoice_item_inherits_concept_description(db):
|
||||||
|
concept = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-1"), T, C)
|
||||||
|
item = invoices_service.create_item(
|
||||||
|
db,
|
||||||
|
InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, quantity=1, unit_amount=1500),
|
||||||
|
T,
|
||||||
|
C,
|
||||||
|
)
|
||||||
|
assert item.concept == concept.description # copiada del catálogo para el PDF
|
||||||
|
assert item.concept_id == concept.id
|
||||||
|
|
||||||
|
# La respuesta expone las claves fiscales: el frontend etiqueta la partida con ellas.
|
||||||
|
payload = InvoiceItemResponse.model_validate(item).model_dump()
|
||||||
|
assert payload["concept_id"] == concept.id
|
||||||
|
assert payload["concept"] == concept.description
|
||||||
|
assert {"product_service_id", "unit_of_measure_id", "tax_object_id"} <= payload.keys()
|
||||||
|
|
||||||
|
# Si el cliente sí manda el texto, se respeta tal cual.
|
||||||
|
explicit = invoices_service.create_item(
|
||||||
|
db,
|
||||||
|
InvoiceItemCreate(
|
||||||
|
invoice_id=invoice.id, concept_id=concept.id, concept="Flete a la medida", unit_amount=100
|
||||||
|
),
|
||||||
|
T,
|
||||||
|
C,
|
||||||
|
)
|
||||||
|
assert explicit.concept == "Flete a la medida"
|
||||||
|
|
||||||
|
|
||||||
|
def test_invoice_item_without_concept_or_catalog_is_rejected(db):
|
||||||
|
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-2"), T, C)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
invoices_service.create_item(db, InvoiceItemCreate(invoice_id=invoice.id, unit_amount=10), T, C)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_invoice_item_rejects_concept_from_another_company(db):
|
||||||
|
concept = concepts_service.create_concept(db, _concept_payload(db), T, OTHER_COMPANY)
|
||||||
|
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-3"), T, C)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
invoices_service.create_item(
|
||||||
|
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, unit_amount=10), T, C
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_invoice_item_inherits_sat_keys_from_concept(db):
|
||||||
|
"""La partida hereda las claves fiscales del concepto para quedar completa (CFDI)."""
|
||||||
|
concept = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-4"), T, C)
|
||||||
|
|
||||||
|
item = invoices_service.create_item(
|
||||||
|
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, unit_amount=1500), T, C
|
||||||
|
)
|
||||||
|
assert item.product_service_id == concept.product_service_id
|
||||||
|
assert item.unit_of_measure_id == concept.unit_of_measure_id
|
||||||
|
assert item.tax_object_id == concept.tax_object_id
|
||||||
|
|
||||||
|
|
||||||
|
def test_invoice_item_sat_keys_sent_by_client_win_over_concept(db):
|
||||||
|
"""Lo que el cliente envía manda: permite facturar con otra unidad de medida."""
|
||||||
|
concept = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-5"), T, C)
|
||||||
|
other_unit = db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "KGM").one()
|
||||||
|
|
||||||
|
item = invoices_service.create_item(
|
||||||
|
db,
|
||||||
|
InvoiceItemCreate(
|
||||||
|
invoice_id=invoice.id, concept_id=concept.id, unit_of_measure_id=other_unit.id, unit_amount=10
|
||||||
|
),
|
||||||
|
T,
|
||||||
|
C,
|
||||||
|
)
|
||||||
|
assert item.unit_of_measure_id == other_unit.id
|
||||||
|
assert item.product_service_id == concept.product_service_id # el resto sí se hereda
|
||||||
|
|
||||||
|
|
||||||
|
def test_changing_item_concept_reinherits_keys(db):
|
||||||
|
"""Cambiar el concepto de una partida revalida y vuelve a heredar del nuevo."""
|
||||||
|
first = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
second = concepts_service.create_concept(
|
||||||
|
db, _concept_payload(db, code="DESPACHO", ps_code="78141600"), T, C
|
||||||
|
)
|
||||||
|
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-6"), T, C)
|
||||||
|
item = invoices_service.create_item(
|
||||||
|
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=first.id, unit_amount=100), T, C
|
||||||
|
)
|
||||||
|
|
||||||
|
updated = invoices_service.update_item(
|
||||||
|
db, item.id, InvoiceItemUpdate(concept_id=second.id), T, C
|
||||||
|
)
|
||||||
|
assert updated.concept_id == second.id
|
||||||
|
assert updated.product_service_id == second.product_service_id
|
||||||
|
assert updated.concept == second.description
|
||||||
|
|
||||||
|
|
||||||
|
def test_updating_item_rejects_concept_from_another_tenant(db):
|
||||||
|
"""El PATCH valida la referencia igual que el alta: no cruza tenants."""
|
||||||
|
mine = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||||
|
alien = concepts_service.create_concept(db, _concept_payload(db), OTHER_TENANT, C)
|
||||||
|
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-7"), T, C)
|
||||||
|
item = invoices_service.create_item(
|
||||||
|
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=mine.id, unit_amount=100), T, C
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
invoices_service.update_item(db, item.id, InvoiceItemUpdate(concept_id=alien.id), T, C)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Claves fiscales del receptor (crm.accounts) ----------
|
||||||
|
|
||||||
|
def test_account_accepts_sat_fiscal_keys(db):
|
||||||
|
regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one()
|
||||||
|
cfdi_use = db.query(CfdiUse).filter(CfdiUse.code == "G03").one()
|
||||||
|
|
||||||
|
account = accounts_service.create_account(
|
||||||
|
db,
|
||||||
|
AccountCreate(name="Cliente fiscal", tax_regime_id=regime.id, cfdi_use_id=cfdi_use.id),
|
||||||
|
T,
|
||||||
|
C,
|
||||||
|
)
|
||||||
|
assert account.tax_regime_id == regime.id and account.cfdi_use_id == cfdi_use.id
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_rejects_unknown_sat_fiscal_keys(db):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
accounts_service.create_account(db, AccountCreate(name="Cliente malo", cfdi_use_id=999999), T, C)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
account = accounts_service.create_account(db, AccountCreate(name="Cliente ok"), T, C)
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
accounts_service.update_account(db, account.id, AccountUpdate(tax_regime_id=999999), T, C)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_free_text_fiscal_fields_are_preserved(db):
|
||||||
|
"""El texto libre previo se conserva: las FK lo complementan, no lo sustituyen."""
|
||||||
|
account = accounts_service.create_account(
|
||||||
|
db, AccountCreate(name="Cliente heredado", tax_regime="601", cfdi_use="G03"), T, C
|
||||||
|
)
|
||||||
|
assert account.tax_regime == "601" and account.cfdi_use == "G03"
|
||||||
|
assert account.tax_regime_id is None and account.cfdi_use_id is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_legacy_invoices_keep_working_without_sat_fields(db, monkeypatch):
|
||||||
|
"""Las facturas previas, sin claves del SAT, siguen listándose y generando PDF."""
|
||||||
|
stored = {}
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"core.storage_s3.put_object_bytes",
|
||||||
|
lambda key, body, content_type="": stored.update({"key": key, "len": len(body)}),
|
||||||
|
)
|
||||||
|
account = accounts_service.create_account(db, AccountCreate(name="Cliente heredado"), T, C)
|
||||||
|
invoice = invoices_service.create_invoice(
|
||||||
|
db, InvoiceCreate(reference="F-LEGACY", account_id=account.id, tax_rate=Decimal("16")), T, C
|
||||||
|
)
|
||||||
|
invoices_service.create_item(
|
||||||
|
db, InvoiceItemCreate(invoice_id=invoice.id, concept="flete_internacional", unit_amount=1000), T, C
|
||||||
|
)
|
||||||
|
assert invoice.voucher_type_id is None and invoice.payment_form_id is None
|
||||||
|
|
||||||
|
listed = invoices_service.get_invoices(db, T, C)
|
||||||
|
assert invoice.id in [i.id for i in listed]
|
||||||
|
|
||||||
|
sent = invoices_service.send_invoice(db, invoice.id, T, C)
|
||||||
|
assert sent.status == "enviada" and stored["len"] > 0
|
||||||
@@ -28,8 +28,11 @@ export interface Account {
|
|||||||
email: string | null;
|
email: string | null;
|
||||||
phone: string | null;
|
phone: string | null;
|
||||||
website: string | null;
|
website: string | null;
|
||||||
|
/** Texto libre histórico; lo que vale al timbrar son las claves del SAT de abajo. */
|
||||||
tax_regime: string | null;
|
tax_regime: string | null;
|
||||||
cfdi_use: string | null;
|
cfdi_use: string | null;
|
||||||
|
tax_regime_id: number | null;
|
||||||
|
cfdi_use_id: number | null;
|
||||||
payment_method: string | null;
|
payment_method: string | null;
|
||||||
payment_form: string | null;
|
payment_form: string | null;
|
||||||
currency: string | null;
|
currency: string | null;
|
||||||
|
|||||||
62
frontend/src/lib/api/fin/catalogs.test.ts
Normal file
62
frontend/src/lib/api/fin/catalogs.test.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
const get = vi.fn();
|
||||||
|
|
||||||
|
// El cliente de catálogos solo usa `api.get`; se sustituye para contar peticiones.
|
||||||
|
vi.mock('$lib/api', () => ({ api: { get } }));
|
||||||
|
|
||||||
|
const { satCatalogsAPI, clearCatalogCache } = await import('./catalogs');
|
||||||
|
|
||||||
|
const COMPANY_ID = 1;
|
||||||
|
const PAYMENT_FORMS = [
|
||||||
|
{ id: 1, code: '01', description: 'Efectivo', is_active: true },
|
||||||
|
{ id: 2, code: '03', description: 'Transferencia electrónica de fondos', is_active: true }
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('satCatalogsAPI — cacheo en memoria', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
clearCatalogCache();
|
||||||
|
get.mockReset();
|
||||||
|
get.mockResolvedValue({ data: PAYMENT_FORMS, status: 200 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('consulta el backend la primera vez y reusa el cache después', async () => {
|
||||||
|
const first = await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||||
|
const second = await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||||
|
|
||||||
|
expect(first).toEqual(PAYMENT_FORMS);
|
||||||
|
expect(second).toBe(first); // misma referencia: vino del cache
|
||||||
|
expect(get).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cachea por separado cada combinación de parámetros', async () => {
|
||||||
|
await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||||
|
await satCatalogsAPI.paymentForms(COMPANY_ID, { search: 'transferencia' });
|
||||||
|
await satCatalogsAPI.paymentForms(COMPANY_ID, { search: 'transferencia' });
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('no comparte cache entre compañías', async () => {
|
||||||
|
await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||||
|
await satCatalogsAPI.paymentForms(2);
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearCatalogCache obliga a volver a consultar', async () => {
|
||||||
|
await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||||
|
clearCatalogCache();
|
||||||
|
await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||||
|
|
||||||
|
expect(get).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propaga el error del backend y no lo cachea', async () => {
|
||||||
|
get.mockResolvedValueOnce({ error: 'Falla del servidor', status: 500 });
|
||||||
|
await expect(satCatalogsAPI.taxRegimes(COMPANY_ID)).rejects.toThrow('Falla del servidor');
|
||||||
|
|
||||||
|
await satCatalogsAPI.taxRegimes(COMPANY_ID);
|
||||||
|
expect(get).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
96
frontend/src/lib/api/fin/catalogs.ts
Normal file
96
frontend/src/lib/api/fin/catalogs.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* Cliente API — Catálogos del SAT (solo lectura).
|
||||||
|
*
|
||||||
|
* Son catálogos fijos que publica el SAT: una vez cargados no cambian durante la
|
||||||
|
* sesión, así que se guardan en un `Map` del módulo para no repetir la petición en
|
||||||
|
* cada selector. No hay POST/PUT/PATCH/DELETE: el backend tampoco los expone.
|
||||||
|
*/
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
export interface SatCatalogItem {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
is_active: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatTaxRegime extends SatCatalogItem {
|
||||||
|
applies_to_individual: boolean; // persona física
|
||||||
|
applies_to_legal_entity: boolean; // persona moral
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SatTax extends SatCatalogItem {
|
||||||
|
is_withholding: boolean;
|
||||||
|
is_transferred: boolean;
|
||||||
|
is_local: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `description` es la nota larga del SAT y puede venir vacía; el nombre corto va en `name`. */
|
||||||
|
export interface SatUnitOfMeasure extends Omit<SatCatalogItem, 'description'> {
|
||||||
|
description: string | null;
|
||||||
|
name: string;
|
||||||
|
symbol: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PersonType = 'fisica' | 'moral';
|
||||||
|
|
||||||
|
type CatalogParams = Record<string, string | number | boolean | undefined>;
|
||||||
|
|
||||||
|
/** Cache en memoria del módulo, con la query string completa como llave. */
|
||||||
|
const cache = new Map<string, unknown>();
|
||||||
|
|
||||||
|
function buildQuery(companyId: number, params?: CatalogParams): string {
|
||||||
|
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||||
|
for (const [key, value] of Object.entries(params ?? {})) {
|
||||||
|
if (value !== undefined && value !== '') qs.set(key, String(value));
|
||||||
|
}
|
||||||
|
qs.sort(); // llave de cache estable sin importar el orden de los parámetros
|
||||||
|
return qs.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCatalog<T>(
|
||||||
|
path: string,
|
||||||
|
companyId: number,
|
||||||
|
params?: CatalogParams
|
||||||
|
): Promise<T[]> {
|
||||||
|
const query = buildQuery(companyId, params);
|
||||||
|
const key = `${path}?${query}`;
|
||||||
|
const cached = cache.get(key);
|
||||||
|
if (cached) return cached as T[];
|
||||||
|
|
||||||
|
const res = await api.get<T[]>(`/v1/fin/catalogs/${path}?${query}`);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
const rows = res.data ?? [];
|
||||||
|
cache.set(key, rows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Vacía el cache; útil tras actualizar los catálogos con `sync_catalogs`. */
|
||||||
|
export function clearCatalogCache(): void {
|
||||||
|
cache.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const satCatalogsAPI = {
|
||||||
|
taxRegimes: (
|
||||||
|
companyId: number,
|
||||||
|
params?: { search?: string; person_type?: PersonType; active_only?: boolean }
|
||||||
|
) => fetchCatalog<SatTaxRegime>('tax-regimes', companyId, params),
|
||||||
|
taxes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatTax>('taxes', companyId, params),
|
||||||
|
paymentForms: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatCatalogItem>('payment-forms', companyId, params),
|
||||||
|
unitsOfMeasure: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatUnitOfMeasure>('units-of-measure', companyId, params),
|
||||||
|
productsServices: (
|
||||||
|
companyId: number,
|
||||||
|
params?: { search?: string; limit?: number; active_only?: boolean }
|
||||||
|
) => fetchCatalog<SatCatalogItem>('products-services', companyId, params),
|
||||||
|
voucherTypes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatCatalogItem>('voucher-types', companyId, params),
|
||||||
|
paymentMethods: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatCatalogItem>('payment-methods', companyId, params),
|
||||||
|
taxObjects: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatCatalogItem>('tax-objects', companyId, params),
|
||||||
|
cfdiUses: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||||
|
fetchCatalog<SatCatalogItem>('cfdi-uses', companyId, params)
|
||||||
|
};
|
||||||
81
frontend/src/lib/api/fin/concepts.ts
Normal file
81
frontend/src/lib/api/fin/concepts.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* Cliente API — Catálogo de conceptos de facturación.
|
||||||
|
*
|
||||||
|
* Cada concepto está ligado 1:1 a una clave de producto/servicio del SAT dentro de la
|
||||||
|
* empresa; el backend responde 409 si la clave ya está tomada.
|
||||||
|
*/
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { SatCatalogItem, SatUnitOfMeasure } from './catalogs';
|
||||||
|
|
||||||
|
export interface Concept {
|
||||||
|
id: number;
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
product_service_id: number;
|
||||||
|
unit_of_measure_id: number | null;
|
||||||
|
tax_object_id: number | null;
|
||||||
|
unit_price: number | null;
|
||||||
|
currency: string;
|
||||||
|
is_active: boolean;
|
||||||
|
notes: string | null;
|
||||||
|
product_service: SatCatalogItem | null;
|
||||||
|
unit_of_measure: SatUnitOfMeasure | null;
|
||||||
|
tax_object: SatCatalogItem | null;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
created_by: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConceptInput {
|
||||||
|
code: string;
|
||||||
|
description: string;
|
||||||
|
product_service_id: number;
|
||||||
|
unit_of_measure_id?: number | null;
|
||||||
|
tax_object_id?: number | null;
|
||||||
|
unit_price?: number | null;
|
||||||
|
currency?: string;
|
||||||
|
is_active?: boolean;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const conceptsAPI = {
|
||||||
|
async list(
|
||||||
|
companyId: number,
|
||||||
|
params?: { search?: string; active_only?: boolean; product_service_id?: number }
|
||||||
|
): Promise<Concept[]> {
|
||||||
|
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||||
|
if (params?.search) qs.set('search', params.search);
|
||||||
|
if (params?.active_only !== undefined) qs.set('active_only', String(params.active_only));
|
||||||
|
if (params?.product_service_id !== undefined)
|
||||||
|
qs.set('product_service_id', String(params.product_service_id));
|
||||||
|
const res = await api.get<Concept[]>(`/v1/fin/concepts?${qs}`);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async get(id: number, companyId: number): Promise<Concept> {
|
||||||
|
const res = await api.get<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async create(data: ConceptInput, companyId: number): Promise<Concept> {
|
||||||
|
const res = await api.post<Concept>(`/v1/fin/concepts?company_id=${companyId}`, data);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async update(id: number, data: Partial<ConceptInput>, companyId: number): Promise<Concept> {
|
||||||
|
const res = await api.patch<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`, data);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async remove(id: number, companyId: number): Promise<void> {
|
||||||
|
const res = await api.delete(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
import { api } from '$lib/api';
|
import { api } from '$lib/api';
|
||||||
|
|
||||||
|
export * from './catalogs';
|
||||||
|
export * from './concepts';
|
||||||
|
export * from './issuer';
|
||||||
|
|
||||||
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
|
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
|
||||||
|
|
||||||
export interface Invoice {
|
export interface Invoice {
|
||||||
@@ -33,6 +37,11 @@ export interface Invoice {
|
|||||||
owner_user_id: string | null;
|
owner_user_id: string | null;
|
||||||
created_by: string | null;
|
created_by: string | null;
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
|
// Claves fiscales del CFDI (catálogos SAT); nulas mientras no se capturen.
|
||||||
|
voucher_type_id: number | null;
|
||||||
|
payment_form_id: number | null;
|
||||||
|
payment_method_id: number | null;
|
||||||
|
expedition_zip_code: string | null;
|
||||||
tenant_id: number;
|
tenant_id: number;
|
||||||
company_id: number;
|
company_id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -43,17 +52,26 @@ export type InvoiceInput = Partial<Omit<Invoice, 'id' | 'status' | 'subtotal' |
|
|||||||
export interface InvoiceItem {
|
export interface InvoiceItem {
|
||||||
id: number;
|
id: number;
|
||||||
invoice_id: number;
|
invoice_id: number;
|
||||||
|
/** Texto libre que consume el PDF; se hereda del catálogo cuando hay `concept_id`. */
|
||||||
concept: string;
|
concept: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
unit_amount: number;
|
unit_amount: number;
|
||||||
line_total: number;
|
line_total: number;
|
||||||
|
// Claves fiscales de la partida (catálogo de conceptos y catálogos SAT).
|
||||||
|
concept_id: number | null;
|
||||||
|
product_service_id: number | null;
|
||||||
|
unit_of_measure_id: number | null;
|
||||||
|
tax_object_id: number | null;
|
||||||
tenant_id: number;
|
tenant_id: number;
|
||||||
company_id: number;
|
company_id: number;
|
||||||
}
|
}
|
||||||
|
/**
|
||||||
|
* `concept` es opcional cuando se envía `concept_id`: el backend copia ahí la
|
||||||
|
* descripción del concepto del catálogo. Sin ninguno de los dos responde 422.
|
||||||
|
*/
|
||||||
export type InvoiceItemInput = Partial<Omit<InvoiceItem, 'id' | 'line_total' | 'tenant_id' | 'company_id'>> & {
|
export type InvoiceItemInput = Partial<Omit<InvoiceItem, 'id' | 'line_total' | 'tenant_id' | 'company_id'>> & {
|
||||||
invoice_id: number;
|
invoice_id: number;
|
||||||
concept: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface Payment {
|
export interface Payment {
|
||||||
|
|||||||
51
frontend/src/lib/api/fin/issuer.ts
Normal file
51
frontend/src/lib/api/fin/issuer.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Cliente API — Datos fiscales del emisor (una configuración por empresa).
|
||||||
|
*/
|
||||||
|
import { api } from '$lib/api';
|
||||||
|
import type { SatTaxRegime } from './catalogs';
|
||||||
|
|
||||||
|
/** RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave. */
|
||||||
|
export const RFC_REGEX = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/;
|
||||||
|
|
||||||
|
export interface IssuerSettings {
|
||||||
|
id: number;
|
||||||
|
tenant_id: number;
|
||||||
|
company_id: number;
|
||||||
|
legal_name: string;
|
||||||
|
rfc: string;
|
||||||
|
tax_regime_id: number;
|
||||||
|
tax_regime: SatTaxRegime | null;
|
||||||
|
zip_code: string | null;
|
||||||
|
updated_by: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IssuerSettingsInput {
|
||||||
|
legal_name: string;
|
||||||
|
rfc: string;
|
||||||
|
tax_regime_id: number;
|
||||||
|
zip_code?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const issuerAPI = {
|
||||||
|
/**
|
||||||
|
* Devuelve `null` cuando la empresa todavía no captura sus datos fiscales: el
|
||||||
|
* backend responde 404 y la pantalla debe abrirse en modo alta, no en error.
|
||||||
|
*/
|
||||||
|
async get(companyId: number): Promise<IssuerSettings | null> {
|
||||||
|
const res = await api.get<IssuerSettings>(`/v1/fin/settings/issuer?company_id=${companyId}`);
|
||||||
|
if (res.status === 404) return null;
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
},
|
||||||
|
|
||||||
|
async save(data: IssuerSettingsInput, companyId: number): Promise<IssuerSettings> {
|
||||||
|
const res = await api.put<IssuerSettings>(
|
||||||
|
`/v1/fin/settings/issuer?company_id=${companyId}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
if (res.error) throw new Error(res.error);
|
||||||
|
return res.data!;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -1,12 +1,45 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { AccountInput } from '$lib/api/crm';
|
import type { AccountInput } from '$lib/api/crm';
|
||||||
|
import { satCatalogsAPI, type SatCatalogItem, type SatTaxRegime } from '$lib/api/fin';
|
||||||
import {
|
import {
|
||||||
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES,
|
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES,
|
||||||
COMMERCIAL_CLASSIFICATION, CONTACT_METHODS
|
COMMERCIAL_CLASSIFICATION, CONTACT_METHODS
|
||||||
} from '$lib/components/crm/format';
|
} from '$lib/components/crm/format';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
// `form` es un objeto reactivo del padre; se mutan sus propiedades vía bind:value.
|
// `form` es un objeto reactivo del padre; se mutan sus propiedades vía bind:value.
|
||||||
let { form = $bindable(), tab }: { form: AccountInput; tab: string } = $props();
|
// `companyId` solo se usa para consultar los catálogos del SAT del receptor.
|
||||||
|
let { form = $bindable(), tab, companyId = null }: { form: AccountInput; tab: string; companyId?: number | null } = $props();
|
||||||
|
|
||||||
|
let taxRegimes = $state<SatTaxRegime[]>([]);
|
||||||
|
let cfdiUses = $state<SatCatalogItem[]>([]);
|
||||||
|
|
||||||
|
// El régimen se acota al tipo de persona de la cuenta: una persona física no puede
|
||||||
|
// declararse en el 601 y viceversa. Sin tipo de persona se ofrecen todos.
|
||||||
|
const regimesForPersonType = $derived(
|
||||||
|
form.person_type === 'fisica'
|
||||||
|
? taxRegimes.filter((r) => r.applies_to_individual)
|
||||||
|
: form.person_type === 'moral'
|
||||||
|
? taxRegimes.filter((r) => r.applies_to_legal_entity)
|
||||||
|
: taxRegimes
|
||||||
|
);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid || tab !== 'fiscal') return;
|
||||||
|
void loadCatalogs(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCatalogs(cid: number) {
|
||||||
|
try {
|
||||||
|
[taxRegimes, cfdiUses] = await Promise.all([
|
||||||
|
satCatalogsAPI.taxRegimes(cid),
|
||||||
|
satCatalogsAPI.cfdiUses(cid)
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos del SAT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const inputCls =
|
const inputCls =
|
||||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
@@ -35,8 +68,26 @@
|
|||||||
</div>
|
</div>
|
||||||
{:else if tab === 'fiscal'}
|
{:else if tab === 'fiscal'}
|
||||||
<div class="grid gap-4 sm:grid-cols-2">
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Régimen fiscal</span><input class={inputCls} bind:value={form.tax_regime} /></label>
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Uso de CFDI</span><input class={inputCls} bind:value={form.cfdi_use} /></label>
|
<span class="font-medium">Régimen fiscal</span>
|
||||||
|
<select class={inputCls} bind:value={form.tax_regime_id}>
|
||||||
|
<option value={null}>Sin especificar</option>
|
||||||
|
{#each regimesForPersonType as r (r.id)}<option value={r.id}>{r.code} — {r.description}</option>{/each}
|
||||||
|
</select>
|
||||||
|
{#if !form.tax_regime_id && form.tax_regime}
|
||||||
|
<span class="text-xs text-muted-foreground">Capturado antes como texto: «{form.tax_regime}». Elige la clave del SAT que corresponde.</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Uso de CFDI</span>
|
||||||
|
<select class={inputCls} bind:value={form.cfdi_use_id}>
|
||||||
|
<option value={null}>Sin especificar</option>
|
||||||
|
{#each cfdiUses as u (u.id)}<option value={u.id}>{u.code} — {u.description}</option>{/each}
|
||||||
|
</select>
|
||||||
|
{#if !form.cfdi_use_id && form.cfdi_use}
|
||||||
|
<span class="text-xs text-muted-foreground">Capturado antes como texto: «{form.cfdi_use}». Elige la clave del SAT que corresponde.</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
|
||||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
|
||||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
|
||||||
|
|||||||
185
frontend/src/lib/components/fin/ConceptFields.svelte
Normal file
185
frontend/src/lib/components/fin/ConceptFields.svelte
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import {
|
||||||
|
satCatalogsAPI,
|
||||||
|
type ConceptInput,
|
||||||
|
type SatCatalogItem,
|
||||||
|
type SatUnitOfMeasure
|
||||||
|
} from '$lib/api/fin';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let {
|
||||||
|
form = $bindable(),
|
||||||
|
companyId,
|
||||||
|
/** Clave ProdServ ya elegida; se muestra resuelta en vez del buscador. */
|
||||||
|
productService = $bindable(),
|
||||||
|
/** Error del 409 del backend, mostrado junto al campo de clave ProdServ. */
|
||||||
|
productServiceError = $bindable()
|
||||||
|
}: {
|
||||||
|
form: ConceptInput;
|
||||||
|
companyId: number | null;
|
||||||
|
productService: SatCatalogItem | null;
|
||||||
|
productServiceError: string;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
let unitsOfMeasure = $state<SatUnitOfMeasure[]>([]);
|
||||||
|
let taxObjects = $state<SatCatalogItem[]>([]);
|
||||||
|
|
||||||
|
let productServiceQuery = $state('');
|
||||||
|
let productServiceOptions = $state<SatCatalogItem[]>([]);
|
||||||
|
let searchingProductService = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
void loadCatalogs(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function loadCatalogs(cid: number) {
|
||||||
|
try {
|
||||||
|
[unitsOfMeasure, taxObjects] = await Promise.all([
|
||||||
|
satCatalogsAPI.unitsOfMeasure(cid),
|
||||||
|
satCatalogsAPI.taxObjects(cid)
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos del SAT');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Busca claves ProdServ; a partir de 2 caracteres para no traer el catálogo completo. */
|
||||||
|
async function searchProductServices() {
|
||||||
|
const cid = companyId;
|
||||||
|
const term = productServiceQuery.trim();
|
||||||
|
if (!cid || term.length < 2) {
|
||||||
|
productServiceOptions = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchingProductService = true;
|
||||||
|
try {
|
||||||
|
productServiceOptions = await satCatalogsAPI.productsServices(cid, {
|
||||||
|
search: term,
|
||||||
|
limit: 20
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(
|
||||||
|
e instanceof Error ? e.message : 'No se pudo buscar la clave de producto/servicio'
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
searchingProductService = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pick(option: SatCatalogItem) {
|
||||||
|
productService = option;
|
||||||
|
form.product_service_id = option.id;
|
||||||
|
productServiceQuery = '';
|
||||||
|
productServiceOptions = [];
|
||||||
|
productServiceError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearProductService() {
|
||||||
|
productService = null;
|
||||||
|
form.product_service_id = 0;
|
||||||
|
productServiceOptions = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Clave *</span>
|
||||||
|
<input class="font-mono {inputCls}" bind:value={form.code} maxlength="40" required />
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Precio unitario</span>
|
||||||
|
<input type="number" step="0.01" min="0" class={inputCls} bind:value={form.unit_price} />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Descripción *</span>
|
||||||
|
<input class={inputCls} bind:value={form.description} maxlength="500" required />
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Clave de producto/servicio del SAT *</span>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Una clave del SAT solo puede estar asignada a un concepto de la empresa.
|
||||||
|
</p>
|
||||||
|
{#if productService}
|
||||||
|
<div class="flex items-center justify-between gap-2 rounded-md border px-3 py-2">
|
||||||
|
<span class="text-sm">
|
||||||
|
<span class="font-mono">{productService.code}</span>
|
||||||
|
<span class="text-muted-foreground"> — {productService.description}</span>
|
||||||
|
</span>
|
||||||
|
<Button type="button" variant="ghost" size="sm" onclick={clearProductService}
|
||||||
|
>Cambiar</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<input
|
||||||
|
class={inputCls}
|
||||||
|
placeholder="Escribe al menos 2 caracteres (clave o descripción)…"
|
||||||
|
bind:value={productServiceQuery}
|
||||||
|
oninput={searchProductServices}
|
||||||
|
/>
|
||||||
|
{#if searchingProductService}
|
||||||
|
<p class="text-xs text-muted-foreground">Buscando…</p>
|
||||||
|
{:else if productServiceOptions.length > 0}
|
||||||
|
<ul class="max-h-48 overflow-y-auto rounded-md border">
|
||||||
|
{#each productServiceOptions as option (option.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="w-full px-3 py-2 text-left text-sm hover:bg-muted"
|
||||||
|
onclick={() => pick(option)}
|
||||||
|
>
|
||||||
|
<span class="font-mono">{option.code}</span>
|
||||||
|
<span class="text-muted-foreground"> — {option.description}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{:else if productServiceQuery.trim().length >= 2}
|
||||||
|
<p class="text-xs text-muted-foreground">Sin coincidencias en el catálogo.</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
{#if productServiceError}
|
||||||
|
<p class="text-xs text-destructive">{productServiceError}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Unidad de medida</span>
|
||||||
|
<select class={inputCls} bind:value={form.unit_of_measure_id}>
|
||||||
|
<option value={null}>Sin especificar</option>
|
||||||
|
{#each unitsOfMeasure as unit (unit.id)}
|
||||||
|
<option value={unit.id}>{unit.code} — {unit.name}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Objeto de impuesto</span>
|
||||||
|
<select class={inputCls} bind:value={form.tax_object_id}>
|
||||||
|
<option value={null}>Sin especificar</option>
|
||||||
|
{#each taxObjects as taxObject (taxObject.id)}
|
||||||
|
<option value={taxObject.id}>{taxObject.code} — {taxObject.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Moneda</span>
|
||||||
|
<input class={inputCls} bind:value={form.currency} maxlength="3" />
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 self-end text-sm">
|
||||||
|
<input type="checkbox" class="h-4 w-4 rounded border" bind:checked={form.is_active} />
|
||||||
|
<span class="font-medium">Activo</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Notas</span>
|
||||||
|
<textarea rows="3" class={inputCls} bind:value={form.notes}></textarea>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
@@ -67,6 +67,7 @@ export function getNavMain(): NavMainItem[] {
|
|||||||
icon: Receipt,
|
icon: Receipt,
|
||||||
items: [
|
items: [
|
||||||
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
|
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
|
||||||
|
{ title: 'Conceptos', url: '/dashboard/fin/conceptos', permission: 'fin.concept.view' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -83,6 +84,10 @@ export function getNavMain(): NavMainItem[] {
|
|||||||
title: 'Configuración',
|
title: 'Configuración',
|
||||||
url: '/dashboard/settings/general',
|
url: '/dashboard/settings/general',
|
||||||
icon: Settings2,
|
icon: Settings2,
|
||||||
|
items: [
|
||||||
|
{ title: 'General', url: '/dashboard/settings/general' },
|
||||||
|
{ title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' },
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if activeTab.kind === 'info'}
|
{#if activeTab.kind === 'info'}
|
||||||
<AccountFields bind:form tab={tab} />
|
<AccountFields bind:form tab={tab} {companyId} />
|
||||||
<div class="mt-6 flex justify-end border-t pt-4">
|
<div class="mt-6 flex justify-end border-t pt-4">
|
||||||
<Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button>
|
<Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -65,7 +65,7 @@
|
|||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AccountFields bind:form {tab} />
|
<AccountFields bind:form {tab} {companyId} />
|
||||||
|
|
||||||
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||||
<Button variant="outline" href="/dashboard/crm/cuentas">Cancelar</Button>
|
<Button variant="outline" href="/dashboard/crm/cuentas">Cancelar</Button>
|
||||||
|
|||||||
181
frontend/src/routes/dashboard/fin/conceptos/+page.svelte
Normal file
181
frontend/src/routes/dashboard/fin/conceptos/+page.svelte
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Tags, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import * as Table from '$lib/components/ui/table';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { conceptsAPI, type Concept } from '$lib/api/fin';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let items = $state<Concept[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let search = $state('');
|
||||||
|
let activeFilter = $state<'todos' | 'activos' | 'inactivos'>('todos');
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
void load(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
items = await conceptsAPI.list(cid, {
|
||||||
|
search: search.trim() || undefined,
|
||||||
|
active_only: activeFilter === 'todos' ? undefined : activeFilter === 'activos'
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los conceptos');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(concept: Concept) {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
if (!confirm(`¿Dar de baja el concepto "${concept.code}"?`)) return;
|
||||||
|
try {
|
||||||
|
await conceptsAPI.remove(concept.id, cid);
|
||||||
|
toast.success('Concepto dado de baja');
|
||||||
|
await load(cid);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo dar de baja el concepto');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: number | null): string {
|
||||||
|
if (value === null || value === undefined) return '—';
|
||||||
|
return new Intl.NumberFormat('es-MX', { minimumFractionDigits: 2 }).format(Number(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Conceptos de facturación</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Tags class="h-6 w-6" />
|
||||||
|
Conceptos de facturación
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Cada concepto se liga a una clave de producto/servicio del SAT, que no puede repetirse en la
|
||||||
|
empresa.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button href="/dashboard/fin/conceptos/nuevo" disabled={!companyId}>
|
||||||
|
<Plus class="mr-1 h-4 w-4" /> Nuevo concepto
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<div class="relative max-w-sm flex-1">
|
||||||
|
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<input
|
||||||
|
class="w-full py-2 pr-3 pl-8 {inputCls}"
|
||||||
|
placeholder="Buscar por clave o descripción…"
|
||||||
|
bind:value={search}
|
||||||
|
onchange={() => companyId && load(companyId)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
class="{inputCls} max-w-xs"
|
||||||
|
bind:value={activeFilter}
|
||||||
|
onchange={() => companyId && load(companyId)}
|
||||||
|
>
|
||||||
|
<option value="todos">Todos</option>
|
||||||
|
<option value="activos">Solo activos</option>
|
||||||
|
<option value="inactivos">Solo inactivos</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if loading}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else if items.length === 0}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Sin conceptos registrados.</p>
|
||||||
|
{:else}
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<Table.Root>
|
||||||
|
<Table.Header>
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Head>Clave</Table.Head>
|
||||||
|
<Table.Head>Descripción</Table.Head>
|
||||||
|
<Table.Head>Clave ProdServ</Table.Head>
|
||||||
|
<Table.Head>Unidad</Table.Head>
|
||||||
|
<Table.Head>Objeto de impuesto</Table.Head>
|
||||||
|
<Table.Head class="text-right">Precio unitario</Table.Head>
|
||||||
|
<Table.Head>Estado</Table.Head>
|
||||||
|
<Table.Head class="text-right">Acciones</Table.Head>
|
||||||
|
</Table.Row>
|
||||||
|
</Table.Header>
|
||||||
|
<Table.Body>
|
||||||
|
{#each items as concept (concept.id)}
|
||||||
|
<Table.Row>
|
||||||
|
<Table.Cell class="font-mono text-xs font-medium">
|
||||||
|
<a class="hover:underline" href={`/dashboard/fin/conceptos/${concept.id}`}>
|
||||||
|
{concept.code}
|
||||||
|
</a>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>{concept.description}</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs">
|
||||||
|
<span class="font-mono">{concept.product_service?.code ?? '—'}</span>
|
||||||
|
{#if concept.product_service}
|
||||||
|
<span class="block text-muted-foreground">
|
||||||
|
{concept.product_service.description}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs">{concept.unit_of_measure?.name ?? '—'}</Table.Cell>
|
||||||
|
<Table.Cell class="text-xs">{concept.tax_object?.code ?? '—'}</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">
|
||||||
|
{money(concept.unit_price)}
|
||||||
|
{concept.currency}
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell>
|
||||||
|
<span
|
||||||
|
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {concept.is_active
|
||||||
|
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||||
|
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}"
|
||||||
|
>
|
||||||
|
{concept.is_active ? 'Activo' : 'Inactivo'}
|
||||||
|
</span>
|
||||||
|
</Table.Cell>
|
||||||
|
<Table.Cell class="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
href={`/dashboard/fin/conceptos/${concept.id}`}
|
||||||
|
aria-label="Abrir"
|
||||||
|
>
|
||||||
|
<ChevronRight class="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => remove(concept)}
|
||||||
|
aria-label="Dar de baja"
|
||||||
|
>
|
||||||
|
<Trash2 class="h-4 w-4 text-destructive" />
|
||||||
|
</Button>
|
||||||
|
</Table.Cell>
|
||||||
|
</Table.Row>
|
||||||
|
{/each}
|
||||||
|
</Table.Body>
|
||||||
|
</Table.Root>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
155
frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte
Normal file
155
frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, Tags, Trash2 } from '@lucide/svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import ConceptFields from '$lib/components/fin/ConceptFields.svelte';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { conceptsAPI, type Concept, type ConceptInput, type SatCatalogItem } from '$lib/api/fin';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
const conceptId = $derived(Number(page.params.id));
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
let concept = $state<Concept | null>(null);
|
||||||
|
let form = $state<ConceptInput>({ code: '', description: '', product_service_id: 0 });
|
||||||
|
let productService = $state<SatCatalogItem | null>(null);
|
||||||
|
let productServiceError = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
const id = conceptId;
|
||||||
|
if (!cid || !id) return;
|
||||||
|
void load(cid, id);
|
||||||
|
});
|
||||||
|
|
||||||
|
function hydrate(c: Concept) {
|
||||||
|
form = {
|
||||||
|
code: c.code,
|
||||||
|
description: c.description,
|
||||||
|
product_service_id: c.product_service_id,
|
||||||
|
unit_of_measure_id: c.unit_of_measure_id,
|
||||||
|
tax_object_id: c.tax_object_id,
|
||||||
|
unit_price: c.unit_price,
|
||||||
|
currency: c.currency,
|
||||||
|
is_active: c.is_active,
|
||||||
|
notes: c.notes ?? ''
|
||||||
|
};
|
||||||
|
productService = c.product_service;
|
||||||
|
productServiceError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(cid: number, id: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
concept = await conceptsAPI.get(id, cid);
|
||||||
|
hydrate(concept);
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el concepto');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid || !concept) return;
|
||||||
|
if (!form.code.trim() || !form.description.trim()) {
|
||||||
|
toast.error('La clave y la descripción son obligatorias');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.product_service_id) {
|
||||||
|
productServiceError = 'Selecciona la clave de producto/servicio del SAT';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
productServiceError = '';
|
||||||
|
try {
|
||||||
|
concept = await conceptsAPI.update(
|
||||||
|
concept.id,
|
||||||
|
{
|
||||||
|
...form,
|
||||||
|
unit_price:
|
||||||
|
form.unit_price === null || form.unit_price === undefined
|
||||||
|
? null
|
||||||
|
: Number(form.unit_price),
|
||||||
|
notes: form.notes?.trim() ? form.notes : null
|
||||||
|
},
|
||||||
|
cid
|
||||||
|
);
|
||||||
|
hydrate(concept);
|
||||||
|
toast.success('Cambios guardados');
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : 'No se pudieron guardar los cambios';
|
||||||
|
// El 409 del backend por clave ProdServ ya asignada se muestra junto al campo.
|
||||||
|
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
|
||||||
|
else toast.error(message);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove() {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid || !concept) return;
|
||||||
|
if (!confirm(`¿Dar de baja el concepto "${concept.code}"?`)) return;
|
||||||
|
try {
|
||||||
|
await conceptsAPI.remove(concept.id, cid);
|
||||||
|
toast.success('Concepto dado de baja');
|
||||||
|
await goto('/dashboard/fin/conceptos');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudo dar de baja el concepto');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{concept ? `Concepto ${concept.code}` : 'Concepto de facturación'}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/fin/conceptos">
|
||||||
|
<ArrowLeft class="mr-1 h-4 w-4" /> Conceptos
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
{#if loading && !concept}
|
||||||
|
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else if concept}
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Tags class="h-6 w-6" />
|
||||||
|
{concept.code}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
{concept.description}
|
||||||
|
{#if concept.product_service}
|
||||||
|
· <span class="font-mono">{concept.product_service.code}</span>
|
||||||
|
{/if}
|
||||||
|
· {concept.is_active ? 'Activo' : 'Inactivo'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" onclick={remove}>
|
||||||
|
<Trash2 class="mr-1 h-4 w-4 text-destructive" /> Dar de baja
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="pt-6">
|
||||||
|
<form onsubmit={save}>
|
||||||
|
<ConceptFields bind:form bind:productService bind:productServiceError {companyId} />
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end border-t pt-4">
|
||||||
|
<Button type="submit" disabled={saving}
|
||||||
|
>{saving ? 'Guardando…' : 'Guardar cambios'}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { ArrowLeft, Tags } from '@lucide/svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import ConceptFields from '$lib/components/fin/ConceptFields.svelte';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { conceptsAPI, type ConceptInput, type SatCatalogItem } from '$lib/api/fin';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let form = $state<ConceptInput>({
|
||||||
|
code: '',
|
||||||
|
description: '',
|
||||||
|
product_service_id: 0,
|
||||||
|
unit_of_measure_id: null,
|
||||||
|
tax_object_id: null,
|
||||||
|
unit_price: null,
|
||||||
|
currency: 'MXN',
|
||||||
|
is_active: true,
|
||||||
|
notes: ''
|
||||||
|
});
|
||||||
|
let productService = $state<SatCatalogItem | null>(null);
|
||||||
|
let productServiceError = $state('');
|
||||||
|
let saving = $state(false);
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
|
||||||
|
async function save(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
if (!form.code.trim() || !form.description.trim()) {
|
||||||
|
toast.error('La clave y la descripción son obligatorias');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!form.product_service_id) {
|
||||||
|
productServiceError = 'Selecciona la clave de producto/servicio del SAT';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
productServiceError = '';
|
||||||
|
try {
|
||||||
|
const created = await conceptsAPI.create(
|
||||||
|
{
|
||||||
|
...form,
|
||||||
|
unit_price:
|
||||||
|
form.unit_price === null || form.unit_price === undefined
|
||||||
|
? null
|
||||||
|
: Number(form.unit_price),
|
||||||
|
notes: form.notes?.trim() ? form.notes : null
|
||||||
|
},
|
||||||
|
cid
|
||||||
|
);
|
||||||
|
toast.success('Concepto creado');
|
||||||
|
await goto(`/dashboard/fin/conceptos/${created.id}`);
|
||||||
|
} catch (e) {
|
||||||
|
const message = e instanceof Error ? e.message : 'No se pudo crear el concepto';
|
||||||
|
// El 409 del backend por clave ProdServ ya asignada se muestra junto al campo.
|
||||||
|
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
|
||||||
|
else toast.error(message);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Nuevo concepto de facturación</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<Button variant="ghost" size="sm" href="/dashboard/fin/conceptos">
|
||||||
|
<ArrowLeft class="mr-1 h-4 w-4" /> Conceptos
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Tags class="h-6 w-6" /> Nuevo concepto
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Cada concepto se liga a una clave de producto/servicio del SAT.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content class="pt-6">
|
||||||
|
<form onsubmit={save}>
|
||||||
|
<ConceptFields bind:form bind:productService bind:productServiceError {companyId} />
|
||||||
|
|
||||||
|
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||||
|
<Button type="button" variant="outline" href="/dashboard/fin/conceptos">Cancelar</Button>
|
||||||
|
<Button type="submit" disabled={saving || !companyId}
|
||||||
|
>{saving ? 'Guardando…' : 'Crear'}</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import { companyStore } from '$lib/stores/company.svelte';
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
import {
|
import {
|
||||||
invoicesAPI, invoiceItemsAPI, paymentsAPI,
|
invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI,
|
||||||
type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
|
type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
|
||||||
} from '$lib/api/fin';
|
} from '$lib/api/fin';
|
||||||
import { accountsAPI, type Account } from '$lib/api/crm';
|
import { accountsAPI, type Account } from '$lib/api/crm';
|
||||||
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
|
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
|
||||||
@@ -20,6 +20,9 @@
|
|||||||
let items = $state<InvoiceItem[]>([]);
|
let items = $state<InvoiceItem[]>([]);
|
||||||
let payments = $state<Payment[]>([]);
|
let payments = $state<Payment[]>([]);
|
||||||
let accounts = $state<Account[]>([]);
|
let accounts = $state<Account[]>([]);
|
||||||
|
/** Catálogo de conceptos de la empresa; se cargan todos para poder etiquetar
|
||||||
|
* partidas que apunten a un concepto ya inactivo. */
|
||||||
|
let concepts = $state<Concept[]>([]);
|
||||||
let form = $state<InvoiceInput>({});
|
let form = $state<InvoiceInput>({});
|
||||||
let tab = $state('conceptos');
|
let tab = $state('conceptos');
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
@@ -29,6 +32,10 @@
|
|||||||
let addingPay = $state(false);
|
let addingPay = $state(false);
|
||||||
let newItem = $state<InvoiceItemInput>({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 });
|
let newItem = $state<InvoiceItemInput>({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 });
|
||||||
let newPay = $state<PaymentInput>({ invoice_id: 0, amount: 0, method: 'transferencia' });
|
let newPay = $state<PaymentInput>({ invoice_id: 0, amount: 0, method: 'transferencia' });
|
||||||
|
/** Opción elegida en el selector de concepto: `cat:<id>` del catálogo o `txt:<clave>` genérica. */
|
||||||
|
let conceptChoice = $state('txt:flete_internacional');
|
||||||
|
|
||||||
|
const activeConcepts = $derived(concepts.filter((c) => c.is_active));
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const cid = companyId;
|
const cid = companyId;
|
||||||
@@ -40,8 +47,9 @@
|
|||||||
async function load(cid: number, id: number) {
|
async function load(cid: number, id: number) {
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
[invoice, items, payments, accounts] = await Promise.all([
|
[invoice, items, payments, accounts, concepts] = await Promise.all([
|
||||||
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), accountsAPI.list(cid)
|
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid),
|
||||||
|
accountsAPI.list(cid), conceptsAPI.list(cid)
|
||||||
]);
|
]);
|
||||||
form = { ...invoice };
|
form = { ...invoice };
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -127,7 +135,35 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function startItem() { newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; addingItem = true; }
|
function startItem() {
|
||||||
|
newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 };
|
||||||
|
// Si la empresa ya tiene catálogo, se arranca con su primer concepto.
|
||||||
|
conceptChoice = activeConcepts.length ? `cat:${activeConcepts[0].id}` : 'txt:flete_internacional';
|
||||||
|
applyConceptChoice();
|
||||||
|
addingItem = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Traduce la opción del selector a la partida: referencia al catálogo o texto genérico. */
|
||||||
|
function applyConceptChoice() {
|
||||||
|
if (conceptChoice.startsWith('cat:')) {
|
||||||
|
const c = activeConcepts.find((x) => x.id === Number(conceptChoice.slice(4)));
|
||||||
|
if (!c) return;
|
||||||
|
// Solo se manda concept_id: el backend copia ahí la descripción del concepto.
|
||||||
|
newItem.concept_id = c.id;
|
||||||
|
newItem.concept = undefined;
|
||||||
|
if (c.unit_price !== null && c.unit_price !== undefined) newItem.unit_amount = Number(c.unit_price);
|
||||||
|
} else {
|
||||||
|
newItem.concept_id = null;
|
||||||
|
newItem.concept = conceptChoice.slice(4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Etiqueta de la partida: el concepto del catálogo si lo tiene, si no el texto libre. */
|
||||||
|
function itemConceptLabel(it: InvoiceItem): string {
|
||||||
|
const c = it.concept_id ? concepts.find((x) => x.id === it.concept_id) : undefined;
|
||||||
|
return c ? `${c.code} — ${c.description}` : labelOf(QUOTE_CONCEPTS, it.concept);
|
||||||
|
}
|
||||||
|
|
||||||
async function saveItem() {
|
async function saveItem() {
|
||||||
if (!companyId) return;
|
if (!companyId) return;
|
||||||
try { await invoiceItemsAPI.create({ ...newItem, invoice_id: invoiceId }, companyId); addingItem = false; await reload(); toast.success('Concepto agregado'); }
|
try { await invoiceItemsAPI.create({ ...newItem, invoice_id: invoiceId }, companyId); addingItem = false; await reload(); toast.success('Concepto agregado'); }
|
||||||
@@ -207,7 +243,25 @@
|
|||||||
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startItem}><Plus class="mr-1 h-4 w-4" /> Agregar concepto</Button></div>
|
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startItem}><Plus class="mr-1 h-4 w-4" /> Agregar concepto</Button></div>
|
||||||
{#if addingItem}
|
{#if addingItem}
|
||||||
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newItem.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Concepto</span>
|
||||||
|
<select class={inputCls} bind:value={conceptChoice} onchange={applyConceptChoice}>
|
||||||
|
{#if activeConcepts.length > 0}
|
||||||
|
<optgroup label="Catálogo de conceptos">
|
||||||
|
{#each activeConcepts as c (c.id)}<option value={`cat:${c.id}`}>{c.code} — {c.description}</option>{/each}
|
||||||
|
</optgroup>
|
||||||
|
{/if}
|
||||||
|
<optgroup label="Conceptos genéricos (sin clave del SAT)">
|
||||||
|
{#each QUOTE_CONCEPTS as c (c.value)}<option value={`txt:${c.value}`}>{c.label}</option>{/each}
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
{#if activeConcepts.length === 0}
|
||||||
|
<span class="text-xs text-muted-foreground">
|
||||||
|
El catálogo de conceptos está vacío.
|
||||||
|
<a class="underline" href="/dashboard/fin/conceptos">Darlos de alta</a> permite facturar con clave del SAT.
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</label>
|
||||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
|
||||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
|
||||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Importe unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_amount} /></label>
|
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Importe unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_amount} /></label>
|
||||||
@@ -222,7 +276,7 @@
|
|||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each items as it (it.id)}
|
{#each items as it (it.id)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell class="font-medium">{labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
|
<Table.Cell class="font-medium">{itemConceptLabel(it)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
|
||||||
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
|
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
|
||||||
<Table.Cell class="text-right">{formatMoney(it.unit_amount, invoice.currency)}</Table.Cell>
|
<Table.Cell class="text-right">{formatMoney(it.unit_amount, invoice.currency)}</Table.Cell>
|
||||||
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>
|
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>
|
||||||
|
|||||||
200
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
200
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Receipt } from '@lucide/svelte';
|
||||||
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { companyStore } from '$lib/stores/company.svelte';
|
||||||
|
import { authStore, userHasPermission } from '$lib/auth';
|
||||||
|
import {
|
||||||
|
issuerAPI,
|
||||||
|
satCatalogsAPI,
|
||||||
|
RFC_REGEX,
|
||||||
|
type IssuerSettingsInput,
|
||||||
|
type SatTaxRegime
|
||||||
|
} from '$lib/api/fin';
|
||||||
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
|
let form = $state<IssuerSettingsInput>({
|
||||||
|
legal_name: '',
|
||||||
|
rfc: '',
|
||||||
|
tax_regime_id: 0,
|
||||||
|
zip_code: ''
|
||||||
|
});
|
||||||
|
let taxRegimes = $state<SatTaxRegime[]>([]);
|
||||||
|
let loading = $state(false);
|
||||||
|
let saving = $state(false);
|
||||||
|
/** true mientras la empresa no tenga datos capturados (el GET respondió 404). */
|
||||||
|
let isNew = $state(true);
|
||||||
|
let rfcError = $state('');
|
||||||
|
|
||||||
|
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||||
|
const canView = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
|
||||||
|
const canEdit = $derived(userHasPermission($authStore.user, 'fin.settings.edit'));
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid || !canView) return;
|
||||||
|
void load(cid);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load(cid: number) {
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
const [settings, regimes] = await Promise.all([
|
||||||
|
issuerAPI.get(cid),
|
||||||
|
satCatalogsAPI.taxRegimes(cid)
|
||||||
|
]);
|
||||||
|
taxRegimes = regimes;
|
||||||
|
isNew = settings === null;
|
||||||
|
if (settings) {
|
||||||
|
form = {
|
||||||
|
legal_name: settings.legal_name,
|
||||||
|
rfc: settings.rfc,
|
||||||
|
tax_regime_id: settings.tax_regime_id,
|
||||||
|
zip_code: settings.zip_code ?? ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los datos fiscales');
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedRfc(): string {
|
||||||
|
return (form.rfc ?? '').replace(/[\s-]/g, '').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(event: SubmitEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
const cid = companyId;
|
||||||
|
if (!cid) return;
|
||||||
|
|
||||||
|
const rfc = normalizedRfc();
|
||||||
|
if (!RFC_REGEX.test(rfc)) {
|
||||||
|
rfcError = 'El RFC no tiene un formato válido (ej. XAXX010101000)';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
rfcError = '';
|
||||||
|
if (!form.tax_regime_id) {
|
||||||
|
toast.error('Selecciona el régimen fiscal');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await issuerAPI.save(
|
||||||
|
{ ...form, rfc, zip_code: form.zip_code?.trim() ? form.zip_code.trim() : null },
|
||||||
|
cid
|
||||||
|
);
|
||||||
|
isNew = false;
|
||||||
|
toast.success('Datos fiscales guardados');
|
||||||
|
} catch (e) {
|
||||||
|
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar los datos fiscales');
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputCls =
|
||||||
|
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>Configuración de Facturación</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||||
|
<Receipt class="h-6 w-6" />
|
||||||
|
Datos fiscales del emisor
|
||||||
|
</h1>
|
||||||
|
<p class="mt-1 text-sm text-muted-foreground">
|
||||||
|
Identidad fiscal con la que la empresa emite sus comprobantes.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if !canView}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Content>
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
No tienes permiso para consultar los datos fiscales del emisor.
|
||||||
|
</p>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{:else}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title>{isNew ? 'Capturar datos fiscales' : 'Datos fiscales registrados'}</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
{isNew
|
||||||
|
? 'Esta empresa aún no tiene datos fiscales configurados.'
|
||||||
|
: 'Actualiza la información con la que se emiten los comprobantes.'}
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
{#if loading}
|
||||||
|
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||||
|
{:else}
|
||||||
|
<form class="grid max-w-2xl gap-4 sm:grid-cols-2" onsubmit={save}>
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Razón social *</span>
|
||||||
|
<input
|
||||||
|
class={inputCls}
|
||||||
|
bind:value={form.legal_name}
|
||||||
|
maxlength="255"
|
||||||
|
required
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">RFC *</span>
|
||||||
|
<input
|
||||||
|
class="{inputCls} font-mono uppercase"
|
||||||
|
bind:value={form.rfc}
|
||||||
|
maxlength="13"
|
||||||
|
required
|
||||||
|
disabled={!canEdit}
|
||||||
|
oninput={() => (rfcError = '')}
|
||||||
|
/>
|
||||||
|
{#if rfcError}<span class="text-xs text-destructive">{rfcError}</span>{/if}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm">
|
||||||
|
<span class="font-medium">Código postal del lugar de expedición</span>
|
||||||
|
<input
|
||||||
|
class={inputCls}
|
||||||
|
bind:value={form.zip_code}
|
||||||
|
maxlength="5"
|
||||||
|
inputmode="numeric"
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||||
|
<span class="font-medium">Régimen fiscal *</span>
|
||||||
|
<select class={inputCls} bind:value={form.tax_regime_id} required disabled={!canEdit}>
|
||||||
|
<option value={0}>Selecciona un régimen…</option>
|
||||||
|
{#each taxRegimes as regime (regime.id)}
|
||||||
|
<option value={regime.id}>{regime.code} — {regime.description}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div class="flex justify-end sm:col-span-2">
|
||||||
|
<Button type="submit" disabled={saving || !canEdit || !companyId}>
|
||||||
|
{saving ? 'Guardando…' : 'Guardar'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if !canEdit}
|
||||||
|
<p class="text-xs text-muted-foreground sm:col-span-2">
|
||||||
|
Solo puedes consultar: se requiere el permiso de edición de datos fiscales.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export const ssr = false;
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Settings2 } from 'lucide-svelte';
|
import { Settings2, Receipt, ChevronRight } from 'lucide-svelte';
|
||||||
import * as Card from '$lib/components/ui/card';
|
import * as Card from '$lib/components/ui/card';
|
||||||
|
import { Button } from '$lib/components/ui/button';
|
||||||
|
import { authStore, userHasPermission } from '$lib/auth';
|
||||||
|
|
||||||
|
const canViewIssuerSettings = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -18,6 +22,25 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{#if canViewIssuerSettings}
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="flex items-center gap-2">
|
||||||
|
<Receipt class="h-5 w-5" />
|
||||||
|
Facturación
|
||||||
|
</Card.Title>
|
||||||
|
<Card.Description>
|
||||||
|
Datos fiscales del emisor: razón social, RFC, régimen fiscal y lugar de expedición.
|
||||||
|
</Card.Description>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content>
|
||||||
|
<Button variant="outline" href="/dashboard/settings/facturacion">
|
||||||
|
Abrir datos fiscales <ChevronRight class="ml-1 h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>Configuración del sistema</Card.Title>
|
<Card.Title>Configuración del sistema</Card.Title>
|
||||||
|
|||||||
Reference in New Issue
Block a user