Compare commits
10 Commits
8aef99df9c
...
feature/AS
| Author | SHA1 | Date | |
|---|---|---|---|
| f4ef6a037d | |||
| 15717314fd | |||
| ce09e0d30a | |||
| ae0664e987 | |||
| cb2acb11fc | |||
| 5a112a0171 | |||
| 8d9db3505d | |||
| b8b8311ece | |||
| 9cf142add6 | |||
| e24435c74b |
@@ -1,90 +0,0 @@
|
||||
"""crm catalog_items (catálogos de referencia) + columnas nuevas accounts/suppliers
|
||||
|
||||
Revision ID: e6f7a8b9c0d1
|
||||
Revises: d5e6f7a8b9c0
|
||||
Create Date: 2026-07-22 00:00:00.000000
|
||||
|
||||
Soporta T2026-07-081 (Clientes/Prospectos) y T2026-07-082 (Proveedores):
|
||||
catálogos de referencia SAT/ISO + propios del cliente, y campos faltantes
|
||||
(observaciones comerciales, "otro" de medio de contacto y de clasificación).
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
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
|
||||
|
||||
SCHEMA = "crm"
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ----- crm.catalog_items -----
|
||||
op.create_table(
|
||||
"catalog_items",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("catalog", sa.String(length=60), nullable=False),
|
||||
sa.Column("code", sa.String(length=64), nullable=False),
|
||||
sa.Column("label", sa.String(length=255), nullable=False),
|
||||
sa.Column("parent_catalog", sa.String(length=60), nullable=True),
|
||||
sa.Column("parent_code", sa.String(length=64), nullable=True),
|
||||
# NULL = catálogo global (Aduanasoft); con valor = catálogo del tenant.
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=True),
|
||||
sa.Column("sort_order", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("is_system", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("extra", sa.JSON(), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=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=SCHEMA,
|
||||
)
|
||||
op.create_index("ix_crm_catalog_items_id", "catalog_items", ["id"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_catalog_items_catalog", "catalog_items", ["catalog"], schema=SCHEMA)
|
||||
op.create_index("ix_crm_catalog_items_tenant_id", "catalog_items", ["tenant_id"], schema=SCHEMA)
|
||||
op.create_index(
|
||||
"ix_crm_catalog_items_lookup", "catalog_items", ["catalog", "tenant_id", "is_active"], schema=SCHEMA
|
||||
)
|
||||
# Unicidad de clave por catálogo: global (tenant NULL) y por tenant, separadas.
|
||||
op.create_index(
|
||||
"uq_crm_catalog_items_global",
|
||||
"catalog_items",
|
||||
["catalog", "code"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("tenant_id IS NULL"),
|
||||
)
|
||||
op.create_index(
|
||||
"uq_crm_catalog_items_tenant",
|
||||
"catalog_items",
|
||||
["catalog", "code", "tenant_id"],
|
||||
unique=True,
|
||||
schema=SCHEMA,
|
||||
postgresql_where=sa.text("tenant_id IS NOT NULL"),
|
||||
)
|
||||
|
||||
# ----- columnas nuevas -----
|
||||
# Clientes/Prospectos: observaciones comerciales + "otro" del medio de contacto.
|
||||
op.add_column("accounts", sa.Column("commercial_observations", sa.Text(), nullable=True), schema=SCHEMA)
|
||||
op.add_column("accounts", sa.Column("preferred_contact_other", sa.String(length=120), nullable=True), schema=SCHEMA)
|
||||
# Proveedores: "otro" de la clasificación múltiple.
|
||||
op.add_column("suppliers", sa.Column("classification_other", sa.String(length=120), nullable=True), schema=SCHEMA)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("suppliers", "classification_other", schema=SCHEMA)
|
||||
op.drop_column("accounts", "preferred_contact_other", schema=SCHEMA)
|
||||
op.drop_column("accounts", "commercial_observations", schema=SCHEMA)
|
||||
|
||||
op.drop_index("uq_crm_catalog_items_tenant", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("uq_crm_catalog_items_global", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_lookup", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_tenant_id", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_catalog", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_index("ix_crm_catalog_items_id", table_name="catalog_items", schema=SCHEMA)
|
||||
op.drop_table("catalog_items", schema=SCHEMA)
|
||||
@@ -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")
|
||||
@@ -36,12 +36,6 @@ class TokenResponseDTO(BaseModel):
|
||||
tenant: Optional["TenantInfoDTO"] = None
|
||||
tenant_id: Optional[int] = None
|
||||
tenant_slug: Optional[str] = None
|
||||
# Sesión local del CRM (patrón SIWEB) — presente solo con SESSION_STORE_ENABLED.
|
||||
# Es un JWT propio (HS256) que la app usa como bearer para el backend del CRM y
|
||||
# que sobrevive aunque el refresh del token KC contra el Hub falle. El access_token
|
||||
# de arriba sigue siendo el de Keycloak (para llamadas al Hub).
|
||||
session_token: Optional[str] = None
|
||||
session_id: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
json_schema_extra = {
|
||||
@@ -58,12 +52,6 @@ class RefreshTokenRequestDTO(BaseModel):
|
||||
"""DTO para solicitud de refresh token"""
|
||||
|
||||
refresh_token: str = Field(..., description="Refresh token")
|
||||
# Sesión local actual del CRM (patrón SIWEB). Si se envía, el backend preserva el
|
||||
# inicio de sesión (cap absoluto) y puede re-emitirla como fallback cuando el
|
||||
# refresh del token KC contra el Hub falla ("Token is not active" del relay).
|
||||
session_token: Optional[str] = Field(None, description="Sesión local actual del CRM (opcional)")
|
||||
# session_id opaco de la sesión en valkey (guarda los tokens KC fuera del browser).
|
||||
session_id: Optional[str] = Field(None, description="ID de sesión en valkey (opcional)")
|
||||
|
||||
|
||||
class UserInfoResponseDTO(BaseModel):
|
||||
|
||||
@@ -24,12 +24,6 @@ from .dto import (
|
||||
)
|
||||
from .service import AuthService
|
||||
|
||||
import logging
|
||||
from typing import Optional
|
||||
from pydantic import BaseModel
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["Authentication"])
|
||||
security = HTTPBearer()
|
||||
|
||||
@@ -408,16 +402,10 @@ async def dev_login():
|
||||
@router.get("/my-companies")
|
||||
async def get_my_companies(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Retorna las compañías accesibles para el usuario actual.
|
||||
|
||||
Modelo del CRM: una compañía por tenant (1:1) — el ``company_id`` coincide con
|
||||
el ``tenant_id``. Cada agente de carga (tenant) opera como una empresa. Se
|
||||
garantiza el vínculo usuario↔tenant↔company; los permisos de la empresa se
|
||||
resuelven en ``/permissions/me`` (bootstrap de super_admin al primer usuario).
|
||||
|
||||
STUB: implementa con tu modelo de compañías.
|
||||
En dev-local retorna una compañía ficticia para que el dashboard funcione.
|
||||
"""
|
||||
from core.config import settings
|
||||
@@ -427,239 +415,8 @@ async def get_my_companies(
|
||||
"id": settings.DEV_LOCAL_AUTH_COMPANY_ID,
|
||||
"name": "Empresa Dev Local",
|
||||
"tenant_id": settings.DEV_LOCAL_AUTH_TENANT_ID,
|
||||
"rfc": None,
|
||||
"logo": None,
|
||||
"is_active": True,
|
||||
}]
|
||||
|
||||
from core.security import (
|
||||
resolve_effective_tenant_id_from_user,
|
||||
_ensure_user_tenant_for_company,
|
||||
)
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from sqlalchemy import text
|
||||
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
|
||||
# 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
|
||||
# defecto en el primer acceso y AUTO-LIGA al usuario a TODAS las compañías de
|
||||
# su tenant. Así cualquier usuario del mismo tenant (misma organización del
|
||||
# Workspace) entra y ve la(s) compañía(s) sin gestión manual. El ROL no se
|
||||
# asigna aquí: es solo membresía; los permisos se otorgan aparte (un admin
|
||||
# asigna el rol; el primer usuario recibe super_admin vía /permissions/me).
|
||||
if tenant_id:
|
||||
tenant_id = int(tenant_id)
|
||||
company_ids = [
|
||||
int(r[0])
|
||||
for r in db.execute(
|
||||
text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id"),
|
||||
{"tid": tenant_id},
|
||||
).fetchall()
|
||||
]
|
||||
if not company_ids:
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
default_name = (
|
||||
(tenant.name if tenant else None)
|
||||
or current_user.get("tenant_slug")
|
||||
or "Mi empresa"
|
||||
)
|
||||
created = db.execute(
|
||||
text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
|
||||
{"tid": tenant_id, "name": default_name},
|
||||
).fetchone()
|
||||
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
company_ids = [int(created[0])]
|
||||
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
|
||||
|
||||
# Auto-ligado por tenant (solo membresía, sin rol).
|
||||
if user_id:
|
||||
for cid in company_ids:
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid)
|
||||
except Exception as exc:
|
||||
logger.warning("auto-ligado de compañía %s falló (no bloquea): %s", cid, exc)
|
||||
|
||||
# 2) Compañías por MEMBRESÍA (user_tenants ∪ user_company_roles) → funciona
|
||||
# también para hub_admin sin tenant en el token: verá las compañías que creó
|
||||
# o a las que fue asignado. La membresía la determina el CRM, no el Hub.
|
||||
if not user_id:
|
||||
return []
|
||||
rows = db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id, t.name, t.slug
|
||||
FROM a76.company c
|
||||
LEFT JOIN core.tenants t ON t.id = c.tenant_id
|
||||
WHERE c.id IN (
|
||||
SELECT company_id FROM core.user_tenants
|
||||
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
|
||||
UNION
|
||||
SELECT company_id FROM core.user_company_roles
|
||||
WHERE user_id = :uid AND is_active
|
||||
)
|
||||
ORDER BY c.id
|
||||
"""
|
||||
),
|
||||
{"uid": str(user_id)},
|
||||
).fetchall()
|
||||
|
||||
return [
|
||||
{
|
||||
"id": int(r[0]),
|
||||
"name": r[1] or "Empresa",
|
||||
"tenant_id": int(r[4]),
|
||||
"tenant_name": r[5],
|
||||
"tenant_slug": r[6],
|
||||
"rfc": r[2],
|
||||
"logo": r[3],
|
||||
"is_active": True,
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
class _CreateCompanyDTO(BaseModel):
|
||||
name: str
|
||||
tenant_id: int
|
||||
rfc: Optional[str] = None
|
||||
|
||||
|
||||
async def _sync_tenants_from_hub(request: Request, db: Session) -> None:
|
||||
"""
|
||||
Auto-sync Workspace→CRM: trae los tenants del Workspace (Hub GET /hub/tenants) y
|
||||
los da de alta/actualiza en core.tenants con su MISMO ID del Workspace. Así los
|
||||
tenants creados en el Workspace aparecen solos en el CRM para asignarles compañías.
|
||||
Best-effort: usa el token KC de la sesión (valkey); si no está fresco o el Hub no
|
||||
responde, no bloquea (se devuelven los tenants ya sincronizados).
|
||||
"""
|
||||
import httpx
|
||||
from sqlalchemy import text as _text
|
||||
from core.config import settings
|
||||
from core import session_store
|
||||
from api.v1.modules.core.tenants.models import Tenant, TenantType
|
||||
|
||||
sid = request.cookies.get("crm_sid") if request else None
|
||||
kc_token = None
|
||||
if sid:
|
||||
sess = session_store.get_session(sid)
|
||||
kc_token = (sess or {}).get("access_token")
|
||||
if not kc_token:
|
||||
return
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.get(
|
||||
f"{settings.HUB_URL}api/v1/hub/tenants",
|
||||
headers={"Authorization": f"Bearer {kc_token}"},
|
||||
)
|
||||
if r.status_code != 200:
|
||||
logger.info("sync-tenants: Hub devolvió %s — sin sincronizar", r.status_code)
|
||||
return
|
||||
payload = r.json()
|
||||
items = payload.get("tenants", []) if isinstance(payload, dict) else (payload or [])
|
||||
for t in items:
|
||||
tid = t.get("id")
|
||||
if tid is None:
|
||||
continue
|
||||
name = t.get("name") or t.get("display_name") or t.get("slug")
|
||||
slug = t.get("slug") or f"tenant-{tid}"
|
||||
existing = db.query(Tenant).filter(Tenant.id == int(tid)).first()
|
||||
if existing:
|
||||
if name and existing.name != name:
|
||||
existing.name = name
|
||||
else:
|
||||
db.add(Tenant(
|
||||
id=int(tid), name=name or slug, slug=slug,
|
||||
keycloak_realm=slug, type=TenantType.SHARED, is_active=True,
|
||||
))
|
||||
db.commit()
|
||||
db.execute(_text("SELECT setval('core.tenants_id_seq', (SELECT MAX(id) FROM core.tenants))"))
|
||||
db.commit()
|
||||
except Exception as exc:
|
||||
logger.warning("sync-tenants desde Hub falló (no bloquea): %s", exc)
|
||||
try:
|
||||
db.rollback()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/assignable-tenants")
|
||||
async def assignable_tenants(
|
||||
request: Request,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Tenants disponibles para asignar una compañía. El tenant lo crea el Workspace;
|
||||
aquí solo se elige. hub_admin ve TODOS (auto-sincronizados del Hub); un usuario
|
||||
con tenant ve el suyo.
|
||||
"""
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
|
||||
|
||||
if is_hub_admin(current_user):
|
||||
# Sincroniza automáticamente los tenants del Workspace antes de listar.
|
||||
await _sync_tenants_from_hub(request, db)
|
||||
rows = db.query(Tenant).filter(Tenant.is_active == True).order_by(Tenant.id).all() # noqa: E712
|
||||
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in rows]
|
||||
|
||||
tid = resolve_effective_tenant_id_from_user(current_user)
|
||||
if tid:
|
||||
t = db.query(Tenant).filter(Tenant.id == int(tid), Tenant.is_active == True).first() # noqa: E712
|
||||
return [{"id": t.id, "name": t.name, "slug": t.slug}] if t else []
|
||||
# Implementa aquí la consulta real a tu tabla de compañías.
|
||||
return []
|
||||
|
||||
|
||||
@router.post("/companies", status_code=201)
|
||||
async def create_company(
|
||||
data: _CreateCompanyDTO,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""
|
||||
Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al
|
||||
usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con
|
||||
tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me).
|
||||
"""
|
||||
from sqlalchemy import text as _text
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
from core.security import (
|
||||
resolve_effective_tenant_id_from_user,
|
||||
is_hub_admin,
|
||||
_ensure_user_tenant_for_company,
|
||||
)
|
||||
|
||||
name = (data.name or "").strip()
|
||||
if len(name) < 2:
|
||||
raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.")
|
||||
|
||||
tid = int(data.tenant_id)
|
||||
tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712
|
||||
if not tenant:
|
||||
raise HTTPException(status_code=404, detail="Tenant no encontrado.")
|
||||
|
||||
# Autorización: hub_admin (atestado por el Hub) puede crear en cualquier tenant;
|
||||
# un usuario ligado a un tenant, solo en el suyo.
|
||||
if not is_hub_admin(current_user):
|
||||
own = resolve_effective_tenant_id_from_user(current_user)
|
||||
if own is None or int(own) != tid:
|
||||
raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.")
|
||||
|
||||
created = db.execute(
|
||||
_text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"),
|
||||
{"t": tid, "n": name, "r": (data.rfc or None)},
|
||||
).fetchone()
|
||||
db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
|
||||
db.commit()
|
||||
cid = int(created[0])
|
||||
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
if user_id:
|
||||
try:
|
||||
_ensure_user_tenant_for_company(db, str(user_id), tid, cid)
|
||||
except Exception as exc:
|
||||
logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc)
|
||||
|
||||
return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True}
|
||||
|
||||
@@ -213,160 +213,55 @@ class AuthService:
|
||||
logger.error(f"Unexpected login error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Authentication error")
|
||||
|
||||
def _decode_local_session(self, session_token: Optional[str]) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Decodifica una sesión local del CRM (HS256) verificando la firma pero
|
||||
SIN exigir exp — para poder re-emitirla en el refresh. Retorna los claims
|
||||
o None si la firma no valida o no es una sesión local del CRM.
|
||||
"""
|
||||
if not session_token:
|
||||
return None
|
||||
try:
|
||||
claims = jwt.decode(
|
||||
session_token,
|
||||
settings.SECRET_KEY,
|
||||
algorithms=["HS256"],
|
||||
options={"verify_exp": False},
|
||||
)
|
||||
except JWTError:
|
||||
return None
|
||||
if not claims.get("crm_session") or claims.get("source") != "local":
|
||||
return None
|
||||
return claims
|
||||
|
||||
def _session_claims_from_kc(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Construye los claims de la sesión local a partir del token KC (decode)."""
|
||||
kc_claims = self._decode_kc_user_from_token(data.get("access_token", ""))
|
||||
claims: Dict[str, Any] = dict(kc_claims)
|
||||
# tenant_id/tenant_slug explícitos del Hub tienen precedencia sobre el token
|
||||
if data.get("tenant_id") is not None:
|
||||
claims["tenant_id"] = data.get("tenant_id")
|
||||
if data.get("tenant_slug") is not None:
|
||||
claims["tenant_slug"] = data.get("tenant_slug")
|
||||
return claims
|
||||
|
||||
async def _session_claims(self, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
Claims AUTORITATIVOS para la sesión local: se prefiere /auth/me del Hub (trae
|
||||
is_hub_admin, roles, etc. que el token KC crudo no incluye). Si el Hub no
|
||||
responde, se cae al decode del token KC. Así la sesión local sabe si el
|
||||
usuario es hub_admin sin volver a consultar al Hub en cada request.
|
||||
"""
|
||||
from core.security import verify_token
|
||||
|
||||
claims: Dict[str, Any] = {}
|
||||
try:
|
||||
info = await verify_token(data.get("access_token", ""))
|
||||
if isinstance(info, dict):
|
||||
claims = dict(info)
|
||||
except Exception as exc:
|
||||
logger.warning("session_claims: /auth/me no disponible, uso decode KC: %s", exc)
|
||||
|
||||
if not claims:
|
||||
return self._session_claims_from_kc(data)
|
||||
|
||||
# tenant_id/tenant_slug explícitos del Hub tienen precedencia.
|
||||
if data.get("tenant_id") is not None:
|
||||
claims["tenant_id"] = data.get("tenant_id")
|
||||
if data.get("tenant_slug") is not None:
|
||||
claims["tenant_slug"] = data.get("tenant_slug")
|
||||
return claims
|
||||
|
||||
async def refresh_token(self, refresh_data: RefreshTokenRequestDTO) -> TokenResponseDTO:
|
||||
"""
|
||||
Refresca la sesión.
|
||||
|
||||
- Intenta el refresh del token KC contra el Hub (comportamiento histórico).
|
||||
- Con SESSION_STORE_ENABLED, además emite/actualiza la sesión local del CRM
|
||||
(patrón SIWEB) que la app usa como bearer y que dura por inactividad, de
|
||||
modo que el refresh KC solo se intenta al expirar esa sesión (no cada ~60s).
|
||||
- Si el Hub RECHAZA el refresh se devuelve 401 y la sesión termina: se
|
||||
RESPETA la revocación central de Keycloak (sin re-emisión de fallback).
|
||||
Refresca el access token usando el Hub
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
session_enabled = bool(getattr(settings, "SESSION_STORE_ENABLED", False))
|
||||
prev_claims = self._decode_local_session(refresh_data.session_token) if session_enabled else None
|
||||
prev_sst = prev_claims.get("sst") if prev_claims else None
|
||||
prev_session_id = refresh_data.session_id if session_enabled else None
|
||||
|
||||
# Fuente del refresh KC: valkey (sesión) tiene precedencia sobre lo que
|
||||
# mande el cliente (puede estar desactualizado). Fail-silent.
|
||||
kc_refresh = refresh_data.refresh_token
|
||||
if session_enabled and prev_session_id:
|
||||
from core import session_store
|
||||
|
||||
sess = session_store.get_session(prev_session_id)
|
||||
if sess and sess.get("refresh_token"):
|
||||
kc_refresh = sess["refresh_token"]
|
||||
|
||||
# ── Intento de refresh del token KC contra el Hub ────────────────────────
|
||||
kc_ok = False
|
||||
data: Optional[Dict[str, Any]] = None
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json={"refresh_token": kc_refresh},
|
||||
json=refresh_data.model_dump()
|
||||
)
|
||||
kc_ok = response.status_code == 200
|
||||
if kc_ok:
|
||||
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
else:
|
||||
logger.warning("Hub rechazó el refresh (status %s)", response.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("Hub inalcanzable en refresh: %s", exc)
|
||||
kc_ok = False
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
from core.workspace_profile_client import WorkspaceProfileClient
|
||||
|
||||
# ── Camino feliz: el Hub renovó el token KC ──────────────────────────────
|
||||
if kc_ok and data is not None:
|
||||
from core.workspace_profile_sync import sync_workspace_profile_for_user
|
||||
from core.workspace_profile_client import WorkspaceProfileClient
|
||||
|
||||
workspace_profile = None
|
||||
try:
|
||||
workspace_profile = await WorkspaceProfileClient().get_me(data.get("access_token", ""))
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"workspace_profile_sync_failed",
|
||||
extra={"event": "workspace_profile_sync_failed", "phase": "refresh", "error": str(exc)},
|
||||
)
|
||||
workspace_profile = None
|
||||
try:
|
||||
workspace_profile = await WorkspaceProfileClient().get_me(
|
||||
data.get("access_token", "")
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"workspace_profile_sync_failed",
|
||||
extra={
|
||||
"event": "workspace_profile_sync_failed",
|
||||
"phase": "refresh",
|
||||
"error": str(exc),
|
||||
},
|
||||
)
|
||||
workspace_profile = None
|
||||
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=data.get("access_token"),
|
||||
keycloak_user_id=(workspace_profile or {}).get("sub") or data.get("sub") or data.get("user_id"),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
workspace_profile=workspace_profile,
|
||||
force=True,
|
||||
)
|
||||
await sync_workspace_profile_for_user(
|
||||
self.db,
|
||||
access_token=data.get("access_token"),
|
||||
keycloak_user_id=(workspace_profile or {}).get("sub")
|
||||
or data.get("sub")
|
||||
or data.get("user_id"),
|
||||
tenant_id=data.get("tenant_id"),
|
||||
workspace_profile=workspace_profile,
|
||||
force=True,
|
||||
)
|
||||
return TokenResponseDTO(**data)
|
||||
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
|
||||
resp = TokenResponseDTO(**data)
|
||||
|
||||
if session_enabled:
|
||||
from core import local_session, session_store
|
||||
|
||||
start = int(prev_sst) if prev_sst else int(datetime.now(timezone.utc).timestamp())
|
||||
claims = await self._session_claims(data)
|
||||
new_access = data.get("access_token", "")
|
||||
new_refresh = data.get("refresh_token", "")
|
||||
# Reutiliza la sesión de valkey si ya existía; si no, la crea.
|
||||
if prev_session_id and session_store.get_session(prev_session_id):
|
||||
session_store.update_session_tokens(prev_session_id, new_access, new_refresh)
|
||||
resp.session_id = prev_session_id
|
||||
else:
|
||||
resp.session_id = session_store.create_session(new_access, new_refresh, start)
|
||||
resp.session_token = local_session.mint_session_token(claims, session_start=start)
|
||||
|
||||
return resp
|
||||
|
||||
# El Hub rechazó el refresh: la sesión termina y se RESPETA la revocación
|
||||
# central de Keycloak (no hay re-emisión local de fallback). El usuario
|
||||
# re-entra por el App Launcher. La sesión local de larga duración evita el
|
||||
# bucle: el refresh solo se intenta al expirar la sesión local por
|
||||
# inactividad (idle), no cada ~60s como con el token KC crudo.
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired refresh token")
|
||||
except Exception as e:
|
||||
logger.error(f"Token refresh error: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail="Token refresh error")
|
||||
|
||||
async def get_user_info(self, access_token: str) -> UserInfoResponseDTO:
|
||||
"""
|
||||
|
||||
@@ -37,33 +37,13 @@ async def create_invite(
|
||||
required_permissions=["user.create"],
|
||||
)
|
||||
|
||||
# tenant_slug: del token si viene; si el usuario es hub_admin (sin tenant en el
|
||||
# token), se resuelve desde la compañía destino (a76.company → core.tenants).
|
||||
tenant_slug: str = current_user.get("tenant_slug") or ""
|
||||
if not tenant_slug:
|
||||
from sqlalchemy import text as _text
|
||||
row = db.execute(
|
||||
_text(
|
||||
"SELECT t.slug FROM a76.company c "
|
||||
"JOIN core.tenants t ON t.id = c.tenant_id WHERE c.id = :c"
|
||||
),
|
||||
{"c": data.company_id},
|
||||
).first()
|
||||
if row and row[0]:
|
||||
tenant_slug = row[0]
|
||||
|
||||
created_by: str = current_user.get("sub") or ""
|
||||
|
||||
# El invite se crea en el Hub: se necesita el token KC (la sesión local no la
|
||||
# acepta el Hub). Se toma de la sesión (valkey) y se refresca si hace falta.
|
||||
from core.hub_token import get_hub_access_token
|
||||
|
||||
kc_token = await get_hub_access_token(request)
|
||||
|
||||
service = InviteService(db)
|
||||
return await service.create_invite(
|
||||
data=data,
|
||||
created_by=created_by,
|
||||
tenant_slug=tenant_slug,
|
||||
user_access_token=kc_token or credentials.credentials,
|
||||
user_access_token=credentials.credentials,
|
||||
)
|
||||
|
||||
@@ -53,17 +53,12 @@ async def get_user_statistics(
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
from core.hub_token import get_hub_access_token
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
kc_token = await get_hub_access_token(request)
|
||||
if kc_token:
|
||||
token = kc_token
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
@@ -89,19 +84,12 @@ async def list_users(
|
||||
"""
|
||||
tenant_id = validate_access_to_resource(db, company_id, current_user, required_permissions=["user.view"])
|
||||
service = UserService(db, tenant_id, company_id, is_hub_admin=is_hub_admin(current_user))
|
||||
# El Bearer de la app puede ser la sesión local (SIWEB), que el Hub no acepta.
|
||||
# Para listar usuarios del tenant se usa el token KC de la sesión (valkey), refrescado.
|
||||
from core.hub_token import get_hub_access_token
|
||||
|
||||
auth_header = request.headers.get("Authorization") or ""
|
||||
token = (
|
||||
auth_header[7:].strip()
|
||||
if auth_header.lower().startswith("bearer ")
|
||||
else auth_header.strip()
|
||||
)
|
||||
kc_token = await get_hub_access_token(request)
|
||||
if kc_token:
|
||||
token = kc_token
|
||||
hub_tid = resolve_hub_tenant_id_for_api(
|
||||
tenant_id, request.headers.get("X-Tenant-Override")
|
||||
)
|
||||
|
||||
@@ -18,15 +18,16 @@ class AccountBase(BaseModel):
|
||||
# Comercial
|
||||
commercial_classification: str | None = Field(None, max_length=20)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
preferred_contact_other: str | None = Field(None, max_length=120)
|
||||
language: str | None = Field(None, max_length=40)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
website: str | None = Field(None, max_length=255)
|
||||
commercial_observations: str | None = None # observaciones generales
|
||||
# Fiscal
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
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_form: str | None = Field(None, max_length=60)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
@@ -61,14 +62,14 @@ class AccountUpdate(BaseModel):
|
||||
status: str | None = Field(None, max_length=20)
|
||||
commercial_classification: str | None = Field(None, max_length=20)
|
||||
preferred_contact_method: str | None = Field(None, max_length=20)
|
||||
preferred_contact_other: str | None = Field(None, max_length=120)
|
||||
language: str | None = Field(None, max_length=40)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
website: str | None = Field(None, max_length=255)
|
||||
commercial_observations: str | None = None
|
||||
tax_regime: str | None = Field(None, max_length=120)
|
||||
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_form: str | None = Field(None, max_length=60)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 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
|
||||
|
||||
|
||||
@@ -39,19 +40,25 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
# ----- Información comercial -----
|
||||
# Clasificación: importador | exportador | ambos
|
||||
commercial_classification: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Medio de contacto preferido: llamada | correo | videoconferencia | whatsapp | otro
|
||||
# Medio de contacto preferido: llamada | correo | videollamada | whatsapp | otro
|
||||
preferred_contact_method: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Texto libre cuando el medio de contacto es "otro"
|
||||
preferred_contact_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
language: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
commercial_observations: Mapped[str | None] = mapped_column(Text, nullable=True) # observaciones generales
|
||||
|
||||
# ----- 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
|
||||
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_form: Mapped[str | None] = mapped_column(String(60), nullable=True) # forma de pago
|
||||
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 sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.fin.catalogs.models import CfdiUse, TaxRegime
|
||||
|
||||
from .dto import AccountCreate, AccountUpdate
|
||||
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(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
@@ -53,8 +67,10 @@ def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) -
|
||||
def create_account(
|
||||
db: Session, payload: AccountCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Account:
|
||||
data = payload.model_dump()
|
||||
_validate_sat_refs(db, data)
|
||||
account = Account(
|
||||
**payload.model_dump(),
|
||||
**data,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
created_by=user_id,
|
||||
@@ -75,7 +91,9 @@ def update_account(
|
||||
user_id: str | None = None,
|
||||
) -> Account:
|
||||
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)
|
||||
account.updated_by = user_id
|
||||
db.commit()
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
"""Schemas (DTOs) de los catálogos de referencia del CRM."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class CatalogItemBase(BaseModel):
|
||||
code: str = Field(..., max_length=64)
|
||||
label: str = Field(..., max_length=255)
|
||||
parent_catalog: str | None = Field(None, max_length=60)
|
||||
parent_code: str | None = Field(None, max_length=64)
|
||||
sort_order: int = 0
|
||||
is_active: bool = True
|
||||
|
||||
|
||||
class CatalogItemCreate(CatalogItemBase):
|
||||
pass
|
||||
|
||||
|
||||
class CatalogItemUpdate(BaseModel):
|
||||
"""PATCH: todos los campos opcionales."""
|
||||
|
||||
code: str | None = Field(None, max_length=64)
|
||||
label: str | None = Field(None, max_length=255)
|
||||
parent_code: str | None = Field(None, max_length=64)
|
||||
sort_order: int | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class CatalogItemResponse(CatalogItemBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
catalog: str
|
||||
tenant_id: int | None
|
||||
is_system: bool
|
||||
|
||||
|
||||
class CatalogMeta(BaseModel):
|
||||
"""Metadata de un catálogo para la pantalla de administración."""
|
||||
|
||||
catalog: str
|
||||
label: str
|
||||
scope: str # 'global' | 'tenant'
|
||||
is_system: bool
|
||||
count: int
|
||||
@@ -1,54 +0,0 @@
|
||||
"""Modelo de catálogos de referencia del CRM (T2026-07-081/082).
|
||||
|
||||
Un único modelo genérico ``CatalogItem`` respalda todos los catálogos
|
||||
(SAT/ISO y los propios del cliente). Cada fila pertenece a un catálogo
|
||||
(``catalog``) e identifica una opción por ``code`` (clave) + ``label``
|
||||
(descripción que se visualiza).
|
||||
|
||||
Alcance:
|
||||
- ``tenant_id IS NULL`` → catálogo GLOBAL (Aduanasoft), compartido por todos.
|
||||
- ``tenant_id`` con valor → catálogo del CLIENTE (ese tenant lo administra).
|
||||
|
||||
Los catálogos dependientes (p. ej. Estado depende de País) usan
|
||||
``parent_catalog`` + ``parent_code`` para filtrarse.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class CatalogItem(Base):
|
||||
__tablename__ = "catalog_items"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
catalog: Mapped[str] = mapped_column(String(60), nullable=False, index=True)
|
||||
code: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
label: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
|
||||
# Dependencia (Estado→País, Municipio→Estado, …)
|
||||
parent_catalog: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
parent_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# NULL = global (Aduanasoft); con valor = catálogo propio del tenant (cliente).
|
||||
tenant_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
sort_order: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||
# Catálogos base SAT/ISO: no se pueden borrar (solo activar/desactivar).
|
||||
is_system: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
extra: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, nullable=False, server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
@@ -1,10 +1,6 @@
|
||||
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10).
|
||||
"""Endpoints de catálogos de referencia y participantes del proceso (R-T-01, R-T-10)."""
|
||||
|
||||
Incluye el CRUD de catálogos de referencia (T2026-07-081/082): SAT/ISO globales
|
||||
(Aduanasoft) y catálogos propios de cada cliente (tenant).
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
@@ -12,9 +8,7 @@ from core.security import get_current_user
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..suppliers.models import Supplier
|
||||
from . import service as catalog_service
|
||||
from .data import INCOTERMS, PARTICIPANT_ROLES
|
||||
from .dto import CatalogItemCreate, CatalogItemResponse, CatalogItemUpdate, CatalogMeta
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -37,76 +31,6 @@ def list_participant_roles(
|
||||
return PARTICIPANT_ROLES
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Catálogos de referencia (CRUD) — T2026-07-081/082
|
||||
# ----------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/catalogs", response_model=list[CatalogMeta])
|
||||
def list_catalog_meta(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Lista los catálogos disponibles (global + del tenant) con su conteo."""
|
||||
return catalog_service.list_meta(db, current_user["tenant_id"])
|
||||
|
||||
|
||||
@router.get("/catalogs/{catalog}", response_model=list[CatalogItemResponse])
|
||||
def list_catalog_items(
|
||||
catalog: str,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
parent_code: str | None = Query(None, description="Filtra dependientes (ej. Estado por País)"),
|
||||
include_inactive: bool = Query(False),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Opciones de un catálogo (global + del tenant), activas y ordenadas."""
|
||||
return catalog_service.list_items(
|
||||
db, catalog, current_user["tenant_id"], parent_code=parent_code, include_inactive=include_inactive
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/catalogs/{catalog}", response_model=CatalogItemResponse, status_code=status.HTTP_201_CREATED
|
||||
)
|
||||
def create_catalog_item(
|
||||
catalog: str,
|
||||
data: CatalogItemCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
scope: str | None = Query("tenant", description="'tenant' (cliente) o 'global' (Aduanasoft, hub_admin)"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Inserta una opción en un catálogo."""
|
||||
return catalog_service.create_item(db, catalog, data, current_user, scope=scope)
|
||||
|
||||
|
||||
@router.patch("/catalogs/{catalog}/{item_id}", response_model=CatalogItemResponse)
|
||||
def update_catalog_item(
|
||||
catalog: str,
|
||||
item_id: int,
|
||||
data: CatalogItemUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Edita una opción de catálogo."""
|
||||
return catalog_service.update_item(db, catalog, item_id, data, current_user)
|
||||
|
||||
|
||||
@router.delete("/catalogs/{catalog}/{item_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_catalog_item(
|
||||
catalog: str,
|
||||
item_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Borra una opción de catálogo (los catálogos base del sistema no se borran)."""
|
||||
catalog_service.delete_item(db, catalog, item_id, current_user)
|
||||
|
||||
|
||||
@router.get("/participants")
|
||||
def list_participants(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
"""Siembra de catálogos globales (Aduanasoft) del CRM.
|
||||
|
||||
Idempotente: inserta solo las claves que aún no existen (tenant_id NULL). Se
|
||||
puede correr múltiples veces sin duplicar. Para ejecutarlo en un entorno:
|
||||
|
||||
docker compose exec backend python -m api.v1.modules.crm.catalogs.seed
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import CatalogItem
|
||||
from .seed_data import GLOBAL_CATALOGS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def seed_global_catalogs(db: Session) -> dict:
|
||||
"""Inserta los catálogos globales que falten. Devuelve un resumen {catalog: nuevos}."""
|
||||
summary: dict[str, int] = {}
|
||||
for catalog, meta in GLOBAL_CATALOGS.items():
|
||||
is_system = bool(meta.get("is_system", False))
|
||||
existing = {
|
||||
row.code
|
||||
for row in db.query(CatalogItem.code).filter(
|
||||
CatalogItem.catalog == catalog, CatalogItem.tenant_id.is_(None)
|
||||
)
|
||||
}
|
||||
added = 0
|
||||
for order, item in enumerate(meta["items"]):
|
||||
if item["code"] in existing:
|
||||
continue
|
||||
db.add(
|
||||
CatalogItem(
|
||||
catalog=catalog,
|
||||
code=item["code"],
|
||||
label=item["label"],
|
||||
parent_catalog=item.get("parent_catalog"),
|
||||
parent_code=item.get("parent_code"),
|
||||
tenant_id=None,
|
||||
sort_order=order,
|
||||
is_active=True,
|
||||
is_system=is_system,
|
||||
)
|
||||
)
|
||||
added += 1
|
||||
if added:
|
||||
summary[catalog] = added
|
||||
db.commit()
|
||||
total = sum(summary.values())
|
||||
logger.info("seed_global_catalogs: %s nuevas filas en %s catálogos", total, len(summary))
|
||||
return summary
|
||||
|
||||
|
||||
def _run() -> None:
|
||||
from core.database import CoreSessionLocal
|
||||
|
||||
db = CoreSessionLocal()
|
||||
try:
|
||||
result = seed_global_catalogs(db)
|
||||
print("Catálogos sembrados (nuevos):", result or "0 (ya estaban todos)")
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
_run()
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,168 +0,0 @@
|
||||
"""Lógica de negocio de los catálogos de referencia del CRM."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.security import is_hub_admin
|
||||
|
||||
from .dto import CatalogItemCreate, CatalogItemUpdate, CatalogMeta
|
||||
from .models import CatalogItem
|
||||
from .seed_data import GLOBAL_CATALOGS, TENANT_CATALOG_LABELS
|
||||
|
||||
# Metadata de catálogos (labels y si el cliente puede llenarlos).
|
||||
CATALOG_LABELS: dict[str, str] = {k: v["label"] for k, v in GLOBAL_CATALOGS.items()}
|
||||
CATALOG_LABELS.update(TENANT_CATALOG_LABELS)
|
||||
# Catálogos que administra el cliente (tenant). El resto son globales (Aduanasoft).
|
||||
TENANT_CATALOG_KEYS = set(TENANT_CATALOG_LABELS.keys())
|
||||
KNOWN_CATALOGS = set(CATALOG_LABELS.keys())
|
||||
|
||||
|
||||
def _require_known(catalog: str) -> None:
|
||||
if catalog not in KNOWN_CATALOGS:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=f"Catálogo '{catalog}' no existe")
|
||||
|
||||
|
||||
def list_meta(db: Session, tenant_id: int) -> list[CatalogMeta]:
|
||||
"""Lista todos los catálogos disponibles con su conteo (global + del tenant)."""
|
||||
out: list[CatalogMeta] = []
|
||||
for key, label in CATALOG_LABELS.items():
|
||||
is_tenant = key in TENANT_CATALOG_KEYS
|
||||
count = (
|
||||
db.query(CatalogItem)
|
||||
.filter(
|
||||
CatalogItem.catalog == key,
|
||||
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
out.append(
|
||||
CatalogMeta(
|
||||
catalog=key,
|
||||
label=label,
|
||||
scope="tenant" if is_tenant else "global",
|
||||
is_system=bool(GLOBAL_CATALOGS.get(key, {}).get("is_system", False)),
|
||||
count=count,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def list_items(
|
||||
db: Session,
|
||||
catalog: str,
|
||||
tenant_id: int,
|
||||
parent_code: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> list[CatalogItem]:
|
||||
_require_known(catalog)
|
||||
q = db.query(CatalogItem).filter(
|
||||
CatalogItem.catalog == catalog,
|
||||
or_(CatalogItem.tenant_id.is_(None), CatalogItem.tenant_id == tenant_id),
|
||||
)
|
||||
if not include_inactive:
|
||||
q = q.filter(CatalogItem.is_active.is_(True))
|
||||
if parent_code:
|
||||
q = q.filter(CatalogItem.parent_code == parent_code)
|
||||
return q.order_by(CatalogItem.sort_order, CatalogItem.label).all()
|
||||
|
||||
|
||||
def _resolve_write_scope(catalog: str, scope: str | None, current_user: dict) -> int | None:
|
||||
"""Devuelve el tenant_id a usar al escribir (None = global) y valida permisos.
|
||||
|
||||
- scope 'global' → solo hub_admin puede tocar catálogos globales (Aduanasoft).
|
||||
- scope 'tenant' (default) → se guarda en el tenant del usuario.
|
||||
"""
|
||||
wants_global = scope == "global"
|
||||
if wants_global:
|
||||
if not is_hub_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo un administrador de Aduanasoft puede editar catálogos globales.",
|
||||
)
|
||||
return None
|
||||
return int(current_user["tenant_id"])
|
||||
|
||||
|
||||
def create_item(
|
||||
db: Session, catalog: str, data: CatalogItemCreate, current_user: dict, scope: str | None = None
|
||||
) -> CatalogItem:
|
||||
_require_known(catalog)
|
||||
target_tenant = _resolve_write_scope(catalog, scope, current_user)
|
||||
|
||||
# No duplicar por (catalog, code, tenant_id)
|
||||
exists = (
|
||||
db.query(CatalogItem)
|
||||
.filter(
|
||||
CatalogItem.catalog == catalog,
|
||||
CatalogItem.code == data.code,
|
||||
CatalogItem.tenant_id.is_(None) if target_tenant is None else CatalogItem.tenant_id == target_tenant,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if exists:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe la clave '{data.code}' en el catálogo '{catalog}'.",
|
||||
)
|
||||
|
||||
item = CatalogItem(
|
||||
catalog=catalog,
|
||||
code=data.code,
|
||||
label=data.label,
|
||||
parent_catalog=data.parent_catalog,
|
||||
parent_code=data.parent_code,
|
||||
tenant_id=target_tenant,
|
||||
sort_order=data.sort_order,
|
||||
is_active=data.is_active,
|
||||
is_system=False,
|
||||
created_by=current_user.get("sub"),
|
||||
updated_by=current_user.get("sub"),
|
||||
)
|
||||
db.add(item)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def _get_writable(db: Session, catalog: str, item_id: int, current_user: dict) -> CatalogItem:
|
||||
_require_known(catalog)
|
||||
item = db.query(CatalogItem).filter(CatalogItem.id == item_id, CatalogItem.catalog == catalog).first()
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
|
||||
if item.tenant_id is None:
|
||||
# Global (Aduanasoft): solo hub_admin.
|
||||
if not is_hub_admin(current_user):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Solo un administrador de Aduanasoft puede editar este catálogo global.",
|
||||
)
|
||||
elif item.tenant_id != int(current_user["tenant_id"]):
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Elemento no encontrado")
|
||||
return item
|
||||
|
||||
|
||||
def update_item(
|
||||
db: Session, catalog: str, item_id: int, data: CatalogItemUpdate, current_user: dict
|
||||
) -> CatalogItem:
|
||||
item = _get_writable(db, catalog, item_id, current_user)
|
||||
payload: dict[str, Any] = data.model_dump(exclude_unset=True)
|
||||
for field, value in payload.items():
|
||||
setattr(item, field, value)
|
||||
item.updated_by = current_user.get("sub")
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
def delete_item(db: Session, catalog: str, item_id: int, current_user: dict) -> None:
|
||||
item = _get_writable(db, catalog, item_id, current_user)
|
||||
if item.is_system:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="Un catálogo base del sistema no se puede borrar; puedes desactivarlo.",
|
||||
)
|
||||
db.delete(item)
|
||||
db.commit()
|
||||
@@ -13,7 +13,6 @@ class SupplierBase(BaseModel):
|
||||
person_type: str | None = Field(None, max_length=10)
|
||||
status: str = Field("active", max_length=20)
|
||||
classifications: list[str] = Field(default_factory=list)
|
||||
classification_other: str | None = Field(None, max_length=120)
|
||||
# Comercial
|
||||
services_offered: str | None = None
|
||||
coverage: str | None = Field(None, max_length=20)
|
||||
@@ -53,7 +52,6 @@ class SupplierUpdate(BaseModel):
|
||||
person_type: str | None = Field(None, max_length=10)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
classifications: list[str] | None = None
|
||||
classification_other: str | None = Field(None, max_length=120)
|
||||
services_offered: str | None = None
|
||||
coverage: str | None = Field(None, max_length=20)
|
||||
countries: list[str] | None = None
|
||||
|
||||
@@ -30,8 +30,6 @@ class Supplier(Base, TenantScopedMixin, TimestampMixin):
|
||||
# Clasificación (múltiple): naviera, aerolinea, transportista_terrestre, ferrocarril,
|
||||
# agente_aduanal, agente_carga, agente_corresponsal, almacen, aseguradora, paqueteria, otro
|
||||
classifications: Mapped[list | None] = mapped_column(JSON, nullable=True, default=list)
|
||||
# Texto libre cuando la clasificación incluye "otro"
|
||||
classification_other: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
|
||||
# ----- Información comercial -----
|
||||
services_offered: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
|
||||
@@ -19,9 +28,11 @@ class InvoiceItemBase(BaseModel):
|
||||
|
||||
class InvoiceItemCreate(InvoiceItemBase):
|
||||
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)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
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
|
||||
notes: str | None = None
|
||||
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):
|
||||
@@ -94,6 +110,10 @@ class InvoiceUpdate(BaseModel):
|
||||
bank_info: str | None = None
|
||||
notes: str | None = None
|
||||
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):
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
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 api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
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):
|
||||
"""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)
|
||||
created_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):
|
||||
@@ -62,10 +85,54 @@ class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
invoice_id: Mapped[int] = mapped_column(
|
||||
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)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
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"))
|
||||
# ----- 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):
|
||||
|
||||
@@ -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.ops.shipments.models import Shipment
|
||||
|
||||
from ..concepts.models import Concept
|
||||
from .dto import (
|
||||
InvoiceClientReviewInput,
|
||||
InvoiceCreate,
|
||||
@@ -331,9 +332,50 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
|
||||
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:
|
||||
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.flush()
|
||||
_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:
|
||||
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)
|
||||
db.flush()
|
||||
_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
|
||||
|
||||
MODULE = "fin"
|
||||
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")]
|
||||
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos"), ("concept", "conceptos")]
|
||||
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ def register_permissions() -> None:
|
||||
for entity, label in _ENTITIES:
|
||||
for action, verb in _ACTIONS:
|
||||
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()
|
||||
|
||||
@@ -5,8 +5,14 @@ from fastapi import APIRouter, Depends
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
|
||||
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 .issuer.routes import router as issuer_router
|
||||
|
||||
# Enforcement por área/carril (R-T-07): se exige fin.access para el módulo.
|
||||
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)
|
||||
|
||||
@@ -42,19 +42,6 @@ class Settings(BaseSettings):
|
||||
PERMISSION_CACHE_ENABLED: bool = True
|
||||
PERMISSION_CACHE_TTL_SECONDS: int = 300
|
||||
|
||||
# Sesión local del CRM (patrón SIWEB) — desacopla la sesión de la app del
|
||||
# token KC de 60s. Tras SSO/login se guardan los tokens KC en valkey y se emite
|
||||
# una sesión local firmada (HS256) con vida por inactividad (idle) y cap
|
||||
# absoluto. Así el refresh del token KC contra el Hub solo se intenta al expirar
|
||||
# la sesión local (no cada ~60s), lo que elimina el bucle de login.
|
||||
#
|
||||
# SE RESPETA la revocación central de Keycloak: si el Hub rechaza el refresh, la
|
||||
# sesión termina (no hay re-emisión local de fallback). Flag-gated para rollback:
|
||||
# con SESSION_STORE_ENABLED=False el comportamiento no cambia.
|
||||
SESSION_STORE_ENABLED: bool = False
|
||||
SESSION_IDLE_MINUTES: int = 30
|
||||
SESSION_MAX_HOURS: int = 10
|
||||
|
||||
# Synchronization
|
||||
SYNC_SECRET_TOKEN: str = "change-this-sync-token-in-production"
|
||||
CENTRAL_SERVER_URL: str = "http://localhost:8000/api/v1/core/help-center/sync/"
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
"""
|
||||
Obtención de un access token de Keycloak VÁLIDO para llamar a la API del Hub.
|
||||
|
||||
Con el patrón de sesión local (SIWEB) el Bearer de la app es un JWT propio (HS256)
|
||||
que el Hub NO entiende. Para las llamadas server→Hub se usa el token KC guardado en
|
||||
la sesión (valkey, vía cookie crm_sid), refrescándolo si está por expirar.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import httpx
|
||||
from jose import jwt
|
||||
|
||||
from core.config import settings
|
||||
from core import session_store
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _kc_exp_ok(token: str, leeway_seconds: int = 30) -> bool:
|
||||
"""True si el token KC no está expirado (con margen)."""
|
||||
try:
|
||||
claims = jwt.get_unverified_claims(token)
|
||||
exp = claims.get("exp")
|
||||
return isinstance(exp, (int, float)) and (int(exp) - int(time.time())) > leeway_seconds
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
async def get_hub_access_token(request) -> Optional[str]:
|
||||
"""
|
||||
Devuelve un access token KC válido tomado de la sesión (valkey vía crm_sid),
|
||||
refrescándolo contra el Hub si está por expirar. None si no hay sesión.
|
||||
Best-effort: si el refresh falla, devuelve el token guardado (puede estar vencido).
|
||||
"""
|
||||
sid = request.cookies.get("crm_sid") if request is not None else None
|
||||
if not sid:
|
||||
return None
|
||||
sess = session_store.get_session(sid)
|
||||
if not sess:
|
||||
return None
|
||||
|
||||
access = sess.get("access_token")
|
||||
refresh = sess.get("refresh_token")
|
||||
|
||||
if access and _kc_exp_ok(access):
|
||||
return access
|
||||
|
||||
if refresh:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
r = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json={"refresh_token": refresh},
|
||||
)
|
||||
if r.status_code == 200:
|
||||
data = r.json()
|
||||
new_access = data.get("access_token") or access
|
||||
session_store.update_session_tokens(sid, new_access, data.get("refresh_token") or refresh)
|
||||
return new_access
|
||||
logger.info("get_hub_access_token: Hub refresh devolvió %s", r.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("get_hub_access_token: refresh falló: %s", exc)
|
||||
|
||||
return access
|
||||
@@ -1,94 +0,0 @@
|
||||
"""
|
||||
Sesión local del CRM (patrón SIWEB).
|
||||
|
||||
Emite y valida un JWT de sesión propio (HS256, firmado con SECRET_KEY) que
|
||||
transporta la identidad YA verificada por Keycloak/Hub. Desacopla la sesión de la
|
||||
app del token KC de 60s: la app valida esta sesión local (sin ir al Hub) durante
|
||||
su ventana de inactividad, de modo que el refresh del token KC solo se intenta al
|
||||
expirar la sesión local — no cada ~60s. Esto elimina el bucle de login.
|
||||
|
||||
Se RESPETA la revocación central: si el Hub rechaza el refresh, la sesión termina
|
||||
(no hay re-emisión de fallback).
|
||||
|
||||
Marcadores del token:
|
||||
- source: "local" + crm_session: True → distingue de tokens KC (RS256) y del
|
||||
token dev-local (dev_local: True).
|
||||
- sst (session start time, epoch seg) → fija la vida ABSOLUTA máxima (cap).
|
||||
- exp → sliding por inactividad (idle); se
|
||||
re-emite en cada refresh mientras no se supere el cap.
|
||||
|
||||
Seguridad: es un desacople CONSCIENTE de la revocación central de KC (OWASP A07).
|
||||
Se acota con idle corto (= ssoSessionIdleTimeout) y cap absoluto
|
||||
(= ssoSessionMaxLifespan); el logout elimina la sesión de valkey.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from jose import JWTError, jwt
|
||||
|
||||
from core.config import settings
|
||||
|
||||
# Claims de identidad que se propagan del token KC a la sesión local.
|
||||
_IDENTITY_CLAIMS = (
|
||||
"sub", "email", "preferred_username", "username", "name",
|
||||
"given_name", "family_name", "first_name", "last_name",
|
||||
"tenant_id", "tenant_slug", "roles", "permissions",
|
||||
"is_hub_admin", "avatar_url",
|
||||
)
|
||||
|
||||
|
||||
def _now_epoch() -> int:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
def mint_session_token(claims: Dict[str, Any], session_start: Optional[int] = None) -> str:
|
||||
"""
|
||||
Emite un JWT de sesión local a partir de los claims (verificados) del usuario.
|
||||
`session_start` (epoch seg) fija el inicio de sesión para el cap absoluto; si
|
||||
no se provee, se usa el momento actual (sesión nueva).
|
||||
"""
|
||||
now = _now_epoch()
|
||||
sst = int(session_start) if session_start else now
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
k: claims[k] for k in _IDENTITY_CLAIMS if claims.get(k) is not None
|
||||
}
|
||||
payload.update({
|
||||
"source": "local",
|
||||
"crm_session": True,
|
||||
"sst": sst,
|
||||
"iat": now,
|
||||
"exp": now + settings.SESSION_IDLE_MINUTES * 60,
|
||||
})
|
||||
return jwt.encode(payload, settings.SECRET_KEY, algorithm="HS256")
|
||||
|
||||
|
||||
def verify_session_token(token: str) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
Valida un JWT de sesión local. Retorna los claims si es válido, no expiró por
|
||||
inactividad y no superó el cap absoluto de vida; None en cualquier otro caso.
|
||||
Nunca lanza (para poder encadenar con la validación contra el Hub).
|
||||
"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=["HS256"])
|
||||
except JWTError:
|
||||
return None
|
||||
|
||||
# Solo aceptamos tokens de sesión local del CRM (no KC, no dev-local).
|
||||
if not payload.get("crm_session") or payload.get("source") != "local":
|
||||
return None
|
||||
|
||||
# Cap absoluto de vida de sesión (independiente del sliding por idle).
|
||||
sst = payload.get("sst")
|
||||
if isinstance(sst, (int, float)):
|
||||
if _now_epoch() - int(sst) > settings.SESSION_MAX_HOURS * 3600:
|
||||
return None
|
||||
|
||||
return payload
|
||||
|
||||
|
||||
def session_start_of(payload: Dict[str, Any]) -> Optional[int]:
|
||||
"""Extrae el epoch de inicio de sesión (sst) de un payload de sesión local."""
|
||||
sst = payload.get("sst")
|
||||
return int(sst) if isinstance(sst, (int, float)) else None
|
||||
@@ -3,7 +3,6 @@ import time
|
||||
import httpx
|
||||
from datetime import datetime, timezone
|
||||
from typing import Callable, Optional
|
||||
from cachetools import TTLCache
|
||||
from fastapi import Request, Response
|
||||
from fastapi.responses import JSONResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
@@ -13,11 +12,6 @@ from .security import get_tenant_from_token, verify_token, get_active_system
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Caché de validación de licencia por tenant (patrón SIWEB): evita consultar al
|
||||
# Hub en cada request. Valor: "valid" o "invalid:<mensaje>". TTL corto para que
|
||||
# los cambios de licencia se propaguen en minutos.
|
||||
_license_cache: TTLCache = TTLCache(maxsize=1000, ttl=600)
|
||||
|
||||
|
||||
def _normalize_text(value: str | None) -> str:
|
||||
if not value:
|
||||
@@ -151,18 +145,6 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
|
||||
token = auth_header.split(" ")[1]
|
||||
|
||||
# Sesión local del CRM (patrón SIWEB): el Bearer es un JWT HS256 propio que
|
||||
# el Hub NO entiende. No se le reenvía: la licencia se valida con el token KC
|
||||
# guardado en valkey y se cachea por tenant.
|
||||
if getattr(settings, "SESSION_STORE_ENABLED", False):
|
||||
try:
|
||||
from core.local_session import verify_session_token
|
||||
local_claims = verify_session_token(token)
|
||||
except Exception:
|
||||
local_claims = None
|
||||
if local_claims is not None:
|
||||
return await self._handle_local_session_license(request, call_next, local_claims)
|
||||
|
||||
tenant_override = request.headers.get("X-Tenant-Override")
|
||||
if not tenant_override:
|
||||
# Fallback para flujos SSO cuando el override no viaja en header.
|
||||
@@ -325,136 +307,6 @@ class LicenseValidationMiddleware(BaseHTTPMiddleware):
|
||||
}
|
||||
)
|
||||
|
||||
async def _handle_local_session_license(self, request: Request, call_next: Callable, local_claims: dict):
|
||||
"""
|
||||
Valida licencia para una sesión local del CRM (patrón SIWEB).
|
||||
|
||||
El Hub no valida el JWT HS256 local, así que se usa el token KC guardado en
|
||||
valkey (refrescándolo si está vencido) para consultar verify-license, con
|
||||
caché por tenant. Si el Hub no es concluyente (p. ej. su refresh falla), se
|
||||
permite el paso: la sesión local se emitió tras un login válido (el App
|
||||
Launcher solo ofrece apps licenciadas), evitando bloquear por un problema
|
||||
transitorio del Hub. Los resultados concluyentes (válido/ inválido) sí se cachean.
|
||||
"""
|
||||
from core import session_store
|
||||
|
||||
tenant_key = str(local_claims.get("tenant_id") or "")
|
||||
|
||||
cached = _license_cache.get(tenant_key) if tenant_key else None
|
||||
if cached == "valid":
|
||||
return await call_next(request)
|
||||
if isinstance(cached, str) and cached.startswith("invalid:"):
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={"error": "LICENSE_ERROR", "message": cached[len("invalid:"):], "status_code": 402},
|
||||
)
|
||||
|
||||
tenant_override = (
|
||||
tenant_key
|
||||
or request.cookies.get("sso_tenant_id")
|
||||
or request.cookies.get("sso_tenant_pub")
|
||||
or ""
|
||||
)
|
||||
|
||||
sid = request.cookies.get("crm_sid")
|
||||
sess = session_store.get_session(sid) if sid else None
|
||||
kc_token = (sess or {}).get("access_token") or ""
|
||||
kc_refresh = (sess or {}).get("refresh_token") or ""
|
||||
|
||||
async def _verify(tok: str):
|
||||
if not tok:
|
||||
return None
|
||||
headers = {"Authorization": f"Bearer {tok}"}
|
||||
if tenant_override:
|
||||
headers["X-Tenant-Override"] = str(tenant_override)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5.0) as client:
|
||||
return await client.get(
|
||||
f"{settings.HUB_URL}api/v1/auth/verify-license", headers=headers
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("[license] verify-license (sesión local) error de red: %s", exc)
|
||||
return None
|
||||
|
||||
resp = await _verify(kc_token)
|
||||
|
||||
# ¿El KC token guardado está vencido? Refrescar una vez y reintentar.
|
||||
needs_refresh = resp is None or resp.status_code == 401
|
||||
if not needs_refresh and resp.status_code == 200:
|
||||
try:
|
||||
_d = resp.json()
|
||||
except Exception:
|
||||
_d = {}
|
||||
if not _d.get("valid", False) and _is_token_issue_message(
|
||||
_d.get("message"), _d.get("detail"), _d.get("reason")
|
||||
):
|
||||
needs_refresh = True
|
||||
|
||||
if needs_refresh and kc_refresh:
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=8.0) as client:
|
||||
rr = await client.post(
|
||||
f"{settings.HUB_URL}api/v1/auth/refresh",
|
||||
json={"refresh_token": kc_refresh},
|
||||
)
|
||||
if rr.status_code == 200:
|
||||
nt = rr.json()
|
||||
kc_token = nt.get("access_token") or kc_token
|
||||
if sid:
|
||||
session_store.update_session_tokens(
|
||||
sid, kc_token, nt.get("refresh_token") or kc_refresh
|
||||
)
|
||||
resp = await _verify(kc_token)
|
||||
else:
|
||||
logger.warning("[license] refresh KC para verify-license devolvió %s", rr.status_code)
|
||||
except Exception as exc:
|
||||
logger.warning("[license] refresh KC para verify-license falló: %s", exc)
|
||||
|
||||
if resp is not None and resp.status_code == 200:
|
||||
try:
|
||||
data = resp.json()
|
||||
except Exception:
|
||||
data = {}
|
||||
if data.get("valid", False):
|
||||
expires_at_str = data.get("expires_at")
|
||||
if expires_at_str:
|
||||
try:
|
||||
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
||||
if expires_at.tzinfo is None:
|
||||
expires_at = expires_at.replace(tzinfo=timezone.utc)
|
||||
if expires_at < datetime.now(timezone.utc):
|
||||
msg = f"La licencia venció el {expires_at.strftime('%d/%m/%Y')}. Renueva tu suscripción."
|
||||
if tenant_key:
|
||||
_license_cache[tenant_key] = f"invalid:{msg}"
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={"error": "LICENSE_EXPIRED", "message": msg, "status_code": 402},
|
||||
)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
if tenant_key:
|
||||
_license_cache[tenant_key] = "valid"
|
||||
request.state.license_info = data
|
||||
return await call_next(request)
|
||||
|
||||
message = data.get("message", "Sin licencia asignada para este tenant")
|
||||
if not _is_token_issue_message(data.get("message"), data.get("detail"), data.get("reason")):
|
||||
if tenant_key:
|
||||
_license_cache[tenant_key] = f"invalid:{message}"
|
||||
return JSONResponse(
|
||||
status_code=402,
|
||||
content={"error": "LICENSE_ERROR", "message": message, "status_code": 402},
|
||||
)
|
||||
|
||||
# No concluyente (Hub no dio 200, o el problema de token persiste porque su
|
||||
# refresh falla): la sesión local es válida → permitir sin cachear. Evita el
|
||||
# bucle de 401 por el bug de refresh del Hub.
|
||||
logger.warning(
|
||||
"[license] verify-license no concluyente para sesión local (tenant=%s) — se permite",
|
||||
tenant_key,
|
||||
)
|
||||
return await call_next(request)
|
||||
|
||||
|
||||
class RequestLoggingMiddleware(BaseHTTPMiddleware):
|
||||
"""
|
||||
|
||||
@@ -47,19 +47,6 @@ async def verify_token(token: str, tenant_id_override: str = None) -> Dict[str,
|
||||
if cache_key in token_cache:
|
||||
return token_cache[cache_key]
|
||||
|
||||
# Sesión local del CRM (patrón SIWEB): si el token es una sesión local firmada
|
||||
# (HS256, crm_session), validarla sin ir al Hub en cada request. Así el refresh
|
||||
# del token KC solo se intenta al expirar la sesión local (no cada ~60s), lo que
|
||||
# elimina el bucle de login. verify_session_token retorna None para tokens KC
|
||||
# (RS256), así que no interfiere con el flujo normal.
|
||||
if settings.SESSION_STORE_ENABLED:
|
||||
from core.local_session import verify_session_token
|
||||
|
||||
local_claims = verify_session_token(token)
|
||||
if local_claims is not None:
|
||||
token_cache[cache_key] = local_claims
|
||||
return local_claims
|
||||
|
||||
# Shortcut para tokens de desarrollo local
|
||||
if settings.DEV_LOCAL_AUTH:
|
||||
try:
|
||||
@@ -666,20 +653,6 @@ def validate_access_to_resource(
|
||||
|
||||
tenant_id = resolve_effective_tenant_id_from_user(current_user)
|
||||
|
||||
# Si el usuario no trae tenant en el token (p. ej. hub_admin del workspace),
|
||||
# resolverlo desde la compañía activa (a76.company.tenant_id). Permite operar
|
||||
# por compañía seleccionada cuando el token no está ligado a un tenant.
|
||||
if tenant_id is None and company_id:
|
||||
try:
|
||||
from sqlalchemy import text as _text
|
||||
row = db.execute(
|
||||
_text("SELECT tenant_id FROM a76.company WHERE id = :c"), {"c": company_id}
|
||||
).first()
|
||||
if row and row[0] is not None:
|
||||
tenant_id = int(row[0])
|
||||
except Exception as exc:
|
||||
logger.warning("no se pudo resolver tenant desde company_id=%s: %s", company_id, exc)
|
||||
|
||||
# Bypass de checks de permisos: hub_admin (atestado por el Hub en /auth/me)
|
||||
# o rol local "super_admin" en la compañía (fuente de verdad: BD de a76).
|
||||
# Se reemplazó el antiguo "admin" in realm_access.roles para que la
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
"""
|
||||
Store de sesión en Valkey/Redis (patrón SIWEB).
|
||||
|
||||
Guarda los tokens de Keycloak (access + refresh) FUERA del browser, indexados por
|
||||
un session_id opaco. La app usa la sesión local firmada (ver core.local_session)
|
||||
para su propia auth; los tokens KC de aquí solo se usan para llamadas al Hub
|
||||
(provisioning, my-apps, my-tenants), refrescándolos best-effort.
|
||||
|
||||
Fail-silent: si Valkey no está disponible, las operaciones degradan a None/no-op
|
||||
y la sesión local firmada sigue sosteniendo la app.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Optional
|
||||
|
||||
from core.config import settings
|
||||
|
||||
try:
|
||||
import redis # type: ignore
|
||||
except Exception: # pragma: no cover - redis es opcional en algunos entornos
|
||||
redis = None # type: ignore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_KEY_PREFIX = "crm:session:"
|
||||
_client = None
|
||||
|
||||
|
||||
def _get_client():
|
||||
"""Cliente Redis/Valkey compartido (perezoso). None si no está disponible."""
|
||||
global _client
|
||||
if redis is None:
|
||||
return None
|
||||
if _client is None:
|
||||
try:
|
||||
_client = redis.Redis.from_url(settings.VALKEY_URL, decode_responses=True)
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_init_failed: %s", exc)
|
||||
return None
|
||||
return _client
|
||||
|
||||
|
||||
def _ttl_seconds() -> int:
|
||||
# La sesión en valkey vive como máximo lo que la vida absoluta de la sesión.
|
||||
return settings.SESSION_MAX_HOURS * 3600
|
||||
|
||||
|
||||
def create_session(access_token: str, refresh_token: str, session_start: int) -> Optional[str]:
|
||||
"""Crea una sesión con los tokens KC y devuelve el session_id (o None si Valkey no está)."""
|
||||
client = _get_client()
|
||||
if client is None:
|
||||
return None
|
||||
session_id = str(uuid.uuid4())
|
||||
data = json.dumps({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token or "",
|
||||
"sst": int(session_start),
|
||||
})
|
||||
try:
|
||||
client.setex(f"{_KEY_PREFIX}{session_id}", _ttl_seconds(), data)
|
||||
return session_id
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_create_failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def get_session(session_id: str) -> Optional[dict]:
|
||||
"""Devuelve {access_token, refresh_token, sst} de la sesión, o None."""
|
||||
client = _get_client()
|
||||
if client is None or not session_id:
|
||||
return None
|
||||
try:
|
||||
raw = client.get(f"{_KEY_PREFIX}{session_id}")
|
||||
return json.loads(raw) if raw else None
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_get_failed: %s", exc)
|
||||
return None
|
||||
|
||||
|
||||
def update_session_tokens(session_id: str, access_token: str, refresh_token: str) -> None:
|
||||
"""Actualiza los tokens KC de una sesión existente conservando su TTL y su sst."""
|
||||
client = _get_client()
|
||||
if client is None or not session_id:
|
||||
return
|
||||
try:
|
||||
key = f"{_KEY_PREFIX}{session_id}"
|
||||
ttl = client.ttl(key)
|
||||
if ttl and ttl > 0:
|
||||
existing = client.get(key)
|
||||
sst = json.loads(existing).get("sst") if existing else None
|
||||
data = json.dumps({
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token or "",
|
||||
"sst": sst,
|
||||
})
|
||||
client.setex(key, ttl, data)
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_update_failed: %s", exc)
|
||||
|
||||
|
||||
def delete_session(session_id: str) -> None:
|
||||
"""Elimina la sesión (logout). Fail-silent."""
|
||||
client = _get_client()
|
||||
if client is None or not session_id:
|
||||
return
|
||||
try:
|
||||
client.delete(f"{_KEY_PREFIX}{session_id}")
|
||||
except Exception as exc:
|
||||
logger.warning("session_store_delete_failed: %s", exc)
|
||||
@@ -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.suppliers.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
|
||||
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.
|
||||
# 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)
|
||||
session_factory = sessionmaker(bind=engine, future=True)
|
||||
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:
|
||||
yield session
|
||||
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
|
||||
@@ -1,74 +0,0 @@
|
||||
"""
|
||||
Pruebas de la sesión local del CRM (patrón SIWEB) — core.local_session.
|
||||
|
||||
Lógica pura (firma HS256 + claims); no requiere BD ni valkey.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from jose import jwt
|
||||
|
||||
from core.config import settings
|
||||
from core.local_session import mint_session_token, verify_session_token, session_start_of
|
||||
|
||||
|
||||
def _now() -> int:
|
||||
return int(datetime.now(timezone.utc).timestamp())
|
||||
|
||||
|
||||
def test_round_trip_conserva_identidad():
|
||||
claims = {
|
||||
"sub": "kc-user-123",
|
||||
"email": "user@example.com",
|
||||
"tenant_id": 11,
|
||||
"tenant_slug": "aduanasoft",
|
||||
"is_hub_admin": True,
|
||||
}
|
||||
token = mint_session_token(claims)
|
||||
out = verify_session_token(token)
|
||||
|
||||
assert out is not None
|
||||
assert out["sub"] == "kc-user-123"
|
||||
assert out["tenant_id"] == 11
|
||||
assert out["tenant_slug"] == "aduanasoft"
|
||||
assert out["is_hub_admin"] is True
|
||||
assert out["source"] == "local"
|
||||
assert out["crm_session"] is True
|
||||
assert isinstance(out["sst"], int)
|
||||
|
||||
|
||||
def test_cap_absoluto_rechaza_sesion_vieja():
|
||||
# session_start más allá del cap absoluto → verify debe rechazar aunque no expiró por idle.
|
||||
old_start = _now() - (settings.SESSION_MAX_HOURS * 3600 + 120)
|
||||
token = mint_session_token({"sub": "x"}, session_start=old_start)
|
||||
assert verify_session_token(token) is None
|
||||
|
||||
|
||||
def test_preserva_session_start():
|
||||
start = _now() - 60
|
||||
token = mint_session_token({"sub": "x"}, session_start=start)
|
||||
out = verify_session_token(token)
|
||||
assert out is not None
|
||||
assert session_start_of(out) == start
|
||||
|
||||
|
||||
def test_firma_alterada_se_rechaza():
|
||||
token = mint_session_token({"sub": "x"})
|
||||
# Alterar el último carácter de la firma invalida el token.
|
||||
tampered = token[:-1] + ("A" if token[-1] != "A" else "B")
|
||||
assert verify_session_token(tampered) is None
|
||||
|
||||
|
||||
def test_token_no_crm_se_rechaza():
|
||||
# Un HS256 válido pero SIN los marcadores de sesión local no debe aceptarse.
|
||||
other = jwt.encode(
|
||||
{"sub": "x", "exp": _now() + 600},
|
||||
settings.SECRET_KEY,
|
||||
algorithm="HS256",
|
||||
)
|
||||
assert verify_session_token(other) is None
|
||||
|
||||
|
||||
def test_token_basura_se_rechaza():
|
||||
assert verify_session_token("no-es-un-jwt") is None
|
||||
assert verify_session_token("") is None
|
||||
@@ -1,162 +0,0 @@
|
||||
# Runbook — Despliegue a PRODUCCIÓN · CRM Agente de Carga
|
||||
|
||||
> **Quién ejecuta:** un operador con acceso al servidor de producción y a la base de
|
||||
> datos de prod. **Este runbook no lo ejecuta ningún agente automático.**
|
||||
> **Prerrequisito de proceso:** el PR de `feature/crm-workspace-altas-org-usuario`
|
||||
> debe estar **revisado y mergeado** a la rama que corresponda antes de desplegar.
|
||||
>
|
||||
> **Antes de empezar: respaldo.** Toma un backup de la base `crm_core` de prod
|
||||
> (`pg_dump`) y del `.env` actual. Sin respaldo verificado, no continúes.
|
||||
|
||||
Este cambio es grande (auth/RBAC): login 100% vía Workspace/Hub, **sesión local
|
||||
(patrón SIWEB)**, gestión de compañías/usuarios/roles y provisión vía Hub. Léelo
|
||||
completo antes de tocar prod.
|
||||
|
||||
---
|
||||
|
||||
## 0. Alcance del cambio
|
||||
|
||||
- **Auth:** el login deja de hablar directo con Keycloak; todo pasa por el Hub
|
||||
(App Launcher → `/auth/sso?relay=<uuid>` → `POST {HUB_URL}/api/v1/auth/sso-exchange`).
|
||||
- **Sesión local:** cookie de sesión propia (HS256) + token KC guardado en **valkey**
|
||||
por `crm_sid`. Requiere valkey arriba. Se controla con `SESSION_STORE_ENABLED`.
|
||||
- **RBAC/Compañías:** compañías en `a76.company` por tenant; roles por carril
|
||||
(Ventas, Operaciones, Facturación, Consulta); permisos por módulo.
|
||||
|
||||
**Migraciones de esquema:** este set **no** agrega tablas nuevas (usa `core.*`,
|
||||
`a76.company` y valkey). Aun así, **verifica migraciones pendientes** en prod antes
|
||||
de desplegar (paso 5). No corras migraciones a ciegas.
|
||||
|
||||
---
|
||||
|
||||
## 1. Prerrequisitos de infraestructura
|
||||
|
||||
- [ ] DNS de prod (p. ej. `crm.aduanasoft.com`) apuntando al server de prod.
|
||||
- [ ] Docker + Docker Compose en el server.
|
||||
- [ ] Servicios del stack: `backend`, `frontend`, `postgres`, `valkey`, `minio`,
|
||||
`celery_worker`, `celery_beat`.
|
||||
- [ ] **valkey** operativo (lo usan la sesión local y `hub_token`).
|
||||
- [ ] nginx + TLS (certbot) para servir app + API en el **mismo origen**.
|
||||
- [ ] Acceso al **Hub de producción** (no el de testing) y al client/realm correctos.
|
||||
|
||||
---
|
||||
|
||||
## 2. Variables de entorno (`.env` en el server de prod)
|
||||
|
||||
Basado en `deploy/env.testing.example`, pero con **hosts, dominio y secretos de
|
||||
producción**. Nunca subas el `.env` con secretos al repo.
|
||||
|
||||
```env
|
||||
# --- Seguridad ---
|
||||
ENVIRONMENT=production
|
||||
DEV_LOCAL_AUTH=false
|
||||
SECRET_KEY=<openssl rand -hex 32>
|
||||
|
||||
# --- Sesión local (patrón SIWEB) ---
|
||||
SESSION_STORE_ENABLED=true
|
||||
SESSION_IDLE_MINUTES=30
|
||||
SESSION_MAX_HOURS=10
|
||||
VALKEY_URL=redis://valkey:6379/0
|
||||
|
||||
# --- Workspace / Hub de PRODUCCIÓN (mismo Hub que genera el relay) ---
|
||||
WORKSPACE_URL=https://<hub-produccion>
|
||||
HUB_URL=https://<hub-produccion>
|
||||
INTERNAL_HUB_URL=https://<hub-produccion>
|
||||
VITE_HUB_URL=https://<hub-produccion>
|
||||
|
||||
# --- Keycloak (single-realm / single-client) ---
|
||||
KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
|
||||
VITE_KEYCLOAK_URL=https://<keycloak-produccion>/kcauth
|
||||
KEYCLOAK_REALM=master
|
||||
KEYCLOAK_CLIENT_ID=aduanasoft
|
||||
KEYCLOAK_CLIENT_SECRET=<secret del producto provisionado en prod>
|
||||
|
||||
# --- Dominio del CRM (mismo origen app + API vía nginx) ---
|
||||
ORIGIN=https://<dominio-crm-produccion>
|
||||
APP_PUBLIC_URL=https://<dominio-crm-produccion>
|
||||
VITE_API_URL=https://<dominio-crm-produccion>/api/
|
||||
INTERNAL_API_URL=http://backend:8000/api/
|
||||
CORS_ORIGINS=https://<dominio-crm-produccion>
|
||||
|
||||
# --- PostgreSQL ---
|
||||
CORE_DB_HOST=postgres
|
||||
CORE_DB_PORT=5432
|
||||
CORE_DB_NAME=crm_core
|
||||
CORE_DB_USER=<usuario>
|
||||
POSTGRES_APP_PASSWORD=<password fuerte>
|
||||
|
||||
# --- MinIO / S3 ---
|
||||
S3_ENDPOINT_URL=http://minio:9000
|
||||
S3_ACCESS_KEY=<access>
|
||||
S3_SECRET_KEY=<secret>
|
||||
S3_BUCKET=crm
|
||||
S3_REGION=us-east-1
|
||||
S3_USE_SSL=false
|
||||
```
|
||||
|
||||
> **Importante:** `ENVIRONMENT=production` hace que el bootstrap de permisos solo dé
|
||||
> `super_admin` al **primer** usuario (no a todos). Es el comportamiento deseado en prod.
|
||||
|
||||
---
|
||||
|
||||
## 3. Build del frontend — **en CI, no en el server**
|
||||
|
||||
La VM de prod no debe compilar el frontend (el build satura RAM). Compila la imagen
|
||||
en **Jenkins/CI** y publícala al registry, o compílala en una máquina de build:
|
||||
|
||||
```bash
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build frontend
|
||||
# push al registry interno si aplica
|
||||
```
|
||||
|
||||
El backend usa la imagen/código directamente (uvicorn sin --reload).
|
||||
|
||||
---
|
||||
|
||||
## 4. Despliegue
|
||||
|
||||
```bash
|
||||
# En el server de prod, en el directorio del proyecto:
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull # si usas registry
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
docker compose ps # verifica que todos queden healthy
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Post-despliegue (datos) — **operador humano, con respaldo hecho**
|
||||
|
||||
1. **Verificar migraciones pendientes** (si el proyecto usa Alembic u otro):
|
||||
revisar y aplicar **solo** las que correspondan, con backup previo.
|
||||
2. **Seed base** (compañía por tenant + carriles), equivalente a `seed_crm.py`
|
||||
(`ensure_company` + roles Ventas/Operaciones/Facturación/Consulta).
|
||||
3. **Sync de permisos** (registra el catálogo por módulo):
|
||||
|
||||
```bash
|
||||
docker compose exec backend python -c "from api.v1.modules.core.permissions.service import PermissionService; from core.database import CoreSessionLocal; PermissionService(CoreSessionLocal()).sync_permissions()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Verificación / smoke test
|
||||
|
||||
```bash
|
||||
curl -fsS https://<dominio-crm-produccion>/api/health && echo OK
|
||||
```
|
||||
|
||||
- [ ] Login vía **App Launcher del Workspace** entra sin bucle.
|
||||
- [ ] El dashboard carga compañías del tenant (switcher con el tenant correcto).
|
||||
- [ ] **Usuarios**: lista carga tras refrescar; alta por invitación crea enlace.
|
||||
- [ ] **Roles y permisos**: catálogo por módulo carga; marcar un permiso lo guarda
|
||||
(toast) y persiste al refrescar.
|
||||
- [ ] Licencia válida (sin bucle 401 / "sesión expirada").
|
||||
|
||||
---
|
||||
|
||||
## 7. Rollback
|
||||
|
||||
1. `docker compose ... up -d` con la **imagen/tag anterior** del frontend/backend.
|
||||
2. Restaurar `.env` anterior si se cambió.
|
||||
3. Restaurar el backup de `crm_core` **solo** si hubo cambios de datos irreversibles.
|
||||
4. `SESSION_STORE_ENABLED=false` revierte al comportamiento previo de sesión sin
|
||||
redeploy de código (feature-flag), como mitigación rápida.
|
||||
@@ -5,26 +5,11 @@
|
||||
# docker compose -f docker-compose.yml -f deploy/docker-compose.testing.yml up -d --build
|
||||
services:
|
||||
backend:
|
||||
# DNS por-contenedor: el resolver del host no es alcanzable desde los contenedores;
|
||||
# el backend debe resolver workspace.aduanasoft.com (Hub) en runtime.
|
||||
dns:
|
||||
- "8.8.8.8"
|
||||
- "1.1.1.1"
|
||||
ports: !override
|
||||
- "127.0.0.1:8000:8000"
|
||||
# Sesión local del CRM (patrón SIWEB). El backend toma su config de esta lista
|
||||
# (no lee el .env dentro del contenedor), así que los flags van aquí. Valores
|
||||
# desde el .env por sustitución. SESSION_STORE_ENABLED=false revierte al comportamiento previo.
|
||||
environment:
|
||||
- SESSION_STORE_ENABLED=${SESSION_STORE_ENABLED:-true}
|
||||
- SESSION_IDLE_MINUTES=${SESSION_IDLE_MINUTES:-30}
|
||||
- SESSION_MAX_HOURS=${SESSION_MAX_HOURS:-10}
|
||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--log-level", "info"]
|
||||
|
||||
frontend:
|
||||
dns:
|
||||
- "8.8.8.8"
|
||||
- "1.1.1.1"
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile.prod
|
||||
@@ -36,11 +21,8 @@ services:
|
||||
VITE_KEYCLOAK_CLIENT_ID: ${KEYCLOAK_CLIENT_ID:-aduanasoft}
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
# El callback OIDC (/auth/callback) intercambia el código por tokens con el
|
||||
# secret del client confidencial 'aduanasoft'. El compose base no lo pasa al frontend.
|
||||
- KEYCLOAK_CLIENT_SECRET=${KEYCLOAK_CLIENT_SECRET:-}
|
||||
volumes: !override []
|
||||
command: !override ["node", "build/index.js"]
|
||||
command: !override ["pnpm", "start"]
|
||||
ports: !override
|
||||
- "127.0.0.1:5173:5173"
|
||||
|
||||
@@ -49,10 +31,3 @@ services:
|
||||
|
||||
minio:
|
||||
ports: !override []
|
||||
|
||||
# En el server no existe el Hub local: app-hub deja de ser red externa y se crea local.
|
||||
# El CRM alcanza el Hub por su URL pública (workspace.aduanasoft.com), no por esta red.
|
||||
networks:
|
||||
app-hub:
|
||||
external: false
|
||||
driver: bridge
|
||||
|
||||
@@ -49,8 +49,7 @@ FROM node:22-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# wget de busybox ya viene en alpine; el apk es best-effort (redes restringidas sin CDN de Alpine)
|
||||
RUN apk add --no-cache wget || true
|
||||
RUN apk add --no-cache wget
|
||||
|
||||
RUN npm config set strict-ssl false && \
|
||||
npm install -g pnpm
|
||||
|
||||
@@ -28,8 +28,11 @@ export interface Account {
|
||||
email: string | null;
|
||||
phone: 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;
|
||||
cfdi_use: string | null;
|
||||
tax_regime_id: number | null;
|
||||
cfdi_use_id: number | null;
|
||||
payment_method: string | null;
|
||||
payment_form: string | null;
|
||||
currency: string | null;
|
||||
|
||||
@@ -57,9 +57,7 @@ export const permissionsAPI = {
|
||||
if (params?.action) queryParams.set('action', params.action);
|
||||
if (params?.search) queryParams.set('search', params.search);
|
||||
const query = queryParams.toString();
|
||||
// Sin slash final: la ruta backend es @router.get("") = /v1/core/permissions.
|
||||
// Con slash FastAPI responde 307 y el cliente server-side no lo sigue.
|
||||
const response = await api.get(`/v1/core/permissions${query ? '?' + query : ''}`);
|
||||
const response = await api.get(`/v1/core/permissions/${query ? '?' + query : ''}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -67,7 +65,7 @@ export const permissionsAPI = {
|
||||
* Obtener un permiso por ID
|
||||
*/
|
||||
async getById(id: number): Promise<Permission> {
|
||||
const response = await api.get(`/v1/core/permissions/${id}`);
|
||||
const response = await api.get(`/v1/core/permissions/${id}/`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -75,7 +73,7 @@ export const permissionsAPI = {
|
||||
* Crear un nuevo permiso
|
||||
*/
|
||||
async create(data: CreatePermissionData): Promise<Permission> {
|
||||
const response = await api.post('/v1/core/permissions', data);
|
||||
const response = await api.post('/v1/core/permissions/', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -83,7 +81,7 @@ export const permissionsAPI = {
|
||||
* Actualizar un permiso
|
||||
*/
|
||||
async update(id: number, data: UpdatePermissionData): Promise<Permission> {
|
||||
const response = await api.put(`/v1/core/permissions/${id}`, data);
|
||||
const response = await api.put(`/v1/core/permissions/${id}/`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -91,14 +89,14 @@ export const permissionsAPI = {
|
||||
* Eliminar un permiso
|
||||
*/
|
||||
async delete(id: number): Promise<void> {
|
||||
await api.delete(`/v1/core/permissions/${id}`);
|
||||
await api.delete(`/v1/core/permissions/${id}/`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Obtener módulos únicos
|
||||
*/
|
||||
async getModules(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/modules');
|
||||
const response = await api.get('/v1/core/permissions/modules/');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
@@ -106,7 +104,7 @@ export const permissionsAPI = {
|
||||
* Obtener acciones únicas
|
||||
*/
|
||||
async getActions(): Promise<string[]> {
|
||||
const response = await api.get('/v1/core/permissions/actions');
|
||||
const response = await api.get('/v1/core/permissions/actions/');
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -48,11 +48,7 @@ export const rolePermissionsAPI = {
|
||||
companyId: number,
|
||||
data: AssignPermissionData
|
||||
): Promise<RolePermission> {
|
||||
// El backend recibe permission_id como query param (no en el body).
|
||||
const response = await api.post(
|
||||
`/v1/core/permissions/roles/${roleId}/permissions?permission_id=${data.permission_id}&company_id=${companyId}`,
|
||||
{}
|
||||
);
|
||||
const response = await api.post(`/v1/core/permissions/roles/${roleId}/permissions?company_id=${companyId}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
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';
|
||||
|
||||
export * from './catalogs';
|
||||
export * from './concepts';
|
||||
export * from './issuer';
|
||||
|
||||
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
|
||||
|
||||
export interface Invoice {
|
||||
@@ -33,6 +37,11 @@ export interface Invoice {
|
||||
owner_user_id: string | null;
|
||||
created_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;
|
||||
company_id: number;
|
||||
created_at: string;
|
||||
@@ -43,17 +52,26 @@ export type InvoiceInput = Partial<Omit<Invoice, 'id' | 'status' | 'subtotal' |
|
||||
export interface InvoiceItem {
|
||||
id: number;
|
||||
invoice_id: number;
|
||||
/** Texto libre que consume el PDF; se hereda del catálogo cuando hay `concept_id`. */
|
||||
concept: string;
|
||||
description: string | null;
|
||||
quantity: number;
|
||||
unit_amount: 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;
|
||||
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'>> & {
|
||||
invoice_id: number;
|
||||
concept: string;
|
||||
};
|
||||
|
||||
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!;
|
||||
}
|
||||
};
|
||||
@@ -407,12 +407,10 @@ export const initAuth = async (): Promise<boolean> => {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sin token local: no hay sesión en el cliente. El login entra SIEMPRE por
|
||||
// el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange);
|
||||
// el CRM NO inicializa Keycloak en el browser. Si no hay token, el layout
|
||||
// del servidor reenvía al Workspace.
|
||||
// Sin token local, intentar Keycloak JS (flujo SSO)
|
||||
const authenticated = await initKeycloak();
|
||||
authStore.setLoading(false);
|
||||
return false;
|
||||
return authenticated;
|
||||
} catch (err) {
|
||||
console.error('[auth] Error en initAuth:', err);
|
||||
authStore.setLoading(false);
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { AccountInput } from '$lib/api/crm';
|
||||
import { satCatalogsAPI, type SatCatalogItem, type SatTaxRegime } from '$lib/api/fin';
|
||||
import {
|
||||
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES,
|
||||
COMMERCIAL_CLASSIFICATION, CONTACT_METHODS
|
||||
} 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.
|
||||
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 =
|
||||
'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>
|
||||
{:else if tab === 'fiscal'}
|
||||
<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"><span class="font-medium">Uso de CFDI</span><input class={inputCls} bind:value={form.cfdi_use} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<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">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>
|
||||
|
||||
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>
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Briefcase,
|
||||
Ship,
|
||||
Receipt,
|
||||
Building,
|
||||
} from '@lucide/svelte';
|
||||
|
||||
export type SystemContext = 'fixed_asset' | 'inventory';
|
||||
@@ -68,13 +67,9 @@ export function getNavMain(): NavMainItem[] {
|
||||
icon: Receipt,
|
||||
items: [
|
||||
{ title: 'Facturas y cobranza', url: '/dashboard/fin/facturas' },
|
||||
{ title: 'Conceptos', url: '/dashboard/fin/conceptos', permission: 'fin.concept.view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Compañías',
|
||||
url: '/dashboard/companias',
|
||||
icon: Building,
|
||||
},
|
||||
{
|
||||
title: 'Usuarios',
|
||||
url: '/dashboard/users',
|
||||
@@ -89,6 +84,10 @@ export function getNavMain(): NavMainItem[] {
|
||||
title: 'Configuración',
|
||||
url: '/dashboard/settings/general',
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{ title: 'General', url: '/dashboard/settings/general' },
|
||||
{ title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -145,18 +145,9 @@
|
||||
>
|
||||
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
|
||||
{#if userTenants.length === 0}
|
||||
{#if companyStore.activeCompany?.tenant_name}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<div class="flex size-6 items-center justify-center rounded-md border bg-muted">
|
||||
<BuildingIcon class="size-3.5" />
|
||||
</div>
|
||||
<span class="truncate font-medium">{companyStore.activeCompany.tenant_name}</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Sin tenant asignado</span>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
<DropdownMenu.Item disabled class="gap-2 p-2">
|
||||
<span class="text-muted-foreground">Sin tenant asignado</span>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
{#each userTenants as tenant (tenant.id)}
|
||||
<DropdownMenu.Item
|
||||
|
||||
@@ -82,60 +82,6 @@ export function clearAuthTokens(cookies: Cookies) {
|
||||
cookies.delete('id_token', { path: '/' });
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
cookies.delete('active_system', { path: '/' });
|
||||
// Sesión local del CRM (patrón SIWEB)
|
||||
cookies.delete('kc_access_token', { path: '/' });
|
||||
cookies.delete('crm_sid', { path: '/' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Token de Keycloak para llamadas DIRECTAS al Hub (my-apps, my-tenants,
|
||||
* provisioning). Con el patrón de sesión local (SIWEB) el `access_token` guarda
|
||||
* la sesión local del CRM, así que el token KC vive en su propia cookie
|
||||
* `kc_access_token`. Fallback a `access_token` cuando el patrón está apagado
|
||||
* (kc_access_token ausente) → comportamiento histórico intacto.
|
||||
*/
|
||||
export function getKcAccessToken(cookies: Cookies): string | null {
|
||||
return cookies.get('kc_access_token') ?? getAccessTokenFromCookies(cookies);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aplica a las cookies la respuesta de /v1/auth/refresh considerando la sesión
|
||||
* local del CRM (patrón SIWEB):
|
||||
* - Con `session_token`: `access_token` = sesión local (bearer de la app),
|
||||
* `kc_access_token` = token KC (para el Hub), `crm_sid` = id de sesión valkey.
|
||||
* - Sin él: comportamiento histórico (`access_token` = token KC).
|
||||
* Devuelve el token que la app debe usar para reintentar (la sesión local si aplica).
|
||||
*/
|
||||
export function applyRefreshedTokens(
|
||||
cookies: Cookies,
|
||||
data: { access_token?: string; refresh_token?: string; session_token?: string; session_id?: string }
|
||||
): string | null {
|
||||
const secure = isSecureContext();
|
||||
if (data.session_token) {
|
||||
setAccessTokenCookies(cookies, data.session_token, { secure, maxAge: 60 * 60 * 24 * 7 });
|
||||
// access_token KC vacío = fallback sin token fresco → conservar el actual.
|
||||
if (data.access_token) {
|
||||
cookies.set('kc_access_token', data.access_token, {
|
||||
path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 7
|
||||
});
|
||||
}
|
||||
if (data.session_id) {
|
||||
cookies.set('crm_sid', data.session_id, {
|
||||
path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30
|
||||
});
|
||||
}
|
||||
if (data.refresh_token) {
|
||||
cookies.set('refresh_token', data.refresh_token, {
|
||||
path: '/', httpOnly: true, sameSite: 'lax', secure, maxAge: 60 * 60 * 24 * 30
|
||||
});
|
||||
}
|
||||
return data.session_token;
|
||||
}
|
||||
if (data.access_token) {
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
return data.access_token;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,21 +113,12 @@ export async function refreshAccessToken(
|
||||
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
// Sesión local actual del CRM (patrón SIWEB): se envía para preservar el
|
||||
// inicio de sesión (cap absoluto) y permitir el re-emitido de fallback
|
||||
// cuando el refresh del token KC contra el Hub falla.
|
||||
const currentSession = getAccessTokenFromCookies(cookies);
|
||||
const sessionId = cookies.get('crm_sid');
|
||||
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
...(currentSession ? { session_token: currentSession } : {}),
|
||||
...(sessionId ? { session_id: sessionId } : {})
|
||||
})
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -190,8 +127,10 @@ export async function refreshAccessToken(
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Actualiza las cookies teniendo en cuenta la sesión local (o histórico si no aplica).
|
||||
return applyRefreshedTokens(cookies, data);
|
||||
// Actualizar las cookies con los nuevos tokens
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
|
||||
return data.access_token;
|
||||
} catch (error) {
|
||||
console.error('🔄 [API] Error al refrescar token:', error);
|
||||
return null;
|
||||
|
||||
@@ -5,14 +5,9 @@ const DEFAULT_WORKSPACE_BASE_URL = 'https://workspace.aduanasoft.com';
|
||||
const RETURN_PATH_COOKIE = 'workspace_return_path';
|
||||
|
||||
/**
|
||||
* Autenticación 100% vía el Hub/Workspace (patrón SIWEB). El CRM NUNCA habla
|
||||
* directo a Keycloak: el login entra por el App Launcher del workspace (relay
|
||||
* → /auth/sso → Hub /sso-exchange) y el resto de auth va por la API del Hub.
|
||||
*/
|
||||
|
||||
/**
|
||||
* True solo cuando la URL pública usa HTTPS. Se usa para el flag `secure` de las
|
||||
* cookies (en vez de NODE_ENV) para que funcionen en dev HTTP LAN (192.168.x.x).
|
||||
* Returns true only when the public-facing URL uses HTTPS.
|
||||
* Use this for cookie `secure` flag instead of NODE_ENV so that
|
||||
* cookies work on HTTP LAN dev environments (e.g. 192.168.x.x).
|
||||
*/
|
||||
export function isSecureContext(): boolean {
|
||||
const origin = (env.ORIGIN || process.env.ORIGIN || '').trim();
|
||||
@@ -25,8 +20,9 @@ function stripTrailingSlashes(value: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Detecta URLs solo accesibles localmente (localhost, IPs LAN/privadas, hosts
|
||||
* internos de Docker). No son válidas como URL pública del Workspace.
|
||||
* Detecta si una URL apunta a un host que solo es accesible localmente:
|
||||
* localhost, 127.0.0.1, IPs de red LAN/privada y hostnames internos de Docker.
|
||||
* Estas URLs no son válidas como redirect_uri ni como KC public URL en producción.
|
||||
*/
|
||||
function isDevOnlyUrl(rawUrl: string): boolean {
|
||||
try {
|
||||
@@ -64,14 +60,27 @@ export function getWorkspaceBaseUrl(): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri.
|
||||
* Corrige el caso donde `ORIGIN` env var apunta a localhost en producción.
|
||||
* Normaliza la URL base del sistema (Mi Aplicación) para construir redirect_uri seguros.
|
||||
*
|
||||
* Problema habitual en producción: SvelteKit deriva `url.origin` de la variable de entorno
|
||||
* `ORIGIN`. Si el contenedor se despliega con `ORIGIN=http://localhost:5173` (valor del .env
|
||||
* de dev), todos los redirect_uri generados por el servidor apuntan a localhost.
|
||||
*
|
||||
* Esta función:
|
||||
* 1. Usa `requestOrigin` si ya es una URL pública (no dev-only).
|
||||
* 2. Si es localhost, busca `SITE_URL` (env var de producción recomendada) como fallback.
|
||||
* 3. Como último recurso devuelve requestOrigin tal cual (entorno dev genuino).
|
||||
*
|
||||
* Var de entorno recomendada en producción:
|
||||
* SITE_URL=https://mi-app.dominio.com (además de arreglar ORIGIN)
|
||||
*/
|
||||
export function resolveSystemBaseUrl(requestOrigin: string): string {
|
||||
if (!isDevOnlyUrl(requestOrigin)) {
|
||||
return stripTrailingSlashes(requestOrigin);
|
||||
}
|
||||
|
||||
// requestOrigin es dev-only → ORIGIN env var apunta a localhost en producción.
|
||||
// Buscar URL pública en env vars adicionales.
|
||||
const candidates = [
|
||||
(env.SITE_URL || '').trim(),
|
||||
(env.APP_URL || '').trim(),
|
||||
@@ -84,17 +93,31 @@ export function resolveSystemBaseUrl(requestOrigin: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
// Entorno dev genuino: devolver requestOrigin tal cual
|
||||
return stripTrailingSlashes(requestOrigin);
|
||||
}
|
||||
|
||||
/**
|
||||
* URL de login del Workspace. NO lleva `return_to` a Mi Aplicación: el Hub
|
||||
* muestra el App Launcher y el usuario re-entra al CRM por relay
|
||||
* (→ /auth/sso?relay=). Así se evita el rebote a /login sin sesión (bucle) y no
|
||||
* se usa ningún flujo OIDC directo contra Keycloak.
|
||||
*/
|
||||
export function getWorkspaceLoginUrl(): string {
|
||||
return `${getWorkspaceBaseUrl()}/login`;
|
||||
export type WorkspaceLoginUrlOptions = {
|
||||
/**
|
||||
* URL del login del Hub sin `return_to`. Usar en `post_logout_redirect_uri` para que,
|
||||
* tras logout en KC, el Hub aplique myApps() (launcher si el usuario tiene varias apps).
|
||||
* Con `return_to` a Mi Aplicación, el re-login siempre rebotaba a esa app aunque hubiera más.
|
||||
*/
|
||||
forPostLogout?: boolean;
|
||||
};
|
||||
|
||||
export function getWorkspaceLoginUrl(
|
||||
systemBaseUrl: string,
|
||||
options?: WorkspaceLoginUrlOptions
|
||||
): string {
|
||||
const workspaceBaseUrl = getWorkspaceBaseUrl();
|
||||
if (options?.forPostLogout) {
|
||||
return `${workspaceBaseUrl}/login`;
|
||||
}
|
||||
// return_to includes sso_verified=1 so the workspace preserves it when redirecting
|
||||
// back, regardless of what additional params the workspace appends.
|
||||
const loginUrl = `${systemBaseUrl}/login?sso_verified=1`;
|
||||
return `${workspaceBaseUrl}/login?return_to=${encodeURIComponent(loginUrl)}`;
|
||||
}
|
||||
|
||||
export function storeReturnPath(cookies: Cookies, path: string): void {
|
||||
@@ -108,6 +131,26 @@ export function storeReturnPath(cookies: Cookies, path: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
export function getPublicKeycloakBaseUrl(): string {
|
||||
const configuredKeycloakUrl = (env.VITE_KEYCLOAK_URL || '').trim();
|
||||
// Si VITE_KEYCLOAK_URL apunta a un host dev-only (localhost, IP LAN, Docker service),
|
||||
// ignorarlo y derivar la URL del hostname público del Workspace.
|
||||
// Esto protege contra builds donde el .env de dev llega a producción por error.
|
||||
if (configuredKeycloakUrl && !isDevOnlyUrl(configuredKeycloakUrl)) {
|
||||
return stripTrailingSlashes(configuredKeycloakUrl);
|
||||
}
|
||||
|
||||
return `${getWorkspaceBaseUrl()}/kcauth`;
|
||||
}
|
||||
|
||||
export function getKeycloakRealm(): string {
|
||||
return (env.KEYCLOAK_REALM || env.VITE_KEYCLOAK_REALM || 'master').trim();
|
||||
}
|
||||
|
||||
export function getKeycloakClientId(): string {
|
||||
return (env.KEYCLOAK_CLIENT_ID || env.VITE_KEYCLOAK_CLIENT_ID || 'app-frontend').trim();
|
||||
}
|
||||
|
||||
export function getCleanReturnPath(url: URL): string {
|
||||
const cleanParams = new URLSearchParams(url.searchParams);
|
||||
cleanParams.delete('sso_verified');
|
||||
@@ -143,9 +186,68 @@ export function clearWorkspaceReturnPath(cookies: Cookies): void {
|
||||
cookies.delete(RETURN_PATH_COOKIE, { path: '/' });
|
||||
}
|
||||
|
||||
export function buildKeycloakAuthorizationUrl(systemBaseUrl: string, redirectPath: string): string {
|
||||
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
|
||||
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
|
||||
const redirectUri = `${publicBase}/auth/callback`;
|
||||
const state = JSON.stringify({ redirect_url: redirectPath });
|
||||
const params = new URLSearchParams({
|
||||
client_id: getKeycloakClientId(),
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
prompt: 'none',
|
||||
state
|
||||
});
|
||||
|
||||
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Construye URL de login directo en KC sin prompt=none.
|
||||
* Usa la sesión KC existente si la hay; si no, muestra el form de login.
|
||||
* Usar cuando se recibe ?redirect= del Hub (rompe el loop Hub↔login).
|
||||
*/
|
||||
export function buildKeycloakLoginUrl(systemBaseUrl: string, redirectPath: string): string {
|
||||
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost por ORIGIN env var mal configurado
|
||||
const publicBase = resolveSystemBaseUrl(systemBaseUrl);
|
||||
const redirectUri = `${publicBase}/auth/callback`;
|
||||
const state = JSON.stringify({ redirect_url: redirectPath });
|
||||
const params = new URLSearchParams({
|
||||
client_id: getKeycloakClientId(),
|
||||
redirect_uri: redirectUri,
|
||||
response_type: 'code',
|
||||
scope: 'openid',
|
||||
state
|
||||
});
|
||||
|
||||
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/auth?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
|
||||
// Modo local: nunca salir al workspace, mostrar el login local.
|
||||
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
storeWorkspaceReturnPath(cookies, url);
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
}
|
||||
|
||||
export function redirectToKeycloakAuthorization(systemBaseUrl: string, redirectPath: string): never {
|
||||
throw redirect(303, buildKeycloakAuthorizationUrl(systemBaseUrl, redirectPath));
|
||||
}
|
||||
|
||||
export function redirectToKeycloakLogin(systemBaseUrl: string, redirectPath: string): never {
|
||||
throw redirect(303, buildKeycloakLoginUrl(systemBaseUrl, redirectPath));
|
||||
}
|
||||
|
||||
/**
|
||||
* URL del Hub FastAPI para llamadas server-to-server (ej. sso-exchange).
|
||||
* No aplica isDevOnlyUrl: las URLs internas de Docker son válidas aquí.
|
||||
* Lee HUB_BACKEND_URL (override explícito) → INTERNAL_HUB_URL (ya en docker-compose)
|
||||
* → fallback a URL pública del workspace (vía proxy SvelteKit del Hub).
|
||||
*/
|
||||
export function getHubBackendUrl(): string {
|
||||
const direct =
|
||||
@@ -155,15 +257,19 @@ export function getHubBackendUrl(): string {
|
||||
return getWorkspaceBaseUrl();
|
||||
}
|
||||
|
||||
/**
|
||||
* Redirige al login del Workspace (App Launcher). Único punto de entrada de
|
||||
* login: el CRM no inicia ningún flujo contra Keycloak.
|
||||
*/
|
||||
export function redirectToWorkspaceLogin(cookies: Cookies, url: URL): never {
|
||||
// Modo local: nunca salir al workspace, mostrar el login local.
|
||||
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
|
||||
throw redirect(303, '/login');
|
||||
export function buildKeycloakLogoutUrl(systemBaseUrl: string, idTokenHint?: string): string {
|
||||
const keycloakBaseUrl = getPublicKeycloakBaseUrl();
|
||||
const postLogoutRedirectUri = `${systemBaseUrl}/auth/post-logout`;
|
||||
const params = new URLSearchParams({
|
||||
client_id: getKeycloakClientId(),
|
||||
post_logout_redirect_uri: postLogoutRedirectUri
|
||||
});
|
||||
|
||||
// Con id_token_hint KC acepta cualquier post_logout_redirect_uri sin necesidad
|
||||
// de que esté registrado explícitamente en el cliente.
|
||||
if (idTokenHint) {
|
||||
params.set('id_token_hint', idTokenHint);
|
||||
}
|
||||
storeWorkspaceReturnPath(cookies, url);
|
||||
throw redirect(303, getWorkspaceLoginUrl());
|
||||
}
|
||||
|
||||
return `${keycloakBaseUrl}/realms/${getKeycloakRealm()}/protocol/openid-connect/logout?${params.toString()}`;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
slugifyTenantName,
|
||||
validateTenantForm,
|
||||
hubErrorMessage,
|
||||
isForbiddenStatus,
|
||||
TENANT_SLUG_RE
|
||||
} from './workspace-provision.shared';
|
||||
|
||||
describe('slugifyTenantName', () => {
|
||||
it('convierte nombre con acentos y espacios a slug válido', () => {
|
||||
const slug = slugifyTenantName('Logística Peña & Cía S.A. de C.V.');
|
||||
expect(slug).toBe('logistica-pena-cia-s-a-de-c-v');
|
||||
expect(TENANT_SLUG_RE.test(slug)).toBe(true);
|
||||
});
|
||||
|
||||
it('quita guiones al inicio y al final', () => {
|
||||
expect(slugifyTenantName(' --Hola-- ')).toBe('hola');
|
||||
});
|
||||
|
||||
it('cadena sin caracteres válidos da string vacío', () => {
|
||||
expect(slugifyTenantName('!!!')).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateTenantForm', () => {
|
||||
it('acepta datos válidos', () => {
|
||||
expect(
|
||||
validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'a@b.com' })
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rechaza nombre demasiado corto', () => {
|
||||
expect(
|
||||
validateTenantForm({ name: 'A', slug: 'a-b', contact_email: 'a@b.com' })
|
||||
).toMatch(/al menos 2/);
|
||||
});
|
||||
|
||||
it('rechaza slug con mayúsculas o espacios', () => {
|
||||
expect(
|
||||
validateTenantForm({ name: 'Empresa ABC', slug: 'Empresa ABC', contact_email: 'a@b.com' })
|
||||
).toMatch(/slug/i);
|
||||
});
|
||||
|
||||
it('rechaza email inválido', () => {
|
||||
expect(
|
||||
validateTenantForm({ name: 'Empresa ABC', slug: 'empresa-abc', contact_email: 'no-email' })
|
||||
).toMatch(/email/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hubErrorMessage', () => {
|
||||
it('devuelve el detail string tal cual', () => {
|
||||
expect(hubErrorMessage({ detail: 'Slug ya existe' }, 409)).toBe('Slug ya existe');
|
||||
});
|
||||
|
||||
it('formatea el primer error de validación de Pydantic', () => {
|
||||
const body = { detail: [{ loc: ['body', 'contact_email'], msg: 'value is not a valid email' }] };
|
||||
expect(hubErrorMessage(body, 422)).toBe('contact_email: value is not a valid email');
|
||||
});
|
||||
|
||||
it('mensaje de permisos ante 403 sin detail', () => {
|
||||
expect(hubErrorMessage(null, 403)).toMatch(/permisos/i);
|
||||
});
|
||||
|
||||
it('mensaje genérico con status ante cuerpo desconocido', () => {
|
||||
expect(hubErrorMessage(null, 500)).toMatch(/500/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isForbiddenStatus', () => {
|
||||
it('true para 401 y 403', () => {
|
||||
expect(isForbiddenStatus(401)).toBe(true);
|
||||
expect(isForbiddenStatus(403)).toBe(true);
|
||||
});
|
||||
it('false para otros', () => {
|
||||
expect(isForbiddenStatus(422)).toBe(false);
|
||||
expect(isForbiddenStatus(200)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,85 +0,0 @@
|
||||
/**
|
||||
* Helpers puros para el alta de organizaciones/usuarios en el Workspace (Hub).
|
||||
* Sin dependencias de entorno para poder testearse en aislamiento (Vitest).
|
||||
*/
|
||||
|
||||
// Slug del tenant: mismas reglas que el Hub (TenantCreateDTO.slug → ^[a-z0-9-]+$).
|
||||
export const TENANT_SLUG_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
// Roles válidos para invitar un usuario. Son los que el Hub reconoce en su
|
||||
// flujo de alta (ver ProvisionUserRequestDTO / create_invite). No inventar otros.
|
||||
export const WORKSPACE_INVITE_ROLES = ['user', 'admin', 'supervisor', 'operador', 'visor'] as const;
|
||||
export type WorkspaceInviteRole = (typeof WORKSPACE_INVITE_ROLES)[number];
|
||||
|
||||
/**
|
||||
* Deriva un slug candidato a partir del nombre de la organización:
|
||||
* minúsculas, sin acentos, espacios y símbolos → guiones.
|
||||
*/
|
||||
export function slugifyTenantName(name: string): string {
|
||||
return name
|
||||
.normalize('NFD')
|
||||
.replace(/[̀-ͯ]/g, '') // quita acentos (marcas combinantes Unicode)
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
export type TenantFormValues = {
|
||||
name: string;
|
||||
slug: string;
|
||||
contact_email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Valida los campos obligatorios del alta de organización antes de llamar al Hub,
|
||||
* para dar feedback inmediato sin gastar un round-trip.
|
||||
* Devuelve un mensaje de error o null si es válido.
|
||||
*/
|
||||
export function validateTenantForm(values: TenantFormValues): string | null {
|
||||
if (!values.name || values.name.trim().length < 2) {
|
||||
return 'El nombre de la organización debe tener al menos 2 caracteres.';
|
||||
}
|
||||
if (!TENANT_SLUG_RE.test(values.slug)) {
|
||||
return 'El slug solo admite minúsculas, dígitos y guiones (sin espacios ni acentos).';
|
||||
}
|
||||
if (!values.contact_email || !values.contact_email.includes('@')) {
|
||||
return 'El email de contacto es obligatorio y debe ser válido.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extrae un mensaje legible del cuerpo de error de FastAPI (Hub).
|
||||
* - 401/403 → mensaje de permisos.
|
||||
* - detail string → tal cual.
|
||||
* - detail array (422 Pydantic) → "campo: msg" del primer error.
|
||||
* - cualquier otro → mensaje genérico con el status.
|
||||
*/
|
||||
export function hubErrorMessage(body: unknown, status: number): string {
|
||||
const detail =
|
||||
body && typeof body === 'object' ? (body as { detail?: unknown }).detail : null;
|
||||
|
||||
if (typeof detail === 'string' && detail.trim()) {
|
||||
return detail;
|
||||
}
|
||||
|
||||
if (Array.isArray(detail) && detail.length > 0) {
|
||||
const first = detail[0] as { loc?: unknown[]; msg?: string };
|
||||
const loc = Array.isArray(first.loc) ? first.loc : [];
|
||||
const field = loc.length ? String(loc[loc.length - 1]) : '';
|
||||
const msg = first.msg ?? 'dato inválido';
|
||||
return field ? `${field}: ${msg}` : msg;
|
||||
}
|
||||
|
||||
if (status === 401 || status === 403) {
|
||||
return 'No tienes permisos de administrador en el workspace para esta acción.';
|
||||
}
|
||||
|
||||
return `El workspace respondió con un error (${status}).`;
|
||||
}
|
||||
|
||||
/** True si el status del Hub indica falta de permisos/sesión. */
|
||||
export function isForbiddenStatus(status: number): boolean {
|
||||
return status === 401 || status === 403;
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
/**
|
||||
* Orquestación del Hub (Workspace) para dar de alta ORGANIZACIONES (tenants) y
|
||||
* USUARIOS (invitaciones) desde el CRM.
|
||||
*
|
||||
* Principio de seguridad: SIEMPRE se llama server-side reenviando el token del
|
||||
* usuario autenticado (Bearer). Es el Hub quien valida el permiso —
|
||||
* `hub_admin`/superadmin para tenants, `hub_admin` o `admin` del tenant para
|
||||
* invitaciones. El CRM NO guarda secretos ni hace bypass de autorización.
|
||||
*
|
||||
* Endpoints del Hub (base = getHubBackendUrl()):
|
||||
* GET /api/v1/hub/tenants → lista de organizaciones (solo hub_admin)
|
||||
* POST /api/v1/hub/tenants → crea tenant + realm Keycloak (solo hub_admin)
|
||||
* POST /api/v1/hub/invites → invitación de un solo uso + email (hub_admin | admin del tenant)
|
||||
*/
|
||||
|
||||
import { getHubBackendUrl } from '$lib/server/workspace-auth';
|
||||
import { hubErrorMessage, isForbiddenStatus } from '$lib/server/workspace-provision.shared';
|
||||
|
||||
const HUB_API_PREFIX = '/api/v1/hub';
|
||||
|
||||
function hubUrl(path: string): string {
|
||||
return `${getHubBackendUrl()}${HUB_API_PREFIX}${path}`;
|
||||
}
|
||||
|
||||
export type WorkspaceTenant = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug: string;
|
||||
display_name?: string | null;
|
||||
contact_name?: string | null;
|
||||
contact_email: string;
|
||||
status: string;
|
||||
is_self_hosted: boolean;
|
||||
has_license: boolean;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type WorkspaceInvite = {
|
||||
id: number;
|
||||
email: string;
|
||||
tenant_slug: string;
|
||||
role: string;
|
||||
invite_url: string;
|
||||
expires_at: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type HubResult<T> =
|
||||
| { ok: true; data: T }
|
||||
| { ok: false; status: number; forbidden: boolean; error: string };
|
||||
|
||||
/** Construye el resultado de error a partir de una respuesta no-2xx del Hub. */
|
||||
async function toErrorResult<T>(res: Response): Promise<HubResult<T>> {
|
||||
const body = await res.json().catch(() => null);
|
||||
return {
|
||||
ok: false,
|
||||
status: res.status,
|
||||
forbidden: isForbiddenStatus(res.status),
|
||||
error: hubErrorMessage(body, res.status)
|
||||
};
|
||||
}
|
||||
|
||||
const jsonHeaders = (accessToken: string) => ({
|
||||
Authorization: `Bearer ${accessToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
});
|
||||
|
||||
/** Lista las organizaciones del workspace. Requiere hub_admin (403 si no). */
|
||||
export async function listWorkspaceTenants(
|
||||
accessToken: string,
|
||||
fetch: typeof globalThis.fetch
|
||||
): Promise<HubResult<WorkspaceTenant[]>> {
|
||||
const res = await fetch(hubUrl('/tenants'), {
|
||||
headers: { Authorization: `Bearer ${accessToken}` }
|
||||
});
|
||||
if (!res.ok) return toErrorResult<WorkspaceTenant[]>(res);
|
||||
const data = (await res.json()) as { tenants?: WorkspaceTenant[] };
|
||||
return { ok: true, data: Array.isArray(data.tenants) ? data.tenants : [] };
|
||||
}
|
||||
|
||||
export type CreateTenantInput = {
|
||||
name: string;
|
||||
slug: string;
|
||||
contact_email: string;
|
||||
contact_name?: string;
|
||||
contact_phone?: string;
|
||||
display_name?: string;
|
||||
is_self_hosted: boolean;
|
||||
app_url?: string;
|
||||
};
|
||||
|
||||
/** Crea una organización (tenant) y provisiona su realm en Keycloak. */
|
||||
export async function createWorkspaceTenant(
|
||||
accessToken: string,
|
||||
fetch: typeof globalThis.fetch,
|
||||
input: CreateTenantInput
|
||||
): Promise<HubResult<WorkspaceTenant>> {
|
||||
const res = await fetch(hubUrl('/tenants'), {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(accessToken),
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
if (!res.ok) return toErrorResult<WorkspaceTenant>(res);
|
||||
return { ok: true, data: (await res.json()) as WorkspaceTenant };
|
||||
}
|
||||
|
||||
export type CreateInviteInput = {
|
||||
email: string;
|
||||
tenant_slug: string;
|
||||
role: string;
|
||||
};
|
||||
|
||||
/** Genera una invitación de un solo uso para un usuario en un tenant. */
|
||||
export async function createWorkspaceInvite(
|
||||
accessToken: string,
|
||||
fetch: typeof globalThis.fetch,
|
||||
input: CreateInviteInput
|
||||
): Promise<HubResult<WorkspaceInvite>> {
|
||||
const res = await fetch(hubUrl('/invites'), {
|
||||
method: 'POST',
|
||||
headers: jsonHeaders(accessToken),
|
||||
body: JSON.stringify(input)
|
||||
});
|
||||
if (!res.ok) return toErrorResult<WorkspaceInvite>(res);
|
||||
return { ok: true, data: (await res.json()) as WorkspaceInvite };
|
||||
}
|
||||
@@ -11,8 +11,6 @@ interface Company {
|
||||
rfc?: string;
|
||||
logo?: string;
|
||||
tenant_id: number;
|
||||
tenant_name?: string;
|
||||
tenant_slug?: string;
|
||||
}
|
||||
|
||||
class CompanyStore {
|
||||
@@ -160,9 +158,7 @@ class CompanyStore {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
// tenant_id de la compañía → override para que el backend escale por
|
||||
// ese tenant (necesario cuando el usuario es hub_admin sin tenant en el token).
|
||||
body: JSON.stringify({ companyId: company.id, tenantId: company.tenant_id }),
|
||||
body: JSON.stringify({ companyId: company.id }),
|
||||
credentials: 'include'
|
||||
});
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
|
||||
import { json } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { getServerApiUrl, applyRefreshedTokens, clearAuthTokens } from '$lib/server/api';
|
||||
import { getAccessTokenFromCookies } from '$lib/server/access-token-cookie';
|
||||
import { getServerApiUrl, setAuthTokens } from '$lib/server/api';
|
||||
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||
|
||||
export const POST = async ({ cookies, fetch }: RequestEvent) => {
|
||||
const refreshToken = cookies.get('refresh_token');
|
||||
@@ -27,43 +27,34 @@ export const POST = async ({ cookies, fetch }: RequestEvent) => {
|
||||
try {
|
||||
const baseUrl = getServerApiUrl();
|
||||
|
||||
// Sesión local actual del CRM (patrón SIWEB): se reenvía para preservar el
|
||||
// inicio de sesión y permitir el re-emitido de fallback cuando el refresh KC falla.
|
||||
const currentSession = getAccessTokenFromCookies(cookies);
|
||||
const sessionId = cookies.get('crm_sid');
|
||||
|
||||
const response = await fetch(`${baseUrl}v1/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
refresh_token: refreshToken,
|
||||
...(currentSession ? { session_token: currentSession } : {}),
|
||||
...(sessionId ? { session_id: sessionId } : {})
|
||||
})
|
||||
body: JSON.stringify({ refresh_token: refreshToken })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// El refresh falló y no hubo sesión local que re-emitir (cap superado o
|
||||
// patrón apagado). Limpiar cookies para que el server redirija al login.
|
||||
clearAuthTokens(cookies);
|
||||
// El refresh token expiró o fue invalidado por Keycloak (sesión terminada).
|
||||
// Limpiar las cookies para que el servidor redirigir al login en la siguiente carga.
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
clearAccessTokenCookies(cookies);
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
|
||||
const status = response.status === 401 ? 401 : 400;
|
||||
return json({ error: 'Refresh token expired or invalid' }, { status });
|
||||
}
|
||||
|
||||
const data = (await response.json()) as {
|
||||
access_token?: string;
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
session_token?: string;
|
||||
session_id?: string;
|
||||
expires_in?: number;
|
||||
};
|
||||
|
||||
// Actualiza cookies considerando la sesión local; devuelve el bearer de la app.
|
||||
const appToken = applyRefreshedTokens(cookies, data);
|
||||
// Actualizar las cookies HttpOnly con los nuevos tokens
|
||||
setAuthTokens(cookies, data.access_token, data.refresh_token);
|
||||
|
||||
// Devolver solo el token de app al cliente (sesión local si aplica, o KC).
|
||||
return json({ access_token: appToken ?? data.access_token ?? '' });
|
||||
// Devolver solo el access_token al cliente
|
||||
return json({ access_token: data.access_token });
|
||||
} catch (error) {
|
||||
console.error('[silent-refresh] Error inesperado:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { json } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { getServerApiUrl, getAuthTokens, getKcAccessToken, setAuthTokens } from '$lib/server/api';
|
||||
import { getServerApiUrl, getAuthTokens, setAuthTokens } from '$lib/server/api';
|
||||
|
||||
export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
||||
const body = await request.json();
|
||||
@@ -28,11 +28,9 @@ export const POST = async ({ request, cookies, fetch }: RequestEvent) => {
|
||||
// Modo SSO relay: validar acceso vía Hub y actualizar cookie de override
|
||||
if (tenant_id) {
|
||||
try {
|
||||
// Validación contra el Hub → token KC (el access_token puede ser la sesión local).
|
||||
const kcToken = getKcAccessToken(cookies) ?? accessToken;
|
||||
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
|
||||
headers: { 'Authorization': `Bearer ${kcToken}` },
|
||||
headers: { 'Authorization': `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!tenantsRes.ok) {
|
||||
return json({ error: 'Could not validate tenant access' }, { status: 403 });
|
||||
|
||||
@@ -6,28 +6,22 @@ import type { RequestHandler } from './$types';
|
||||
|
||||
export const POST: RequestHandler = async ({ cookies, request }) => {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const companyId = body?.companyId;
|
||||
const tenantId = body?.tenantId;
|
||||
const { companyId } = await request.json();
|
||||
|
||||
if (!companyId || typeof companyId !== 'number') {
|
||||
return json({ error: 'Invalid company ID' }, { status: 400 });
|
||||
}
|
||||
|
||||
const isProd = process.env.NODE_ENV === 'production';
|
||||
const base = { path: '/', maxAge: 60 * 60 * 24 * 30, sameSite: 'lax' as const, secure: isProd };
|
||||
// Establecer la cookie desde el servidor
|
||||
cookies.set('active_company_id', companyId.toString(), {
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30, // 30 días
|
||||
sameSite: 'lax',
|
||||
httpOnly: false, // Permitir acceso desde JavaScript
|
||||
secure: process.env.NODE_ENV === 'production'
|
||||
});
|
||||
|
||||
// Compañía activa (legible desde JS)
|
||||
cookies.set('active_company_id', companyId.toString(), { ...base, httpOnly: false });
|
||||
|
||||
// Fijar el tenant de la compañía como override → el backend escala por ese
|
||||
// tenant aunque el token no lo traiga (caso hub_admin operando por compañía).
|
||||
if (typeof tenantId === 'number' && Number.isFinite(tenantId)) {
|
||||
cookies.set('sso_tenant_id', tenantId.toString(), { ...base, httpOnly: true });
|
||||
cookies.set('sso_tenant_pub', tenantId.toString(), { ...base, httpOnly: false });
|
||||
}
|
||||
|
||||
return json({ success: true, companyId, tenantId: tenantId ?? null });
|
||||
return json({ success: true, companyId });
|
||||
} catch (error) {
|
||||
console.error('Error setting active company:', error);
|
||||
return json({ error: 'Internal server error' }, { status: 500 });
|
||||
|
||||
@@ -1,15 +1,132 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { redirect, isRedirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||
import {
|
||||
clearWorkspaceReturnPath,
|
||||
getWorkspaceLoginUrl,
|
||||
readWorkspaceReturnPath,
|
||||
storeReturnPath,
|
||||
} from '$lib/server/workspace-auth';
|
||||
|
||||
/**
|
||||
* Callback OIDC — OBSOLETO.
|
||||
*
|
||||
* El CRM ya no inicia flujo de autorización contra Keycloak: el login entra por
|
||||
* el App Launcher del Workspace (relay → /auth/sso → Hub /sso-exchange). Esta
|
||||
* ruta se conserva solo para no romper enlaces viejos; cualquier acceso se
|
||||
* redirige al dashboard (el layout valida la sesión y, si no hay, reenvía al
|
||||
* Workspace). No se intercambia ningún `code` con Keycloak.
|
||||
*/
|
||||
export const load: PageServerLoad = async () => {
|
||||
throw redirect(303, '/dashboard');
|
||||
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
// Obtener el código y state de los query params
|
||||
const code = url.searchParams.get('code');
|
||||
const state = url.searchParams.get('state');
|
||||
const errorParam = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
if (errorParam) {
|
||||
console.error('❌ [Callback Server] KC auth error:', errorParam, errorDescription);
|
||||
// login_required means no KC session exists yet → send to Workspace login.
|
||||
// Preserve the intended destination through the detour so /login can pick it up.
|
||||
if (state) {
|
||||
try {
|
||||
const stateObj = JSON.parse(state);
|
||||
const returnPath = stateObj.redirect_url;
|
||||
if (returnPath && returnPath.startsWith('/') && returnPath !== '/login') {
|
||||
storeReturnPath(cookies, returnPath);
|
||||
}
|
||||
} catch { /* ignore malformed state */ }
|
||||
}
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
console.error('❌ [Callback Server] No se recibió código de autorización');
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
}
|
||||
|
||||
try {
|
||||
// Intercambiar código por tokens usando el backend de Keycloak
|
||||
// En el servidor (SSR), usar KEYCLOAK_URL que apunta a http://keycloak:8080
|
||||
// En producción o fuera de Docker, usar VITE_KEYCLOAK_URL como fallback
|
||||
const KEYCLOAK_URL = process.env.KEYCLOAK_URL || process.env.VITE_KEYCLOAK_URL || 'http://localhost:8080';
|
||||
const KEYCLOAK_REALM = process.env.KEYCLOAK_REALM || process.env.VITE_KEYCLOAK_REALM || 'master';
|
||||
const KEYCLOAK_CLIENT_ID = process.env.KEYCLOAK_CLIENT_ID || process.env.VITE_KEYCLOAK_CLIENT_ID || 'app-backend';
|
||||
const KEYCLOAK_CLIENT_SECRET = process.env.KEYCLOAK_CLIENT_SECRET || '';
|
||||
|
||||
// La redirect_uri debe coincidir exactamente con la registrada en Keycloak.
|
||||
// resolveSystemBaseUrl corrige el caso donde url.origin es localhost porque
|
||||
// ORIGIN env var apunta a localhost en producción (usa SITE_URL como fallback).
|
||||
const { resolveSystemBaseUrl } = await import('$lib/server/workspace-auth');
|
||||
const redirectUri = `${resolveSystemBaseUrl(url.origin)}/auth/callback`;
|
||||
|
||||
const tokenEndpoint = `${KEYCLOAK_URL}/realms/${KEYCLOAK_REALM}/protocol/openid-connect/token`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: code,
|
||||
redirect_uri: redirectUri,
|
||||
client_id: KEYCLOAK_CLIENT_ID,
|
||||
...(KEYCLOAK_CLIENT_SECRET && { client_secret: KEYCLOAK_CLIENT_SECRET })
|
||||
});
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded'
|
||||
},
|
||||
body: body.toString()
|
||||
});
|
||||
|
||||
if (!tokenResponse.ok) {
|
||||
const errorData = await tokenResponse.text();
|
||||
console.error('❌ [Callback Server] Error al intercambiar código:', errorData);
|
||||
throw new Error('Error al obtener tokens');
|
||||
}
|
||||
|
||||
const tokens = await tokenResponse.json();
|
||||
|
||||
// Establecer las cookies en el servidor
|
||||
// access_token → NO HttpOnly (el cliente JS lo usa para el header Authorization)
|
||||
// refresh_token → HttpOnly (el JS nunca lo lee; el servidor lo gestiona)
|
||||
const { isSecureContext } = await import('$lib/server/workspace-auth');
|
||||
const isProduction = isSecureContext();
|
||||
|
||||
setAccessTokenCookies(cookies, tokens.access_token, {
|
||||
secure: isProduction,
|
||||
maxAge: 60 * 60 * 24 * 7 // 7 días
|
||||
});
|
||||
|
||||
if (tokens.refresh_token) {
|
||||
cookies.set('refresh_token', tokens.refresh_token, {
|
||||
path: '/',
|
||||
httpOnly: true, // *** HttpOnly: nunca expuesto a JS ***
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 30 // 30 días
|
||||
});
|
||||
}
|
||||
|
||||
if (tokens.id_token) {
|
||||
cookies.set('id_token', tokens.id_token, {
|
||||
path: '/',
|
||||
httpOnly: true,
|
||||
secure: isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7
|
||||
});
|
||||
}
|
||||
|
||||
// Obtener la URL de redirección del state o ir al dashboard
|
||||
let redirectTo = readWorkspaceReturnPath(cookies, '/dashboard');
|
||||
if (state) {
|
||||
try {
|
||||
const stateObj = JSON.parse(state);
|
||||
redirectTo = stateObj.redirect_url || '/dashboard';
|
||||
} catch (e) {
|
||||
console.warn('⚠️ [Callback Server] No se pudo obtener redirect_url del state');
|
||||
}
|
||||
}
|
||||
|
||||
clearWorkspaceReturnPath(cookies);
|
||||
|
||||
// Redirigir a la página de destino
|
||||
throw redirect(303, redirectTo);
|
||||
|
||||
} catch (err: any) {
|
||||
if (isRedirect(err)) throw err;
|
||||
console.error('❌ [Callback Server] Error procesando autenticación:', err);
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -3,10 +3,11 @@ import type { RequestHandler } from './$types';
|
||||
import { getWorkspaceLoginUrl } from '$lib/server/workspace-auth';
|
||||
|
||||
/**
|
||||
* Ruta de retorno post-logout. El CRM ya no dispara logout contra Keycloak
|
||||
* (el cierre completo se hace desde el Workspace); se conserva por compatibilidad
|
||||
* y redirige al App Launcher del Workspace.
|
||||
* KC redirects here after completing the logout flow.
|
||||
* This URL is covered by the app's registered wildcard in KC (e.g. mi-app.dominio.com/*).
|
||||
* We then send the user to workspace login so it can apply myApps() launcher logic.
|
||||
*/
|
||||
export const GET: RequestHandler = async () => {
|
||||
throw redirect(303, getWorkspaceLoginUrl());
|
||||
export const GET: RequestHandler = async ({ request, url }) => {
|
||||
const systemBaseUrl = url.origin;
|
||||
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
|
||||
};
|
||||
|
||||
@@ -134,32 +134,12 @@ export const load: PageServerLoad = async ({ url, cookies }) => {
|
||||
const isProduction = isSecureContext();
|
||||
console.log('[SSO] ORIGIN-based secure context:', isProduction);
|
||||
|
||||
// Sesión local del CRM (patrón SIWEB): si el refresh proactivo devolvió una
|
||||
// sesión local firmada, el access_token guarda ESA sesión (bearer de la app,
|
||||
// sobrevive aunque el refresh KC del Hub falle) y el token KC va a su propia
|
||||
// cookie kc_access_token (solo para llamadas directas al Hub). Si no vino
|
||||
// (flag apagado), comportamiento histórico: access_token = token KC.
|
||||
const sessionToken = typeof tokens.session_token === 'string' ? tokens.session_token : null;
|
||||
const kcAccessToken = tokens.access_token as string;
|
||||
|
||||
// access_token — NO HttpOnly (Bearer desde JS); fragmentado si el JWT supera ~4KB
|
||||
setAccessTokenCookies(cookies, sessionToken ?? kcAccessToken, {
|
||||
setAccessTokenCookies(cookies, tokens.access_token as string, {
|
||||
secure: isProduction,
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
|
||||
if (sessionToken) {
|
||||
// kc_access_token — HttpOnly; solo el server lo usa para llamar al Hub.
|
||||
cookies.set('kc_access_token', kcAccessToken, {
|
||||
path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 7,
|
||||
});
|
||||
if (typeof tokens.session_id === 'string') {
|
||||
cookies.set('crm_sid', tokens.session_id, {
|
||||
path: '/', httpOnly: true, secure: isProduction, sameSite: 'lax', maxAge: 60 * 60 * 24 * 30,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// refresh_token — HttpOnly (never exposed to JS)
|
||||
if (typeof tokens.refresh_token === 'string') {
|
||||
cookies.set('refresh_token', tokens.refresh_token, {
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { LayoutServerLoad } from './$types';
|
||||
import {
|
||||
validateAuth,
|
||||
getAuthTokens,
|
||||
getKcAccessToken,
|
||||
getUserCompanies,
|
||||
clearAuthTokens
|
||||
} from '$lib/server/api';
|
||||
@@ -31,15 +30,12 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
let myApps: { apps: unknown[]; routing: unknown } = { apps: [], routing: null };
|
||||
|
||||
if (!DEV_LOCAL_AUTH) {
|
||||
// Llamadas DIRECTAS al Hub → token KC (con el patrón de sesión local, el
|
||||
// access_token guarda la sesión local del CRM, no el token de Keycloak).
|
||||
const kcToken = getKcAccessToken(cookies) ?? accessToken;
|
||||
try {
|
||||
const hubUrl = (env.INTERNAL_HUB_URL || env.HUB_URL || 'http://localhost:8001').replace(/\/+$/, '');
|
||||
const tenantOverride = cookies.get('sso_tenant_id');
|
||||
const tenantsRes = await fetch(`${hubUrl}/api/v1/auth/my-tenants`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${kcToken}`,
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
...(tenantOverride ? { 'X-Tenant-Override': tenantOverride } : {})
|
||||
}
|
||||
});
|
||||
@@ -50,7 +46,8 @@ export const load: LayoutServerLoad = async ({ cookies, url, fetch }) => {
|
||||
// No bloquear el dashboard si falla la carga de tenants
|
||||
}
|
||||
|
||||
myApps = await fetchMyApps(kcToken, fetch, cookies.get('sso_tenant_id'));
|
||||
const freshAccessToken = getAuthTokens(cookies).accessToken ?? accessToken;
|
||||
myApps = await fetchMyApps(freshAccessToken, fetch, cookies.get('sso_tenant_id'));
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Building, Plus } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { api } from '$lib/api';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
type Tenant = { id: number; name: string; slug: string };
|
||||
|
||||
let tenants = $state<Tenant[]>([]);
|
||||
let loading = $state(true);
|
||||
let submitting = $state(false);
|
||||
let name = $state('');
|
||||
let rfc = $state('');
|
||||
let tenantId = $state<number | null>(null);
|
||||
|
||||
const companies = $derived(companyStore.companies);
|
||||
|
||||
onMount(async () => {
|
||||
const res = await api.get<Tenant[]>('/v1/auth/assignable-tenants');
|
||||
if (res.data) {
|
||||
tenants = res.data;
|
||||
if (tenants.length === 1) tenantId = tenants[0].id;
|
||||
}
|
||||
await companyStore.loadCompanies();
|
||||
loading = false;
|
||||
});
|
||||
|
||||
async function createCompany() {
|
||||
if (name.trim().length < 2) {
|
||||
toast.error('El nombre de la compañía es obligatorio');
|
||||
return;
|
||||
}
|
||||
if (!tenantId) {
|
||||
toast.error('Selecciona el tenant al que pertenece');
|
||||
return;
|
||||
}
|
||||
submitting = true;
|
||||
try {
|
||||
const res = await api.post<{ id: number }>('/v1/auth/companies', {
|
||||
name: name.trim(),
|
||||
tenant_id: tenantId,
|
||||
rfc: rfc.trim() || null
|
||||
});
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Compañía creada');
|
||||
name = '';
|
||||
rfc = '';
|
||||
await companyStore.loadCompanies();
|
||||
// Seleccionarla como activa para poder trabajar de inmediato.
|
||||
const created = res.data?.id
|
||||
? companyStore.companies.find((c) => c.id === res.data!.id)
|
||||
: null;
|
||||
if (created) await companyStore.setActiveCompany(created);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo crear la compañía');
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Building class="h-6 w-6" /> Compañías
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Da de alta las empresas del CRM. Cada compañía pertenece a un tenant (organización) del
|
||||
Workspace. Al crear una, quedas asignado como administrador y se selecciona como activa.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva compañía</Card.Title>
|
||||
<Card.Description>El tenant lo crea el Workspace; aquí eliges bajo cuál registrar la empresa.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Nombre / Razón social *</span>
|
||||
<input class={inputCls} bind:value={name} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">RFC</span>
|
||||
<input class="font-mono {inputCls}" maxlength="13" bind:value={rfc} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Tenant (Workspace) *</span>
|
||||
<select class={inputCls} bind:value={tenantId}>
|
||||
<option value={null} disabled>Selecciona…</option>
|
||||
{#each tenants as t (t.id)}
|
||||
<option value={t.id}>{t.name} ({t.slug})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button onclick={createCompany} disabled={submitting}>
|
||||
{submitting ? 'Creando…' : 'Crear compañía'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Compañías ({companies.length})</Card.Title>
|
||||
<Card.Description>Empresas a las que tienes acceso.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if companies.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Aún no tienes compañías. Crea una arriba.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4 font-medium">Nombre</th>
|
||||
<th class="py-2 pr-4 font-medium">RFC</th>
|
||||
<th class="py-2 pr-4 font-medium">Tenant</th>
|
||||
<th class="py-2 pr-4 font-medium">Activa</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each companies as c (c.id)}
|
||||
<tr class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{c.name}</td>
|
||||
<td class="py-2 pr-4 font-mono text-xs">{c.rfc ?? '—'}</td>
|
||||
<td class="py-2 pr-4">{c.tenant_id}</td>
|
||||
<td class="py-2 pr-4">
|
||||
{#if companyStore.activeCompany?.id === c.id}
|
||||
<span class="rounded-full bg-emerald-500/15 px-2 py-0.5 text-xs text-emerald-600">activa</span>
|
||||
{:else}
|
||||
<button class="text-xs text-primary hover:underline" onclick={() => companyStore.setActiveCompany(c)}>usar</button>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@
|
||||
</div>
|
||||
|
||||
{#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">
|
||||
<Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<AccountFields bind:form {tab} />
|
||||
<AccountFields bind:form {tab} {companyId} />
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||
<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 { companyStore } from '$lib/stores/company.svelte';
|
||||
import {
|
||||
invoicesAPI, invoiceItemsAPI, paymentsAPI,
|
||||
type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
|
||||
invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI,
|
||||
type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
|
||||
} from '$lib/api/fin';
|
||||
import { accountsAPI, type Account } from '$lib/api/crm';
|
||||
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
|
||||
@@ -20,6 +20,9 @@
|
||||
let items = $state<InvoiceItem[]>([]);
|
||||
let payments = $state<Payment[]>([]);
|
||||
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 tab = $state('conceptos');
|
||||
let loading = $state(false);
|
||||
@@ -29,6 +32,10 @@
|
||||
let addingPay = $state(false);
|
||||
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' });
|
||||
/** 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(() => {
|
||||
const cid = companyId;
|
||||
@@ -40,8 +47,9 @@
|
||||
async function load(cid: number, id: number) {
|
||||
loading = true;
|
||||
try {
|
||||
[invoice, items, payments, accounts] = await Promise.all([
|
||||
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), accountsAPI.list(cid)
|
||||
[invoice, items, payments, accounts, concepts] = await Promise.all([
|
||||
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid),
|
||||
accountsAPI.list(cid), conceptsAPI.list(cid)
|
||||
]);
|
||||
form = { ...invoice };
|
||||
} 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() {
|
||||
if (!companyId) return;
|
||||
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>
|
||||
{#if addingItem}
|
||||
<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">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>
|
||||
@@ -222,7 +276,7 @@
|
||||
<Table.Body>
|
||||
{#each items as it (it.id)}
|
||||
<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">{formatMoney(it.unit_amount, invoice.currency)}</Table.Cell>
|
||||
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>
|
||||
|
||||
@@ -1,231 +1,26 @@
|
||||
<script lang="ts">
|
||||
import { Shield, Plus, Trash2, ChevronRight, ChevronDown } from '@lucide/svelte';
|
||||
import { Shield } from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
|
||||
import { permissionsAPI, type Permission } from '$lib/api/dashboard/admin/permissions';
|
||||
import { rolePermissionsAPI } from '$lib/api/dashboard/admin/role-permissions';
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
let roles = $state<CompanyRole[]>([]);
|
||||
let catalog = $state<Permission[]>([]);
|
||||
let loading = $state(true);
|
||||
let creating = $state(false);
|
||||
let name = $state('');
|
||||
let code = $state('');
|
||||
let description = $state('');
|
||||
let expandedRoleId = $state<number | null>(null);
|
||||
// roleId → Set de permission_id asignados
|
||||
let rolePerms = $state<Record<number, Set<number>>>({});
|
||||
let savingPerm = $state<string | null>(null);
|
||||
|
||||
// Catálogo agrupado por módulo
|
||||
const byModule = $derived.by(() => {
|
||||
const m: Record<string, Permission[]> = {};
|
||||
for (const p of catalog) (m[p.module] ??= []).push(p);
|
||||
return m;
|
||||
});
|
||||
|
||||
function slugify(s: string): string {
|
||||
return s.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!companyId) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const [rRes, pRes] = await Promise.all([
|
||||
rolesAPI.list(companyId, { page_size: 100 }),
|
||||
permissionsAPI.list({ page_size: 1000 })
|
||||
]);
|
||||
roles = rRes.data?.items ?? [];
|
||||
catalog = pRes.items ?? [];
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar roles/permisos');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Recarga los roles cuando cambia la compañía activa —incluida la hidratación
|
||||
// inicial tras refrescar (en onMount el store aún no tenía compañía, por eso
|
||||
// antes no aparecían hasta re-seleccionarla).
|
||||
$effect(() => {
|
||||
const id = companyId;
|
||||
expandedRoleId = null;
|
||||
rolePerms = {};
|
||||
if (id) {
|
||||
void load();
|
||||
} else {
|
||||
roles = [];
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function createRole() {
|
||||
if (!companyId) return;
|
||||
const n = name.trim();
|
||||
if (n.length < 2) {
|
||||
toast.error('El nombre del rol es obligatorio');
|
||||
return;
|
||||
}
|
||||
const c = (code.trim() || slugify(n));
|
||||
creating = true;
|
||||
try {
|
||||
const res = await rolesAPI.create(companyId, { name: n, code: c, description: description.trim() || undefined });
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Rol creado');
|
||||
name = '';
|
||||
code = '';
|
||||
description = '';
|
||||
await load();
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function delRole(r: CompanyRole) {
|
||||
if (!companyId) return;
|
||||
if (!confirm(`¿Eliminar el rol "${r.name}"?`)) return;
|
||||
const res = await rolesAPI.delete(r.id, companyId);
|
||||
if (res.error) {
|
||||
toast.error(res.error);
|
||||
return;
|
||||
}
|
||||
toast.success('Rol eliminado');
|
||||
await load();
|
||||
}
|
||||
|
||||
async function toggleExpand(r: CompanyRole) {
|
||||
if (expandedRoleId === r.id) {
|
||||
expandedRoleId = null;
|
||||
return;
|
||||
}
|
||||
expandedRoleId = r.id;
|
||||
if (!rolePerms[r.id] && companyId) {
|
||||
try {
|
||||
const resp = await rolePermissionsAPI.listByRole(r.id, companyId);
|
||||
rolePerms = { ...rolePerms, [r.id]: new Set((resp.permissions ?? []).map((p) => p.permission_id)) };
|
||||
} catch {
|
||||
rolePerms = { ...rolePerms, [r.id]: new Set() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function togglePerm(roleId: number, perm: Permission, checked: boolean) {
|
||||
if (!companyId) return;
|
||||
savingPerm = `${roleId}:${perm.id}`;
|
||||
try {
|
||||
if (checked) {
|
||||
await rolePermissionsAPI.assign(roleId, companyId, { permission_id: perm.id });
|
||||
rolePerms[roleId]?.add(perm.id);
|
||||
} else {
|
||||
await rolePermissionsAPI.remove(roleId, perm.id, companyId);
|
||||
rolePerms[roleId]?.delete(perm.id);
|
||||
}
|
||||
rolePerms = { ...rolePerms };
|
||||
toast.success(checked ? 'Permiso agregado' : 'Permiso quitado');
|
||||
} catch (e) {
|
||||
toast.error('No se pudo actualizar el permiso');
|
||||
} finally {
|
||||
savingPerm = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Shield class="h-6 w-6" /> Roles y permisos
|
||||
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Shield class="h-6 w-6" />
|
||||
Roles y permisos
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Define roles (carriles) por compañía y qué puede hacer cada uno. Los usuarios se asignan en Usuarios.
|
||||
<p class="text-muted-foreground text-sm mt-1">
|
||||
Gestión de roles y control de acceso.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !companyId}
|
||||
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa para gestionar sus roles.</Card.Content></Card.Root>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nuevo rol</Card.Title>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Nombre *</span><input class={inputCls} bind:value={name} placeholder="Ventas" /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Código</span><input class="font-mono {inputCls}" bind:value={code} placeholder="(auto)" title="Se genera del nombre si lo dejas vacío" /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={description} /></label>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end"><Button onclick={createRole} disabled={creating}>{creating ? 'Creando…' : 'Crear rol'}</Button></div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Roles ({roles.length})</Card.Title>
|
||||
<Card.Description>Haz clic en un rol para ver y editar sus permisos.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content class="space-y-2">
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if roles.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No hay roles. Crea uno arriba.</p>
|
||||
{:else}
|
||||
{#each roles as r (r.id)}
|
||||
<div class="rounded-md border">
|
||||
<div class="flex items-center gap-2 px-3 py-2">
|
||||
<button type="button" class="flex flex-1 items-center gap-2 text-left" onclick={() => toggleExpand(r)}>
|
||||
{#if expandedRoleId === r.id}<ChevronDown class="h-4 w-4" />{:else}<ChevronRight class="h-4 w-4" />{/if}
|
||||
<span class="font-medium">{r.name}</span>
|
||||
<span class="font-mono text-xs text-muted-foreground">{r.code}</span>
|
||||
</button>
|
||||
<Button variant="ghost" size="sm" onclick={() => delRole(r)}><Trash2 class="h-4 w-4 text-destructive" /></Button>
|
||||
</div>
|
||||
{#if expandedRoleId === r.id}
|
||||
<div class="border-t p-3">
|
||||
{#if !rolePerms[r.id]}
|
||||
<p class="text-sm text-muted-foreground">Cargando permisos…</p>
|
||||
{:else}
|
||||
<div class="space-y-4">
|
||||
{#each Object.entries(byModule) as [mod, perms] (mod)}
|
||||
<div>
|
||||
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{mod}</p>
|
||||
<div class="grid gap-1 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{#each perms as p (p.id)}
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="h-4 w-4 rounded border"
|
||||
checked={rolePerms[r.id].has(p.id)}
|
||||
disabled={savingPerm === `${r.id}:${p.id}`}
|
||||
onchange={(e) => togglePerm(r.id, p, (e.currentTarget as HTMLInputElement).checked)}
|
||||
/>
|
||||
<span title={p.code}>{p.description || p.code}</span>
|
||||
</label>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Roles del sistema</Card.Title>
|
||||
<Card.Description>Implementa aquí la gestión de roles y permisos de tu proyecto.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
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">
|
||||
import { Settings2 } from 'lucide-svelte';
|
||||
import { Settings2, Receipt, ChevronRight } from 'lucide-svelte';
|
||||
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>
|
||||
|
||||
<svelte:head>
|
||||
@@ -18,6 +22,25 @@
|
||||
</p>
|
||||
</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.Header>
|
||||
<Card.Title>Configuración del sistema</Card.Title>
|
||||
|
||||
@@ -1,253 +1,23 @@
|
||||
<script lang="ts">
|
||||
import { Users, X, UserPlus, Copy, Check } from '@lucide/svelte';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { Users } from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { usersAPI, type User } from '$lib/api/dashboard/users';
|
||||
import { rolesAPI, type CompanyRole } from '$lib/api/dashboard/admin/roles';
|
||||
import { userRolesAPI, type UserRole } from '$lib/api/dashboard/admin/user-roles';
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-2 py-1 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
let users = $state<User[]>([]);
|
||||
let roles = $state<CompanyRole[]>([]);
|
||||
let assignments = $state<UserRole[]>([]);
|
||||
let loading = $state(true);
|
||||
let busy = $state<string | null>(null);
|
||||
|
||||
// Alta de usuario por invitación
|
||||
let inviteEmail = $state('');
|
||||
let inviteRoleId = $state<number | null>(null);
|
||||
let inviting = $state(false);
|
||||
let inviteUrl = $state<string | null>(null);
|
||||
let copied = $state(false);
|
||||
|
||||
// user_id → asignaciones de rol
|
||||
const rolesByUser = $derived.by(() => {
|
||||
const m: Record<string, UserRole[]> = {};
|
||||
for (const a of assignments) (m[String(a.user_id)] ??= []).push(a);
|
||||
return m;
|
||||
});
|
||||
|
||||
function fullName(u: User): string {
|
||||
const n = `${u.first_name ?? ''} ${u.last_name ?? ''}`.trim();
|
||||
return n || u.username || u.email || u.id;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
if (!companyId) {
|
||||
loading = false;
|
||||
return;
|
||||
}
|
||||
loading = true;
|
||||
try {
|
||||
const [uRes, rRes, arRes] = await Promise.all([
|
||||
usersAPI.list(companyId, { page_size: 100 }).catch(() => ({ users: [] as User[] })),
|
||||
rolesAPI.list(companyId, { page_size: 100 }),
|
||||
userRolesAPI.list(companyId, { page_size: 500 }).catch(() => ({ items: [] as UserRole[] } as any))
|
||||
]);
|
||||
users = uRes.users ?? [];
|
||||
roles = rRes.data?.items ?? [];
|
||||
assignments = arRes.items ?? [];
|
||||
} catch {
|
||||
toast.error('No se pudieron cargar los usuarios');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Recarga al cambiar la compañía activa —incluida la hidratación inicial tras
|
||||
// refrescar (antes no cargaba hasta re-seleccionar la compañía).
|
||||
$effect(() => {
|
||||
const id = companyId;
|
||||
if (id) {
|
||||
void load();
|
||||
} else {
|
||||
users = [];
|
||||
loading = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function assignRole(user: User, roleIdRaw: string) {
|
||||
if (!companyId) return;
|
||||
const roleId = Number(roleIdRaw);
|
||||
if (!roleId) return;
|
||||
if (rolesByUser[String(user.id)]?.some((a) => a.company_role_id === roleId)) {
|
||||
toast.info('El usuario ya tiene ese rol');
|
||||
return;
|
||||
}
|
||||
busy = `assign:${user.id}`;
|
||||
try {
|
||||
await userRolesAPI.assign(companyId, { user_id: String(user.id), company_role_id: roleId });
|
||||
toast.success('Rol asignado');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo asignar el rol');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRole(a: UserRole) {
|
||||
if (!companyId) return;
|
||||
busy = `remove:${a.id}`;
|
||||
try {
|
||||
await userRolesAPI.remove(a.id, companyId);
|
||||
toast.success('Rol removido');
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo remover el rol');
|
||||
} finally {
|
||||
busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteUser() {
|
||||
if (!companyId) return;
|
||||
const email = inviteEmail.trim();
|
||||
if (!email || !email.includes('@')) {
|
||||
toast.error('Ingresa un email válido');
|
||||
return;
|
||||
}
|
||||
if (!inviteRoleId) {
|
||||
toast.error('Selecciona el rol del usuario');
|
||||
return;
|
||||
}
|
||||
inviting = true;
|
||||
inviteUrl = null;
|
||||
try {
|
||||
const res = await usersAPI.invite({ email, company_id: companyId, role_id: inviteRoleId });
|
||||
toast.success('Invitación creada');
|
||||
inviteUrl = res.invite_url ?? null;
|
||||
inviteEmail = '';
|
||||
await load();
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo dar de alta el usuario');
|
||||
} finally {
|
||||
inviting = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyInvite() {
|
||||
if (!inviteUrl) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(inviteUrl);
|
||||
copied = true;
|
||||
toast.success('Enlace copiado');
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch {
|
||||
toast.error('No se pudo copiar');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Users class="h-6 w-6" /> Usuarios
|
||||
<h1 class="text-2xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Users class="h-6 w-6" />
|
||||
Usuarios
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Usuarios de la compañía activa y sus roles. Para dar de alta usuarios nuevos usa Workspace → Usuarios (invitaciones).
|
||||
</p>
|
||||
<p class="text-muted-foreground text-sm mt-1">Gestión de usuarios y accesos.</p>
|
||||
</div>
|
||||
|
||||
{#if !companyId}
|
||||
<Card.Root><Card.Content class="pt-6 text-sm text-muted-foreground">Selecciona una compañía activa.</Card.Content></Card.Root>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2"><UserPlus class="h-4 w-4" /> Dar de alta usuario</Card.Title>
|
||||
<Card.Description>Se envía una invitación por email; el usuario crea su contraseña y queda en la compañía con el rol elegido.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Email *</span>
|
||||
<input type="email" class={inputCls} bind:value={inviteEmail} placeholder="persona@empresa.com" />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Rol *</span>
|
||||
<select class={inputCls} bind:value={inviteRoleId}>
|
||||
<option value={null} disabled>Selecciona…</option>
|
||||
{#each roles as r (r.id)}<option value={r.id}>{r.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center justify-between gap-2">
|
||||
{#if roles.length === 0}
|
||||
<span class="text-xs text-muted-foreground">Primero crea roles en "Roles y permisos".</span>
|
||||
{:else}<span></span>{/if}
|
||||
<Button onclick={inviteUser} disabled={inviting || roles.length === 0}>{inviting ? 'Enviando…' : 'Invitar / dar de alta'}</Button>
|
||||
</div>
|
||||
{#if inviteUrl}
|
||||
<div class="mt-3 flex items-center gap-2 rounded-md border bg-muted/40 p-2">
|
||||
<span class="whitespace-nowrap text-xs text-muted-foreground">Si el correo no llega, comparte:</span>
|
||||
<input class="flex-1 font-mono text-xs {inputCls}" readonly value={inviteUrl} />
|
||||
<Button variant="outline" size="sm" onclick={copyInvite}>{#if copied}<Check class="h-4 w-4" />{:else}<Copy class="h-4 w-4" />{/if}</Button>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Usuarios ({users.length})</Card.Title>
|
||||
<Card.Description>Asigna o quita roles (los roles se definen en Roles y permisos).</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if users.length === 0}
|
||||
<p class="text-sm text-muted-foreground">No hay usuarios en esta compañía todavía.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4 font-medium">Usuario</th>
|
||||
<th class="py-2 pr-4 font-medium">Email</th>
|
||||
<th class="py-2 pr-4 font-medium">Roles</th>
|
||||
<th class="py-2 pr-4 font-medium">Asignar rol</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each users as u (u.id)}
|
||||
<tr class="border-b last:border-0 align-top">
|
||||
<td class="py-2 pr-4">{fullName(u)}</td>
|
||||
<td class="py-2 pr-4 text-muted-foreground">{u.email ?? '—'}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each rolesByUser[String(u.id)] ?? [] as a (a.id)}
|
||||
<span class="inline-flex items-center gap-1 rounded-full bg-primary/10 px-2 py-0.5 text-xs text-primary">
|
||||
{a.company_role?.name ?? a.company_role?.code ?? `rol ${a.company_role_id}`}
|
||||
<button type="button" class="hover:text-destructive" disabled={busy === `remove:${a.id}`} onclick={() => removeRole(a)} title="Quitar rol"><X class="h-3 w-3" /></button>
|
||||
</span>
|
||||
{:else}
|
||||
<span class="text-xs text-muted-foreground">sin rol</span>
|
||||
{/each}
|
||||
</div>
|
||||
</td>
|
||||
<td class="py-2 pr-4">
|
||||
<select
|
||||
class={inputCls}
|
||||
disabled={busy === `assign:${u.id}` || roles.length === 0}
|
||||
onchange={(e) => { const el = e.currentTarget as HTMLSelectElement; assignRole(u, el.value); el.value = ''; }}
|
||||
>
|
||||
<option value="">+ rol…</option>
|
||||
{#each roles as r (r.id)}<option value={r.id}>{r.name}</option>{/each}
|
||||
</select>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Usuarios del sistema</Card.Title>
|
||||
<Card.Description>Implementa aquí la gestión de usuarios de tu proyecto.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<p class="text-sm text-muted-foreground">Sección en construcción.</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
|
||||
@@ -1,78 +0,0 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { getKcAccessToken } from '$lib/server/api';
|
||||
import {
|
||||
listWorkspaceTenants,
|
||||
createWorkspaceTenant,
|
||||
type CreateTenantInput
|
||||
} from '$lib/server/workspace-provision';
|
||||
import { validateTenantForm } from '$lib/server/workspace-provision.shared';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, fetch }) => {
|
||||
const accessToken = getKcAccessToken(cookies);
|
||||
if (!accessToken) {
|
||||
return { tenants: [], forbidden: true, loadError: null };
|
||||
}
|
||||
|
||||
const res = await listWorkspaceTenants(accessToken, fetch);
|
||||
if (!res.ok) {
|
||||
// 401/403 → el usuario no es admin del workspace: se muestra estado informativo,
|
||||
// no un error. Otros status sí se reportan como error de carga.
|
||||
return { tenants: [], forbidden: res.forbidden, loadError: res.forbidden ? null : res.error };
|
||||
}
|
||||
|
||||
return { tenants: res.data, forbidden: false, loadError: null };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
create: async ({ request, cookies, fetch }) => {
|
||||
const accessToken = getKcAccessToken(cookies);
|
||||
if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' });
|
||||
|
||||
const data = await request.formData();
|
||||
const name = String(data.get('name') ?? '').trim();
|
||||
const slug = String(data.get('slug') ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const contact_email = String(data.get('contact_email') ?? '').trim();
|
||||
const contact_name = String(data.get('contact_name') ?? '').trim();
|
||||
const contact_phone = String(data.get('contact_phone') ?? '').trim();
|
||||
const display_name = String(data.get('display_name') ?? '').trim();
|
||||
const app_url = String(data.get('app_url') ?? '').trim();
|
||||
const is_self_hosted = data.get('is_self_hosted') === 'on';
|
||||
|
||||
// Valores para repoblar el formulario si algo falla.
|
||||
const values = {
|
||||
name,
|
||||
slug,
|
||||
contact_email,
|
||||
contact_name,
|
||||
contact_phone,
|
||||
display_name,
|
||||
app_url,
|
||||
is_self_hosted
|
||||
};
|
||||
|
||||
const validationError = validateTenantForm({ name, slug, contact_email });
|
||||
if (validationError) return fail(422, { error: validationError, values });
|
||||
|
||||
// Solo se envían los campos opcionales con valor, para no mandar strings vacíos.
|
||||
const payload: CreateTenantInput = {
|
||||
name,
|
||||
slug,
|
||||
contact_email,
|
||||
is_self_hosted,
|
||||
...(contact_name ? { contact_name } : {}),
|
||||
...(contact_phone ? { contact_phone } : {}),
|
||||
...(display_name ? { display_name } : {}),
|
||||
...(app_url ? { app_url } : {})
|
||||
};
|
||||
|
||||
const res = await createWorkspaceTenant(accessToken, fetch, payload);
|
||||
if (!res.ok) {
|
||||
return fail(res.forbidden ? 403 : 422, { error: res.error, values });
|
||||
}
|
||||
|
||||
return { success: true, tenantName: res.data.name, tenantSlug: res.data.slug };
|
||||
}
|
||||
};
|
||||
@@ -1,180 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { Building2, Plus, ShieldAlert, RefreshCw } from '@lucide/svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
// Valores repoblados tras un fallo de validación del servidor.
|
||||
const v = $derived((form && 'values' in form ? form.values : null) as Record<string, unknown> | null);
|
||||
function prev(field: string): string {
|
||||
const val = v?.[field];
|
||||
return typeof val === 'string' ? val : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Building2 class="h-6 w-6" /> Organizaciones del workspace
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Da de alta un cliente nuevo (tenant). Se crea su realm en Keycloak y su licencia base.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if data.forbidden}
|
||||
<Card.Root>
|
||||
<Card.Content class="flex items-start gap-3 pt-6">
|
||||
<ShieldAlert class="mt-0.5 h-5 w-5 text-amber-500" />
|
||||
<div class="text-sm">
|
||||
<p class="font-medium">Requiere permisos de administrador del workspace</p>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
El alta de organizaciones solo está disponible para administradores del Hub
|
||||
(<code>hub_admin</code>). Tu usuario actual no tiene ese rol.
|
||||
</p>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
{#if data.loadError}
|
||||
<div class="rounded-md border border-destructive/40 bg-destructive/10 px-4 py-3 text-sm text-destructive">
|
||||
No se pudo consultar el workspace: {data.loadError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Alta de organización -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2"><Plus class="h-4 w-4" /> Nueva organización</Card.Title>
|
||||
<Card.Description>El slug identifica al tenant; no se puede cambiar después.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/create"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ result, update }) => {
|
||||
submitting = false;
|
||||
if (result.type === 'success') {
|
||||
toast.success(`Organización creada: ${result.data?.tenantName ?? ''}`);
|
||||
await update({ reset: true });
|
||||
} else if (result.type === 'failure') {
|
||||
toast.error(String(result.data?.error ?? 'No se pudo crear la organización'));
|
||||
await update({ reset: false });
|
||||
} else {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Nombre / Razón social *</span>
|
||||
<input class={inputCls} name="name" required minlength="2" value={prev('name')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Slug *</span>
|
||||
<input
|
||||
class="font-mono {inputCls}"
|
||||
name="slug"
|
||||
required
|
||||
pattern="[a-z0-9-]+"
|
||||
placeholder="empresa-abc"
|
||||
title="Solo minúsculas, dígitos y guiones"
|
||||
value={prev('slug')}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Nombre para mostrar</span>
|
||||
<input class={inputCls} name="display_name" value={prev('display_name')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Contacto (nombre)</span>
|
||||
<input class={inputCls} name="contact_name" value={prev('contact_name')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Email de contacto *</span>
|
||||
<input type="email" class={inputCls} name="contact_email" required value={prev('contact_email')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Teléfono de contacto</span>
|
||||
<input class={inputCls} name="contact_phone" value={prev('contact_phone')} />
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">URL de la app (opcional)</span>
|
||||
<input class={inputCls} name="app_url" placeholder="https://app.empresa-abc.com" value={prev('app_url')} />
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm sm:col-span-2">
|
||||
<input type="checkbox" name="is_self_hosted" class="h-4 w-4 rounded border" />
|
||||
<span>Cliente self-hosted (instalación propia)</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? 'Creando…' : 'Crear organización'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
|
||||
<!-- Organizaciones existentes -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Organizaciones ({data.tenants.length})</Card.Title>
|
||||
<Card.Description>Tenants activos en el workspace.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if data.tenants.length === 0}
|
||||
<p class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<RefreshCw class="h-4 w-4" /> Aún no hay organizaciones registradas.
|
||||
</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="text-left text-muted-foreground">
|
||||
<tr class="border-b">
|
||||
<th class="py-2 pr-4 font-medium">Nombre</th>
|
||||
<th class="py-2 pr-4 font-medium">Slug</th>
|
||||
<th class="py-2 pr-4 font-medium">Contacto</th>
|
||||
<th class="py-2 pr-4 font-medium">Estatus</th>
|
||||
<th class="py-2 pr-4 font-medium">Licencia</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.tenants as t (t.id)}
|
||||
<tr class="border-b last:border-0">
|
||||
<td class="py-2 pr-4">{t.display_name || t.name}</td>
|
||||
<td class="py-2 pr-4 font-mono text-xs">{t.slug}</td>
|
||||
<td class="py-2 pr-4">{t.contact_email}</td>
|
||||
<td class="py-2 pr-4">
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-xs {t.status === 'active'
|
||||
? 'bg-emerald-500/15 text-emerald-600'
|
||||
: 'bg-muted text-muted-foreground'}"
|
||||
>
|
||||
{t.status}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2 pr-4">{t.has_license ? 'Sí' : '—'}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -1,60 +0,0 @@
|
||||
import { fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { getUserCompanies } from '$lib/server/api';
|
||||
import { createWorkspaceInvite } from '$lib/server/workspace-provision';
|
||||
import { WORKSPACE_INVITE_ROLES } from '$lib/server/workspace-provision.shared';
|
||||
|
||||
export const load: PageServerLoad = async ({ cookies, fetch }) => {
|
||||
// La organización destino sale de las compañías del usuario (mismo origen que
|
||||
// el switcher, vía /v1/auth/my-companies). Así no se escribe el slug a mano ni
|
||||
// se depende de listar todos los tenants del Hub con un token KC que caduca.
|
||||
const companiesRaw = await getUserCompanies(cookies, fetch);
|
||||
const companies = (companiesRaw ?? [])
|
||||
.filter((c) => c && c.tenant_slug)
|
||||
.map((c) => ({
|
||||
id: c.id as number,
|
||||
name: c.name as string,
|
||||
tenant_slug: c.tenant_slug as string,
|
||||
tenant_name: (c.tenant_name as string) || (c.tenant_slug as string)
|
||||
}));
|
||||
|
||||
return { companies, roles: WORKSPACE_INVITE_ROLES };
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
invite: async ({ request, cookies, fetch }) => {
|
||||
const accessToken = getKcAccessToken(cookies);
|
||||
if (!accessToken) return fail(401, { error: 'Tu sesión expiró. Vuelve a entrar al CRM.' });
|
||||
|
||||
const data = await request.formData();
|
||||
const email = String(data.get('email') ?? '').trim();
|
||||
const tenant_slug = String(data.get('tenant_slug') ?? '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const role = String(data.get('role') ?? 'user').trim();
|
||||
|
||||
const values = { email, tenant_slug, role };
|
||||
|
||||
if (!email || !email.includes('@')) {
|
||||
return fail(422, { error: 'Ingresa un email válido para invitar.', values });
|
||||
}
|
||||
if (!tenant_slug) {
|
||||
return fail(422, { error: 'Selecciona la compañía destino.', values });
|
||||
}
|
||||
if (!WORKSPACE_INVITE_ROLES.includes(role as (typeof WORKSPACE_INVITE_ROLES)[number])) {
|
||||
return fail(422, { error: 'Rol inválido.', values });
|
||||
}
|
||||
|
||||
const res = await createWorkspaceInvite(accessToken, fetch, { email, tenant_slug, role });
|
||||
if (!res.ok) {
|
||||
return fail(res.forbidden ? 403 : 422, { error: res.error, values });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
email: res.data.email,
|
||||
inviteUrl: res.data.invite_url,
|
||||
expiresAt: res.data.expires_at
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -1,141 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { UserPlus, Copy, Check, MailCheck } from '@lucide/svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import type { PageData, ActionData } from './$types';
|
||||
|
||||
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||
|
||||
let submitting = $state(false);
|
||||
let copied = $state(false);
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
|
||||
const v = $derived((form && 'values' in form ? form.values : null) as Record<string, unknown> | null);
|
||||
function prev(field: string): string {
|
||||
const val = v?.[field];
|
||||
return typeof val === 'string' ? val : '';
|
||||
}
|
||||
|
||||
// Invitación recién creada (para copiar/compartir el enlace).
|
||||
const created = $derived(
|
||||
form && 'success' in form && form.success
|
||||
? { email: form.email, inviteUrl: form.inviteUrl }
|
||||
: null
|
||||
);
|
||||
|
||||
async function copyInvite(url: string) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
copied = true;
|
||||
toast.success('Enlace de invitación copiado');
|
||||
setTimeout(() => (copied = false), 2000);
|
||||
} catch {
|
||||
toast.error('No se pudo copiar el enlace');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<UserPlus class="h-6 w-6" /> Alta de usuarios (invitaciones)
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Invita a un usuario a una organización del workspace. Recibe un enlace de un solo uso para
|
||||
fijar su contraseña y activarse.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if created}
|
||||
<Card.Root class="border-emerald-500/40">
|
||||
<Card.Content class="pt-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<MailCheck class="mt-0.5 h-5 w-5 text-emerald-600" />
|
||||
<div class="min-w-0 flex-1 text-sm">
|
||||
<p class="font-medium">Invitación enviada a {created.email}</p>
|
||||
<p class="mt-1 text-muted-foreground">
|
||||
Si el correo no llega, comparte este enlace directamente:
|
||||
</p>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<input class="flex-1 font-mono text-xs {inputCls}" readonly value={created.inviteUrl} />
|
||||
<Button variant="outline" size="sm" onclick={() => copyInvite(String(created.inviteUrl))}>
|
||||
{#if copied}<Check class="h-4 w-4" />{:else}<Copy class="h-4 w-4" />{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Nueva invitación</Card.Title>
|
||||
<Card.Description>Elige la compañía destino y el rol del usuario.</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<form
|
||||
method="POST"
|
||||
action="?/invite"
|
||||
use:enhance={() => {
|
||||
submitting = true;
|
||||
return async ({ result, update }) => {
|
||||
submitting = false;
|
||||
if (result.type === 'success') {
|
||||
toast.success('Invitación creada');
|
||||
await update({ reset: true });
|
||||
} else if (result.type === 'failure') {
|
||||
toast.error(String(result.data?.error ?? 'No se pudo crear la invitación'));
|
||||
await update({ reset: false });
|
||||
} else {
|
||||
await update();
|
||||
}
|
||||
};
|
||||
}}
|
||||
>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Email del usuario *</span>
|
||||
<input type="email" class={inputCls} name="email" required value={prev('email')} />
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Compañía destino *</span>
|
||||
{#if data.companies.length > 0}
|
||||
<select class={inputCls} name="tenant_slug" required value={prev('tenant_slug')}>
|
||||
<option value="" disabled>Selecciona…</option>
|
||||
{#each data.companies as c (c.id)}
|
||||
<option value={c.tenant_slug}>{c.name} — {c.tenant_name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{:else}
|
||||
<span class="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">
|
||||
No hay compañías disponibles en tu tenant. Crea una en Compañías.
|
||||
</span>
|
||||
<input type="hidden" name="tenant_slug" value="" />
|
||||
{/if}
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Rol *</span>
|
||||
<select class={inputCls} name="role" required value={prev('role') || 'user'}>
|
||||
{#each data.roles as r (r)}
|
||||
<option value={r}>{r}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button type="submit" disabled={submitting || data.companies.length === 0}>
|
||||
{submitting ? 'Enviando…' : 'Enviar invitación'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -1,7 +1,7 @@
|
||||
import { redirect, fail } from '@sveltejs/kit';
|
||||
import type { PageServerLoad, Actions } from './$types';
|
||||
import { getServerApiUrl, getAuthTokens } from '$lib/server/api';
|
||||
import { redirectToWorkspaceLogin } from '$lib/server/workspace-auth';
|
||||
import { redirectToKeycloakLogin } from '$lib/server/workspace-auth';
|
||||
|
||||
export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
const code = url.searchParams.get('code')?.toUpperCase().trim() ?? '';
|
||||
@@ -13,7 +13,7 @@ export const load: PageServerLoad = async ({ url, cookies, fetch }) => {
|
||||
|
||||
if (!accessToken) {
|
||||
// Sesión KC expiró entre redirecciones — volver a auth
|
||||
redirectToWorkspaceLogin(cookies, url);
|
||||
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
|
||||
}
|
||||
|
||||
const apiUrl = getServerApiUrl();
|
||||
@@ -76,7 +76,7 @@ export const actions: Actions = {
|
||||
|
||||
if (!accessToken) {
|
||||
// Redirigir a Keycloak; al volver, el callback irá a /join?code=XXX&step=consume
|
||||
redirectToWorkspaceLogin(cookies, url);
|
||||
redirectToKeycloakLogin(url.origin, `/join?code=${code}&step=consume`);
|
||||
}
|
||||
|
||||
// Si ya hay sesión, consumir directamente vía redirect a step=consume
|
||||
|
||||
@@ -4,9 +4,12 @@ import type { Actions, PageServerLoad } from './$types';
|
||||
import { clearAuthTokens, getAuthTokens } from '$lib/server/api';
|
||||
import { setAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||
import {
|
||||
getWorkspaceLoginUrl,
|
||||
readWorkspaceReturnPath,
|
||||
clearWorkspaceReturnPath,
|
||||
redirectToWorkspaceLogin,
|
||||
storeReturnPath,
|
||||
redirectToKeycloakAuthorization,
|
||||
redirectToKeycloakLogin,
|
||||
getHubBackendUrl,
|
||||
isSecureContext,
|
||||
} from '$lib/server/workspace-auth';
|
||||
@@ -62,17 +65,29 @@ export const load: PageServerLoad = async ({ cookies, url }) => {
|
||||
|
||||
clearAuthTokens(cookies);
|
||||
|
||||
// Vuelta desde el Workspace tras autenticarse: el CRM NO inicia ningún flujo
|
||||
// OIDC contra Keycloak. La sesión se obtiene por relay del App Launcher
|
||||
// (→ /auth/sso). Si el usuario llega aquí ya autenticado en el Hub, se le
|
||||
// manda al dashboard; si no hay sesión local, el layout lo reenvía al
|
||||
// Workspace (App Launcher) para re-entrar por relay.
|
||||
if (url.searchParams.get('sso_verified') === '1') {
|
||||
throw redirect(303, '/dashboard');
|
||||
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
|
||||
const intendedPath =
|
||||
existingReturnPath && existingReturnPath !== '/login'
|
||||
? existingReturnPath
|
||||
: (url.searchParams.get('redirect') || '/dashboard');
|
||||
storeReturnPath(cookies, intendedPath);
|
||||
redirectToKeycloakAuthorization(url.origin, intendedPath);
|
||||
}
|
||||
|
||||
// Sin sesión → App Launcher del Workspace (relay). Nunca Keycloak directo.
|
||||
redirectToWorkspaceLogin(cookies, url);
|
||||
const redirectParam = url.searchParams.get('redirect');
|
||||
if (redirectParam) {
|
||||
const existingReturnPath = readWorkspaceReturnPath(cookies, '');
|
||||
const intendedPath =
|
||||
existingReturnPath && existingReturnPath !== '/login'
|
||||
? existingReturnPath
|
||||
: redirectParam;
|
||||
storeReturnPath(cookies, intendedPath);
|
||||
redirectToKeycloakLogin(url.origin, intendedPath);
|
||||
}
|
||||
|
||||
storeReturnPath(cookies, '/dashboard');
|
||||
throw redirect(303, getWorkspaceLoginUrl(url.origin));
|
||||
};
|
||||
|
||||
export const actions: Actions = {
|
||||
|
||||
@@ -1,28 +1,37 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type { RequestHandler } from './$types';
|
||||
import { clearAuthTokens } from '$lib/server/api';
|
||||
import { clearWorkspaceReturnPath, getWorkspaceLoginUrl } from '$lib/server/workspace-auth';
|
||||
import { clearAccessTokenCookies } from '$lib/server/access-token-cookie';
|
||||
import {
|
||||
buildKeycloakLogoutUrl,
|
||||
clearWorkspaceReturnPath,
|
||||
getWorkspaceLoginUrl
|
||||
} from '$lib/server/workspace-auth';
|
||||
|
||||
/**
|
||||
* Logout del CRM. Limpia la sesión LOCAL (cookies) y devuelve al Workspace.
|
||||
* NO habla directo a Keycloak: el cierre de sesión completo (Hub/KC) se hace
|
||||
* desde el Workspace. Patrón SIWEB.
|
||||
*/
|
||||
export const POST: RequestHandler = async ({ cookies }) => {
|
||||
// Eliminar todas las cookies de autenticación (incluye sesión local + token KC)
|
||||
clearAuthTokens(cookies);
|
||||
export const POST: RequestHandler = async ({ cookies, url }) => {
|
||||
const systemBaseUrl = url.origin;
|
||||
|
||||
const idToken = cookies.get('id_token');
|
||||
|
||||
// Eliminar todas las cookies de autenticación
|
||||
clearAccessTokenCookies(cookies);
|
||||
cookies.delete('refresh_token', { path: '/' });
|
||||
cookies.delete('id_token', { path: '/' });
|
||||
cookies.delete('active_company_id', { path: '/' });
|
||||
cookies.delete('active_system', { path: '/' });
|
||||
cookies.delete('sso_tenant_id', { path: '/' });
|
||||
cookies.delete('sso_tenant_pub', { path: '/' });
|
||||
clearWorkspaceReturnPath(cookies);
|
||||
|
||||
// Modo local: no hay Workspace — ir al login local.
|
||||
// En modo local no hay Keycloak ni Hub — ir directo al login local.
|
||||
if ((env.DEV_LOCAL_AUTH ?? '').toLowerCase() === 'true') {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
|
||||
// Volver al Workspace (App Launcher). Para cerrar la sesión del Hub por
|
||||
// completo, el usuario cierra sesión desde el Workspace.
|
||||
throw redirect(303, getWorkspaceLoginUrl());
|
||||
// Sin id_token_hint KC rechaza post_logout_redirect_uri no registrado.
|
||||
if (!idToken) {
|
||||
throw redirect(303, getWorkspaceLoginUrl(systemBaseUrl, { forPostLogout: true }));
|
||||
}
|
||||
|
||||
throw redirect(303, buildKeycloakLogoutUrl(systemBaseUrl, idToken));
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user