Compare commits
25 Commits
developmen
...
feature/AS
| Author | SHA1 | Date | |
|---|---|---|---|
| c99cf2c7e9 | |||
| c195c05c0d | |||
| 061b48d30f | |||
| 7c0a04fb3b | |||
| e32c9f110e | |||
| 926a75c5f8 | |||
| 21ea958f33 | |||
| 6e208876f7 | |||
| 61af5c80fe | |||
| 192a2d9d89 | |||
| be45f950b8 | |||
| bc50a7d099 | |||
| d1fcd236e4 | |||
| 5c4df590d4 | |||
| dfb3c8a08d | |||
| f4ef6a037d | |||
| 15717314fd | |||
| ce09e0d30a | |||
| ae0664e987 | |||
| cb2acb11fc | |||
| 5a112a0171 | |||
| 8d9db3505d | |||
| b8b8311ece | |||
| 9cf142add6 | |||
| e24435c74b |
24
.env.example
24
.env.example
@@ -112,3 +112,27 @@ SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||
|
||||
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
|
||||
SPOKE_URLS=""
|
||||
|
||||
# ==================================
|
||||
# PAC COMERCIO DIGITAL (timbrado CFDI)
|
||||
# ==================================
|
||||
# El modo de timbrado se decide POR FACTURA, en fin.invoices.stamping_mode.
|
||||
# Esta variable solo fija con qué valor nacen las facturas que no lo especifican.
|
||||
# pruebas -> pruebas.comercio-digital.mx | produccion -> ws.comercio-digital.mx
|
||||
# Una factura en "produccion" emite un CFDI con validez fiscal real ante el SAT.
|
||||
PAC_DEFAULT_MODE=pruebas
|
||||
# Credenciales del web service (headers usrws / pwdws). NO commitear valores reales:
|
||||
# van en .env, que está en .gitignore.
|
||||
PAC_USER=
|
||||
PAC_PASSWORD=
|
||||
# Opcional: correo al que el PAC notifica el comprobante (header email).
|
||||
PAC_NOTIFICATION_EMAIL=
|
||||
# Clave maestra que cifra las contraseñas de los CSD en la base de datos. OBLIGATORIA para
|
||||
# poder cargar certificados desde Configuración de Facturación. Generarla con:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
# Si se cambia, las contraseñas ya guardadas dejan de poder descifrarse y hay que recargar
|
||||
# los certificados.
|
||||
CSD_ENCRYPTION_KEY=
|
||||
# LEGADO: contraseña global del CSD. Sólo se usa como respaldo si una empresa no tiene la
|
||||
# suya guardada. Lo correcto es cargar el CSD por empresa desde la interfaz.
|
||||
CSD_PASSWORD=
|
||||
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -84,3 +84,8 @@ celerybeat-schedule.*
|
||||
celerybeat.pid
|
||||
backend/api/v1/modules/reports/generated/
|
||||
docker-compose.override.yml
|
||||
|
||||
SUNRISE/
|
||||
# Corredor de la corrida autonoma continua: vive local, no se versiona.
|
||||
automatizacion/
|
||||
docker-compose.dev.yml
|
||||
|
||||
180
backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py
Normal file
180
backend/alembic/versions/c5d6e7f8a9b0_crm_carril_efc.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""Carril CRM -> EFC: columnas efc_* en crm.cases + outbox transaccional (dos tablas).
|
||||
|
||||
El expediente NO se crea aquí: ya existe como ``crm.cases`` (ver d4e5f6a7b8c9), con su folio
|
||||
en ``reference`` y su consecutivo en ``crm.folio_counters``. Esta migración solo le cuelga lo
|
||||
que el carril hacia EFC necesita y crea el outbox. Es aditiva a propósito: la estructura del
|
||||
expediente es de quien la definió, nosotros aportamos la conexión.
|
||||
|
||||
Las seis columnas ``efc_*`` son un ESPEJO de lo que hay en EFC, nunca el handle. El handle con
|
||||
el que el CRM habla de un expediente es su ``id`` y su ``reference``: ``efc_pedimento_id`` es
|
||||
un caché de la resolución y el ``pedimento_app`` del lado de EFC es mutable —se reescribe al
|
||||
completar el provisional—, así que apoyarse en él rompería en cuanto llegue la data real.
|
||||
|
||||
``efc_storage_token`` es INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda
|
||||
los objetos de este expediente. Que no cambie nunca es lo que permite completar el pedimento
|
||||
sin mover un solo archivo.
|
||||
|
||||
``efc_link_state`` nace en 'PENDING' para las filas que ya existen. Eso es deliberado: al
|
||||
encender el carril, el barrido de respaldo replica el histórico. Mientras ``EFC_API_URL`` esté
|
||||
vacía el carril está apagado y no se encola nada.
|
||||
|
||||
Revision ID: c5d6e7f8a9b0
|
||||
Revises: d4e5f6a7b8c9
|
||||
Create Date: 2026-08-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "c5d6e7f8a9b0"
|
||||
down_revision: Union[str, None] = "d4e5f6a7b8c9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- crm.cases: el espejo de EFC ----------
|
||||
op.add_column("cases", sa.Column("efc_organizacion_id", sa.String(length=36), nullable=True), schema="crm")
|
||||
op.add_column("cases", sa.Column("efc_pedimento_id", sa.String(length=36), nullable=True), schema="crm")
|
||||
op.add_column("cases", sa.Column("efc_storage_token", sa.String(length=25), nullable=True), schema="crm")
|
||||
op.add_column(
|
||||
"cases",
|
||||
# PENDING | LINKED | FAILED
|
||||
sa.Column("efc_link_state", sa.String(length=20), nullable=False, server_default=sa.text("'PENDING'")),
|
||||
schema="crm",
|
||||
)
|
||||
# El diagnóstico se guarda en la fila para que se vea en la ficha del expediente, sin
|
||||
# obligar a nadie a ir a los logs del worker.
|
||||
op.add_column("cases", sa.Column("efc_error_code", sa.String(length=60), nullable=True), schema="crm")
|
||||
op.add_column("cases", sa.Column("efc_error_detail", sa.Text(), nullable=True), schema="crm")
|
||||
op.create_index("ix_crm_cases_efc_link_state", "cases", ["efc_link_state"], schema="crm")
|
||||
|
||||
# Relleno del token para los expedientes que ya existían. Sin esto, el barrido de
|
||||
# reconciliación los encola, EFC los rechaza con
|
||||
# {'storage_token': ['This field may not be null.']} y agotan sus 8 intentos hasta
|
||||
# quedar en `failed`: ruido permanente por un dato que se podía derivar.
|
||||
#
|
||||
# La condición de longitud replica la guarda de storage_token(): en los 25 caracteres de
|
||||
# `pedimento_app` caben hasta 6 dígitos de company. Lo que no cabe se queda NULL a
|
||||
# propósito y el encolado lo salta avisando, porque un token recortado apuntaría a la
|
||||
# carpeta de otro expediente y mezclaría documentos en silencio.
|
||||
#
|
||||
# `reference IS NOT NULL` porque el folio es nullable en crm.cases: un expediente sin
|
||||
# folio no tiene con qué identificarse ante EFC.
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE crm.cases
|
||||
SET efc_storage_token = 'CRM-' || company_id::text || '-' || reference
|
||||
WHERE efc_storage_token IS NULL
|
||||
AND reference IS NOT NULL
|
||||
AND length('CRM-' || company_id::text || '-' || reference) <= 25
|
||||
"""
|
||||
)
|
||||
|
||||
# ---------- crm.efc_sync_outbox: metadatos (alta del provisional y completado) ----------
|
||||
op.create_table(
|
||||
"efc_sync_outbox",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
|
||||
sa.Column("kind", sa.String(length=20), nullable=False),
|
||||
sa.Column("payload", sa.JSON(), nullable=False),
|
||||
sa.Column("expediente_ref", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("efc_pedimento_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
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.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema="crm",
|
||||
)
|
||||
op.create_index("ix_crm_efc_sync_outbox_status", "efc_sync_outbox", ["status"], schema="crm")
|
||||
op.create_index("ix_crm_efc_sync_outbox_kind_status", "efc_sync_outbox", ["kind", "status"], schema="crm")
|
||||
op.create_index("ix_crm_efc_sync_outbox_expediente_ref", "efc_sync_outbox", ["expediente_ref"], schema="crm")
|
||||
op.create_index("ix_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", ["tenant_id"], schema="crm")
|
||||
op.create_index("ix_crm_efc_sync_outbox_company_id", "efc_sync_outbox", ["company_id"], schema="crm")
|
||||
op.create_foreign_key(
|
||||
"fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", "tenants",
|
||||
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
|
||||
)
|
||||
|
||||
# ---------- crm.efc_file_outbox: archivos ----------
|
||||
op.create_table(
|
||||
"efc_file_outbox",
|
||||
sa.Column("id", sa.Integer(), nullable=False, autoincrement=True),
|
||||
sa.Column("kind", sa.String(length=30), nullable=False),
|
||||
sa.Column("s3_key", sa.String(length=1024), nullable=False),
|
||||
sa.Column("file_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("content_type", sa.String(length=100), nullable=True),
|
||||
sa.Column("efc_tipo", sa.String(length=40), nullable=False),
|
||||
# La pareja (tabla, id) desambigua entre las DOS secuencias de documentos del CRM:
|
||||
# crm.documents.id = 5 y ops.shipment_documents.id = 5 coexisten.
|
||||
sa.Column("source_table", sa.String(length=30), nullable=False),
|
||||
sa.Column("source_id", sa.Integer(), nullable=True),
|
||||
sa.Column("crm_document_ref", sa.String(length=64), nullable=True),
|
||||
sa.Column("expediente_ref", sa.Integer(), nullable=False),
|
||||
sa.Column("delete_local", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("status", sa.String(length=10), nullable=False, server_default=sa.text("'pending'")),
|
||||
sa.Column("attempts", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.Column("last_error", sa.Text(), nullable=True),
|
||||
sa.Column("sent_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("efc_document_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
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.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema="crm",
|
||||
)
|
||||
op.create_index("ix_crm_efc_file_outbox_status", "efc_file_outbox", ["status"], schema="crm")
|
||||
op.create_index("ix_crm_efc_file_outbox_kind_status", "efc_file_outbox", ["kind", "status"], schema="crm")
|
||||
# Índice de la guarda _ya_entregado, que es lo que se consulta en cada encolado.
|
||||
op.create_index("ix_crm_efc_file_outbox_source", "efc_file_outbox", ["source_table", "source_id"], schema="crm")
|
||||
op.create_index("ix_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", ["expediente_ref"], schema="crm")
|
||||
op.create_index("ix_crm_efc_file_outbox_tenant_id", "efc_file_outbox", ["tenant_id"], schema="crm")
|
||||
op.create_index("ix_crm_efc_file_outbox_company_id", "efc_file_outbox", ["company_id"], schema="crm")
|
||||
op.create_foreign_key(
|
||||
"fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", "tenants",
|
||||
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
|
||||
)
|
||||
# expediente_ref -> crm.cases.id. El nombre de la columna conserva el término del dominio:
|
||||
# crm.cases ES el expediente (así lo nombra su propio docstring), y el carril, EFC y el
|
||||
# ticket hablan de expedientes. Renombrarlo a case_ref solo movería la ambigüedad de sitio.
|
||||
op.create_foreign_key(
|
||||
"fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", "cases",
|
||||
["expediente_ref"], ["id"], source_schema="crm", referent_schema="crm",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", schema="crm", type_="foreignkey")
|
||||
op.drop_constraint("fk_crm_efc_file_outbox_tenant_id", "efc_file_outbox", schema="crm", type_="foreignkey")
|
||||
op.drop_index("ix_crm_efc_file_outbox_company_id", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_tenant_id", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_expediente_ref", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_source", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_kind_status", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_file_outbox_status", table_name="efc_file_outbox", schema="crm")
|
||||
op.drop_table("efc_file_outbox", schema="crm")
|
||||
|
||||
op.drop_constraint("fk_crm_efc_sync_outbox_tenant_id", "efc_sync_outbox", schema="crm", type_="foreignkey")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_company_id", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_tenant_id", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_expediente_ref", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_kind_status", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_index("ix_crm_efc_sync_outbox_status", table_name="efc_sync_outbox", schema="crm")
|
||||
op.drop_table("efc_sync_outbox", schema="crm")
|
||||
|
||||
op.drop_index("ix_crm_cases_efc_link_state", table_name="cases", schema="crm")
|
||||
op.drop_column("cases", "efc_error_detail", schema="crm")
|
||||
op.drop_column("cases", "efc_error_code", schema="crm")
|
||||
op.drop_column("cases", "efc_link_state", schema="crm")
|
||||
op.drop_column("cases", "efc_storage_token", schema="crm")
|
||||
op.drop_column("cases", "efc_pedimento_id", schema="crm")
|
||||
op.drop_column("cases", "efc_organizacion_id", schema="crm")
|
||||
@@ -0,0 +1,88 @@
|
||||
"""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.
|
||||
|
||||
Re-encadenada al integrar main: esta rama y la del CRM habían salido las dos de
|
||||
d5e6f7a8b9c0, y con dos cabezas ``alembic upgrade head`` falla. La historia queda lineal,
|
||||
con las migraciones de facturación detrás de las del CRM.
|
||||
|
||||
Revision ID: f7a8b9c0d1e2
|
||||
Revises: g1h2i3j4k5l6
|
||||
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] = "g1h2i3j4k5l6"
|
||||
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")
|
||||
@@ -0,0 +1,274 @@
|
||||
"""Catálogos SAT (schema sat), conceptos de facturación, datos fiscales del emisor
|
||||
y amarre de facturas y partidas a los catálogos.
|
||||
|
||||
Renumerada al integrar main: nació como e6f7a8b9c0d1, el mismo identificador que la
|
||||
migración de catálogos del CRM, porque ambas ramas salieron de d5e6f7a8b9c0 sin verse. Se
|
||||
renumera ésta y no la del CRM porque aquélla ya está en main y hay otra migración que la
|
||||
referencia por id.
|
||||
|
||||
Revision ID: g1h2i3j4k5l6
|
||||
Revises: c5d6e7f8a9b0
|
||||
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 = "g1h2i3j4k5l6"
|
||||
down_revision: Union[str, None] = "c5d6e7f8a9b0"
|
||||
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,91 @@
|
||||
"""Timbrado de CFDI: ``fin.invoice_stamps`` y ``fin.invoices.stamping_mode``.
|
||||
|
||||
Escrita a mano y no con ``--autogenerate``: el autogenerate de este proyecto arrastra
|
||||
drift preexistente entre los modelos y la base (llaves foráneas de ``core``, cambios de
|
||||
tipo en ``invite_tokens``), y generaba 1,516 operaciones ajenas a este ticket. Aquí van
|
||||
sólo los dos cambios del timbrado.
|
||||
|
||||
Revision ID: h3i4j5k6l7m8
|
||||
Revises: f7a8b9c0d1e2
|
||||
Create Date: 2026-08-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "h3i4j5k6l7m8"
|
||||
down_revision: Union[str, None] = "f7a8b9c0d1e2"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- fin.invoices: modo de timbrado por factura ----------
|
||||
# NOT NULL con server_default: las facturas existentes quedan en 'pruebas', que es el
|
||||
# valor seguro. Marcar como 'produccion' es siempre una decisión explícita.
|
||||
op.add_column(
|
||||
"invoices",
|
||||
sa.Column(
|
||||
"stamping_mode",
|
||||
sa.String(length=12),
|
||||
nullable=False,
|
||||
server_default=sa.text("'pruebas'"),
|
||||
),
|
||||
schema="fin",
|
||||
)
|
||||
|
||||
# ---------- fin.invoice_stamps ----------
|
||||
op.create_table(
|
||||
"invoice_stamps",
|
||||
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_id", sa.Integer(), nullable=False),
|
||||
sa.Column("mode", sa.String(length=12), nullable=False),
|
||||
sa.Column("status", sa.String(length=12), nullable=False, server_default=sa.text("'pendiente'")),
|
||||
# Timbre Fiscal Digital
|
||||
sa.Column("uuid", sa.String(length=36), nullable=True),
|
||||
sa.Column("stamped_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("pac_rfc", sa.String(length=13), nullable=True),
|
||||
sa.Column("sat_cert_number", sa.String(length=20), nullable=True),
|
||||
sa.Column("sat_seal", sa.Text(), nullable=True),
|
||||
sa.Column("cfd_seal", sa.Text(), nullable=True),
|
||||
# Respuesta del PAC
|
||||
sa.Column("pac_code", sa.Integer(), nullable=True),
|
||||
sa.Column("pac_balance", sa.Integer(), nullable=True),
|
||||
sa.Column("error_message", sa.Text(), nullable=True),
|
||||
sa.Column("xml_file_key", sa.String(length=512), nullable=True),
|
||||
sa.Column("created_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.Column("deleted_at", sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(["invoice_id"], ["fin.invoices.id"]),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema="fin",
|
||||
)
|
||||
op.create_index("ix_fin_invoice_stamps_id", "invoice_stamps", ["id"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_stamps_invoice_id", "invoice_stamps", ["invoice_id"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_stamps_status", "invoice_stamps", ["status"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_stamps_uuid", "invoice_stamps", ["uuid"], schema="fin")
|
||||
# Un UUID lo emite el SAT una sola vez. Parcial sobre uuid IS NOT NULL porque los intentos
|
||||
# fallidos no traen UUID y colisionarían entre sí bajo un único convencional.
|
||||
op.create_index(
|
||||
"uq_fin_invoice_stamps_uuid",
|
||||
"invoice_stamps",
|
||||
["uuid"],
|
||||
unique=True,
|
||||
schema="fin",
|
||||
postgresql_where=sa.text("uuid IS NOT NULL AND deleted_at IS NULL"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("uq_fin_invoice_stamps_uuid", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_index("ix_fin_invoice_stamps_uuid", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_index("ix_fin_invoice_stamps_status", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_index("ix_fin_invoice_stamps_invoice_id", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_index("ix_fin_invoice_stamps_id", table_name="invoice_stamps", schema="fin")
|
||||
op.drop_table("invoice_stamps", schema="fin")
|
||||
op.drop_column("invoices", "stamping_mode", schema="fin")
|
||||
43
backend/alembic/versions/i4j5k6l7m8n9_fin_issuer_csd.py
Normal file
43
backend/alembic/versions/i4j5k6l7m8n9_fin_issuer_csd.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""CSD por empresa en ``fin.issuer_settings``.
|
||||
|
||||
Cierra el hueco de que el certificado de sello digital tuviera que dejarse a mano en el
|
||||
almacenamiento y de que su contraseña fuera una variable de entorno global: con varias
|
||||
empresas emisoras eso no funciona, porque cada una tiene su propio certificado.
|
||||
|
||||
La contraseña se guarda cifrada (``core.crypto``); la clave maestra vive en el entorno.
|
||||
|
||||
Escrita a mano, no con ``--autogenerate``: ver la nota de la migración h3i4j5k6l7m8.
|
||||
|
||||
Revision ID: i4j5k6l7m8n9
|
||||
Revises: h3i4j5k6l7m8
|
||||
Create Date: 2026-08-10 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "i4j5k6l7m8n9"
|
||||
down_revision: Union[str, None] = "h3i4j5k6l7m8"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_COLUMNAS = (
|
||||
("csd_cer_file_key", sa.String(length=512)),
|
||||
("csd_key_file_key", sa.String(length=512)),
|
||||
("csd_password_enc", sa.Text()),
|
||||
("csd_cert_number", sa.String(length=20)),
|
||||
("csd_uploaded_at", sa.DateTime()),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for nombre, tipo in _COLUMNAS:
|
||||
op.add_column("issuer_settings", sa.Column(nombre, tipo, nullable=True), schema="fin")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Al revés, para que el orden de la tabla quede como estaba.
|
||||
for nombre, _ in reversed(_COLUMNAS):
|
||||
op.drop_column("issuer_settings", nombre, schema="fin")
|
||||
@@ -0,0 +1,40 @@
|
||||
"""XML enviado y recibido de cada intento de timbrado, en ``fin.invoice_stamps``.
|
||||
|
||||
Hasta ahora sólo se guardaba el XML del timbrado exitoso, que es justo el caso en el que
|
||||
menos falta hace. Cuando el PAC rechaza el comprobante no queda rastro de qué se le mandó
|
||||
ni de qué contestó: el XML sellado vive en memoria durante la petición y desaparece con
|
||||
ella, y el cuerpo de la respuesta también. Estas dos columnas apuntan al par enviado/recibido
|
||||
que se guarda en el almacenamiento por cada intento.
|
||||
|
||||
Escrita a mano, no con ``--autogenerate``: ver la nota de la migración h3i4j5k6l7m8.
|
||||
|
||||
Revision ID: j5k6l7m8n9o0
|
||||
Revises: i4j5k6l7m8n9
|
||||
Create Date: 2026-08-11 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "j5k6l7m8n9o0"
|
||||
down_revision: Union[str, None] = "i4j5k6l7m8n9"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
_COLUMNAS = (
|
||||
("request_xml_file_key", sa.String(length=512)),
|
||||
("response_xml_file_key", sa.String(length=512)),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
for nombre, tipo in _COLUMNAS:
|
||||
op.add_column("invoice_stamps", sa.Column(nombre, tipo, nullable=True), schema="fin")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# Al revés, para que el orden de la tabla quede como estaba.
|
||||
for nombre, _ in reversed(_COLUMNAS):
|
||||
op.drop_column("invoice_stamps", nombre, schema="fin")
|
||||
@@ -8,7 +8,7 @@ from datetime import datetime
|
||||
from typing import Set, Optional, List
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import and_, or_
|
||||
from sqlalchemy import and_, or_, text
|
||||
|
||||
from core.database import RLS_TENANT_KEY
|
||||
from .cache import PermissionCache
|
||||
@@ -51,8 +51,18 @@ class PermissionService:
|
||||
return None
|
||||
|
||||
try:
|
||||
# Sin modelo de compañía en la plantilla — implementa la consulta aquí.
|
||||
pass
|
||||
# La tabla de compañías del CRM es ``a76.company`` (esquema legado). No tiene modelo
|
||||
# ORM en este proyecto, así que se consulta con SQL crudo, igual que ``seed_crm.py``.
|
||||
#
|
||||
# Sin este respaldo la función devolvía None siempre que la sesión no traía contexto
|
||||
# RLS, y con ella se scopean las consultas de permisos de los cinco llamadores de
|
||||
# abajo: el efecto neto era que nadie resolvía permisos.
|
||||
row = self.db.execute(
|
||||
text("SELECT tenant_id FROM a76.company WHERE id = :company_id"),
|
||||
{"company_id": company_id},
|
||||
).first()
|
||||
if row is not None and row[0] is not None:
|
||||
return int(row[0])
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"resolve_tenant_id_for_company_failed",
|
||||
@@ -470,8 +480,35 @@ class PermissionService:
|
||||
sync_res = self.sync_permissions()
|
||||
logger.info(f"Bootstrap: Sincronización completa. {sync_res.get('synced', 0)} nuevos, {sync_res.get('total_registered', 0)} totales.")
|
||||
|
||||
# 1. Obtener el tenant_id (implementa con tu modelo de compañía)
|
||||
tenant_id = self.db.info.get(RLS_TENANT_KEY) or 1
|
||||
# 1. tenant_id del rol: se toma de la COMPAÑÍA, que es su fuente autoritativa.
|
||||
# ``company_roles`` referencia a la vez a ``a76.company`` y a ``core.tenants``, así
|
||||
# que el tenant del rol tiene que ser el de su compañía o la fila queda cruzada
|
||||
# entre dos tenants.
|
||||
#
|
||||
# Antes esto era ``self.db.info.get(RLS_TENANT_KEY) or 1``. Cuando la sesión no
|
||||
# traía contexto RLS —justo el caso de ``/permissions/me`` en el primer acceso— el
|
||||
# rol se creaba con ``tenant_id=1``; en una instalación real ese tenant no existe y
|
||||
# el INSERT moría con ForeignKeyViolation. El bootstrap quedaba a medias, sin rol
|
||||
# ni permisos, toda la API respondía 403 y ``/permissions/me`` seguía devolviendo
|
||||
# 200 con la lista vacía: el fallo se leía en pantalla como "no tienes permisos"
|
||||
# en lugar de como el error de configuración que era.
|
||||
# Se consulta la compañía DIRECTAMENTE y no vía ``_resolve_tenant_id_for_company``:
|
||||
# ese helper prefiere el contexto RLS, que es el tenant del REQUEST y puede no ser
|
||||
# el de la compañía. Para leer permisos esa preferencia está bien y ahorra una
|
||||
# consulta en el camino caliente; para escribir una fila atada por FK a las dos
|
||||
# tablas, no: si difirieran, el rol nacería cruzado.
|
||||
_row = self.db.execute(
|
||||
text("SELECT tenant_id FROM a76.company WHERE id = :company_id"),
|
||||
{"company_id": company_id},
|
||||
).first()
|
||||
if _row is None or _row[0] is None:
|
||||
logger.error(
|
||||
"Bootstrap: la compañía %s no existe en a76.company, no hay tenant al que "
|
||||
"colgar el rol. Se aborta sin crear nada.",
|
||||
company_id,
|
||||
)
|
||||
return False
|
||||
tenant_id = int(_row[0])
|
||||
|
||||
# 2. Buscar si ya existe el rol "super_admin"
|
||||
admin_role = self.db.query(CompanyRole).filter(
|
||||
|
||||
@@ -28,6 +28,9 @@ class AccountBase(BaseModel):
|
||||
# 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)
|
||||
@@ -71,6 +74,8 @@ class AccountUpdate(BaseModel):
|
||||
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
|
||||
|
||||
|
||||
@@ -51,8 +52,17 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
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,4 +1,4 @@
|
||||
from sqlalchemy import ForeignKey, Integer, String, text
|
||||
from sqlalchemy import ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
@@ -26,3 +26,27 @@ class Case(Base, TenantScopedMixin, TimestampMixin):
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierto'"), index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# ── Espejo del carril hacia EFC (T2026-08-046) ──────────────────────────────────────────
|
||||
# EFC es la fuente única de los documentos del expediente: cada expediente se refleja allá
|
||||
# como un *pedimento provisional* y los archivos viven en su MinIO, no en el del CRM.
|
||||
#
|
||||
# Estas columnas son un ESPEJO, nunca el handle. El handle con el que el CRM habla de este
|
||||
# expediente es su ``id`` y su ``reference``: ``efc_pedimento_id`` es un caché de la
|
||||
# resolución, y del lado de EFC el ``pedimento_app`` es mutable —se reescribe al completar
|
||||
# el provisional con la data aduanera real—, así que apoyarse en él rompería justo cuando
|
||||
# llegue esa data. La liga vive en EFC, en la tabla desechable ``pedimento_expediente``.
|
||||
efc_organizacion_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
efc_pedimento_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
# INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda los objetos de este
|
||||
# expediente. Que no cambie nunca es lo que permite completar el pedimento sin mover ni un
|
||||
# archivo.
|
||||
efc_storage_token: Mapped[str | None] = mapped_column(String(25), nullable=True)
|
||||
# PENDING | LINKED | FAILED
|
||||
efc_link_state: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, server_default=text("'PENDING'"), index=True
|
||||
)
|
||||
# El diagnóstico se guarda en la fila para que se vea en la ficha del expediente, sin
|
||||
# obligar a nadie a ir a los logs del worker.
|
||||
efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
efc_error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
@@ -22,6 +22,27 @@ def create_case(
|
||||
)
|
||||
db.add(case)
|
||||
db.flush()
|
||||
|
||||
# ── Carril hacia EFC (T2026-08-046) ──────────────────────────────────────────────────
|
||||
# EFC es la fuente única de los documentos del expediente. El reflejo allá —un pedimento
|
||||
# provisional— se pide AQUÍ, en el instante en que nace el folio, porque el folio es
|
||||
# precisamente la llave con la que las dos mitades se reconocen.
|
||||
#
|
||||
# El token de almacenamiento se fija al nacer y NO cambia nunca: es la carpeta de MinIO
|
||||
# del lado de EFC. Que sea inmutable es lo que permite completar el provisional con la
|
||||
# data aduanera real sin mover un solo archivo.
|
||||
#
|
||||
# Todo es best-effort y va en la MISMA transacción que el expediente:
|
||||
# - con ``EFC_API_URL`` vacía es un no-op y el expediente vive igual, solo en el CRM;
|
||||
# - si el encolado o el despacho fallan, no se propaga el error: el barrido del beat
|
||||
# recoge lo pendiente. Un sistema de terceros caído no puede romper un alta.
|
||||
# El import es diferido para no acoplar el arranque del módulo del expediente al carril.
|
||||
from ..expediente_gateway import service as gateway
|
||||
from ..expediente_gateway.storage import storage_token
|
||||
|
||||
case.efc_storage_token = storage_token(company_id, case.reference)
|
||||
db.flush()
|
||||
gateway.replicate_expediente_best_effort(db, case)
|
||||
return case
|
||||
|
||||
|
||||
|
||||
64
backend/api/v1/modules/crm/expediente_gateway/doc_types.py
Normal file
64
backend/api/v1/modules/crm/expediente_gateway/doc_types.py
Normal file
@@ -0,0 +1,64 @@
|
||||
"""Catálogo CERRADO de tipos de documento que EFC acepta del CRM.
|
||||
|
||||
Estas 22 claves son **exactamente** las de ``TIPOS_DOCUMENTO_CRM`` en
|
||||
``api/record/views_integrations_crm.py`` de EFC. La lista está duplicada a mano en dos repos con
|
||||
despliegue independiente, así que ``tests/test_doc_types_paridad.py`` la fija: si alguien agrega un
|
||||
tipo de un solo lado, ese test se pone rojo antes de que un documento se rechace en producción.
|
||||
|
||||
Por qué es un conjunto cerrado y no texto libre, a diferencia del carril de Anexo22 —que manda el
|
||||
tipo suelto y deja que EFC lo resuelva por nombre—: en el CRM ``doc_type`` es ``String(60)`` /
|
||||
``String(30)`` **sin validación de backend**, los catálogos viven solo en TypeScript
|
||||
(``frontend/src/lib/api/crm/format.ts``). Un typo crearía un ``DocumentType`` basura en el catálogo
|
||||
**global** de EFC, que es compartido por todas las organizaciones y no se limpia solo.
|
||||
|
||||
Las tres fuentes del CRM y su origen:
|
||||
|
||||
- ``crm.documents`` → ``DOC_TYPES`` de ``format.ts``
|
||||
- ``ops.shipment_documents`` → ``SHIPMENT_DOC_TYPES`` del mismo archivo
|
||||
- ``fin.invoices`` → el PDF de factura (``factura_venta``)
|
||||
|
||||
``otro`` existe en las dos listas del CRM y significa lo mismo en ambas: es una sola entrada.
|
||||
"""
|
||||
|
||||
# --- crm.documents ---------------------------------------------------------------------------
|
||||
_TIPOS_DOCUMENTOS_CLIENTE = (
|
||||
"constancia_fiscal",
|
||||
"acta_constitutiva",
|
||||
"identificacion",
|
||||
"comprobante_domicilio",
|
||||
"contrato",
|
||||
"presentacion",
|
||||
"certificacion",
|
||||
"licencia",
|
||||
"convenio",
|
||||
"tarifario",
|
||||
)
|
||||
|
||||
# --- ops.shipment_documents ------------------------------------------------------------------
|
||||
_TIPOS_DOCUMENTOS_EMBARQUE = (
|
||||
"MBL",
|
||||
"HBL",
|
||||
"MAWB",
|
||||
"HAWB",
|
||||
"CMR",
|
||||
"factura_comercial",
|
||||
"packing_list",
|
||||
"carta_encomienda",
|
||||
"carta_garantia",
|
||||
"certificado_permiso",
|
||||
)
|
||||
|
||||
# --- fin.invoices ----------------------------------------------------------------------------
|
||||
_TIPOS_FACTURACION = ("factura_venta",)
|
||||
|
||||
# --- común a varias fuentes -------------------------------------------------------------------
|
||||
_TIPOS_COMUNES = ("otro",)
|
||||
|
||||
EFC_DOC_TYPES: frozenset[str] = frozenset(
|
||||
_TIPOS_DOCUMENTOS_CLIENTE + _TIPOS_DOCUMENTOS_EMBARQUE + _TIPOS_FACTURACION + _TIPOS_COMUNES
|
||||
)
|
||||
|
||||
|
||||
def is_valid_doc_type(doc_type: str | None) -> bool:
|
||||
"""``True`` si EFC va a aceptar ese tipo. Se valida en el CRM para no gastar un viaje de red."""
|
||||
return bool(doc_type) and doc_type in EFC_DOC_TYPES
|
||||
131
backend/api/v1/modules/crm/expediente_gateway/models.py
Normal file
131
backend/api/v1/modules/crm/expediente_gateway/models.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""Outbox transaccional del carril CRM Agentes de Carga -> EFC.
|
||||
|
||||
DOS tablas separadas POR PROPÓSITO, igual que en el carril de referencia de Anexo22: una para los
|
||||
expedientes (metadatos, JSON) y otra para los archivos (binarios que viven en MinIO y se referencian
|
||||
por su ``s3_key``). Un worker de Celery las drena hacia EFC con reintentos.
|
||||
|
||||
**Diferencia con el original, y es necesaria:** aquí las filas se insertan en la MISMA transacción
|
||||
que el expediente o el documento, porque el CRM es mono-base. En Anexo22 el outbox vivía en otra
|
||||
base que el pedimento, y ese doble-commit es justamente lo que obligó a inventar el barrido de
|
||||
huecos. Aquí el barrido se conserva —cubre lo creado antes de activar la integración y cualquier
|
||||
crash— pero deja de ser el parche de una ventana estructural.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
# Tipo de trabajo (columna kind) del outbox de EXPEDIENTES.
|
||||
KIND_EXPEDIENTE = "expediente"
|
||||
KIND_COMPLETAR = "completar"
|
||||
|
||||
# Tipos del outbox de ARCHIVOS (efc_file_outbox).
|
||||
FILE_KIND_DOCUMENTO = "documento"
|
||||
|
||||
# Tablas de origen posibles de un archivo. El CRM tiene DOS tablas de documentos con secuencias
|
||||
# independientes, así que `source_id` por sí solo es ambiguo: crm.documents.id = 5 y
|
||||
# ops.shipment_documents.id = 5 coexisten.
|
||||
SOURCE_CRM_DOCUMENTS = "crm.documents"
|
||||
SOURCE_OPS_SHIPMENT_DOCUMENTS = "ops.shipment_documents"
|
||||
SOURCE_FIN_INVOICES = "fin.invoices"
|
||||
|
||||
# Estados (columna status).
|
||||
STATUS_PENDING = "pending"
|
||||
STATUS_SENT = "sent"
|
||||
STATUS_FAILED = "failed"
|
||||
|
||||
# Tope de reintentos antes de marcar 'failed' (reconciliación / reintento manual).
|
||||
# Heredado del carril de Anexo22. Con barridos de 120 s son ~17 minutos de insistencia antes de
|
||||
# rendirse y dejar la fila visible para que una persona la reintente a mano.
|
||||
MAX_ATTEMPTS = 8
|
||||
|
||||
|
||||
class EfcSyncOutbox(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Cola de metadatos hacia EFC: crear el expediente provisional y completarlo."""
|
||||
|
||||
__tablename__ = "efc_sync_outbox"
|
||||
__table_args__ = (
|
||||
Index("ix_crm_efc_sync_outbox_status", "status"),
|
||||
Index("ix_crm_efc_sync_outbox_kind_status", "kind", "status"),
|
||||
{"schema": "crm"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
|
||||
kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
|
||||
# Datos para construir el request a EFC (folio, storage_token, tenant slug, company, y la data
|
||||
# aduanera si el kind es 'completar').
|
||||
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
|
||||
# id local del expediente (crm.cases.id) que originó la fila.
|
||||
expediente_ref: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, index=True)
|
||||
|
||||
# Ciclo de vida.
|
||||
status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'"))
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
# Acuse de EFC al confirmar (trazabilidad).
|
||||
efc_pedimento_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||
|
||||
|
||||
class EfcFileOutbox(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Cola de ARCHIVOS hacia EFC.
|
||||
|
||||
El binario vive en el MinIO del CRM (durable); esta fila referencia su ``s3_key`` y el expediente
|
||||
destino. El worker lo sube a EFC y, con ``delete_local`` (corte directo), BORRA la copia local al
|
||||
confirmar la entrega.
|
||||
|
||||
``delete_local`` **es el mecanismo de «EFC es la fuente única»**: "solo EFC" es el estado FINAL
|
||||
(eventual), no el inmediato. Entre que el usuario sube el archivo y que EFC lo confirma, la copia
|
||||
local es lo único que hay, y borrarla antes perdería el archivo si la entrega fallara.
|
||||
|
||||
``source_table`` es un añadido necesario sobre el original de Anexo22, que solo llevaba
|
||||
``source_id``. El CRM tiene dos tablas de documentos con secuencias independientes, así que un
|
||||
entero solo es ambiguo entre ellas. Es el mismo problema que Anexo22 resolvió con su mapa por
|
||||
``kind``, y su comentario dice qué pasa si se ignora: un UPDATE con el id de otra tabla **vacía la
|
||||
columna de un documento ajeno** que tuviera ese mismo entero — daño en el dato de otro, sin un
|
||||
solo error visible. Un ``(kind, source_table)`` que no esté en el mapa **no toca nada**, en vez
|
||||
de caer por omisión.
|
||||
"""
|
||||
|
||||
__tablename__ = "efc_file_outbox"
|
||||
__table_args__ = (
|
||||
Index("ix_crm_efc_file_outbox_status", "status"),
|
||||
Index("ix_crm_efc_file_outbox_kind_status", "kind", "status"),
|
||||
Index("ix_crm_efc_file_outbox_source", "source_table", "source_id"),
|
||||
{"schema": "crm"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
kind: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
|
||||
# Objeto en MinIO a subir + metadata para el upload a EFC.
|
||||
s3_key: Mapped[str] = mapped_column(String(1024), nullable=False)
|
||||
file_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
content_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
efc_tipo: Mapped[str] = mapped_column(String(40), nullable=False) # tipo de documento en EFC
|
||||
|
||||
# Origen: la pareja (tabla, id) desambigua entre las dos secuencias de documentos del CRM.
|
||||
source_table: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
source_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True)
|
||||
# El handle autoritativo que viaja a EFC y garantiza la idempotencia del lado de allá.
|
||||
crm_document_ref: Mapped[Optional[str]] = mapped_column(String(64), nullable=True)
|
||||
|
||||
expediente_ref: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("crm.cases.id"), nullable=False, index=True
|
||||
)
|
||||
delete_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||
|
||||
# Ciclo de vida.
|
||||
status: Mapped[str] = mapped_column(String(10), nullable=False, server_default=text(f"'{STATUS_PENDING}'"))
|
||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
last_error: Mapped[Optional[str]] = mapped_column(Text, nullable=True)
|
||||
sent_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True)
|
||||
efc_document_id: Mapped[Optional[str]] = mapped_column(String(36), nullable=True)
|
||||
58
backend/api/v1/modules/crm/expediente_gateway/routes.py
Normal file
58
backend/api/v1/modules/crm/expediente_gateway/routes.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Endpoints de operación y observabilidad del carril CRM -> EFC.
|
||||
|
||||
Tablero mínimo para ver y reintentar la entrega de expedientes y documentos a EFC. Autenticado con
|
||||
el auth normal del CRM y acotado por tenant/company, como el resto del módulo.
|
||||
Montado bajo ``/v1/crm`` → ``/v1/crm/expediente-gateway/...``
|
||||
"""
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
|
||||
router = APIRouter(prefix="/expediente-gateway", tags=["EFC Gateway (ops)"])
|
||||
|
||||
|
||||
@router.get("/outbox")
|
||||
def list_outbox(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
tipo: str | None = Query(None, description="Filtrar por tabla: sync|file"),
|
||||
status: str | None = Query(None, description="Filtrar por status: pending|sent|failed"),
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Filas de los dos outbox, para ver los fallos y su ``last_error``."""
|
||||
return service.list_outbox(db, current_user["tenant_id"], company_id, tipo, status, limit)
|
||||
|
||||
|
||||
@router.post("/outbox/{outbox_id}/retry")
|
||||
def retry_outbox(
|
||||
outbox_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
tipo: str = Query("file", description="Tabla de la fila: sync|file"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Reintento manual de una fila: la resetea a ``pending`` y la re-despacha.
|
||||
|
||||
Una fila inexistente devuelve **404 con mensaje específico**, no un 200 silencioso: el frontend
|
||||
pinta el botón de reintento según lo que reciba, y un 200 le haría creer que la entrega volvió a
|
||||
la cola cuando no hay nada que entregar.
|
||||
"""
|
||||
ok = service.retry_outbox_row(db, outbox_id, current_user["tenant_id"], company_id, tipo)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Fila de outbox no encontrada")
|
||||
return {"status": "requeued", "id": outbox_id}
|
||||
|
||||
|
||||
@router.get("/metrics")
|
||||
def metrics(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Conteo de los dos outbox por status (pending/sent/failed) para monitoreo."""
|
||||
return service.outbox_metrics(db, current_user["tenant_id"], company_id)
|
||||
755
backend/api/v1/modules/crm/expediente_gateway/service.py
Normal file
755
backend/api/v1/modules/crm/expediente_gateway/service.py
Normal file
@@ -0,0 +1,755 @@
|
||||
"""Carril CRM Agentes de Carga -> EFC: encolado, entrega y reconciliación.
|
||||
|
||||
Clon del gateway de Anexo22 (``anexo22/.../pedimentos/pedimento_gateway/service.py``), que es el
|
||||
carril de referencia ya en producción. Quien conozca uno debe poder leer el otro, así que la tabla
|
||||
de equivalencias va aquí:
|
||||
|
||||
====================================== ======================================
|
||||
Anexo22 CRM
|
||||
====================================== ======================================
|
||||
``replicate_pedimento_best_effort`` ``replicate_expediente_best_effort``
|
||||
``_enqueue_pedimento_outbox`` ``_enqueue_expediente_outbox``
|
||||
``_dispatch_delivery`` igual
|
||||
``deliver_row`` / ``_deliver_pedimento`` ``deliver_row`` / ``_deliver_expediente``
|
||||
``_register_failure`` **idéntico**
|
||||
``_ya_entregado(source_id, kind)`` ``_ya_entregado(source_table, source_id, kind)``
|
||||
``deliver_file_row`` **idéntico**, con ensure-then-upload y ``delete_local``
|
||||
``_register_file_failure`` **idéntico**
|
||||
``_resolve_org_id`` + ``_org_id_cache`` igual — dict módulo-global, por worker, sin invalidación
|
||||
``list_outbox`` / ``retry_outbox_row`` / ``outbox_metrics`` igual, para las dos tablas
|
||||
``find_pedimento_gaps`` ``find_expediente_gaps``
|
||||
====================================== ======================================
|
||||
|
||||
**La máquina de reintentos tiene tres capas y las tres se conservan:**
|
||||
|
||||
1. En el cliente HTTP: 3 intentos, backoff lineal ``0.15 * (attempt + 1)``, corte seco en 4xx.
|
||||
2. En el worker: ``deliver_row`` **nunca lanza**; registra el fallo en la propia fila.
|
||||
3. En el beat: barridos cada 120 s que re-despachan lo ``pending``.
|
||||
|
||||
No hay ``autoretry_for``, ``retry_backoff`` ni ``max_retries`` en las tareas: duplicarían el
|
||||
mecanismo que ya está en el cliente y en el barrido.
|
||||
|
||||
**Cuatro guardas de idempotencia**, en este orden:
|
||||
1. ``_ya_entregado(source_table, source_id, kind)`` antes de encolar.
|
||||
2. ``if row.status == STATUS_SENT: return`` al entrar a entregar.
|
||||
3. El ``crm_document_ref`` que viaja con la subida: EFC devuelve 200 con el que ya existía.
|
||||
4. El UNIQUE parcial del lado de EFC — la única que garantiza la base.
|
||||
|
||||
**Por qué ``find_expediente_gaps`` sigue aquí aunque el CRM sea mono-base.** En Anexo22 el outbox se
|
||||
commitea aparte del pedimento (dos bases distintas) y ese doble-commit es lo que obligó a inventar el
|
||||
barrido de huecos. Aquí la fila del outbox va en la MISMA transacción que el expediente, así que esa
|
||||
ventana no existe. El barrido se conserva porque cubre otras dos cosas: los expedientes creados
|
||||
**antes** de activar la integración, y cualquier crash. Queda escrito para que el siguiente que lo
|
||||
lea no lo borre creyendo que es redundante.
|
||||
"""
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.database import scoped_core_db
|
||||
from core.efc_client import EfcClient, EfcClientError, efc_client
|
||||
|
||||
from ..cases.models import Case
|
||||
from .models import (
|
||||
KIND_COMPLETAR,
|
||||
KIND_EXPEDIENTE,
|
||||
MAX_ATTEMPTS,
|
||||
STATUS_FAILED,
|
||||
STATUS_PENDING,
|
||||
STATUS_SENT,
|
||||
EfcFileOutbox,
|
||||
EfcSyncOutbox,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache de organización EFC por slug de tenant. Dict módulo-global: vive por worker y NO se
|
||||
# invalida, igual que el del carril de Anexo22. Es correcto porque la organización de un tenant no
|
||||
# cambia de id: el resolver de EFC es idempotente y devuelve siempre la misma. Si algún día pudiera
|
||||
# cambiar, reiniciar el worker la vuelve a resolver.
|
||||
_org_id_cache: dict[str, str] = {}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _savepoint(db: Session):
|
||||
"""Aísla un encolado dentro de la transacción del usuario con un SAVEPOINT.
|
||||
|
||||
**Esto es lo único del encolado que NO se clona del carril de Anexo22, y la razón es de fondo.**
|
||||
Allá el outbox vive en otra base que el pedimento, así que su ``except`` podía hacer
|
||||
``db.rollback()`` sin consecuencias: revertía la sesión del outbox y la del pedimento ni se
|
||||
enteraba.
|
||||
|
||||
Aquí el CRM es mono-base y el encolado corre DENTRO de la transacción del usuario. Un
|
||||
``db.rollback()`` en el ``except`` se llevaría por delante la solicitud y el expediente que el
|
||||
usuario acaba de crear — exactamente lo contrario de best-effort, y sin un solo error visible
|
||||
para él. Con el SAVEPOINT, un fallo del encolado deshace **solo** la fila del outbox y la
|
||||
operación local sigue en pie para que el llamador la commitee.
|
||||
"""
|
||||
nested = db.begin_nested()
|
||||
try:
|
||||
yield nested
|
||||
except Exception:
|
||||
nested.rollback()
|
||||
raise
|
||||
|
||||
|
||||
# ══ Expediente: encolado y entrega ══════════════════════════════════════════
|
||||
|
||||
def replicate_expediente_best_effort(db: Session, expediente: Case) -> None:
|
||||
"""Encola la réplica del expediente a EFC y dispara la entrega inmediata.
|
||||
|
||||
Best-effort en todo: si EFC no está configurado, o si el encolado o el despacho fallan, **no se
|
||||
propaga el error**. El expediente local ya existe y la operación del usuario no se puede romper
|
||||
porque un sistema de terceros no conteste. El barrido periódico recoge lo que quede pendiente.
|
||||
"""
|
||||
if not settings.EFC_API_URL:
|
||||
return
|
||||
row = _enqueue_expediente_outbox(db, expediente)
|
||||
if row is None:
|
||||
return
|
||||
_dispatch_delivery(row.id, row.tenant_id, row.company_id)
|
||||
|
||||
|
||||
def _enqueue_expediente_outbox(db: Session, expediente: Case) -> Optional[EfcSyncOutbox]:
|
||||
"""Inserta la fila de outbox del expediente. Devuelve ``None`` si falla, sin romper nada.
|
||||
|
||||
A diferencia del original, **no commitea**: el CRM es mono-base, así que la fila viaja en la
|
||||
misma transacción que el expediente. Eso cierra de raíz la ventana del doble-commit que en
|
||||
Anexo22 obligó a inventar el barrido de huecos.
|
||||
"""
|
||||
try:
|
||||
if _expediente_ya_encolado(db, expediente.id):
|
||||
return None
|
||||
# Sin folio o sin token no hay nada que replicar: EFC exige los dos y responde
|
||||
# {'storage_token': ['This field may not be null.']}, que NO es un fallo transitorio.
|
||||
# Encolarlo de todos modos quemaría los 8 intentos para acabar en `failed`, ensuciando
|
||||
# el tablero de ops con algo que ningún reintento puede arreglar.
|
||||
#
|
||||
# Pasa de verdad en dos casos: expedientes nacidos antes de que existiera el carril
|
||||
# (los rellena la migración c5d6e7f8a9b0) y aquellos cuyo token no cabe en los 25
|
||||
# caracteres de `pedimento_app`. Se avisa en WARNING porque es una omisión silenciosa:
|
||||
# el expediente vive en el CRM y sus documentos nunca llegarán a EFC.
|
||||
if not expediente.reference or not expediente.efc_storage_token:
|
||||
logger.warning(
|
||||
"expediente_gateway: expediente id=%s SIN replicar — folio=%r token=%r. "
|
||||
"No se encola: EFC rechaza ambos nulos y el reintento no lo arregla.",
|
||||
expediente.id, expediente.reference, expediente.efc_storage_token,
|
||||
)
|
||||
return None
|
||||
# El slug del tenant NO se resuelve aquí: se rellena al ENTREGAR. Resolverlo ahora abriría
|
||||
# una segunda sesión de base (``scoped_core_db``) dentro de la transacción del usuario, que
|
||||
# es justo lo que el encolado debe evitar. Es además lo que hace el carril de referencia.
|
||||
payload = {
|
||||
"source": "crm",
|
||||
"crm_company_id": expediente.company_id,
|
||||
"crm_expediente_id": expediente.id,
|
||||
"folio": expediente.reference,
|
||||
"storage_token": expediente.efc_storage_token,
|
||||
}
|
||||
row = EfcSyncOutbox(
|
||||
kind=KIND_EXPEDIENTE,
|
||||
payload=payload,
|
||||
expediente_ref=expediente.id,
|
||||
status=STATUS_PENDING,
|
||||
tenant_id=expediente.tenant_id,
|
||||
company_id=expediente.company_id,
|
||||
)
|
||||
with _savepoint(db):
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo encolar el expediente id=%s en el outbox",
|
||||
getattr(expediente, "id", None), exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _expediente_ya_encolado(db: Session, expediente_id: int) -> bool:
|
||||
"""¿Ya hay una fila viva de alta para este expediente? Evita encolar la misma réplica dos veces."""
|
||||
return (
|
||||
db.query(EfcSyncOutbox.id)
|
||||
.filter(
|
||||
EfcSyncOutbox.expediente_ref == expediente_id,
|
||||
EfcSyncOutbox.kind == KIND_EXPEDIENTE,
|
||||
EfcSyncOutbox.status.in_((STATUS_PENDING, STATUS_SENT)),
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def enqueue_completar_best_effort(db: Session, expediente: Case, campos: dict) -> None:
|
||||
"""Encola el completado del provisional en EFC con la data aduanera real."""
|
||||
if not settings.EFC_API_URL:
|
||||
return
|
||||
try:
|
||||
row = EfcSyncOutbox(
|
||||
kind=KIND_COMPLETAR,
|
||||
payload={
|
||||
"source": "crm",
|
||||
"crm_company_id": expediente.company_id,
|
||||
"crm_expediente_id": expediente.id,
|
||||
"folio": expediente.reference,
|
||||
"pedimento": campos,
|
||||
},
|
||||
expediente_ref=expediente.id,
|
||||
status=STATUS_PENDING,
|
||||
tenant_id=expediente.tenant_id,
|
||||
company_id=expediente.company_id,
|
||||
)
|
||||
with _savepoint(db):
|
||||
db.add(row)
|
||||
db.flush()
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo encolar el completado del expediente id=%s",
|
||||
getattr(expediente, "id", None), exc_info=True,
|
||||
)
|
||||
return
|
||||
_dispatch_delivery(row.id, row.tenant_id, row.company_id)
|
||||
|
||||
|
||||
def _dispatch_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
"""Dispara la tarea de entrega propagando el contexto RLS por headers de Celery.
|
||||
|
||||
Best-effort: si el broker no responde, el barrido la recoge. Los headers son obligatorios —
|
||||
``core/celery_app.py`` materializa el contexto de RLS a partir de ellos, y sin ellos la tarea
|
||||
corre sin tenant y no ve nada.
|
||||
"""
|
||||
try:
|
||||
from .tasks import deliver_outbox_row # import diferido: evita ciclo con celery_app
|
||||
deliver_outbox_row.apply_async(
|
||||
args=[outbox_id, tenant_id, company_id],
|
||||
headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo despachar la entrega outbox_id=%s (lo tomará el sweep)",
|
||||
outbox_id, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def deliver_row(db: Session, row: EfcSyncOutbox, client: Optional[EfcClient] = None) -> None:
|
||||
"""Entrega una fila del outbox de expedientes a EFC. Actualiza estado y ``attempts``.
|
||||
|
||||
**No lanza nunca**: los fallos se registran en la propia fila para reconciliación. Un fallo no
|
||||
puede matar al worker ni perder la intención de entregar.
|
||||
"""
|
||||
client = client or efc_client
|
||||
if not client.is_configured:
|
||||
logger.info("expediente_gateway: EFC no configurado; se deja pendiente row=%s", row.id)
|
||||
return
|
||||
if row.status == STATUS_SENT:
|
||||
return
|
||||
try:
|
||||
if row.kind == KIND_EXPEDIENTE:
|
||||
_deliver_expediente(db, row, client)
|
||||
elif row.kind == KIND_COMPLETAR:
|
||||
_deliver_completar(db, row, client)
|
||||
else:
|
||||
row.status = STATUS_FAILED
|
||||
row.last_error = f"kind desconocido: {row.kind}"
|
||||
db.commit()
|
||||
except EfcClientError as exc:
|
||||
_register_failure(db, row, exc, retryable=exc.retryable)
|
||||
except Exception as exc: # noqa: BLE001 — cualquier fallo se registra, no rompe el worker
|
||||
_register_failure(db, row, exc, retryable=True)
|
||||
|
||||
|
||||
def _register_failure(db: Session, row: EfcSyncOutbox, exc: Exception, retryable: bool) -> None:
|
||||
row.attempts = (row.attempts or 0) + 1
|
||||
row.last_error = str(exc)[:2000]
|
||||
if (not retryable) or row.attempts >= MAX_ATTEMPTS:
|
||||
row.status = STATUS_FAILED
|
||||
db.commit()
|
||||
logger.warning(
|
||||
"expediente_gateway: entrega falló row=%s attempts=%s retryable=%s status=%s: %s",
|
||||
row.id, row.attempts, retryable, row.status, exc,
|
||||
)
|
||||
|
||||
|
||||
def _deliver_expediente(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None:
|
||||
payload = dict(row.payload or {})
|
||||
org_id = _resolve_org_id(client, row.tenant_id)
|
||||
payload["organizacion"] = {"efc_organizacion_id": org_id}
|
||||
payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or ""
|
||||
resp = client.ingest_expediente(payload)
|
||||
efc = (resp or {}).get("efc") or {}
|
||||
|
||||
row.status = STATUS_SENT
|
||||
row.sent_at = datetime.now(timezone.utc)
|
||||
row.efc_pedimento_id = efc.get("pedimento_id")
|
||||
_stamp_expediente_link(db, row.expediente_ref, org_id, efc.get("pedimento_id"))
|
||||
db.commit()
|
||||
logger.info(
|
||||
"expediente_gateway: expediente replicado row=%s efc_pedimento_id=%s",
|
||||
row.id, row.efc_pedimento_id,
|
||||
)
|
||||
|
||||
|
||||
def _deliver_completar(db: Session, row: EfcSyncOutbox, client: EfcClient) -> None:
|
||||
payload = dict(row.payload or {})
|
||||
org_id = _resolve_org_id(client, row.tenant_id)
|
||||
payload["organizacion"] = {"efc_organizacion_id": org_id}
|
||||
payload["crm_tenant_slug"] = _tenant_slug(row.tenant_id)[0] or ""
|
||||
folio = payload.get("folio")
|
||||
client.completar_expediente(folio, payload)
|
||||
row.status = STATUS_SENT
|
||||
row.sent_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
logger.info("expediente_gateway: expediente completado en EFC row=%s folio=%s", row.id, folio)
|
||||
|
||||
|
||||
def _stamp_expediente_link(db: Session, expediente_id: Optional[int], org_id: str,
|
||||
pedimento_id: Optional[str]) -> None:
|
||||
"""Refleja en la fila del expediente que EFC ya lo tiene, para que la UI lo pinte.
|
||||
|
||||
Es un espejo, no un handle: el CRM sigue hablando de este expediente por su ``folio``. Se guarda
|
||||
porque el proxy de descarga necesita el ``organizacion_id`` para preguntarle a EFC.
|
||||
"""
|
||||
if expediente_id is None:
|
||||
return
|
||||
expediente = db.query(Case).filter(Case.id == expediente_id).first()
|
||||
if expediente is None:
|
||||
return
|
||||
expediente.efc_organizacion_id = org_id
|
||||
if pedimento_id:
|
||||
expediente.efc_pedimento_id = pedimento_id
|
||||
expediente.efc_link_state = "LINKED"
|
||||
expediente.efc_error_code = None
|
||||
expediente.efc_error_detail = None
|
||||
|
||||
|
||||
# ══ Archivos: encolado y entrega ════════════════════════════════════════════
|
||||
|
||||
def _ya_entregado(db: Session, source_table: str, source_id: int, kind: str) -> bool:
|
||||
"""¿Este archivo ya se entregó al expediente? Evita re-encolar lo que ya está allá.
|
||||
|
||||
Sin esta guarda, un reintento encolaba otra entrega del mismo archivo — que además **falla al
|
||||
leer el objeto local, porque la primera entrega ya lo borró** con ``delete_local``. Ruido en el
|
||||
log y una fila del outbox condenada a ``failed``.
|
||||
|
||||
Lleva ``source_table`` además de ``source_id``, a diferencia del original: el CRM tiene dos
|
||||
tablas de documentos con secuencias independientes, así que el id solo es ambiguo y esta guarda
|
||||
se dispararía de más, saltándose la entrega de un documento distinto que casualmente comparte
|
||||
entero.
|
||||
"""
|
||||
return (
|
||||
db.query(EfcFileOutbox.id)
|
||||
.filter(
|
||||
EfcFileOutbox.source_table == source_table,
|
||||
EfcFileOutbox.source_id == source_id,
|
||||
EfcFileOutbox.kind == kind,
|
||||
EfcFileOutbox.status == STATUS_SENT,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def enqueue_file_best_effort(
|
||||
db: Session,
|
||||
*,
|
||||
kind: str,
|
||||
s3_key: str,
|
||||
file_name: str,
|
||||
content_type: Optional[str],
|
||||
efc_tipo: str,
|
||||
source_table: str,
|
||||
source_id: int,
|
||||
crm_document_ref: str,
|
||||
expediente_ref: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
delete_local: bool = True,
|
||||
) -> Optional[EfcFileOutbox]:
|
||||
"""Encola un archivo hacia el expediente de EFC. Devuelve la fila, o ``None`` si no se encoló.
|
||||
|
||||
**No commitea**: la fila va en la misma transacción que el documento que la origina, de modo que
|
||||
no puede existir un documento sin su intención de entrega ni al revés.
|
||||
"""
|
||||
if not settings.EFC_API_URL:
|
||||
return None
|
||||
if _ya_entregado(db, source_table, source_id, kind):
|
||||
return None
|
||||
try:
|
||||
row = EfcFileOutbox(
|
||||
kind=kind,
|
||||
s3_key=s3_key,
|
||||
file_name=file_name,
|
||||
content_type=content_type,
|
||||
efc_tipo=efc_tipo,
|
||||
source_table=source_table,
|
||||
source_id=source_id,
|
||||
crm_document_ref=crm_document_ref,
|
||||
expediente_ref=expediente_ref,
|
||||
delete_local=delete_local,
|
||||
status=STATUS_PENDING,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
with _savepoint(db):
|
||||
db.add(row)
|
||||
db.flush()
|
||||
return row
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo encolar el archivo %s (%s:%s)",
|
||||
s3_key, source_table, source_id, exc_info=True,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _dispatch_file_delivery(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
try:
|
||||
from .tasks import deliver_file_outbox_row # import diferido
|
||||
deliver_file_outbox_row.apply_async(
|
||||
args=[outbox_id, tenant_id, company_id],
|
||||
headers={"rls_tenant_id": str(tenant_id), "rls_company_id": str(company_id)},
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo despachar entrega de archivo outbox_id=%s (lo tomará el sweep)",
|
||||
outbox_id, exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
def deliver_file_row(db: Session, row: EfcFileOutbox, client: Optional[EfcClient] = None) -> None:
|
||||
"""Sube el archivo de ``row.s3_key`` al expediente de EFC y, si ``delete_local``, borra la copia.
|
||||
|
||||
**Ensure-then-upload**: si EFC contesta 404 ``expediente_no_encontrado``, la creación del
|
||||
provisional puede venir en camino (el outbox de expedientes y el de archivos son colas
|
||||
distintas), así que se asegura el expediente y se reintenta el upload **una** vez.
|
||||
|
||||
**No lanza nunca**: como ``deliver_row``, registra el fallo en la propia fila.
|
||||
"""
|
||||
client = client or efc_client
|
||||
if not client.is_configured or row.status == STATUS_SENT:
|
||||
return
|
||||
try:
|
||||
org_id = _resolve_org_id(client, row.tenant_id)
|
||||
expediente = db.query(Case).filter(Case.id == row.expediente_ref).first()
|
||||
if expediente is None:
|
||||
raise EfcClientError(
|
||||
f"expediente {row.expediente_ref} no encontrado para el archivo '{row.kind}'",
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
from core.storage_s3 import get_object_bytes
|
||||
content = get_object_bytes(row.s3_key)
|
||||
ct = row.content_type or "application/octet-stream"
|
||||
|
||||
try:
|
||||
resp = client.upload_documento(
|
||||
org_id, row.company_id, expediente.id, row.efc_tipo,
|
||||
row.file_name, content, ct, crm_document_ref=row.crm_document_ref,
|
||||
)
|
||||
except EfcClientError as exc:
|
||||
if exc.status_code == 404 and exc.code == "expediente_no_encontrado":
|
||||
# La creación del provisional puede venir en camino: se asegura y se reintenta UNA vez.
|
||||
client.ingest_expediente({
|
||||
"source": "crm",
|
||||
"crm_tenant_slug": (_tenant_slug(row.tenant_id)[0] or ""),
|
||||
"crm_company_id": row.company_id,
|
||||
"crm_expediente_id": expediente.id,
|
||||
"folio": expediente.reference,
|
||||
"storage_token": expediente.efc_storage_token,
|
||||
"organizacion": {"efc_organizacion_id": org_id},
|
||||
})
|
||||
resp = client.upload_documento(
|
||||
org_id, row.company_id, expediente.id, row.efc_tipo,
|
||||
row.file_name, content, ct, crm_document_ref=row.crm_document_ref,
|
||||
)
|
||||
else:
|
||||
raise
|
||||
|
||||
doc_id = resp.get("id") if isinstance(resp, dict) else None
|
||||
|
||||
if row.delete_local:
|
||||
try:
|
||||
from core.storage_s3 import delete_object_if_exists
|
||||
delete_object_if_exists(row.s3_key)
|
||||
except Exception:
|
||||
# Ya está en EFC: no poder borrar la copia local no invalida la entrega.
|
||||
logger.warning(
|
||||
"expediente_gateway: no se pudo borrar el archivo local %s (ya en EFC)",
|
||||
row.s3_key, exc_info=True,
|
||||
)
|
||||
|
||||
row.status = STATUS_SENT
|
||||
row.sent_at = datetime.now(timezone.utc)
|
||||
row.efc_document_id = doc_id
|
||||
db.commit()
|
||||
_marcar_documento_entregado(db, row, doc_id)
|
||||
logger.info(
|
||||
"expediente_gateway: archivo entregado row=%s kind=%s efc_document_id=%s",
|
||||
row.id, row.kind, doc_id,
|
||||
)
|
||||
except EfcClientError as exc:
|
||||
_register_file_failure(db, row, exc, exc.retryable)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
_register_file_failure(db, row, exc, True)
|
||||
|
||||
|
||||
def _register_file_failure(db: Session, row: EfcFileOutbox, exc: Exception, retryable: bool) -> None:
|
||||
row.attempts = (row.attempts or 0) + 1
|
||||
row.last_error = str(exc)[:2000]
|
||||
if (not retryable) or row.attempts >= MAX_ATTEMPTS:
|
||||
row.status = STATUS_FAILED
|
||||
db.commit()
|
||||
_marcar_documento_fallido(db, row, exc)
|
||||
logger.warning(
|
||||
"expediente_gateway: entrega de archivo falló row=%s attempts=%s status=%s: %s",
|
||||
row.id, row.attempts, row.status, exc,
|
||||
)
|
||||
|
||||
|
||||
# El mapa (kind, source_table) -> modelo del documento de origen. Un par que NO esté aquí **no toca
|
||||
# nada**, en vez de caer por omisión sobre una tabla cualquiera: escribir con el id de otra tabla
|
||||
# vaciaría las columnas de un documento ajeno que tuviera ese mismo entero — daño en el dato de otro,
|
||||
# sin un solo error visible.
|
||||
def _modelo_de_origen(source_table: str):
|
||||
if source_table == "crm.documents":
|
||||
from ..documents.models import Document
|
||||
return Document
|
||||
if source_table == "ops.shipment_documents":
|
||||
from api.v1.modules.ops.shipments.models import ShipmentDocument
|
||||
return ShipmentDocument
|
||||
return None
|
||||
|
||||
|
||||
def _fila_de_origen(db: Session, row: EfcFileOutbox):
|
||||
modelo = _modelo_de_origen(row.source_table)
|
||||
if modelo is None or row.source_id is None:
|
||||
return None
|
||||
return (
|
||||
db.query(modelo)
|
||||
.filter(
|
||||
modelo.id == row.source_id,
|
||||
modelo.tenant_id == row.tenant_id,
|
||||
modelo.company_id == row.company_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _marcar_documento_entregado(db: Session, row: EfcFileOutbox, doc_id) -> None:
|
||||
"""Cierra la entrega en la fila del documento: el badge de la UI pasa a «En expediente»."""
|
||||
documento = _fila_de_origen(db, row)
|
||||
if documento is None:
|
||||
return
|
||||
documento.efc_document_id = str(doc_id) if doc_id else None
|
||||
documento.efc_sync_state = "SYNCED"
|
||||
documento.efc_synced_at = datetime.now(timezone.utc)
|
||||
documento.efc_error_code = None
|
||||
documento.efc_error_detail = None
|
||||
if row.delete_local:
|
||||
# El objeto local ya no está: dejar la key apuntaría a algo inexistente y la descarga se
|
||||
# ramificaría por el camino equivocado.
|
||||
documento.file_key = None
|
||||
db.commit()
|
||||
|
||||
|
||||
def _marcar_documento_fallido(db: Session, row: EfcFileOutbox, exc: Exception) -> None:
|
||||
"""Refleja el fallo en la fila del documento para que la ficha lo muestre sin ir a los logs."""
|
||||
documento = _fila_de_origen(db, row)
|
||||
if documento is None:
|
||||
return
|
||||
documento.efc_attempts = row.attempts
|
||||
documento.efc_error_detail = str(exc)[:2000]
|
||||
documento.efc_error_code = getattr(exc, "code", None)
|
||||
if row.status == STATUS_FAILED:
|
||||
documento.efc_sync_state = "FAILED"
|
||||
db.commit()
|
||||
|
||||
|
||||
# ══ Organización ════════════════════════════════════════════════════════════
|
||||
|
||||
def _resolve_org_id(client: EfcClient, tenant_id: int) -> str:
|
||||
slug, name = _tenant_slug(tenant_id)
|
||||
if not slug:
|
||||
raise EfcClientError(
|
||||
f"tenant {tenant_id} sin slug; no se puede resolver la organización EFC.",
|
||||
retryable=False,
|
||||
)
|
||||
if slug in _org_id_cache:
|
||||
return _org_id_cache[slug]
|
||||
resp = client.resolve_organizacion(slug, name)
|
||||
org_id = resp.get("id") if isinstance(resp, dict) else None
|
||||
if not org_id:
|
||||
raise EfcClientError("El resolver de organización de EFC no devolvió id.", retryable=True)
|
||||
_org_id_cache[slug] = org_id
|
||||
return org_id
|
||||
|
||||
|
||||
def _tenant_slug(tenant_id: int) -> tuple[Optional[str], Optional[str]]:
|
||||
from api.v1.modules.core.tenants.models import Tenant
|
||||
|
||||
with scoped_core_db(tenant_id=tenant_id) as db:
|
||||
t = db.query(Tenant).filter(Tenant.id == tenant_id).first()
|
||||
if t is None:
|
||||
return None, None
|
||||
return t.slug, t.name
|
||||
|
||||
|
||||
# ══ Tablero de ops ══════════════════════════════════════════════════════════
|
||||
|
||||
def _outbox_to_dict(r: EfcSyncOutbox) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"tabla": "sync",
|
||||
"kind": r.kind,
|
||||
"status": r.status,
|
||||
"attempts": r.attempts,
|
||||
"last_error": r.last_error,
|
||||
"expediente_ref": r.expediente_ref,
|
||||
"efc_pedimento_id": r.efc_pedimento_id,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _file_outbox_to_dict(r: EfcFileOutbox) -> dict:
|
||||
return {
|
||||
"id": r.id,
|
||||
"tabla": "file",
|
||||
"kind": r.kind,
|
||||
"status": r.status,
|
||||
"attempts": r.attempts,
|
||||
"last_error": r.last_error,
|
||||
"expediente_ref": r.expediente_ref,
|
||||
"file_name": r.file_name,
|
||||
"efc_tipo": r.efc_tipo,
|
||||
"source_table": r.source_table,
|
||||
"source_id": r.source_id,
|
||||
"crm_document_ref": r.crm_document_ref,
|
||||
"efc_document_id": r.efc_document_id,
|
||||
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||||
"sent_at": r.sent_at.isoformat() if r.sent_at else None,
|
||||
}
|
||||
|
||||
|
||||
def list_outbox(db: Session, tenant_id: int, company_id: int, tipo: Optional[str] = None,
|
||||
status: Optional[str] = None, limit: int = 100) -> list[dict]:
|
||||
"""Lista filas de los DOS outbox para el tablero de ops. ``tipo`` ∈ ``sync`` | ``file``."""
|
||||
salida: list[dict] = []
|
||||
|
||||
if tipo in (None, "", "sync"):
|
||||
q = db.query(EfcSyncOutbox).filter(
|
||||
EfcSyncOutbox.tenant_id == tenant_id, EfcSyncOutbox.company_id == company_id
|
||||
)
|
||||
if status:
|
||||
q = q.filter(EfcSyncOutbox.status == status)
|
||||
salida += [
|
||||
_outbox_to_dict(r)
|
||||
for r in q.order_by(EfcSyncOutbox.created_at.desc()).limit(limit).all()
|
||||
]
|
||||
|
||||
if tipo in (None, "", "file"):
|
||||
q = db.query(EfcFileOutbox).filter(
|
||||
EfcFileOutbox.tenant_id == tenant_id, EfcFileOutbox.company_id == company_id
|
||||
)
|
||||
if status:
|
||||
q = q.filter(EfcFileOutbox.status == status)
|
||||
salida += [
|
||||
_file_outbox_to_dict(r)
|
||||
for r in q.order_by(EfcFileOutbox.created_at.desc()).limit(limit).all()
|
||||
]
|
||||
|
||||
salida.sort(key=lambda d: (d.get("created_at") or ""), reverse=True)
|
||||
return salida[:limit]
|
||||
|
||||
|
||||
def retry_outbox_row(db: Session, outbox_id: int, tenant_id: int, company_id: int,
|
||||
tipo: str = "file") -> bool:
|
||||
"""Reintento manual: resetea la fila a ``pending`` (``attempts=0``) y la re-despacha.
|
||||
|
||||
Devuelve ``False`` si no existe para ese tenant/company — el llamador lo traduce a **404 con
|
||||
mensaje específico**, no a un 200 silencioso: es contrato con el frontend, que pinta el botón
|
||||
según lo que reciba.
|
||||
"""
|
||||
modelo = EfcSyncOutbox if tipo == "sync" else EfcFileOutbox
|
||||
r = (
|
||||
db.query(modelo)
|
||||
.filter(modelo.id == outbox_id, modelo.tenant_id == tenant_id, modelo.company_id == company_id)
|
||||
.first()
|
||||
)
|
||||
if r is None:
|
||||
return False
|
||||
r.status = STATUS_PENDING
|
||||
r.attempts = 0
|
||||
r.last_error = None
|
||||
db.commit()
|
||||
if tipo == "sync":
|
||||
_dispatch_delivery(r.id, r.tenant_id, r.company_id)
|
||||
else:
|
||||
_reset_documento_pendiente(db, r)
|
||||
_dispatch_file_delivery(r.id, r.tenant_id, r.company_id)
|
||||
return True
|
||||
|
||||
|
||||
def _reset_documento_pendiente(db: Session, row: EfcFileOutbox) -> None:
|
||||
documento = _fila_de_origen(db, row)
|
||||
if documento is None:
|
||||
return
|
||||
documento.efc_sync_state = "PENDING"
|
||||
documento.efc_error_code = None
|
||||
documento.efc_error_detail = None
|
||||
db.commit()
|
||||
|
||||
|
||||
def outbox_metrics(db: Session, tenant_id: int, company_id: int) -> dict:
|
||||
"""Conteo de los dos outbox por status (monitoreo). Los conteos suman las dos tablas."""
|
||||
from sqlalchemy import func
|
||||
|
||||
counts = {STATUS_PENDING: 0, STATUS_SENT: 0, STATUS_FAILED: 0}
|
||||
for modelo in (EfcSyncOutbox, EfcFileOutbox):
|
||||
rows = (
|
||||
db.query(modelo.status, func.count())
|
||||
.filter(modelo.tenant_id == tenant_id, modelo.company_id == company_id)
|
||||
.group_by(modelo.status)
|
||||
.all()
|
||||
)
|
||||
for estado, n in rows:
|
||||
counts[estado] = counts.get(estado, 0) + n
|
||||
return {
|
||||
"pending": counts.get(STATUS_PENDING, 0),
|
||||
"sent": counts.get(STATUS_SENT, 0),
|
||||
"failed": counts.get(STATUS_FAILED, 0),
|
||||
}
|
||||
|
||||
|
||||
def find_expediente_gaps(db: Session, limit: int = 200) -> list:
|
||||
"""Expedientes (no borrados) SIN ninguna fila de outbox que los referencie.
|
||||
|
||||
Nunca se encolaron: expedientes creados **antes** de activar la integración, o un crash. Se
|
||||
re-encolan para no perder la réplica.
|
||||
|
||||
Los ``failed`` **no son huecos** —existen como fila, son visibles y reintentables desde el
|
||||
tablero—, así que la fila los excluye por estar presente, no por su estado. Corre sin contexto
|
||||
de tenant (beat); cada expediente lleva el suyo.
|
||||
"""
|
||||
from sqlalchemy import exists
|
||||
|
||||
ya_encolado = exists().where(EfcSyncOutbox.expediente_ref == Case.id)
|
||||
return (
|
||||
db.query(Case)
|
||||
.filter(
|
||||
Case.deleted_at.is_(None),
|
||||
~ya_encolado,
|
||||
# Mismo criterio que el encolado: lo que le falta folio o token no es un hueco
|
||||
# recuperable, es algo que EFC rechazaría siempre. Sin este filtro la
|
||||
# reconciliación los reencola cada 5 minutos para verlos fallar de nuevo.
|
||||
Case.reference.isnot(None),
|
||||
Case.efc_storage_token.isnot(None),
|
||||
)
|
||||
.order_by(Case.id.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
39
backend/api/v1/modules/crm/expediente_gateway/storage.py
Normal file
39
backend/api/v1/modules/crm/expediente_gateway/storage.py
Normal file
@@ -0,0 +1,39 @@
|
||||
"""La llave de almacenamiento del expediente en EFC.
|
||||
|
||||
Vive en el carril y no en el módulo del expediente a propósito: el expediente (``crm.cases``) es
|
||||
del CRM y no sabe nada de EFC; esto es exclusivamente cómo EFC nombra su carpeta.
|
||||
|
||||
El generador de folios NO está aquí. Es ``crm/common/folios.py::next_folio``, que ya reserva el
|
||||
consecutivo mensual por ``(tenant, company, entidad, periodo)`` con bloqueo de fila. El carril lo
|
||||
consume, no lo reimplementa.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# Longitud de ``Pedimento.pedimento_app`` en EFC (api/customs/models.py). El token se guarda ahí.
|
||||
PEDIMENTO_APP_MAX = 25
|
||||
|
||||
|
||||
def storage_token(company_id: int, folio: str) -> str:
|
||||
"""``CRM-{company_id}-{folio}`` — la llave del pedimento provisional en EFC.
|
||||
|
||||
Empieza con letras, así que es imposible que colisione con la llave de un pedimento real, que
|
||||
es ``^\\d{2}-\\d{2}-\\d{4}-\\d{7}$``. El ``company_id`` va dentro porque el puente con EFC es
|
||||
tenant → organización 1:1 pero un tenant tiene N companies: sin él, dos companies del mismo
|
||||
tenant generarían el mismo ``EXP2026-08-001`` y chocarían en el ``unique_together`` de EFC.
|
||||
|
||||
PRESUPUESTO DE CARACTERES: ``CRM-`` (4) + company + ``-`` (1) + ``EXP2026-08-001`` (14) = 19 +
|
||||
los dígitos del company. En los 25 de ``pedimento_app`` caben hasta **6 dígitos** de company,
|
||||
no 7 como decía la primera versión de este docstring: con 7 salen 26 y el insert del lado de
|
||||
EFC reventaría. Un consecutivo de 4 dígitos (mes con más de 999 expedientes) gasta uno más.
|
||||
|
||||
Se valida en vez de truncar: un token recortado apuntaría a la carpeta de OTRO expediente y
|
||||
los documentos se mezclarían en silencio, que es peor que fallar aquí.
|
||||
"""
|
||||
token = f"CRM-{company_id}-{folio}"
|
||||
if len(token) > PEDIMENTO_APP_MAX:
|
||||
raise ValueError(
|
||||
f"storage_token de {len(token)} caracteres excede los {PEDIMENTO_APP_MAX} de "
|
||||
f"pedimento_app en EFC: {token!r}. Revisa el largo del company_id o del consecutivo."
|
||||
)
|
||||
return token
|
||||
143
backend/api/v1/modules/crm/expediente_gateway/tasks.py
Normal file
143
backend/api/v1/modules/crm/expediente_gateway/tasks.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Tareas Celery del carril CRM Agentes de Carga -> EFC.
|
||||
|
||||
- ``deliver_outbox_row`` / ``sweep_outbox``: expedientes (alta del provisional y completado).
|
||||
- ``deliver_file_outbox_row`` / ``sweep_file_outbox``: archivos.
|
||||
- ``sweep_expediente_gaps``: reconciliación de expedientes que nunca se encolaron.
|
||||
|
||||
**La trampa de RLS, que es lo que más fácil se pasa por alto.** ``core/celery_app.py`` materializa el
|
||||
contexto desde los headers ``rls_tenant_id`` / ``rls_company_id``. Por tanto:
|
||||
|
||||
- Las tareas **por fila** se despachan siempre con esos headers.
|
||||
- Los **barridos corren sin contexto de tenant**: leen los ids pendientes con una sesión sin scope y
|
||||
despachan una tarea hija por fila con sus propios headers. Si un barrido abriera una sesión con
|
||||
scope e iterara, o no vería nada o se saltaría el aislamiento.
|
||||
|
||||
**Sin ``autoretry_for``, ``retry_backoff`` ni ``max_retries``**: duplicarían el mecanismo de
|
||||
reintento que ya está en el cliente (3 intentos con backoff lineal) y en el barrido (cada 120 s
|
||||
hasta ``MAX_ATTEMPTS``).
|
||||
"""
|
||||
import logging
|
||||
|
||||
from core.celery_app import celery_app
|
||||
from core.config import settings
|
||||
from core.database import scoped_core_db
|
||||
|
||||
from . import service
|
||||
from .models import STATUS_PENDING, EfcFileOutbox, EfcSyncOutbox
|
||||
|
||||
# ── Registro de modelos: NO son imports decorativos, no los quites ──────────────────────
|
||||
# El worker de Celery NO carga la app: importa este módulo y sus dependencias, y nada más.
|
||||
# SQLAlchemy resuelve las ForeignKey por NOMBRE de tabla contra su registro global, así que
|
||||
# si la clase del otro extremo nunca se importó, la configuración de mappers falla con
|
||||
#
|
||||
# Foreign key associated with column 'cases.account_id' could not find table 'crm.accounts'
|
||||
#
|
||||
# y la tarea muere con PendingRollbackError. El síntoma es cruel: la fila del outbox se
|
||||
# queda en `pending` con attempts=0 y SIN last_error —porque el fallo ocurre antes de poder
|
||||
# registrarlo—, así que el carril se ve encolando bien y no entrega nunca. En la app web no
|
||||
# pasa: `main.py` monta todos los routers y con ellos se importan todos los modelos.
|
||||
#
|
||||
# El juego es el mínimo verificado con `configure_mappers()` en un proceso limpio:
|
||||
# - accounts : cierra la FK cases.account_id, que es la que rompía;
|
||||
# - documents y ops.shipments : las dos fuentes del outbox de archivos;
|
||||
# - tenants : lo consulta el resolver de organización al entregar.
|
||||
from api.v1.modules.core.tenants import models as _m_tenants # noqa: F401
|
||||
from api.v1.modules.crm.accounts import models as _m_accounts # noqa: F401
|
||||
from api.v1.modules.crm.documents import models as _m_documents # noqa: F401
|
||||
from api.v1.modules.ops.shipments import models as _m_shipments # noqa: F401
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ── Expedientes ─────────────────────────────────────────────────────────────
|
||||
|
||||
@celery_app.task(name="expediente_gateway.deliver_outbox_row")
|
||||
def deliver_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
with scoped_core_db(tenant_id, company_id) as db:
|
||||
row = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.id == outbox_id).first()
|
||||
if row is None:
|
||||
logger.warning(
|
||||
"expediente_gateway: outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id
|
||||
)
|
||||
return
|
||||
service.deliver_row(db, row)
|
||||
|
||||
|
||||
@celery_app.task(name="expediente_gateway.sweep_outbox")
|
||||
def sweep_outbox(limit: int = 100) -> int:
|
||||
"""Re-despacha filas pendientes de expediente. Sin contexto de tenant: cada fila lleva el suyo."""
|
||||
with scoped_core_db() as db:
|
||||
rows = (
|
||||
db.query(EfcSyncOutbox.id, EfcSyncOutbox.tenant_id, EfcSyncOutbox.company_id)
|
||||
.filter(EfcSyncOutbox.status == STATUS_PENDING)
|
||||
.order_by(EfcSyncOutbox.created_at.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
for rid, tid, cid in rows:
|
||||
deliver_outbox_row.apply_async(
|
||||
args=[rid, tid, cid],
|
||||
headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""},
|
||||
)
|
||||
if rows:
|
||||
logger.info("expediente_gateway: sweep (expedientes) re-despachó %s filas pendientes", len(rows))
|
||||
return len(rows)
|
||||
|
||||
|
||||
# ── Archivos ────────────────────────────────────────────────────────────────
|
||||
|
||||
@celery_app.task(name="expediente_gateway.deliver_file_outbox_row")
|
||||
def deliver_file_outbox_row(outbox_id: int, tenant_id: int, company_id: int) -> None:
|
||||
with scoped_core_db(tenant_id, company_id) as db:
|
||||
row = db.query(EfcFileOutbox).filter(EfcFileOutbox.id == outbox_id).first()
|
||||
if row is None:
|
||||
logger.warning(
|
||||
"expediente_gateway: file outbox_id=%s no encontrado (tenant=%s)", outbox_id, tenant_id
|
||||
)
|
||||
return
|
||||
service.deliver_file_row(db, row)
|
||||
|
||||
|
||||
@celery_app.task(name="expediente_gateway.sweep_file_outbox")
|
||||
def sweep_file_outbox(limit: int = 100) -> int:
|
||||
"""Re-despacha archivos pendientes (EFC o el broker caídos cuando el usuario subió el archivo)."""
|
||||
with scoped_core_db() as db:
|
||||
rows = (
|
||||
db.query(EfcFileOutbox.id, EfcFileOutbox.tenant_id, EfcFileOutbox.company_id)
|
||||
.filter(EfcFileOutbox.status == STATUS_PENDING)
|
||||
.order_by(EfcFileOutbox.created_at.asc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
for rid, tid, cid in rows:
|
||||
deliver_file_outbox_row.apply_async(
|
||||
args=[rid, tid, cid],
|
||||
headers={"rls_tenant_id": str(tid), "rls_company_id": str(cid) if cid is not None else ""},
|
||||
)
|
||||
if rows:
|
||||
logger.info("expediente_gateway: sweep (archivos) re-despachó %s archivos pendientes", len(rows))
|
||||
return len(rows)
|
||||
|
||||
|
||||
# ── Reconciliación de huecos ────────────────────────────────────────────────
|
||||
|
||||
@celery_app.task(name="expediente_gateway.sweep_expediente_gaps")
|
||||
def sweep_expediente_gaps(limit: int = 200) -> int:
|
||||
"""Detecta expedientes que nunca se encolaron a EFC y los re-encola.
|
||||
|
||||
No-op si la integración está apagada.
|
||||
"""
|
||||
if not settings.EFC_API_URL:
|
||||
return 0
|
||||
n = 0
|
||||
with scoped_core_db() as db:
|
||||
gaps = service.find_expediente_gaps(db, limit=limit)
|
||||
for expediente in gaps:
|
||||
service.replicate_expediente_best_effort(db, expediente)
|
||||
n += 1
|
||||
if n:
|
||||
db.commit()
|
||||
if n:
|
||||
logger.info("expediente_gateway: sweep de huecos re-encoló %s expedientes", n)
|
||||
return n
|
||||
@@ -17,6 +17,7 @@ from .cases.routes import router as cases_router
|
||||
from .catalogs.routes import router as catalogs_router
|
||||
from .contacts.routes import router as contacts_router
|
||||
from .documents.routes import router as documents_router
|
||||
from .expediente_gateway.routes import router as expediente_gateway_router
|
||||
from .leads.routes import router as leads_router
|
||||
from .metrics.routes import router as metrics_router
|
||||
from .opportunities.routes import router as opportunities_router
|
||||
@@ -50,3 +51,7 @@ router.include_router(catalogs_router)
|
||||
router.include_router(uploads_router)
|
||||
router.include_router(rates_router)
|
||||
router.include_router(rates_cost_router)
|
||||
# Tablero de operación del carril hacia EFC: cola pendiente, métricas y reintento manual.
|
||||
# No es una ruta de negocio; existe para que una persona vea y desatore la entrega sin
|
||||
# entrar a la base. Hereda el enforcement de crm.access del router agregador.
|
||||
router.include_router(expediente_gateway_router)
|
||||
|
||||
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()
|
||||
@@ -1,5 +1,6 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, computed_field
|
||||
|
||||
@@ -10,7 +11,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 +29,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 +88,14 @@ 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)
|
||||
# Modo de timbrado de ESTA factura. 'produccion' emite un CFDI con validez fiscal real
|
||||
# ante el SAT; por eso el default es 'pruebas' y subirlo es una decisión explícita.
|
||||
stamping_mode: Literal["pruebas", "produccion"] = "pruebas"
|
||||
|
||||
|
||||
class InvoiceCreate(InvoiceBase):
|
||||
@@ -94,6 +114,11 @@ 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)
|
||||
stamping_mode: Literal["pruebas", "produccion"] | None = None
|
||||
|
||||
|
||||
class InvoiceResponse(InvoiceBase):
|
||||
@@ -120,3 +145,26 @@ class InvoiceResponse(InvoiceBase):
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class InvoiceItemTaxInput(BaseModel):
|
||||
"""Alta o ajuste de un impuesto de la partida.
|
||||
|
||||
El importe no se recibe: se calcula de la base de la partida por la tasa, para que no
|
||||
pueda quedar un desglose que no cuadre con el importe del concepto.
|
||||
"""
|
||||
|
||||
tax_id: int
|
||||
rate: Decimal = Field(..., ge=0, le=1, max_digits=8, decimal_places=6)
|
||||
is_withholding: bool = False
|
||||
|
||||
|
||||
class InvoiceItemTaxResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
invoice_item_id: int
|
||||
tax_id: int
|
||||
is_withholding: bool
|
||||
rate: Decimal | None = None
|
||||
amount: Decimal
|
||||
|
||||
@@ -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."""
|
||||
@@ -51,6 +62,25 @@ 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)
|
||||
# ----- Modo de timbrado (por factura, no por entorno) -----
|
||||
# 'pruebas' | 'produccion'. Determina el host del PAC y, con él, si el comprobante tiene
|
||||
# validez fiscal ante el SAT. Inmutable una vez que la factura tiene un timbre exitoso:
|
||||
# cambiarlo después falsearía el registro de con qué intención se emitió.
|
||||
stamping_mode: Mapped[str] = mapped_column(
|
||||
String(12), nullable=False, server_default=text("'pruebas'")
|
||||
)
|
||||
|
||||
|
||||
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
@@ -63,10 +93,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):
|
||||
|
||||
@@ -4,12 +4,14 @@ from sqlalchemy.orm import Session
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from . import service, taxes_service
|
||||
from .dto import (
|
||||
InvoiceClientReviewInput,
|
||||
InvoiceCreate,
|
||||
InvoiceItemCreate,
|
||||
InvoiceItemResponse,
|
||||
InvoiceItemTaxInput,
|
||||
InvoiceItemTaxResponse,
|
||||
InvoiceItemUpdate,
|
||||
InvoiceResponse,
|
||||
InvoiceUpdate,
|
||||
@@ -132,3 +134,26 @@ def create_payment(payload: PaymentCreate, company_id: int = Query(...), current
|
||||
@router.delete("/payments/{payment_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_payment(payment_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
service.delete_payment(db, payment_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
# ----- Impuestos por partida -----
|
||||
# El traslado de IVA se deriva del % de la factura; estos endpoints son para ajustarlo
|
||||
# (retenciones, tasas distintas) cuando el caso lo pide.
|
||||
|
||||
@router.get("/invoice-items/{item_id}/taxes", response_model=list[InvoiceItemTaxResponse])
|
||||
def list_item_taxes(item_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
return taxes_service.list_item_taxes(db, item_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.put("/invoice-items/{item_id}/taxes", response_model=InvoiceItemTaxResponse)
|
||||
def set_item_tax(item_id: int, payload: InvoiceItemTaxInput, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
"""Alta o ajuste. La combinación impuesto + traslado/retención es única por partida."""
|
||||
return taxes_service.set_item_tax(
|
||||
db, item_id, payload.tax_id, payload.rate, payload.is_withholding,
|
||||
current_user["tenant_id"], company_id,
|
||||
)
|
||||
|
||||
|
||||
@router.delete("/invoice-item-taxes/{tax_row_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_item_tax(tax_row_id: int, company_id: int = Query(...), current_user: dict = Depends(get_current_user), db: Session = Depends(get_core_db)):
|
||||
taxes_service.delete_item_tax(db, tax_row_id, current_user["tenant_id"], company_id)
|
||||
|
||||
@@ -11,6 +11,7 @@ from api.v1.modules.crm.common.folios import next_folio
|
||||
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,
|
||||
@@ -19,6 +20,7 @@ from .dto import (
|
||||
InvoiceUpdate,
|
||||
PaymentCreate,
|
||||
)
|
||||
from . import taxes_service
|
||||
from .models import Invoice, InvoiceItem, Payment
|
||||
from .pdf import build_invoice_pdf
|
||||
|
||||
@@ -118,16 +120,43 @@ def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
_reject_stamping_mode_change(db, obj, data, tenant_id, company_id)
|
||||
for f, v in data.items():
|
||||
setattr(obj, f, v)
|
||||
obj.updated_by = user_id
|
||||
db.flush()
|
||||
if "tax_rate" in data:
|
||||
# El % global es la fuente del IVA derivado de cada partida: si cambia, se propaga.
|
||||
taxes_service.sync_invoice_taxes(db, obj)
|
||||
_recompute(db, obj) # tax_rate pudo cambiar
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def _reject_stamping_mode_change(db, obj: Invoice, data: dict, tenant_id, company_id) -> None:
|
||||
"""El modo de timbrado es inmutable una vez que la factura tiene timbre.
|
||||
|
||||
Cambiarlo después falsearía el registro de con qué intención se emitió el comprobante: el
|
||||
CFDI ya existe ante el SAT con la validez que le dio el entorno donde se timbró, y ese
|
||||
hecho no se edita.
|
||||
"""
|
||||
nuevo = data.get("stamping_mode")
|
||||
if nuevo is None or nuevo == obj.stamping_mode:
|
||||
return
|
||||
# Import diferido: stamping importa invoices, y al revés sería circular.
|
||||
from ..stamping.service import get_stamp # noqa: PLC0415
|
||||
|
||||
if get_stamp(db, obj.id, tenant_id, company_id):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=(
|
||||
"La factura ya está timbrada: el modo de timbrado no se puede cambiar "
|
||||
f"(sigue en {obj.stamping_mode!r})."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def delete_invoice(db, invoice_id, tenant_id, company_id) -> None:
|
||||
obj = get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
@@ -344,11 +373,53 @@ 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()
|
||||
taxes_service.sync_item_taxes(db, item, invoice)
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
@@ -357,10 +428,17 @@ 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))
|
||||
invoice = get_invoice(db, item.invoice_id, tenant_id, company_id)
|
||||
taxes_service.sync_item_taxes(db, item, invoice)
|
||||
_recompute(db, invoice)
|
||||
db.commit()
|
||||
db.refresh(item)
|
||||
return item
|
||||
|
||||
201
backend/api/v1/modules/fin/invoices/taxes_service.py
Normal file
201
backend/api/v1/modules/fin/invoices/taxes_service.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""Impuestos de las partidas de la factura.
|
||||
|
||||
El CFDI exige el desglose **por partida**, pero la factura ya captura un porcentaje global de
|
||||
impuesto. Duplicar la captura sería trabajo doble y una fuente de incoherencias, así que el
|
||||
traslado de IVA se **deriva** de ``invoices.tax_rate`` y se recalcula cuando cambia el importe
|
||||
o el porcentaje.
|
||||
|
||||
La derivación no pisa lo capturado a mano: en cuanto alguien ajusta los impuestos de una
|
||||
partida (una retención, una tasa distinta, un exento), esa partida deja de recalcularse sola.
|
||||
Es la diferencia entre un valor por defecto útil y un automatismo que borra trabajo ajeno.
|
||||
"""
|
||||
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..catalogs.models import Tax, TaxObject
|
||||
from .models import Invoice, InvoiceItem, InvoiceItemTax
|
||||
|
||||
# c_ObjetoImp que obligan al desglose de impuestos en el comprobante.
|
||||
_OBJETO_CON_DESGLOSE = {"02"}
|
||||
# c_Impuesto del IVA.
|
||||
_IVA = "002"
|
||||
|
||||
|
||||
def _cents(value: Decimal) -> Decimal:
|
||||
return value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def _item_taxes(db: Session, item_id: int) -> list[InvoiceItemTax]:
|
||||
return (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(InvoiceItemTax.invoice_item_id == item_id, InvoiceItemTax.deleted_at.is_(None))
|
||||
.order_by(InvoiceItemTax.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _tax_object_code(db: Session, item: InvoiceItem) -> str:
|
||||
if not item.tax_object_id:
|
||||
return ""
|
||||
row = db.query(TaxObject).filter(TaxObject.id == item.tax_object_id).first()
|
||||
return row.code if row else ""
|
||||
|
||||
|
||||
def sync_item_taxes(db: Session, item: InvoiceItem, invoice: Invoice) -> None:
|
||||
"""Deja el traslado de IVA de la partida al día con el ``tax_rate`` de la factura.
|
||||
|
||||
No hace nada si:
|
||||
- la partida no es objeto de impuesto con desglose (``ObjetoImp`` distinto de 02), o
|
||||
- ya hay impuestos que no son el traslado de IVA derivado — señal de captura manual.
|
||||
"""
|
||||
if _tax_object_code(db, item) not in _OBJETO_CON_DESGLOSE:
|
||||
# Si dejó de ser objeto de impuesto, se retira el traslado derivado: un CFDI con
|
||||
# ObjetoImp 01 y nodo de impuestos es motivo de rechazo.
|
||||
for t in _item_taxes(db, item.id):
|
||||
db.delete(t)
|
||||
return
|
||||
|
||||
iva = db.query(Tax).filter(Tax.code == _IVA).first()
|
||||
if not iva:
|
||||
return # sin catálogo no hay nada que derivar; la validación del timbrado lo reportará
|
||||
|
||||
existentes = _item_taxes(db, item.id)
|
||||
manuales = [t for t in existentes if t.is_withholding or t.tax_id != iva.id]
|
||||
if manuales:
|
||||
return # hay captura manual: no se toca
|
||||
|
||||
rate = (Decimal(invoice.tax_rate or 0) / Decimal(100)).quantize(Decimal("0.000001"))
|
||||
base = _cents(Decimal(item.quantity or 0) * Decimal(item.unit_amount or 0))
|
||||
amount = _cents(base * rate)
|
||||
|
||||
traslado = next((t for t in existentes if t.tax_id == iva.id and not t.is_withholding), None)
|
||||
if rate == 0:
|
||||
# Tasa 0 no es lo mismo que exento, pero con el % en cero lo que hay es una factura sin
|
||||
# IVA capturado: se retira el traslado en vez de declarar 0.00 y que el SAT lo cuestione.
|
||||
if traslado:
|
||||
db.delete(traslado)
|
||||
return
|
||||
|
||||
if traslado is None:
|
||||
traslado = InvoiceItemTax(
|
||||
invoice_item_id=item.id,
|
||||
tax_id=iva.id,
|
||||
is_withholding=False,
|
||||
tenant_id=item.tenant_id,
|
||||
company_id=item.company_id,
|
||||
)
|
||||
db.add(traslado)
|
||||
traslado.rate = rate
|
||||
traslado.amount = amount
|
||||
|
||||
|
||||
def sync_invoice_taxes(db: Session, invoice: Invoice) -> None:
|
||||
"""Recalcula el IVA derivado de todas las partidas. Se llama al cambiar ``tax_rate``."""
|
||||
items = (
|
||||
db.query(InvoiceItem)
|
||||
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
|
||||
.all()
|
||||
)
|
||||
for item in items:
|
||||
sync_item_taxes(db, item, invoice)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Ajuste manual
|
||||
# --------------------------------------------------------------------------------------
|
||||
def list_item_taxes(
|
||||
db: Session, item_id: int, tenant_id: int, company_id: int
|
||||
) -> list[InvoiceItemTax]:
|
||||
_get_item(db, item_id, tenant_id, company_id)
|
||||
return _item_taxes(db, item_id)
|
||||
|
||||
|
||||
def _get_item(db: Session, item_id: int, tenant_id: int, company_id: int) -> InvoiceItem:
|
||||
item = (
|
||||
db.query(InvoiceItem)
|
||||
.filter(
|
||||
InvoiceItem.id == item_id,
|
||||
InvoiceItem.tenant_id == tenant_id,
|
||||
InvoiceItem.company_id == company_id,
|
||||
InvoiceItem.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not item:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Partida no encontrada")
|
||||
return item
|
||||
|
||||
|
||||
def set_item_tax(
|
||||
db: Session,
|
||||
item_id: int,
|
||||
tax_id: int,
|
||||
rate: Decimal,
|
||||
is_withholding: bool,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
) -> InvoiceItemTax:
|
||||
"""Alta o ajuste de un impuesto de la partida. El importe se calcula de la base y la tasa."""
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
tax = db.query(Tax).filter(Tax.id == tax_id).first()
|
||||
if not tax:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="El impuesto indicado no existe en el catálogo del SAT",
|
||||
)
|
||||
if is_withholding and not tax.is_withholding:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"El impuesto {tax.code} ({tax.description}) no puede retenerse",
|
||||
)
|
||||
if not is_withholding and not tax.is_transferred:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"El impuesto {tax.code} ({tax.description}) no puede trasladarse",
|
||||
)
|
||||
|
||||
base = _cents(Decimal(item.quantity or 0) * Decimal(item.unit_amount or 0))
|
||||
obj = (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(
|
||||
InvoiceItemTax.invoice_item_id == item_id,
|
||||
InvoiceItemTax.tax_id == tax_id,
|
||||
InvoiceItemTax.is_withholding == is_withholding,
|
||||
InvoiceItemTax.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if obj is None:
|
||||
obj = InvoiceItemTax(
|
||||
invoice_item_id=item_id,
|
||||
tax_id=tax_id,
|
||||
is_withholding=is_withholding,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
db.add(obj)
|
||||
obj.rate = Decimal(rate).quantize(Decimal("0.000001"))
|
||||
obj.amount = _cents(base * Decimal(rate))
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_item_tax(db: Session, tax_row_id: int, tenant_id: int, company_id: int) -> None:
|
||||
obj = (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(
|
||||
InvoiceItemTax.id == tax_row_id,
|
||||
InvoiceItemTax.tenant_id == tenant_id,
|
||||
InvoiceItemTax.company_id == company_id,
|
||||
InvoiceItemTax.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Impuesto no encontrado")
|
||||
db.delete(obj)
|
||||
db.commit()
|
||||
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."""
|
||||
159
backend/api/v1/modules/fin/issuer/csd_service.py
Normal file
159
backend/api/v1/modules/fin/issuer/csd_service.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""Carga y lectura del CSD (Certificado de Sello Digital) de la empresa.
|
||||
|
||||
El ``.key`` es material con el que se puede firmar a nombre de la empresa ante el SAT: no se
|
||||
devuelve nunca por la API, ni entero ni en partes. Sólo entra (al subirlo) y se usa del lado
|
||||
del servidor (al timbrar).
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from cryptography.hazmat.primitives import hashes
|
||||
from cryptography.hazmat.primitives.asymmetric import padding
|
||||
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.crypto import SecretsNotConfigured, encrypt_secret
|
||||
from core.s3_keys import tenant_company_prefix
|
||||
|
||||
from ...fin.stamping import sealer
|
||||
from .models import IssuerSettings
|
||||
from .service import get_issuer_settings
|
||||
|
||||
# Tamaño máximo razonable: un .cer del SAT ronda los 2 KB y un .key los 2 KB. El tope evita
|
||||
# que alguien suba un archivo enorme por error o a propósito.
|
||||
_MAX_BYTES = 64 * 1024
|
||||
|
||||
|
||||
def _csd_keys(tenant_id: int, company_id: int) -> tuple[str, str]:
|
||||
"""Claves de almacenamiento del par. Estables: subir de nuevo reemplaza el anterior."""
|
||||
prefijo = tenant_company_prefix(tenant_id, company_id) + "certificates/"
|
||||
return prefijo + "cfdi.cer", prefijo + "cfdi.key"
|
||||
|
||||
|
||||
def _verify_pair(cer_bytes: bytes, key_bytes: bytes, password: str) -> str:
|
||||
"""Comprueba que la llave privada corresponde al certificado. Devuelve el NoCertificado.
|
||||
|
||||
Se hace firmando un dato de prueba con la llave y verificándolo con la pública del
|
||||
certificado. Es la única forma de saberlo antes de timbrar: si no cuadran, el error
|
||||
aparecería hasta que el PAC rechace el comprobante, con un mensaje que no menciona el CSD.
|
||||
"""
|
||||
try:
|
||||
cert_number, _ = sealer.read_certificate(cer_bytes)
|
||||
private_key = sealer.load_private_key(key_bytes, password)
|
||||
except sealer.SealingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
try:
|
||||
cert = load_der_x509_certificate(cer_bytes)
|
||||
except ValueError:
|
||||
cert = load_pem_x509_certificate(cer_bytes)
|
||||
|
||||
reto = b"verificacion-de-par-csd"
|
||||
firma = private_key.sign(reto, padding.PKCS1v15(), hashes.SHA256())
|
||||
try:
|
||||
cert.public_key().verify(firma, reto, padding.PKCS1v15(), hashes.SHA256())
|
||||
except Exception as exc: # noqa: BLE001 — cualquier fallo aquí es "no corresponden"
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=(
|
||||
"La llave privada (.key) no corresponde al certificado (.cer). "
|
||||
"Verifica que ambos archivos sean del mismo CSD."
|
||||
),
|
||||
) from exc
|
||||
|
||||
return cert_number
|
||||
|
||||
|
||||
def upload_csd(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
cer_bytes: bytes,
|
||||
key_bytes: bytes,
|
||||
password: str,
|
||||
user_id: str | None = None,
|
||||
) -> IssuerSettings:
|
||||
"""Valida el par, lo guarda en almacenamiento y cifra la contraseña."""
|
||||
# Exige que ya existan los datos fiscales: el CSD pertenece a un emisor, no al aire.
|
||||
obj = get_issuer_settings(db, tenant_id, company_id)
|
||||
|
||||
if not password:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="La contraseña de la llave privada es obligatoria.",
|
||||
)
|
||||
for etiqueta, datos in (("certificado (.cer)", cer_bytes), ("llave privada (.key)", key_bytes)):
|
||||
if not datos:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Falta el archivo del {etiqueta}.",
|
||||
)
|
||||
if len(datos) > _MAX_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"El archivo del {etiqueta} es demasiado grande para ser un CSD.",
|
||||
)
|
||||
|
||||
cert_number = _verify_pair(cer_bytes, key_bytes, password)
|
||||
|
||||
# La contraseña se cifra ANTES de subir los archivos: si no hay clave maestra, no se deja
|
||||
# material criptográfico en el almacenamiento a medio configurar.
|
||||
try:
|
||||
password_enc = encrypt_secret(password)
|
||||
except SecretsNotConfigured as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
from core.storage_s3 import (
|
||||
put_object_bytes,
|
||||
) # noqa: PLC0415 (import diferido: tests sin MinIO)
|
||||
|
||||
cer_key, key_key = _csd_keys(tenant_id, company_id)
|
||||
put_object_bytes(cer_key, cer_bytes, content_type="application/x-x509-ca-cert")
|
||||
put_object_bytes(key_key, key_bytes, content_type="application/octet-stream")
|
||||
|
||||
obj.csd_cer_file_key = cer_key
|
||||
obj.csd_key_file_key = key_key
|
||||
obj.csd_password_enc = password_enc
|
||||
obj.csd_cert_number = cert_number
|
||||
obj.csd_uploaded_at = datetime.now(timezone.utc)
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_csd(
|
||||
db: Session, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> IssuerSettings:
|
||||
"""Desvincula el CSD de la empresa.
|
||||
|
||||
Se borran también los objetos del almacenamiento: dejar una llave privada huérfana es
|
||||
justo lo que no se quiere. Si el borrado remoto falla, la referencia se limpia igual —
|
||||
sin ella el sistema ya no puede firmar.
|
||||
"""
|
||||
obj = get_issuer_settings(db, tenant_id, company_id)
|
||||
claves = [k for k in (obj.csd_cer_file_key, obj.csd_key_file_key) if k]
|
||||
if claves:
|
||||
try:
|
||||
from core.storage_s3 import delete_object_if_exists # noqa: PLC0415
|
||||
|
||||
for k in claves:
|
||||
delete_object_if_exists(k)
|
||||
except Exception: # noqa: BLE001
|
||||
# No se propaga: la referencia se limpia igual y el CSD queda inutilizable.
|
||||
pass
|
||||
|
||||
obj.csd_cer_file_key = None
|
||||
obj.csd_key_file_key = None
|
||||
obj.csd_password_enc = None
|
||||
obj.csd_cert_number = None
|
||||
obj.csd_uploaded_at = None
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
71
backend/api/v1/modules/fin/issuer/dto.py
Normal file
71
backend/api/v1/modules/fin/issuer/dto.py
Normal file
@@ -0,0 +1,71 @@
|
||||
"""Esquemas de los datos fiscales del emisor."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, computed_field, 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
|
||||
# ----- Estado del CSD -----
|
||||
# Se expone SI hay certificado cargado y cuál, nunca su contenido ni la contraseña: con el
|
||||
# .key se puede firmar a nombre de la empresa ante el SAT.
|
||||
csd_cert_number: str | None = None
|
||||
csd_uploaded_at: datetime | None = None
|
||||
|
||||
@computed_field
|
||||
@property
|
||||
def has_csd(self) -> bool:
|
||||
"""Hay par de archivos y contraseña guardados, o sea que ya se puede timbrar."""
|
||||
return bool(self.csd_cert_number and self.csd_uploaded_at)
|
||||
57
backend/api/v1/modules/fin/issuer/models.py
Normal file
57
backend/api/v1/modules/fin/issuer/models.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""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 datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, 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 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)
|
||||
|
||||
# ----- CSD (Certificado de Sello Digital) de la empresa -----
|
||||
# Los archivos viven en MinIO; aquí sólo su clave. El .key es material con el que se puede
|
||||
# firmar a nombre de la empresa: no se expone nunca por la API, ni siquiera su contenido en
|
||||
# base64. Sólo se sube y se usa del lado del servidor.
|
||||
csd_cer_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
csd_key_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
# Contraseña de la llave privada, cifrada con la clave maestra del entorno (core.crypto).
|
||||
# Nunca se devuelve en una respuesta; sólo se sabe si está puesta o no.
|
||||
csd_password_enc: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Informativo, para mostrar en la pantalla qué certificado está cargado.
|
||||
csd_cert_number: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
csd_uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
|
||||
tax_regime: Mapped["TaxRegime"] = relationship("TaxRegime", lazy="selectin")
|
||||
97
backend/api/v1/modules/fin/issuer/routes.py
Normal file
97
backend/api/v1/modules/fin/issuer/routes.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Endpoints de los datos fiscales del emisor (una configuración por empresa)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, File, Form, Query, UploadFile
|
||||
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 csd_service, 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"),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/settings/issuer/csd",
|
||||
response_model=IssuerSettingsResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
|
||||
)
|
||||
async def upload_csd(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
cer: UploadFile = File(..., description="Certificado del CSD (.cer)"),
|
||||
key: UploadFile = File(..., description="Llave privada del CSD (.key)"),
|
||||
password: str = Form(..., description="Contraseña de la llave privada"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Carga el CSD de la empresa.
|
||||
|
||||
Antes de guardar nada se comprueba que la llave privada corresponde al certificado: si no,
|
||||
el error saldría hasta que el PAC rechace un comprobante, con un mensaje que no menciona
|
||||
el CSD. La contraseña se guarda cifrada y **no se devuelve nunca**.
|
||||
"""
|
||||
return csd_service.upload_csd(
|
||||
db,
|
||||
current_user["tenant_id"],
|
||||
company_id,
|
||||
await cer.read(),
|
||||
await key.read(),
|
||||
password,
|
||||
current_user.get("sub") or current_user.get("id"),
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/settings/issuer/csd",
|
||||
response_model=IssuerSettingsResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
|
||||
)
|
||||
def delete_csd(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Desvincula el CSD y borra sus archivos del almacenamiento."""
|
||||
return csd_service.delete_csd(
|
||||
db,
|
||||
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,16 @@ 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
|
||||
from .stamping.routes import router as stamping_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)
|
||||
router.include_router(stamping_router)
|
||||
|
||||
1
backend/api/v1/modules/fin/stamping/__init__.py
Normal file
1
backend/api/v1/modules/fin/stamping/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Timbrado de CFDI 4.0 ante el PAC (Comercio Digital)."""
|
||||
382
backend/api/v1/modules/fin/stamping/cfdi_builder.py
Normal file
382
backend/api/v1/modules/fin/stamping/cfdi_builder.py
Normal file
@@ -0,0 +1,382 @@
|
||||
"""Construcción del XML CFDI 4.0 de tipo ingreso.
|
||||
|
||||
El orden de los atributos **no es libre**: la cadena original se calcula recorriendo el
|
||||
comprobante en el orden del XSD, y el sello se hace sobre esa cadena. Aquí se respeta el
|
||||
mismo orden que usa el sistema legado (``CFDI.cs:14340-14394``), verificado contra el XSD.
|
||||
|
||||
Todo el dinero se maneja con ``Decimal``. Con ``float``, 0.1 + 0.2 no da 0.30 y el total del
|
||||
comprobante no cuadra con la suma de las partidas: el PAC lo rechaza.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
|
||||
from lxml import etree
|
||||
|
||||
CFDI_NS = "http://www.sat.gob.mx/cfd/4"
|
||||
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
|
||||
SCHEMA_LOCATION = (
|
||||
"http://www.sat.gob.mx/cfd/4 http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd"
|
||||
)
|
||||
|
||||
VERSION = "4.0"
|
||||
TIPO_INGRESO = "I"
|
||||
|
||||
# Declaración XML escrita a mano, con comillas DOBLES. lxml emite la suya con comillas simples
|
||||
# (<?xml version='1.0' encoding='UTF-8'?>), que es XML válido —la especificación admite ambas—
|
||||
# pero Comercio Digital lo rechaza con el código 642 "la versión del XML no es 1.0" porque
|
||||
# compara la cadena literal version="1.0" en vez de parsear el prólogo. Por eso el comprobante
|
||||
# se serializa sin declaración y ésta se antepone.
|
||||
XML_DECLARATION = b'<?xml version="1.0" encoding="UTF-8"?>\n'
|
||||
# c_Exportacion: "01" = No aplica. Es obligatorio en 4.0 y este módulo no emite exportaciones.
|
||||
EXPORTACION_NO_APLICA = "01"
|
||||
|
||||
# Decimales por moneda (c_Moneda). El SAT admite hasta ese número en importes.
|
||||
_DECIMALES = {"MXN": 2, "USD": 2, "EUR": 2}
|
||||
_DECIMALES_DEFECTO = 2
|
||||
|
||||
|
||||
class CfdiBuildError(Exception):
|
||||
"""El comprobante no se puede construir con los datos disponibles."""
|
||||
|
||||
def __init__(self, missing: list[str]):
|
||||
# Se acumulan TODOS los faltantes en vez de fallar en el primero: quien captura la
|
||||
# factura necesita la lista completa, no descubrirlos de uno en uno.
|
||||
self.missing = missing
|
||||
super().__init__("Faltan datos fiscales: " + "; ".join(missing))
|
||||
|
||||
|
||||
def _serialize(root: etree._Element) -> bytes:
|
||||
"""Serializa el comprobante en UTF-8 con la declaración que acepta el PAC."""
|
||||
# xml_declaration=False es obligatorio: con encoding distinto de ASCII, lxml la añade sola.
|
||||
return XML_DECLARATION + etree.tostring(root, xml_declaration=False, encoding="UTF-8")
|
||||
|
||||
|
||||
def _money(value: Decimal | float | int | None, currency: str) -> str:
|
||||
"""Importe con los decimales de la moneda, sin separador de miles."""
|
||||
dec = _DECIMALES.get(currency.upper(), _DECIMALES_DEFECTO)
|
||||
q = Decimal(1).scaleb(-dec)
|
||||
return str(Decimal(str(value or 0)).quantize(q, rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
def _qty(value: Decimal | float | int | None) -> str:
|
||||
"""Cantidad: hasta 6 decimales, sin ceros finales innecesarios."""
|
||||
d = Decimal(str(value or 0)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP)
|
||||
return format(d.normalize(), "f")
|
||||
|
||||
|
||||
def _rate(value: Decimal | float | int) -> str:
|
||||
"""Tasa o cuota: el SAT la exige con 6 decimales (p. ej. 0.160000)."""
|
||||
return str(Decimal(str(value)).quantize(Decimal("0.000001"), rounding=ROUND_HALF_UP))
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaxLine:
|
||||
"""Impuesto trasladado o retenido de una partida."""
|
||||
|
||||
code: str # c_Impuesto: "002" = IVA
|
||||
rate: Decimal
|
||||
amount: Decimal
|
||||
is_withholding: bool = False
|
||||
factor: str = "Tasa" # c_TipoFactor: Tasa | Cuota | Exento
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConceptLine:
|
||||
"""Partida del comprobante."""
|
||||
|
||||
product_service_code: str # ClaveProdServ
|
||||
unit_code: str # ClaveUnidad
|
||||
description: str
|
||||
quantity: Decimal
|
||||
unit_price: Decimal
|
||||
tax_object: str # c_ObjetoImp: "01" no objeto, "02" sí objeto
|
||||
taxes: list[TaxLine] = field(default_factory=list)
|
||||
identification: str | None = None # NoIdentificacion
|
||||
|
||||
@property
|
||||
def amount(self) -> Decimal:
|
||||
return (self.quantity * self.unit_price).quantize(Decimal("0.01"), rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CfdiData:
|
||||
"""Todo lo que necesita un CFDI 4.0 de ingreso, ya resuelto contra los catálogos."""
|
||||
|
||||
# Comprobante
|
||||
folio: str | None
|
||||
serie: str | None
|
||||
date: str # YYYY-MM-DDTHH:MM:SS, hora local del lugar de expedición
|
||||
payment_form: str # c_FormaPago
|
||||
payment_method: str # c_MetodoPago: PUE | PPD
|
||||
currency: str
|
||||
exchange_rate: Decimal | None
|
||||
expedition_zip: str # LugarExpedicion
|
||||
payment_conditions: str | None
|
||||
# Emisor
|
||||
issuer_rfc: str
|
||||
issuer_name: str
|
||||
issuer_tax_regime: str # c_RegimenFiscal
|
||||
# Receptor
|
||||
receiver_rfc: str
|
||||
receiver_name: str
|
||||
receiver_zip: str # DomicilioFiscalReceptor
|
||||
receiver_tax_regime: str # c_RegimenFiscal del receptor
|
||||
receiver_cfdi_use: str # c_UsoCFDI
|
||||
# Partidas
|
||||
concepts: list[ConceptLine]
|
||||
|
||||
def validate(self) -> None:
|
||||
"""Acumula los faltantes obligatorios del CFDI 4.0 y los reporta juntos."""
|
||||
faltantes: list[str] = []
|
||||
obligatorios = {
|
||||
"fecha de emisión": self.date,
|
||||
"forma de pago (c_FormaPago)": self.payment_form,
|
||||
"método de pago (c_MetodoPago)": self.payment_method,
|
||||
"moneda": self.currency,
|
||||
"lugar de expedición (CP)": self.expedition_zip,
|
||||
"RFC del emisor": self.issuer_rfc,
|
||||
"razón social del emisor": self.issuer_name,
|
||||
"régimen fiscal del emisor": self.issuer_tax_regime,
|
||||
"RFC del receptor": self.receiver_rfc,
|
||||
"razón social del receptor": self.receiver_name,
|
||||
"domicilio fiscal del receptor (CP)": self.receiver_zip,
|
||||
"régimen fiscal del receptor": self.receiver_tax_regime,
|
||||
"uso de CFDI del receptor": self.receiver_cfdi_use,
|
||||
}
|
||||
for etiqueta, valor in obligatorios.items():
|
||||
if not valor:
|
||||
faltantes.append(f"falta {etiqueta}")
|
||||
|
||||
if not self.concepts:
|
||||
faltantes.append("la factura no tiene partidas")
|
||||
|
||||
for i, c in enumerate(self.concepts, start=1):
|
||||
if not c.product_service_code:
|
||||
faltantes.append(f"partida {i}: falta la clave de producto/servicio")
|
||||
if not c.unit_code:
|
||||
faltantes.append(f"partida {i}: falta la clave de unidad")
|
||||
if not c.tax_object:
|
||||
faltantes.append(f"partida {i}: falta el objeto de impuesto")
|
||||
if not c.description:
|
||||
faltantes.append(f"partida {i}: falta la descripción")
|
||||
if c.quantity is None or c.quantity <= 0:
|
||||
faltantes.append(f"partida {i}: la cantidad debe ser mayor que cero")
|
||||
# ObjetoImp "02" significa "sí objeto de impuesto": el SAT exige entonces el
|
||||
# desglose. Sin él, el comprobante se rechaza; no se inventa una tasa por defecto.
|
||||
if c.tax_object == "02" and not c.taxes:
|
||||
faltantes.append(
|
||||
f"partida {i}: es objeto de impuesto (02) pero no tiene impuestos capturados"
|
||||
)
|
||||
|
||||
if self.currency.upper() != "MXN" and not self.exchange_rate:
|
||||
faltantes.append("falta el tipo de cambio (moneda distinta de MXN)")
|
||||
|
||||
if faltantes:
|
||||
raise CfdiBuildError(faltantes)
|
||||
|
||||
# ----- Totales -----
|
||||
@property
|
||||
def subtotal(self) -> Decimal:
|
||||
return sum((c.amount for c in self.concepts), Decimal("0"))
|
||||
|
||||
@property
|
||||
def transferred(self) -> Decimal:
|
||||
return sum(
|
||||
(t.amount for c in self.concepts for t in c.taxes if not t.is_withholding),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
@property
|
||||
def withheld(self) -> Decimal:
|
||||
return sum(
|
||||
(t.amount for c in self.concepts for t in c.taxes if t.is_withholding),
|
||||
Decimal("0"),
|
||||
)
|
||||
|
||||
@property
|
||||
def total(self) -> Decimal:
|
||||
return self.subtotal + self.transferred - self.withheld
|
||||
|
||||
|
||||
def build_xml(data: CfdiData, cert_number: str = "", cert_b64: str = "") -> bytes:
|
||||
"""XML CFDI 4.0 de ingreso, **sin** el atributo ``Sello``.
|
||||
|
||||
``cert_number`` y ``cert_b64`` salen del ``.cer`` y **tienen que venir puestos aquí**, no
|
||||
después: la cadena original incluye ``NoCertificado``. Si se calculara la cadena con el
|
||||
atributo vacío y se rellenara luego, el sello firmaría un texto distinto del que verifica
|
||||
el SAT, y el comprobante se rechazaría con un error que no menciona el certificado.
|
||||
|
||||
``Sello`` sí se deja vacío —la cadena original no lo incluye, es su resultado— y lo inserta
|
||||
``apply_seal`` en su posición.
|
||||
"""
|
||||
data.validate()
|
||||
cur = data.currency.upper()
|
||||
|
||||
comprobante = etree.Element(
|
||||
f"{{{CFDI_NS}}}Comprobante",
|
||||
nsmap={"cfdi": CFDI_NS, "xsi": XSI_NS},
|
||||
)
|
||||
comprobante.set(f"{{{XSI_NS}}}schemaLocation", SCHEMA_LOCATION)
|
||||
|
||||
# ORDEN DEL XSD. No reordenar: la cadena original -y por tanto el sello- depende de él.
|
||||
comprobante.set("Version", VERSION)
|
||||
if data.serie:
|
||||
comprobante.set("Serie", data.serie)
|
||||
if data.folio:
|
||||
comprobante.set("Folio", str(data.folio))
|
||||
# Sin desplazamiento horario: el "-06:00" es de CFDI 3.3 (ver CFDI.cs:12654). En 4.0 la
|
||||
# fecha va en hora local del lugar de expedición, a secas.
|
||||
comprobante.set("Fecha", data.date)
|
||||
# Sello vacío: reserva su posición en el orden del XSD para que apply_seal lo rellene sin
|
||||
# mover nada. lxml conserva el orden de inserción de los atributos.
|
||||
comprobante.set("Sello", "")
|
||||
comprobante.set("FormaPago", data.payment_form)
|
||||
comprobante.set("NoCertificado", cert_number)
|
||||
comprobante.set("Certificado", cert_b64)
|
||||
if data.payment_conditions:
|
||||
comprobante.set("CondicionesDePago", data.payment_conditions)
|
||||
comprobante.set("SubTotal", _money(data.subtotal, cur))
|
||||
comprobante.set("Moneda", cur)
|
||||
if cur != "MXN" and data.exchange_rate:
|
||||
comprobante.set(
|
||||
"TipoCambio", str(Decimal(str(data.exchange_rate)).quantize(Decimal("0.0001")))
|
||||
)
|
||||
comprobante.set("Total", _money(data.total, cur))
|
||||
comprobante.set("TipoDeComprobante", TIPO_INGRESO)
|
||||
comprobante.set("Exportacion", EXPORTACION_NO_APLICA)
|
||||
comprobante.set("MetodoPago", data.payment_method)
|
||||
comprobante.set("LugarExpedicion", data.expedition_zip)
|
||||
|
||||
emisor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Emisor")
|
||||
emisor.set("Rfc", data.issuer_rfc)
|
||||
emisor.set("Nombre", data.issuer_name)
|
||||
emisor.set("RegimenFiscal", data.issuer_tax_regime)
|
||||
|
||||
receptor = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Receptor")
|
||||
receptor.set("Rfc", data.receiver_rfc)
|
||||
receptor.set("Nombre", data.receiver_name)
|
||||
receptor.set("DomicilioFiscalReceptor", data.receiver_zip)
|
||||
receptor.set("RegimenFiscalReceptor", data.receiver_tax_regime)
|
||||
receptor.set("UsoCFDI", data.receiver_cfdi_use)
|
||||
|
||||
conceptos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Conceptos")
|
||||
for c in data.concepts:
|
||||
nodo = etree.SubElement(conceptos, f"{{{CFDI_NS}}}Concepto")
|
||||
nodo.set("ClaveProdServ", c.product_service_code)
|
||||
if c.identification:
|
||||
nodo.set("NoIdentificacion", c.identification)
|
||||
nodo.set("Cantidad", _qty(c.quantity))
|
||||
nodo.set("ClaveUnidad", c.unit_code)
|
||||
nodo.set("Descripcion", c.description)
|
||||
nodo.set("ValorUnitario", _money(c.unit_price, cur))
|
||||
nodo.set("Importe", _money(c.amount, cur))
|
||||
nodo.set("ObjetoImp", c.tax_object)
|
||||
if c.taxes:
|
||||
_add_concept_taxes(nodo, c, cur)
|
||||
|
||||
if any(c.taxes for c in data.concepts):
|
||||
_add_totals(comprobante, data, cur)
|
||||
|
||||
return _serialize(comprobante)
|
||||
|
||||
|
||||
def _add_concept_taxes(nodo: etree._Element, c: ConceptLine, cur: str) -> None:
|
||||
"""Nodo ``Impuestos`` de una partida: primero Traslados, después Retenciones."""
|
||||
impuestos = etree.SubElement(nodo, f"{{{CFDI_NS}}}Impuestos")
|
||||
|
||||
traslados = [t for t in c.taxes if not t.is_withholding]
|
||||
if traslados:
|
||||
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
|
||||
for t in traslados:
|
||||
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
|
||||
el.set("Base", _money(c.amount, cur))
|
||||
el.set("Impuesto", t.code)
|
||||
el.set("TipoFactor", t.factor)
|
||||
# Un impuesto exento no lleva TasaOCuota ni Importe: ponerlos es motivo de rechazo.
|
||||
if t.factor != "Exento":
|
||||
el.set("TasaOCuota", _rate(t.rate))
|
||||
el.set("Importe", _money(t.amount, cur))
|
||||
|
||||
retenciones = [t for t in c.taxes if t.is_withholding]
|
||||
if retenciones:
|
||||
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
|
||||
for t in retenciones:
|
||||
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
|
||||
el.set("Base", _money(c.amount, cur))
|
||||
el.set("Impuesto", t.code)
|
||||
el.set("TipoFactor", t.factor)
|
||||
el.set("TasaOCuota", _rate(t.rate))
|
||||
el.set("Importe", _money(t.amount, cur))
|
||||
|
||||
|
||||
def _add_totals(comprobante: etree._Element, data: CfdiData, cur: str) -> None:
|
||||
"""Nodo ``Impuestos`` del comprobante: totales agrupados por impuesto, factor y tasa."""
|
||||
impuestos = etree.SubElement(comprobante, f"{{{CFDI_NS}}}Impuestos")
|
||||
|
||||
def agrupa(withholding: bool) -> dict[tuple[str, str, str], Decimal]:
|
||||
acc: dict[tuple[str, str, str], Decimal] = {}
|
||||
for c in data.concepts:
|
||||
for t in c.taxes:
|
||||
if t.is_withholding != withholding or t.factor == "Exento":
|
||||
continue
|
||||
clave = (t.code, t.factor, _rate(t.rate))
|
||||
acc[clave] = acc.get(clave, Decimal("0")) + t.amount
|
||||
return acc
|
||||
|
||||
# En el XSD, Retenciones va ANTES que Traslados dentro del nodo Impuestos del comprobante
|
||||
# —al revés que dentro del concepto—. Es una asimetría real del esquema, no un descuido.
|
||||
retenidos = agrupa(True)
|
||||
if retenidos:
|
||||
impuestos.set("TotalImpuestosRetenidos", _money(data.withheld, cur))
|
||||
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Retenciones")
|
||||
for (code, _factor, _tasa), monto in sorted(retenidos.items()):
|
||||
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Retencion")
|
||||
el.set("Impuesto", code)
|
||||
el.set("Importe", _money(monto, cur))
|
||||
|
||||
trasladados = agrupa(False)
|
||||
if trasladados:
|
||||
impuestos.set("TotalImpuestosTrasladados", _money(data.transferred, cur))
|
||||
cont = etree.SubElement(impuestos, f"{{{CFDI_NS}}}Traslados")
|
||||
for (code, factor, tasa), monto in sorted(trasladados.items()):
|
||||
el = etree.SubElement(cont, f"{{{CFDI_NS}}}Traslado")
|
||||
el.set(
|
||||
"Base",
|
||||
_money(
|
||||
sum(
|
||||
(
|
||||
c.amount
|
||||
for c in data.concepts
|
||||
for t in c.taxes
|
||||
if not t.is_withholding
|
||||
and t.code == code
|
||||
and t.factor == factor
|
||||
and _rate(t.rate) == tasa
|
||||
),
|
||||
Decimal("0"),
|
||||
),
|
||||
cur,
|
||||
),
|
||||
)
|
||||
el.set("Impuesto", code)
|
||||
el.set("TipoFactor", factor)
|
||||
el.set("TasaOCuota", tasa)
|
||||
el.set("Importe", _money(monto, cur))
|
||||
|
||||
|
||||
def apply_seal(xml_bytes: bytes, seal: str) -> bytes:
|
||||
"""Inserta el ``Sello`` en el comprobante ya construido.
|
||||
|
||||
Sólo el sello: ``NoCertificado`` y ``Certificado`` ya venían de ``build_xml`` porque el
|
||||
primero entra en la cadena original que se acaba de firmar.
|
||||
"""
|
||||
root = etree.fromstring(xml_bytes)
|
||||
if root.get("NoCertificado") is None or not root.get("NoCertificado"):
|
||||
raise CfdiBuildError(
|
||||
["el comprobante llegó a sellarse sin NoCertificado: la cadena original sería inválida"]
|
||||
)
|
||||
root.set("Sello", seal)
|
||||
return _serialize(root)
|
||||
31
backend/api/v1/modules/fin/stamping/dto.py
Normal file
31
backend/api/v1/modules/fin/stamping/dto.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Esquemas del timbrado de CFDI."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class InvoiceStampResponse(BaseModel):
|
||||
"""Resultado de un timbrado.
|
||||
|
||||
No expone el XML completo: se descarga por URL firmada desde ``/stamp/xml-url``.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
invoice_id: int
|
||||
mode: str # pruebas | produccion
|
||||
status: str # pendiente | timbrado | error
|
||||
uuid: str | None = None
|
||||
stamped_at: datetime | None = None
|
||||
pac_rfc: str | None = None
|
||||
sat_cert_number: str | None = None
|
||||
pac_code: int | None = None
|
||||
pac_balance: int | None = None
|
||||
error_message: str | None = None
|
||||
xml_file_key: str | None = None
|
||||
# Rastro del intento: se descargan por URL firmada desde ``/stamp/attempts/{id}/xml-url``.
|
||||
request_xml_file_key: str | None = None
|
||||
response_xml_file_key: str | None = None
|
||||
created_at: datetime | None = None
|
||||
93
backend/api/v1/modules/fin/stamping/models.py
Normal file
93
backend/api/v1/modules/fin/stamping/models.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Timbrado de CFDI — ``fin.invoice_stamps``.
|
||||
|
||||
Una fila por **intento** de timbrado, incluidos los fallidos: sin ellos no hay forma de
|
||||
reconstruir por qué una factura no se timbró, y el error del PAC llega en un header HTTP que
|
||||
se pierde en cuanto termina la petición.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
_ALIVE = text("deleted_at IS NULL")
|
||||
|
||||
# Modos de timbrado. El host del PAC se deriva de aquí y de ningún otro lado.
|
||||
MODE_TEST = "pruebas"
|
||||
MODE_PROD = "produccion"
|
||||
STAMPING_MODES = (MODE_TEST, MODE_PROD)
|
||||
|
||||
# RFC del proveedor de certificación según el entorno (CFDI.cs:16665 del sistema legado).
|
||||
# Sirve para verificar que el timbre recibido viene del entorno que se pidió.
|
||||
PAC_RFC_BY_MODE = {
|
||||
MODE_TEST: "SPR190613I52",
|
||||
MODE_PROD: "SCD110105654",
|
||||
}
|
||||
|
||||
# Estados del intento.
|
||||
STATUS_PENDING = "pendiente"
|
||||
STATUS_STAMPED = "timbrado"
|
||||
STATUS_ERROR = "error"
|
||||
|
||||
|
||||
class InvoiceStamp(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Intento de timbrado de una factura ante el PAC."""
|
||||
|
||||
__tablename__ = "invoice_stamps"
|
||||
__table_args__ = (
|
||||
# Un UUID no puede repetirse: el SAT lo emite una sola vez. El índice es parcial
|
||||
# sobre uuid IS NOT NULL porque los intentos fallidos no traen UUID y serían todos
|
||||
# "iguales" entre sí bajo un único convencional.
|
||||
Index(
|
||||
"uq_fin_invoice_stamps_uuid",
|
||||
"uuid",
|
||||
unique=True,
|
||||
postgresql_where=text("uuid IS NOT NULL AND deleted_at IS NULL"),
|
||||
sqlite_where=text("uuid IS NOT NULL AND deleted_at IS NULL"),
|
||||
),
|
||||
{"schema": "fin"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
invoice_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
|
||||
)
|
||||
# Copiado de invoices.stamping_mode al transmitir y congelado aquí: es el registro de
|
||||
# contra qué entorno se timbró de verdad, aunque la factura cambie después.
|
||||
mode: Mapped[str] = mapped_column(String(12), nullable=False)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(12), nullable=False, server_default=text("'pendiente'"), index=True
|
||||
)
|
||||
|
||||
# ----- Datos del Timbre Fiscal Digital (sólo si el PAC timbró) -----
|
||||
uuid: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
|
||||
stamped_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) # FechaTimbrado
|
||||
pac_rfc: Mapped[str | None] = mapped_column(String(13), nullable=True) # RfcProvCertif
|
||||
sat_cert_number: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True
|
||||
) # NoCertificadoSAT
|
||||
sat_seal: Mapped[str | None] = mapped_column(Text, nullable=True) # SelloSAT
|
||||
cfd_seal: Mapped[str | None] = mapped_column(Text, nullable=True) # SelloCFD
|
||||
|
||||
# ----- Respuesta del PAC -----
|
||||
# El legado leía estos dos headers en una variable local que descartaba, así que su código
|
||||
# de respuesta y su saldo de folios se perdían siempre (CFDI.cs:19324-19336). Aquí se
|
||||
# persisten: sin ellos no se sabe cuántos folios quedan ni qué contestó el PAC.
|
||||
pac_code: Mapped[int | None] = mapped_column(Integer, nullable=True) # header codigo
|
||||
pac_balance: Mapped[int | None] = mapped_column(Integer, nullable=True) # header saldo
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True) # header errmsg
|
||||
|
||||
# XML timbrado en MinIO. Sólo lo tienen los intentos exitosos: es el comprobante que se
|
||||
# descarga, y su clave lleva el UUID.
|
||||
xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
|
||||
# ----- Rastro del intento en MinIO -----
|
||||
# El par enviado/recibido de CADA intento, incluidos los rechazados. Es lo único que
|
||||
# permite reconstruir por qué el PAC rechazó un comprobante: el XML sellado se construye
|
||||
# en memoria y se pierde al terminar la petición, y el cuerpo de la respuesta también.
|
||||
request_xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
response_xml_file_key: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
171
backend/api/v1/modules/fin/stamping/pac_comercio_digital.py
Normal file
171
backend/api/v1/modules/fin/stamping/pac_comercio_digital.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""Cliente del PAC Comercio Digital — servicio ``timbrarV5``.
|
||||
|
||||
El contrato está tomado del sistema legado (``CFDI.cs:19274-19346``), que es la única fuente
|
||||
de verdad disponible: se transmite el XML **sellado** en crudo por POST y la respuesta trae el
|
||||
comprobante timbrado en el cuerpo y los metadatos en cabeceras HTTP.
|
||||
|
||||
Tres defectos del legado se corrigen aquí en vez de replicarse — ver ``_read_headers``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
|
||||
from .models import MODE_TEST, STAMPING_MODES
|
||||
|
||||
# Códigos de error propios del cliente, con los mismos números que usa el legado para que los
|
||||
# reportes de ambos sistemas se puedan comparar.
|
||||
ERR_USER = 701 # usuario con longitud inválida
|
||||
ERR_PASSWORD = 702 # password vacío
|
||||
ERR_EMPTY_XML = 711 # XML vacío o demasiado corto
|
||||
ERR_NETWORK = 833 # excepción de red
|
||||
ERR_HTTP = 998 # respuesta HTTP distinta de 200
|
||||
|
||||
# El usrws de Comercio Digital tiene forma de RFC.
|
||||
_USER_MIN, _USER_MAX = 12, 13
|
||||
# Un CFDI sellado nunca baja de este tamaño; por debajo, es que algo se truncó.
|
||||
_MIN_XML_BYTES = 200
|
||||
|
||||
|
||||
class PacConfigError(Exception):
|
||||
"""Configuración inválida del PAC. Se detecta antes de tocar la red."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class StampResult:
|
||||
"""Respuesta del PAC ante un intento de timbrado."""
|
||||
|
||||
ok: bool
|
||||
code: int | None
|
||||
error_message: str
|
||||
xml: str = ""
|
||||
uuid: str = ""
|
||||
balance: int | None = None
|
||||
email_error: str = ""
|
||||
|
||||
|
||||
def resolve_host(mode: str, host_test: str, host_prod: str) -> str:
|
||||
"""Host del PAC a partir del modo. **Es la única forma de elegirlo.**
|
||||
|
||||
No hay parámetro de host, ni de URL, ni forma de pasarlos desde la capa HTTP: el modo sale
|
||||
de ``invoices.stamping_mode`` y nada más. En el legado, ``CFDIPacUrl`` es un campo mutable
|
||||
que cualquier rama del código reasigna, y un host vacío cae silenciosamente al de pruebas
|
||||
(``CFDI.cs:19288``). Aquí un modo desconocido es un error, no un valor por defecto: el
|
||||
default equivocado emite un CFDI con validez fiscal real.
|
||||
"""
|
||||
if mode not in STAMPING_MODES:
|
||||
raise PacConfigError(
|
||||
f"Modo de timbrado inválido: {mode!r}. Sólo se admiten {STAMPING_MODES}."
|
||||
)
|
||||
return host_test if mode == MODE_TEST else host_prod
|
||||
|
||||
|
||||
def stamp(
|
||||
xml_bytes: bytes,
|
||||
*,
|
||||
mode: str,
|
||||
user: str,
|
||||
password: str,
|
||||
host_test: str,
|
||||
host_prod: str,
|
||||
email: str = "",
|
||||
timeout: int = 15,
|
||||
) -> StampResult:
|
||||
"""Transmite el CFDI sellado al PAC y devuelve el resultado.
|
||||
|
||||
Nunca lanza por causas de red o del PAC: esas se devuelven como ``StampResult`` con
|
||||
``ok=False``, porque el llamador tiene que persistir el intento fallido. Sí lanza
|
||||
``PacConfigError`` si el modo es inválido, que es un error de programación, no de operación.
|
||||
"""
|
||||
host = resolve_host(mode, host_test, host_prod)
|
||||
|
||||
# Validaciones previas: mismas condiciones y códigos que el legado, antes de salir a la red.
|
||||
if not user or not (_USER_MIN <= len(user) <= _USER_MAX):
|
||||
return StampResult(False, ERR_USER, f"{ERR_USER} Usuario del PAC inválido o no configurado")
|
||||
if not password:
|
||||
return StampResult(False, ERR_PASSWORD, f"{ERR_PASSWORD} Password del PAC no configurado")
|
||||
if not xml_bytes or len(xml_bytes) < _MIN_XML_BYTES:
|
||||
return StampResult(False, ERR_EMPTY_XML, f"{ERR_EMPTY_XML} Contenido XML vacío")
|
||||
|
||||
url = f"https://{host}/timbre4/timbrarV5"
|
||||
headers = {
|
||||
"usrws": user,
|
||||
"pwdws": password,
|
||||
"tipo": "XML",
|
||||
"Content-Type": "text/plain",
|
||||
}
|
||||
if email:
|
||||
headers["email"] = email.lower()
|
||||
|
||||
try:
|
||||
# El cuerpo va en crudo: ni base64 ni SOAP. httpx no reintenta por defecto, y así debe
|
||||
# ser: reintentar un timbrado puede consumir un folio y generar un CFDI duplicado.
|
||||
response = httpx.post(url, content=xml_bytes, headers=headers, timeout=timeout)
|
||||
except httpx.HTTPError as exc:
|
||||
return StampResult(
|
||||
False, ERR_NETWORK, f"{ERR_NETWORK} Error de transmisión a {host}: {exc}"
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
# El cuerpo se conserva aunque el estado no sea 200: cuando el PAC contesta con un
|
||||
# error de servidor, lo que explica el rechazo suele venir precisamente ahí.
|
||||
return StampResult(
|
||||
False,
|
||||
ERR_HTTP,
|
||||
f"{ERR_HTTP} El PAC respondió HTTP {response.status_code}",
|
||||
response.text,
|
||||
)
|
||||
|
||||
return _read_headers(response)
|
||||
|
||||
|
||||
def _read_headers(response: httpx.Response) -> StampResult:
|
||||
"""Interpreta la respuesta del PAC.
|
||||
|
||||
Aquí se corrigen tres defectos del legado (``CFDI.cs:19324-19336``):
|
||||
|
||||
1. ``codigo`` **se lee y se conserva**. El legado lo asignaba a una variable local que
|
||||
descartaba, así que su parámetro de salida quedaba siempre en 999 o 991.
|
||||
2. ``saldo`` **también**. Mismo patrón: se perdía siempre, y con él la única señal de
|
||||
cuántos folios quedan.
|
||||
3. La presencia de una cabecera se comprueba de verdad. En .NET, ``GetResponseHeader``
|
||||
devuelve ``""`` y no ``null`` cuando falta, así que la rama ``== null`` del legado
|
||||
prácticamente nunca se cumplía.
|
||||
"""
|
||||
headers = response.headers
|
||||
error_message = (headers.get("errmsg") or "").strip()
|
||||
uuid = (headers.get("uuid") or "").strip()
|
||||
email_error = (headers.get("erremail") or "").strip()
|
||||
|
||||
def entero(nombre: str) -> int | None:
|
||||
crudo = (headers.get(nombre) or "").strip()
|
||||
if not crudo:
|
||||
return None
|
||||
try:
|
||||
return int(crudo)
|
||||
except ValueError:
|
||||
# El PAC mandó algo no numérico: se ignora el valor, pero no se rompe el timbrado
|
||||
# por ello. Queda como None, que es "no informado".
|
||||
return None
|
||||
|
||||
code = entero("codigo")
|
||||
balance = entero("saldo")
|
||||
|
||||
# Criterio de éxito del legado: errmsg vacío. Se le añade la exigencia de UUID, porque una
|
||||
# respuesta 200 sin errmsg y sin UUID no es un comprobante timbrado.
|
||||
if error_message:
|
||||
return StampResult(False, code, error_message, response.text, uuid, balance, email_error)
|
||||
if not uuid:
|
||||
return StampResult(
|
||||
False,
|
||||
code,
|
||||
"El PAC respondió sin error pero no devolvió UUID",
|
||||
response.text,
|
||||
"",
|
||||
balance,
|
||||
email_error,
|
||||
)
|
||||
|
||||
return StampResult(True, code, "", response.text, uuid, balance, email_error)
|
||||
95
backend/api/v1/modules/fin/stamping/routes.py
Normal file
95
backend/api/v1/modules/fin/stamping/routes.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""Endpoints del timbrado de CFDI."""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
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 InvoiceStampResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _uid(cu: dict) -> str | None:
|
||||
return cu.get("sub") or cu.get("id")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/invoices/{invoice_id}/stamp",
|
||||
response_model=InvoiceStampResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
def stamp_invoice(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Timbra la factura ante el PAC.
|
||||
|
||||
El modo (pruebas o producción) sale de ``invoices.stamping_mode`` y **no se puede pasar
|
||||
por aquí**: ni por cuerpo, ni por query, ni por cabecera. Es lo único que separa un timbre
|
||||
de prueba de un CFDI con validez fiscal ante el SAT.
|
||||
|
||||
Es idempotente: si la factura ya tiene timbre, lo devuelve sin volver a llamar al PAC.
|
||||
"""
|
||||
return service.stamp_invoice(
|
||||
db, invoice_id, current_user["tenant_id"], company_id, _uid(current_user)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}/stamp", response_model=InvoiceStampResponse)
|
||||
def get_invoice_stamp(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Timbre vigente de la factura."""
|
||||
stamp = service.get_stamp(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
if not stamp:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="La factura no está timbrada"
|
||||
)
|
||||
return stamp
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}/stamp/attempts", response_model=list[InvoiceStampResponse])
|
||||
def list_stamp_attempts(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Historial de intentos de timbrado, incluidos los rechazados por el PAC."""
|
||||
return service.list_attempts(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}/stamp/attempts/{attempt_id}/xml-url")
|
||||
def get_stamp_attempt_xml_url(
|
||||
invoice_id: int,
|
||||
attempt_id: int,
|
||||
kind: str = Query(..., pattern="^(request|response)$"),
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""URL firmada del XML transmitido al PAC (``request``) o del que contestó (``response``)."""
|
||||
url = service.get_attempt_xml_url(
|
||||
db, invoice_id, attempt_id, kind, current_user["tenant_id"], company_id
|
||||
)
|
||||
return {"url": url}
|
||||
|
||||
|
||||
@router.get("/invoices/{invoice_id}/stamp/xml-url")
|
||||
def get_stamp_xml_url(
|
||||
invoice_id: int,
|
||||
company_id: int = Query(...),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""URL firmada para descargar el XML timbrado."""
|
||||
url = service.get_stamp_xml_url(db, invoice_id, current_user["tenant_id"], company_id)
|
||||
return {"url": url}
|
||||
147
backend/api/v1/modules/fin/stamping/sealer.py
Normal file
147
backend/api/v1/modules/fin/stamping/sealer.py
Normal file
@@ -0,0 +1,147 @@
|
||||
"""Cadena original y sello del CFDI.
|
||||
|
||||
El sello es la firma del emisor sobre el comprobante. Se obtiene en dos pasos:
|
||||
|
||||
1. **Cadena original**: transformación XSLT oficial del SAT sobre el XML *sin* sello.
|
||||
2. **Sello**: firma RSA con SHA-256 de esa cadena, en base64.
|
||||
|
||||
Ambos pasos son exactos: un carácter de diferencia en la cadena produce un sello que el SAT
|
||||
rechaza. Por eso la cadena no se construye a mano ni se "normaliza" — sale tal cual del XSLT.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import threading
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.x509 import load_der_x509_certificate, load_pem_x509_certificate
|
||||
from lxml import etree
|
||||
|
||||
_XSLT_DIR = Path(__file__).parent / "xslt"
|
||||
_XSLT_CADENA = _XSLT_DIR / "cadenaoriginal_4_0.xslt"
|
||||
|
||||
# La compilación del XSLT es cara (33 includes) y el resultado es inmutable: se hace una vez.
|
||||
# El lock evita que dos peticiones concurrentes la compilen a la vez en el arranque.
|
||||
_transform: etree.XSLT | None = None
|
||||
_transform_lock = threading.Lock()
|
||||
|
||||
|
||||
class SealingError(Exception):
|
||||
"""Falla al calcular la cadena original o el sello."""
|
||||
|
||||
|
||||
def _get_transform() -> etree.XSLT:
|
||||
global _transform
|
||||
if _transform is None:
|
||||
with _transform_lock:
|
||||
if _transform is None: # otro hilo pudo compilarla mientras esperábamos
|
||||
if not _XSLT_CADENA.is_file():
|
||||
raise SealingError(
|
||||
f"No encuentro el XSLT de la cadena original en {_XSLT_CADENA}"
|
||||
)
|
||||
# Los includes del XSLT apuntan a rutas RELATIVAS locales (ver xslt/README.md):
|
||||
# no hay resolución por red, ni aquí ni dentro de libxslt.
|
||||
_transform = etree.XSLT(etree.parse(str(_XSLT_CADENA)))
|
||||
return _transform
|
||||
|
||||
|
||||
def build_original_string(xml_bytes: bytes) -> str:
|
||||
"""Cadena original del comprobante, vía el XSLT oficial del SAT.
|
||||
|
||||
``xml_bytes`` es el CFDI **sin** los atributos ``Sello``, ``NoCertificado`` ni
|
||||
``Certificado``: son justamente los que se calculan a partir de esta cadena.
|
||||
"""
|
||||
try:
|
||||
doc = etree.fromstring(xml_bytes)
|
||||
except etree.XMLSyntaxError as exc:
|
||||
raise SealingError(f"El XML del comprobante no es válido: {exc}") from exc
|
||||
|
||||
cadena = str(_get_transform()(doc))
|
||||
# Una cadena vacía o sin los delimitadores significa que la transformación no aplicó ninguna
|
||||
# plantilla — pasa si el XSLT perdió sus includes. Firmar eso daría un sello con pinta de
|
||||
# correcto sobre nada, así que se corta aquí.
|
||||
if not cadena.startswith("||") or not cadena.endswith("||"):
|
||||
raise SealingError(
|
||||
"La cadena original no tiene la forma esperada (debe abrir y cerrar con '||'). "
|
||||
"Revisa los includes de xslt/cadenaoriginal_4_0.xslt."
|
||||
)
|
||||
return cadena
|
||||
|
||||
|
||||
def load_private_key(key_der: bytes, password: str) -> rsa.RSAPrivateKey:
|
||||
"""Carga la llave privada del CSD.
|
||||
|
||||
El ``.key`` que entrega el SAT es PKCS#8 **DER** cifrado con contraseña. Se acepta también
|
||||
PEM para no obligar a convertir llaves que ya estén en ese formato.
|
||||
"""
|
||||
if not password:
|
||||
raise SealingError(
|
||||
"No se configuró la contraseña de la llave privada del CSD (CSD_PASSWORD)."
|
||||
)
|
||||
clave = password.encode("utf-8")
|
||||
try:
|
||||
key = serialization.load_der_private_key(key_der, password=clave)
|
||||
except ValueError:
|
||||
try:
|
||||
key = serialization.load_pem_private_key(key_der, password=clave)
|
||||
except ValueError as exc:
|
||||
# Mismo error para "archivo corrupto" y "contraseña incorrecta" porque la librería no
|
||||
# los distingue; el mensaje nombra las dos causas para no mandar a nadie al lugar
|
||||
# equivocado.
|
||||
raise SealingError(
|
||||
"No pude abrir la llave privada del CSD: el archivo no es una llave válida "
|
||||
"o la contraseña es incorrecta."
|
||||
) from exc
|
||||
if not isinstance(key, rsa.RSAPrivateKey):
|
||||
raise SealingError("La llave privada del CSD no es RSA.")
|
||||
return key
|
||||
|
||||
|
||||
def read_certificate(cer_bytes: bytes) -> tuple[str, str]:
|
||||
"""Devuelve ``(numero_de_certificado, certificado_base64)`` a partir del ``.cer``.
|
||||
|
||||
- El ``.cer`` del SAT es X.509 **DER**.
|
||||
- El atributo ``Certificado`` del comprobante es el base64 de ese DER.
|
||||
- El atributo ``NoCertificado`` son los 20 dígitos del número de serie. El SAT lo codifica
|
||||
de forma que los bytes del serial son directamente sus caracteres ASCII, así que se
|
||||
decodifica en vez de imprimirse en hexadecimal.
|
||||
"""
|
||||
try:
|
||||
cert = load_der_x509_certificate(cer_bytes)
|
||||
der = cer_bytes
|
||||
except ValueError:
|
||||
try:
|
||||
cert = load_pem_x509_certificate(cer_bytes)
|
||||
der = cert.public_bytes(serialization.Encoding.DER)
|
||||
except ValueError as exc:
|
||||
raise SealingError("El archivo del certificado no es un X.509 válido (.cer).") from exc
|
||||
|
||||
serial_bytes = cert.serial_number.to_bytes((cert.serial_number.bit_length() + 7) // 8, "big")
|
||||
try:
|
||||
numero = serial_bytes.decode("ascii")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise SealingError(
|
||||
"El número de serie del certificado no tiene el formato del SAT "
|
||||
"(sus bytes deben ser los 20 dígitos en ASCII)."
|
||||
) from exc
|
||||
if len(numero) != 20 or not numero.isdigit():
|
||||
raise SealingError(f"El número de certificado debe ser 20 dígitos; se obtuvo {numero!r}.")
|
||||
|
||||
return numero, base64.b64encode(der).decode("ascii")
|
||||
|
||||
|
||||
def sign(original_string: str, private_key: rsa.RSAPrivateKey) -> str:
|
||||
"""Sello del comprobante: RSA sobre SHA-256 de la cadena original, en base64.
|
||||
|
||||
SHA-256 y no SHA-1: SHA-1 sólo aplica al CFDI de retenciones con otros PAC (ver
|
||||
``CFDI.cs:8157`` del sistema legado). Para CFDI de ingreso con Comercio Digital es SHA-256.
|
||||
"""
|
||||
firma = private_key.sign(
|
||||
original_string.encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
return base64.b64encode(firma).decode("ascii")
|
||||
533
backend/api/v1/modules/fin/stamping/service.py
Normal file
533
backend/api/v1/modules/fin/stamping/service.py
Normal file
@@ -0,0 +1,533 @@
|
||||
"""Orquestación del timbrado: factura → XML → sello → PAC → persistencia."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.config import settings
|
||||
from core.s3_keys import (
|
||||
STAMP_XML_KINDS,
|
||||
invoice_stamp_attempt_xml_key,
|
||||
invoice_stamp_xml_key,
|
||||
)
|
||||
|
||||
from ..catalogs.models import (
|
||||
CfdiUse,
|
||||
PaymentForm,
|
||||
PaymentMethod,
|
||||
ProductService,
|
||||
Tax,
|
||||
TaxObject,
|
||||
TaxRegime,
|
||||
UnitOfMeasure,
|
||||
)
|
||||
from ..invoices.models import Invoice, InvoiceItem, InvoiceItemTax
|
||||
from ..issuer.models import IssuerSettings
|
||||
from . import cfdi_builder as builder
|
||||
from . import pac_comercio_digital as pac
|
||||
from . import sealer
|
||||
from .models import (
|
||||
PAC_RFC_BY_MODE,
|
||||
STAMPING_MODES,
|
||||
STATUS_ERROR,
|
||||
STATUS_STAMPED,
|
||||
InvoiceStamp,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
CFDI_NS = "http://www.sat.gob.mx/cfd/4"
|
||||
TFD_NS = "http://www.sat.gob.mx/TimbreFiscalDigital"
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Lectura
|
||||
# --------------------------------------------------------------------------------------
|
||||
def get_stamp(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> InvoiceStamp | None:
|
||||
"""Timbre vigente de la factura, si lo hay. Sólo cuenta el exitoso."""
|
||||
return (
|
||||
db.query(InvoiceStamp)
|
||||
.filter(
|
||||
InvoiceStamp.invoice_id == invoice_id,
|
||||
InvoiceStamp.tenant_id == tenant_id,
|
||||
InvoiceStamp.company_id == company_id,
|
||||
InvoiceStamp.status == STATUS_STAMPED,
|
||||
InvoiceStamp.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(InvoiceStamp.id.desc())
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def list_attempts(
|
||||
db: Session, invoice_id: int, tenant_id: int, company_id: int
|
||||
) -> list[InvoiceStamp]:
|
||||
"""Todos los intentos de la factura, del más reciente al más antiguo.
|
||||
|
||||
A diferencia de ``get_stamp``, incluye los rechazados: son los que hay que consultar
|
||||
cuando el PAC devuelve un error y hace falta ver qué se le mandó.
|
||||
"""
|
||||
return (
|
||||
db.query(InvoiceStamp)
|
||||
.filter(
|
||||
InvoiceStamp.invoice_id == invoice_id,
|
||||
InvoiceStamp.tenant_id == tenant_id,
|
||||
InvoiceStamp.company_id == company_id,
|
||||
InvoiceStamp.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(InvoiceStamp.id.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_attempt_xml_url(
|
||||
db: Session, invoice_id: int, attempt_id: int, kind: str, tenant_id: int, company_id: int
|
||||
) -> str:
|
||||
"""URL firmada del XML enviado o recibido en un intento concreto."""
|
||||
from core.storage_s3 import presigned_get_url # noqa: PLC0415
|
||||
|
||||
if kind not in STAMP_XML_KINDS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Tipo de XML inválido: {kind!r}. Sólo se admiten {list(STAMP_XML_KINDS)}.",
|
||||
)
|
||||
|
||||
attempt = (
|
||||
db.query(InvoiceStamp)
|
||||
.filter(
|
||||
InvoiceStamp.id == attempt_id,
|
||||
# invoice_id va en el filtro, no sólo en la ruta: sin él, el id de un intento de
|
||||
# otra factura de la misma empresa devolvería su XML.
|
||||
InvoiceStamp.invoice_id == invoice_id,
|
||||
InvoiceStamp.tenant_id == tenant_id,
|
||||
InvoiceStamp.company_id == company_id,
|
||||
InvoiceStamp.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not attempt:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Intento no encontrado")
|
||||
|
||||
key = getattr(attempt, f"{kind}_xml_file_key")
|
||||
if not key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"El intento no tiene guardado el XML de {kind}",
|
||||
)
|
||||
return presigned_get_url(key)
|
||||
|
||||
|
||||
def _get_invoice(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> Invoice:
|
||||
obj = (
|
||||
db.query(Invoice)
|
||||
.filter(
|
||||
Invoice.id == invoice_id,
|
||||
Invoice.tenant_id == tenant_id,
|
||||
Invoice.company_id == company_id,
|
||||
Invoice.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Factura no encontrada")
|
||||
return obj
|
||||
|
||||
|
||||
def _code(db: Session, model, pk: int | None) -> str:
|
||||
"""Clave del SAT de un catálogo, o cadena vacía si no está capturado.
|
||||
|
||||
Devolver "" en vez de lanzar es deliberado: la validación del builder acumula TODOS los
|
||||
faltantes y los reporta juntos, en vez de obligar a descubrirlos de uno en uno.
|
||||
"""
|
||||
if not pk:
|
||||
return ""
|
||||
row = db.query(model).filter(model.id == pk).first()
|
||||
return row.code if row else ""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Armado de los datos fiscales
|
||||
# --------------------------------------------------------------------------------------
|
||||
def _build_data(db: Session, invoice: Invoice, tenant_id: int, company_id: int) -> builder.CfdiData:
|
||||
"""Reúne emisor, receptor y partidas resolviendo las claves contra los catálogos."""
|
||||
from ...crm.accounts.models import Account
|
||||
from ...crm.addresses.models import Address
|
||||
|
||||
issuer = (
|
||||
db.query(IssuerSettings)
|
||||
.filter(
|
||||
IssuerSettings.tenant_id == tenant_id,
|
||||
IssuerSettings.company_id == company_id,
|
||||
IssuerSettings.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not issuer:
|
||||
raise builder.CfdiBuildError(
|
||||
["no hay datos fiscales del emisor configurados para la empresa (fin.issuer_settings)"]
|
||||
)
|
||||
|
||||
account = None
|
||||
if invoice.account_id:
|
||||
account = db.query(Account).filter(Account.id == invoice.account_id).first()
|
||||
if not account:
|
||||
raise builder.CfdiBuildError(["la factura no tiene cliente asignado"])
|
||||
|
||||
# CP fiscal del receptor: vive en la dirección de tipo 'fiscal' de la cuenta.
|
||||
receiver_zip = ""
|
||||
direccion = (
|
||||
db.query(Address)
|
||||
.filter(
|
||||
Address.account_id == account.id,
|
||||
Address.address_type == "fiscal",
|
||||
Address.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if direccion and direccion.postal_code:
|
||||
receiver_zip = (direccion.postal_code or "").strip()[:5]
|
||||
|
||||
items = (
|
||||
db.query(InvoiceItem)
|
||||
.filter(InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None))
|
||||
.order_by(InvoiceItem.id)
|
||||
.all()
|
||||
)
|
||||
|
||||
concepts: list[builder.ConceptLine] = []
|
||||
for it in items:
|
||||
taxes: list[builder.TaxLine] = []
|
||||
for t in (
|
||||
db.query(InvoiceItemTax)
|
||||
.filter(InvoiceItemTax.invoice_item_id == it.id, InvoiceItemTax.deleted_at.is_(None))
|
||||
.order_by(InvoiceItemTax.id)
|
||||
.all()
|
||||
):
|
||||
taxes.append(
|
||||
builder.TaxLine(
|
||||
code=_code(db, Tax, t.tax_id),
|
||||
rate=Decimal(str(t.rate or 0)),
|
||||
amount=Decimal(str(t.amount or 0)),
|
||||
is_withholding=bool(t.is_withholding),
|
||||
)
|
||||
)
|
||||
concepts.append(
|
||||
builder.ConceptLine(
|
||||
product_service_code=_code(db, ProductService, it.product_service_id),
|
||||
unit_code=_code(db, UnitOfMeasure, it.unit_of_measure_id),
|
||||
description=(it.description or it.concept or "").strip(),
|
||||
quantity=Decimal(str(it.quantity or 0)),
|
||||
unit_price=Decimal(str(it.unit_amount or 0)),
|
||||
tax_object=_code(db, TaxObject, it.tax_object_id),
|
||||
taxes=taxes,
|
||||
)
|
||||
)
|
||||
|
||||
# Fecha del comprobante: la de emisión si existe, y si no, ahora. Sin desplazamiento
|
||||
# horario, que en CFDI 4.0 no se pone (ver cfdi_builder).
|
||||
if invoice.issue_date:
|
||||
fecha = datetime.combine(invoice.issue_date, datetime.now().time())
|
||||
else:
|
||||
fecha = datetime.now()
|
||||
|
||||
return builder.CfdiData(
|
||||
folio=invoice.reference or str(invoice.id),
|
||||
serie=None,
|
||||
date=fecha.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
payment_form=_code(db, PaymentForm, invoice.payment_form_id),
|
||||
payment_method=_code(db, PaymentMethod, invoice.payment_method_id),
|
||||
currency=(invoice.currency or "MXN").upper(),
|
||||
exchange_rate=None,
|
||||
expedition_zip=(invoice.expedition_zip_code or issuer.zip_code or "").strip()[:5],
|
||||
payment_conditions=None,
|
||||
issuer_rfc=(issuer.rfc or "").strip().upper(),
|
||||
issuer_name=(issuer.legal_name or "").strip(),
|
||||
issuer_tax_regime=_code(db, TaxRegime, issuer.tax_regime_id),
|
||||
receiver_rfc=(account.rfc or "").strip().upper(),
|
||||
receiver_name=(account.name or "").strip(),
|
||||
receiver_zip=receiver_zip,
|
||||
receiver_tax_regime=_code(db, TaxRegime, account.tax_regime_id),
|
||||
receiver_cfdi_use=_code(db, CfdiUse, account.cfdi_use_id),
|
||||
concepts=concepts,
|
||||
)
|
||||
|
||||
|
||||
def _load_csd(db: Session, tenant_id: int, company_id: int) -> tuple[bytes, bytes, str]:
|
||||
"""Bytes del ``.cer``, del ``.key`` y la contraseña descifrada del CSD de la empresa.
|
||||
|
||||
Las rutas salen de ``fin.issuer_settings``, no de una convención fija: cada empresa carga
|
||||
su propio certificado desde la configuración fiscal.
|
||||
|
||||
Import diferido de ``storage_s3``: importar arriba abre conexión y rompe los tests, que
|
||||
corren sin MinIO. Es el mismo patrón que usa ``invoices.service``.
|
||||
"""
|
||||
from core.crypto import SecretDecryptionError, SecretsNotConfigured, decrypt_secret # noqa: PLC0415
|
||||
|
||||
issuer = (
|
||||
db.query(IssuerSettings)
|
||||
.filter(
|
||||
IssuerSettings.tenant_id == tenant_id,
|
||||
IssuerSettings.company_id == company_id,
|
||||
IssuerSettings.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not issuer or not issuer.csd_cer_file_key or not issuer.csd_key_file_key:
|
||||
raise builder.CfdiBuildError(
|
||||
[
|
||||
"la empresa no tiene CSD cargado: súbelo en Configuración de Facturación "
|
||||
"(certificado .cer, llave .key y su contraseña)"
|
||||
]
|
||||
)
|
||||
|
||||
# Contraseña por empresa; la global de entorno queda sólo como respaldo del esquema previo.
|
||||
if issuer.csd_password_enc:
|
||||
try:
|
||||
password = decrypt_secret(issuer.csd_password_enc)
|
||||
except (SecretDecryptionError, SecretsNotConfigured) as exc:
|
||||
raise builder.CfdiBuildError([str(exc)]) from exc
|
||||
elif settings.CSD_PASSWORD:
|
||||
password = settings.CSD_PASSWORD
|
||||
else:
|
||||
raise builder.CfdiBuildError(
|
||||
["la empresa no tiene guardada la contraseña de su CSD: vuelve a cargarlo"]
|
||||
)
|
||||
|
||||
from core.storage_s3 import get_object_bytes # noqa: PLC0415
|
||||
|
||||
try:
|
||||
cer = get_object_bytes(issuer.csd_cer_file_key)
|
||||
key = get_object_bytes(issuer.csd_key_file_key)
|
||||
except Exception as exc: # noqa: BLE001 — cualquier fallo aquí es "no hay CSD utilizable"
|
||||
raise builder.CfdiBuildError(
|
||||
[f"no pude leer los archivos del CSD desde el almacenamiento: {exc}"]
|
||||
) from exc
|
||||
return cer, key, password
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------------------
|
||||
# Timbrado
|
||||
# --------------------------------------------------------------------------------------
|
||||
def stamp_invoice(
|
||||
db: Session,
|
||||
invoice_id: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> InvoiceStamp:
|
||||
"""Genera, sella y transmite el CFDI de la factura.
|
||||
|
||||
Es idempotente: si la factura ya tiene un timbre exitoso lo devuelve tal cual, **sin**
|
||||
llamar al PAC. Retimbrar cuesta un folio y genera un comprobante duplicado ante el SAT,
|
||||
que después hay que cancelar.
|
||||
"""
|
||||
invoice = _get_invoice(db, invoice_id, tenant_id, company_id)
|
||||
|
||||
existente = get_stamp(db, invoice_id, tenant_id, company_id)
|
||||
if existente:
|
||||
return existente
|
||||
|
||||
if invoice.status == "cancelada":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="La factura está cancelada"
|
||||
)
|
||||
|
||||
mode = (invoice.stamping_mode or "").strip()
|
||||
if mode not in STAMPING_MODES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Modo de timbrado inválido en la factura: {mode!r}",
|
||||
)
|
||||
|
||||
if not settings.PAC_USER or not settings.PAC_PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="No están configuradas las credenciales del PAC (PAC_USER / PAC_PASSWORD).",
|
||||
)
|
||||
|
||||
# ----- Datos, CSD, XML y sello -----
|
||||
try:
|
||||
data = _build_data(db, invoice, tenant_id, company_id)
|
||||
cer_bytes, key_bytes, csd_password = _load_csd(db, tenant_id, company_id)
|
||||
cert_number, cert_b64 = sealer.read_certificate(cer_bytes)
|
||||
xml = builder.build_xml(data, cert_number=cert_number, cert_b64=cert_b64)
|
||||
cadena = sealer.build_original_string(xml)
|
||||
private_key = sealer.load_private_key(key_bytes, csd_password)
|
||||
sello = sealer.sign(cadena, private_key)
|
||||
xml_sellado = builder.apply_seal(xml, sello)
|
||||
except builder.CfdiBuildError as exc:
|
||||
# 422 con la lista completa: son datos que falta capturar, no un fallo del sistema.
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"message": "Faltan datos fiscales para timbrar", "missing": exc.missing},
|
||||
) from exc
|
||||
except sealer.SealingError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)
|
||||
) from exc
|
||||
|
||||
# ----- Transmisión -----
|
||||
resultado = pac.stamp(
|
||||
xml_sellado,
|
||||
mode=mode,
|
||||
user=settings.PAC_USER,
|
||||
password=settings.PAC_PASSWORD,
|
||||
host_test=settings.PAC_HOST_TEST,
|
||||
host_prod=settings.PAC_HOST_PROD,
|
||||
email=settings.PAC_NOTIFICATION_EMAIL,
|
||||
timeout=settings.PAC_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
stamp = InvoiceStamp(
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
invoice_id=invoice.id,
|
||||
mode=mode,
|
||||
status=STATUS_ERROR,
|
||||
pac_code=resultado.code,
|
||||
pac_balance=resultado.balance,
|
||||
error_message=resultado.error_message or None,
|
||||
created_by=user_id,
|
||||
)
|
||||
|
||||
# El id se necesita para nombrar los XML del intento, y sólo existe después del flush.
|
||||
db.add(stamp)
|
||||
db.flush()
|
||||
_store_attempt_xml(stamp, xml_sellado, resultado.xml)
|
||||
|
||||
if not resultado.ok:
|
||||
db.commit()
|
||||
db.refresh(stamp)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail={
|
||||
"message": "El PAC rechazó el comprobante",
|
||||
"pac_code": resultado.code,
|
||||
"pac_error": resultado.error_message,
|
||||
"stamp_id": stamp.id,
|
||||
},
|
||||
)
|
||||
|
||||
# ----- Verificación del timbre recibido -----
|
||||
try:
|
||||
tfd = _read_tfd(resultado.xml)
|
||||
except ValueError as exc:
|
||||
stamp.error_message = str(exc)
|
||||
db.commit()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail=f"El PAC devolvió un XML que no pude interpretar: {exc}",
|
||||
) from exc
|
||||
|
||||
esperado = PAC_RFC_BY_MODE[mode]
|
||||
if tfd["pac_rfc"] != esperado:
|
||||
# Red de seguridad final: se pidió un entorno y contestó otro. Nunca se da por bueno.
|
||||
stamp.error_message = (
|
||||
f"El timbre viene del PAC {tfd['pac_rfc']!r} y para el modo {mode!r} se esperaba "
|
||||
f"{esperado!r}: se timbró contra un entorno distinto del solicitado."
|
||||
)
|
||||
db.commit()
|
||||
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=stamp.error_message)
|
||||
|
||||
# ----- Persistencia -----
|
||||
stamp.status = STATUS_STAMPED
|
||||
stamp.uuid = tfd["uuid"]
|
||||
stamp.stamped_at = tfd["stamped_at"]
|
||||
stamp.pac_rfc = tfd["pac_rfc"]
|
||||
stamp.sat_cert_number = tfd["sat_cert_number"]
|
||||
stamp.sat_seal = tfd["sat_seal"]
|
||||
stamp.cfd_seal = tfd["cfd_seal"]
|
||||
stamp.error_message = None
|
||||
|
||||
key = invoice_stamp_xml_key(tenant_id, company_id, invoice.id, tfd["uuid"])
|
||||
try:
|
||||
from core.storage_s3 import put_object_bytes # noqa: PLC0415
|
||||
|
||||
put_object_bytes(key, resultado.xml.encode("utf-8"), content_type="application/xml")
|
||||
stamp.xml_file_key = key
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# El comprobante YA está timbrado ante el SAT: perder el archivo no puede invalidar el
|
||||
# timbre ni provocar un retimbrado. Se guarda el registro sin la clave y se anota.
|
||||
stamp.error_message = f"Timbrado correcto, pero no se pudo guardar el XML: {exc}"
|
||||
|
||||
db.commit()
|
||||
db.refresh(stamp)
|
||||
return stamp
|
||||
|
||||
|
||||
def _store_attempt_xml(stamp: InvoiceStamp, sent: bytes, received: str) -> None:
|
||||
"""Guarda el par enviado/recibido del intento y anota sus claves en ``stamp``.
|
||||
|
||||
Nunca propaga una excepción. Este rastro es para diagnóstico: si el almacenamiento está
|
||||
caído no puede tumbar un timbrado que el SAT ya dio por bueno, ni convertir el rechazo del
|
||||
PAC —que es lo que hay que contarle a quien factura— en un error de almacenamiento. Lo que
|
||||
no se pudo subir queda con la clave en NULL y en el log.
|
||||
"""
|
||||
from core.storage_s3 import put_object_bytes # noqa: PLC0415
|
||||
|
||||
# El de respuesta puede venir vacío: un fallo de red corta antes de que el PAC conteste.
|
||||
partes = [("request", sent), ("response", received.encode("utf-8") if received else b"")]
|
||||
for kind, cuerpo in partes:
|
||||
if not cuerpo:
|
||||
continue
|
||||
key = invoice_stamp_attempt_xml_key(
|
||||
stamp.tenant_id, stamp.company_id, stamp.invoice_id, stamp.id, kind
|
||||
)
|
||||
try:
|
||||
put_object_bytes(key, cuerpo, content_type="application/xml")
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("No se pudo guardar el XML de %s del intento %s", kind, stamp.id)
|
||||
continue
|
||||
setattr(stamp, f"{kind}_xml_file_key", key)
|
||||
|
||||
|
||||
def _read_tfd(xml_text: str) -> dict:
|
||||
"""Extrae el Timbre Fiscal Digital del XML que devolvió el PAC."""
|
||||
from lxml import etree # noqa: PLC0415
|
||||
|
||||
try:
|
||||
root = etree.fromstring(xml_text.encode("utf-8") if isinstance(xml_text, str) else xml_text)
|
||||
except etree.XMLSyntaxError as exc:
|
||||
raise ValueError(f"XML mal formado: {exc}") from exc
|
||||
|
||||
nodo = root.find(f".//{{{TFD_NS}}}TimbreFiscalDigital")
|
||||
if nodo is None:
|
||||
raise ValueError("no trae el nodo TimbreFiscalDigital")
|
||||
|
||||
uuid = (nodo.get("UUID") or "").strip()
|
||||
if not uuid:
|
||||
raise ValueError("el TimbreFiscalDigital no trae UUID")
|
||||
|
||||
crudo = (nodo.get("FechaTimbrado") or "").strip()
|
||||
try:
|
||||
stamped_at = datetime.fromisoformat(crudo) if crudo else None
|
||||
except ValueError:
|
||||
# Fecha ilegible: no invalida el timbre, que ya existe ante el SAT. Se deja en NULL.
|
||||
stamped_at = None
|
||||
|
||||
return {
|
||||
"uuid": uuid,
|
||||
"stamped_at": stamped_at,
|
||||
"pac_rfc": (nodo.get("RfcProvCertif") or "").strip(),
|
||||
"sat_cert_number": (nodo.get("NoCertificadoSAT") or "").strip() or None,
|
||||
"sat_seal": (nodo.get("SelloSAT") or "").strip() or None,
|
||||
"cfd_seal": (nodo.get("SelloCFD") or "").strip() or None,
|
||||
}
|
||||
|
||||
|
||||
def get_stamp_xml_url(db: Session, invoice_id: int, tenant_id: int, company_id: int) -> str:
|
||||
"""URL firmada del XML timbrado. Las presignadas caducan, así que se genera al vuelo."""
|
||||
from core.storage_s3 import presigned_get_url # noqa: PLC0415
|
||||
|
||||
stamp = get_stamp(db, invoice_id, tenant_id, company_id)
|
||||
if not stamp or not stamp.xml_file_key:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="La factura no tiene XML timbrado almacenado",
|
||||
)
|
||||
return presigned_get_url(stamp.xml_file_key)
|
||||
61
backend/api/v1/modules/fin/stamping/xslt/README.md
Normal file
61
backend/api/v1/modules/fin/stamping/xslt/README.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# XSLT de la cadena original — CFDI 4.0
|
||||
|
||||
La cadena original es la secuencia de datos que se firma para producir el sello del comprobante.
|
||||
Sólo se obtiene aplicando la transformación oficial del SAT: no se construye a mano.
|
||||
|
||||
| Archivo | Origen |
|
||||
|---|---|
|
||||
| `cadenaoriginal_4_0.xslt` | `http://www.sat.gob.mx/sitio_internet/cfd/4/cadenaoriginal_4_0/cadenaoriginal_4_0.xslt` |
|
||||
| `utilerias.xslt` | `http://www.sat.gob.mx/sitio_internet/cfd/2/cadenaoriginal_2_0/utilerias.xslt` |
|
||||
| `sin_complemento.xslt` | Nuestro. Marcador de posición, ver abajo. |
|
||||
|
||||
## La única modificación: los `href` de los `xsl:include`
|
||||
|
||||
El archivo del SAT trae 33 `xsl:include` apuntando a URLs de `sat.gob.mx`. **Se reescribieron los
|
||||
`href` a rutas relativas locales**; no se tocó ni una plantilla, ni un `xsl:template`, ni el orden
|
||||
de los campos.
|
||||
|
||||
- El include de `utilerias.xslt` apunta al archivo local del mismo nombre.
|
||||
- Los otros 32, todos de complementos (Carta Porte, Comercio Exterior, Nómina, Pagos…), apuntan a
|
||||
`sin_complemento.xslt`, que es un stylesheet vacío.
|
||||
|
||||
**Por qué es inocuo:** cada XSLT de complemento sólo aporta plantillas que hacen `match` sobre nodos
|
||||
de su propio complemento. Este módulo emite CFDI 4.0 tipo ingreso **sin complementos**, así que esas
|
||||
plantillas nunca se invocan. Verificado: la cadena original que produce esta versión local es
|
||||
**idéntica, carácter a carácter**, a la que produce el archivo del SAT resolviendo los includes por
|
||||
red. Hay una prueba que lo fija en `backend/tests/test_fin_stamping.py`.
|
||||
|
||||
## Por qué se hizo así, y no con un resolver
|
||||
|
||||
Porque **`libxslt` sale a internet a resolver los `xsl:include` aunque el parser de lxml se cree con
|
||||
`no_network=True`**. Está comprobado: con el archivo original y sin resolver, la transformación
|
||||
completa funciona, lo que sólo es posible si descargó `utilerias.xslt` de `sat.gob.mx` en ese
|
||||
momento.
|
||||
|
||||
Eso es inaceptable aquí por tres razones: el timbrado dependería de que `sat.gob.mx` esté arriba y
|
||||
responda rápido; la cadena original —el dato que se firma— vendría de una descarga no verificada en
|
||||
tiempo de ejecución; y un cambio silencioso en el servidor del SAT cambiaría los sellos sin que
|
||||
nadie lo note.
|
||||
|
||||
Se intentó primero con un `etree.Resolver` personalizado, que es lo que hace el sistema legado
|
||||
(`CFDI.cs`, el bloque `SafeXsltResolver` comentado hacia la línea 8333). No funcionó: el resolver
|
||||
también intercepta la resolución del documento principal, y devolver un stylesheet vacío ahí deja la
|
||||
transformación sin plantillas y **la cadena original sale vacía, sin ningún error**. Un sello sobre
|
||||
una cadena vacía es un CFDI que el SAT rechaza — o peor, un sello que parece válido y no lo es.
|
||||
|
||||
Con los `href` locales el problema desaparece de raíz: no hay nada que resolver fuera del directorio.
|
||||
|
||||
## Cómo actualizar estos archivos
|
||||
|
||||
1. Descarga el original del SAT (URLs de la tabla).
|
||||
2. Reescribe los `href` de los `xsl:include`: el de `utilerias.xslt` al archivo local, el resto a
|
||||
`sin_complemento.xslt`.
|
||||
3. Corre `pytest tests/test_fin_stamping.py`. La prueba de la cadena original tiene que seguir
|
||||
verde: si cambió el orden o el número de campos, el sello cambia y hay que revisarlo con Fiscal
|
||||
antes de subir nada.
|
||||
|
||||
## Si algún día se soporta un complemento
|
||||
|
||||
Trae **su** XSLT oficial, déjalo en este directorio y apunta el `href` de ese include concreto al
|
||||
archivo real, en lugar de a `sin_complemento.xslt`. No basta con añadir el nodo al XML: sin su
|
||||
plantilla, el complemento no entra en la cadena original y el sello sale mal.
|
||||
409
backend/api/v1/modules/fin/stamping/xslt/cadenaoriginal_4_0.xslt
Normal file
409
backend/api/v1/modules/fin/stamping/xslt/cadenaoriginal_4_0.xslt
Normal file
@@ -0,0 +1,409 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:fn="http://www.w3.org/2005/xpath-functions" xmlns:cfdi="http://www.sat.gob.mx/cfd/4" xmlns:cce11="http://www.sat.gob.mx/ComercioExterior11" xmlns:cce20="http://www.sat.gob.mx/ComercioExterior20" xmlns:donat="http://www.sat.gob.mx/donat" xmlns:divisas="http://www.sat.gob.mx/divisas" xmlns:implocal="http://www.sat.gob.mx/implocal" xmlns:leyendasFisc="http://www.sat.gob.mx/leyendasFiscales" xmlns:pfic="http://www.sat.gob.mx/pfic" xmlns:tpe="http://www.sat.gob.mx/TuristaPasajeroExtranjero" xmlns:nomina12="http://www.sat.gob.mx/nomina12" xmlns:registrofiscal="http://www.sat.gob.mx/registrofiscal" xmlns:pagoenespecie="http://www.sat.gob.mx/pagoenespecie" xmlns:aerolineas="http://www.sat.gob.mx/aerolineas" xmlns:valesdedespensa="http://www.sat.gob.mx/valesdedespensa" xmlns:notariospublicos="http://www.sat.gob.mx/notariospublicos" xmlns:vehiculousado="http://www.sat.gob.mx/vehiculousado" xmlns:servicioparcial="http://www.sat.gob.mx/servicioparcialconstruccion" xmlns:decreto="http://www.sat.gob.mx/renovacionysustitucionvehiculos" xmlns:destruccion="http://www.sat.gob.mx/certificadodestruccion" xmlns:obrasarte="http://www.sat.gob.mx/arteantiguedades" xmlns:ine="http://www.sat.gob.mx/ine" xmlns:iedu="http://www.sat.gob.mx/iedu" xmlns:ventavehiculos="http://www.sat.gob.mx/ventavehiculos" xmlns:detallista="http://www.sat.gob.mx/detallista" xmlns:ecc12="http://www.sat.gob.mx/EstadoDeCuentaCombustible12" xmlns:consumodecombustibles11="http://www.sat.gob.mx/ConsumoDeCombustibles11" xmlns:gceh="http://www.sat.gob.mx/GastosHidrocarburos10" xmlns:ieeh="http://www.sat.gob.mx/IngresosHidrocarburos10" xmlns:cartaporte20="http://www.sat.gob.mx/CartaPorte20" xmlns:pago20="http://www.sat.gob.mx/Pagos20" xmlns:cartaporte30="http://www.sat.gob.mx/CartaPorte30" xmlns:cartaporte31="http://www.sat.gob.mx/CartaPorte31" xmlns:hidrocarburospetroliferos="http://www.sat.gob.mx/hidrocarburospetroliferos">
|
||||
|
||||
<!-- Con el siguiente método se establece que la salida deberá ser en texto -->
|
||||
<xsl:output method="text" version="1.0" encoding="UTF-8" indent="no"/>
|
||||
<!--
|
||||
En esta sección se define la inclusión de las plantillas de utilerías para colapsar espacios
|
||||
-->
|
||||
<xsl:include href="utilerias.xslt"/>
|
||||
<!--
|
||||
En esta sección se define la inclusión de las demás plantillas de transformación para
|
||||
la generación de las cadenas originales de los complementos fiscales
|
||||
-->
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
<xsl:include href="sin_complemento.xslt"/>
|
||||
|
||||
<!-- Aquí iniciamos el procesamiento de la cadena original con su | inicial y el terminador || -->
|
||||
<xsl:template match="/">|<xsl:apply-templates select="/cfdi:Comprobante"/>||</xsl:template>
|
||||
<!-- Aquí iniciamos el procesamiento de los datos incluidos en el comprobante -->
|
||||
<xsl:template match="cfdi:Comprobante">
|
||||
<!-- Iniciamos el tratamiento de los atributos de comprobante -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Version"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Serie"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Folio"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Fecha"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@FormaPago"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@NoCertificado"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@CondicionesDePago"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@SubTotal"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Descuento"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Moneda"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TipoCambio"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Total"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoDeComprobante"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Exportacion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@MetodoPago"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@LugarExpedicion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Confirmacion"/>
|
||||
</xsl:call-template>
|
||||
<!--
|
||||
Llamadas para procesar al los sub nodos del comprobante
|
||||
-->
|
||||
<xsl:apply-templates select="./cfdi:InformacionGlobal"/>
|
||||
<xsl:for-each select="./cfdi:CfdiRelacionados">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
<xsl:apply-templates select="./cfdi:Emisor"/>
|
||||
<xsl:apply-templates select="./cfdi:Receptor"/>
|
||||
<xsl:apply-templates select="./cfdi:Conceptos"/>
|
||||
<xsl:apply-templates select="./cfdi:Impuestos"/>
|
||||
<xsl:apply-templates select="./cfdi:Complemento"/>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo InformacionGlobal -->
|
||||
<xsl:template match="cfdi:InformacionGlobal">
|
||||
<!-- Iniciamos el tratamiento de los atributos del nodo tipo InformacionGlobal -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Periodicidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Meses"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Año"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo CFDIRelacionados -->
|
||||
<xsl:template match="cfdi:CfdiRelacionados">
|
||||
<!-- Iniciamos el tratamiento de los atributos del nodo tipo CFDIRelacionados -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoRelacion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:for-each select="./cfdi:CfdiRelacionado">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@UUID"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Emisor -->
|
||||
<xsl:template match="cfdi:Emisor">
|
||||
<!-- Iniciamos el tratamiento de los atributos del nodo tipo Emisor -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Rfc"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Nombre"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@RegimenFiscal"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@FacAtrAdquirente"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Receptor -->
|
||||
<xsl:template match="cfdi:Receptor">
|
||||
<!-- Iniciamos el tratamiento de los atributos del nodo tipo Receptor -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Rfc"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Nombre"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@DomicilioFiscalReceptor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@ResidenciaFiscal"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@NumRegIdTrib"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@RegimenFiscalReceptor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@UsoCFDI"/>
|
||||
</xsl:call-template>
|
||||
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Conceptos -->
|
||||
<xsl:template match="cfdi:Conceptos">
|
||||
<!-- Llamada para procesar los distintos nodos tipo Concepto -->
|
||||
<xsl:for-each select="./cfdi:Concepto">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!--Manejador de nodos tipo Concepto-->
|
||||
<xsl:template match="cfdi:Concepto">
|
||||
<!-- Iniciamos el tratamiento de los atributos del Concepto -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ClaveProdServ"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@NoIdentificacion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Cantidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ClaveUnidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Unidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Descripcion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ValorUnitario"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Descuento"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ObjetoImp"/>
|
||||
</xsl:call-template>
|
||||
|
||||
<!-- Manejo de sub nodos de información Traslado de Conceptos:Concepto:Impuestos:Traslados-->
|
||||
<xsl:for-each select="./cfdi:Impuestos/cfdi:Traslados/cfdi:Traslado">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Base"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Impuesto"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoFactor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TasaOCuota"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
|
||||
<!-- Manejo de sub nodos de Retencion por cada una de los Conceptos:Concepto:Impuestos:Retenciones-->
|
||||
<xsl:for-each select="./cfdi:Impuestos/cfdi:Retenciones/cfdi:Retencion">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Base"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Impuesto"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoFactor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TasaOCuota"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
|
||||
<!-- Manejo de los distintos sub nodos a cuenta de terceros de forma indistinta a su grado de dependencia -->
|
||||
<xsl:for-each select="./cfdi:ACuentaTerceros">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
|
||||
<!-- Manejo de los distintos sub nodos de información aduanera de forma indistinta a su grado de dependencia -->
|
||||
<xsl:for-each select="./cfdi:InformacionAduanera">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
|
||||
<!-- Llamada al manejador de nodos de CuentaPredial en caso de existir -->
|
||||
<xsl:if test="./cfdi:CuentaPredial">
|
||||
<xsl:apply-templates select="./cfdi:CuentaPredial"/>
|
||||
</xsl:if>
|
||||
|
||||
<!-- Llamada al manejador de nodos de ComplementoConcepto en caso de existir -->
|
||||
<xsl:if test="./cfdi:ComplementoConcepto">
|
||||
<xsl:apply-templates select="./cfdi:ComplementoConcepto"/>
|
||||
</xsl:if>
|
||||
|
||||
<!-- Llamada al manejador de nodos de Parte en caso de existir -->
|
||||
<xsl:for-each select=".//cfdi:Parte">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo ACuentaTerceros -->
|
||||
<xsl:template match="cfdi:ACuentaTerceros">
|
||||
<!-- Manejo de los atributos del nodo tipo ACuentaTerceros -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@RfcACuentaTerceros"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@NombreACuentaTerceros"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@RegimenFiscalACuentaTerceros"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@DomicilioFiscalACuentaTerceros"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Información Aduanera -->
|
||||
<xsl:template match="cfdi:InformacionAduanera">
|
||||
<!-- Manejo de los atributos de la información aduanera -->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@NumeroPedimento"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Información CuentaPredial -->
|
||||
<xsl:template match="cfdi:CuentaPredial">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Numero"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo ComplementoConcepto -->
|
||||
<xsl:template match="cfdi:ComplementoConcepto">
|
||||
<xsl:for-each select="./*">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Parte -->
|
||||
<xsl:template match="cfdi:Parte">
|
||||
<!-- Iniciamos el tratamiento de los atributos de Parte-->
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@ClaveProdServ"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@NoIdentificacion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Cantidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Unidad"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Descripcion"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@ValorUnitario"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
|
||||
<!-- Manejador de nodos tipo InformacionAduanera-->
|
||||
<xsl:for-each select=".//cfdi:InformacionAduanera">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Complemento -->
|
||||
<xsl:template match="cfdi:Complemento">
|
||||
<xsl:for-each select="./*">
|
||||
<xsl:apply-templates select="."/>
|
||||
</xsl:for-each>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de nodos tipo Domicilio fiscal -->
|
||||
<xsl:template match="cfdi:Impuestos">
|
||||
<!-- Manejo de sub nodos de Retencion por cada una de los Impuestos:Retenciones-->
|
||||
<xsl:for-each select="./cfdi:Retenciones/cfdi:Retencion">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Impuesto"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
<!-- Iniciamos el tratamiento de los atributos de TotalImpuestosRetenidos-->
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TotalImpuestosRetenidos"/>
|
||||
</xsl:call-template>
|
||||
<!-- Manejo de sub nodos de información Traslado de Impuestos:Traslados-->
|
||||
<xsl:for-each select="./cfdi:Traslados/cfdi:Traslado">
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Base"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@Impuesto"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Requerido">
|
||||
<xsl:with-param name="valor" select="./@TipoFactor"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TasaOCuota"/>
|
||||
</xsl:call-template>
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@Importe"/>
|
||||
</xsl:call-template>
|
||||
</xsl:for-each>
|
||||
<!-- Iniciamos el tratamiento de los atributos de TotalImpuestosTrasladados-->
|
||||
<xsl:call-template name="Opcional">
|
||||
<xsl:with-param name="valor" select="./@TotalImpuestosTrasladados"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--
|
||||
Marcador de posicion para los complementos del CFDI que este modulo NO emite.
|
||||
|
||||
cadenaoriginal_4_0.xslt del SAT incluye 32 hojas de estilo de complementos (Carta Porte,
|
||||
Comercio Exterior, Nomina, Pagos...). Cada una solo aporta plantillas que hacen match sobre
|
||||
nodos de su complemento: si el comprobante no los lleva, nunca se invocan y su ausencia no
|
||||
cambia la cadena original ni un caracter.
|
||||
|
||||
Este modulo emite CFDI 4.0 tipo ingreso SIN complementos (ver el plan del ticket, seccion 1),
|
||||
asi que todos esos includes apuntan aqui. Si algun dia se soporta un complemento, hay que
|
||||
traer SU xslt oficial y apuntar el href de ese include al archivo real.
|
||||
-->
|
||||
<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"/>
|
||||
22
backend/api/v1/modules/fin/stamping/xslt/utilerias.xslt
Normal file
22
backend/api/v1/modules/fin/stamping/xslt/utilerias.xslt
Normal file
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<xsl:stylesheet version="2.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:fn="http://www.w3.org/2005/xpath-functions">
|
||||
|
||||
<!-- Manejador de datos requeridos -->
|
||||
<xsl:template name="Requerido">
|
||||
<xsl:param name="valor"/>|<xsl:call-template name="ManejaEspacios">
|
||||
<xsl:with-param name="s" select="$valor"/>
|
||||
</xsl:call-template>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Manejador de datos opcionales -->
|
||||
<xsl:template name="Opcional">
|
||||
<xsl:param name="valor"/>
|
||||
<xsl:if test="$valor">|<xsl:call-template name="ManejaEspacios"><xsl:with-param name="s" select="$valor"/></xsl:call-template></xsl:if>
|
||||
</xsl:template>
|
||||
|
||||
<!-- Normalizador de espacios en blanco -->
|
||||
<xsl:template name="ManejaEspacios">
|
||||
<xsl:param name="s"/>
|
||||
<xsl:value-of select="normalize-space(string($s))"/>
|
||||
</xsl:template>
|
||||
</xsl:stylesheet>
|
||||
@@ -96,6 +96,10 @@ def _reset_rls_context_from_task(task_id=None, task=None, **_):
|
||||
celery_app.conf.update(
|
||||
include=[
|
||||
"api.v1.modules.core.help_center.tasks",
|
||||
# Sin esta línea el worker rechaza las tareas del carril con "Received unregistered
|
||||
# task of type 'expediente_gateway.deliver_outbox_row'": la fila queda en `pending`
|
||||
# con 0 intentos y NUNCA se drena, aunque el encolado se vea perfecto.
|
||||
"api.v1.modules.crm.expediente_gateway.tasks",
|
||||
# Agrega aquí las tareas de tu proyecto:
|
||||
# "api.v1.modules.example.tasks",
|
||||
]
|
||||
@@ -120,6 +124,25 @@ celery_app.conf.beat_schedule = {
|
||||
"task": "cleanup_orphan_layout_imports",
|
||||
"schedule": 3600.0,
|
||||
},
|
||||
# Carril CRM -> EFC. Los tres intervalos vienen del carril de referencia de Anexo22: 120 s para
|
||||
# las dos colas y 300 s para la reconciliación. El reintento NO es exponencial a propósito —el
|
||||
# backoff corto vive en el cliente HTTP y el largo es este barrido de intervalo fijo.
|
||||
#
|
||||
# Estos barridos son la red que atrapa la ventana entre el encolado y el commit: el despacho
|
||||
# inmediato puede llegar al worker antes de que la transacción confirme, no encontrar la fila
|
||||
# y darse por vencido. Aquí se recoge.
|
||||
"efc-sweep-outbox-every-2-min": {
|
||||
"task": "expediente_gateway.sweep_outbox",
|
||||
"schedule": 120.0,
|
||||
},
|
||||
"efc-sweep-file-outbox-every-2-min": {
|
||||
"task": "expediente_gateway.sweep_file_outbox",
|
||||
"schedule": 120.0,
|
||||
},
|
||||
"efc-sweep-expediente-gaps-every-5-min": {
|
||||
"task": "expediente_gateway.sweep_expediente_gaps",
|
||||
"schedule": 300.0,
|
||||
},
|
||||
}
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -75,7 +75,7 @@ class Settings(BaseSettings):
|
||||
# URL pública del frontend — usada en links de email (invitaciones, etc.)
|
||||
APP_PUBLIC_URL: str = "http://localhost:3000"
|
||||
|
||||
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before")
|
||||
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", "EFC_API_URL", mode="before")
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v and isinstance(v, str):
|
||||
@@ -116,6 +116,43 @@ class Settings(BaseSettings):
|
||||
S3_FILE_STORAGE: bool = True
|
||||
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
|
||||
|
||||
# ----- PAC Comercio Digital (timbrado CFDI) -----
|
||||
# El modo NO se configura aquí: vive en fin.invoices.stamping_mode, por factura. Esta
|
||||
# variable sólo define con qué valor nacen las facturas que no lo especifican.
|
||||
PAC_DEFAULT_MODE: Literal["pruebas", "produccion"] = "pruebas"
|
||||
PAC_HOST_TEST: str = "pruebas.comercio-digital.mx"
|
||||
PAC_HOST_PROD: str = "ws.comercio-digital.mx"
|
||||
PAC_USER: str = "" # header usrws
|
||||
PAC_PASSWORD: str = "" # header pwdws
|
||||
PAC_TIMEOUT_SECONDS: int = 15
|
||||
PAC_NOTIFICATION_EMAIL: str = "" # header email, opcional
|
||||
# Clave maestra que cifra las contraseñas de los CSD guardadas en la base (ver core/crypto).
|
||||
# Sin ella no se pueden cargar certificados. Se genera con:
|
||||
# python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
CSD_ENCRYPTION_KEY: str = ""
|
||||
# Contraseña global del CSD. LEGADO: sólo se usa como respaldo si una empresa no tiene su
|
||||
# propia contraseña guardada. Lo correcto es cargar el CSD por empresa.
|
||||
CSD_PASSWORD: str = ""
|
||||
|
||||
# ── EFC (expediente electrónico) ────────────────────────────────────────────────────────
|
||||
# Carril CRM -> EFC: los documentos del CRM se resguardan en el expediente de EFC.
|
||||
# Los nombres son los MISMOS que usa el gateway de Anexo22 contra el mismo EFC: inventar
|
||||
# otros obligaría a quien opera los dos sistemas a recordar dos juegos de variables para
|
||||
# exactamente lo mismo.
|
||||
# EFC_API_URL vacía = integración APAGADA. Todo el enganche es best-effort y hace no-op:
|
||||
# el CRM sigue funcionando igual, guardando los archivos solo en su MinIO.
|
||||
EFC_API_URL: str = ""
|
||||
EFC_API_KEY: str = "" # == CRM_INTEGRATION_API_KEY del lado de EFC
|
||||
EFC_API_VERIFY_SSL: bool = True
|
||||
EFC_API_TIMEOUT_MS: int = 8000 # metadatos: resolver, ingest, completar
|
||||
# Las subidas van aparte: 8 s no alcanzan para un archivo de 25 MB. Debe quedar POR DEBAJO
|
||||
# del proxy_read_timeout del nginx de EFC (ver core/efc_client.py).
|
||||
EFC_UPLOAD_TIMEOUT_MS: int = 55000
|
||||
# Scaffolding de mTLS: activarlo es configuración, no código.
|
||||
EFC_MTLS_CA_PATH: str = ""
|
||||
EFC_MTLS_CERT_PATH: str = ""
|
||||
EFC_MTLS_KEY_PATH: str = ""
|
||||
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=[".env", "../.env"],
|
||||
case_sensitive=True,
|
||||
|
||||
77
backend/core/crypto.py
Normal file
77
backend/core/crypto.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Cifrado simétrico de secretos que hay que guardar y volver a leer.
|
||||
|
||||
Se usa hoy para la contraseña de la llave privada del CSD: el timbrado necesita abrir el
|
||||
``.key`` sin que haya nadie tecleando, así que la contraseña tiene que estar en reposo. Un
|
||||
hash no sirve — habría que recuperar el valor original, no compararlo.
|
||||
|
||||
**La clave maestra vive en el entorno** (``CSD_ENCRYPTION_KEY``), nunca en la base ni en el
|
||||
repositorio. Quien tenga a la vez la base de datos y esa variable puede descifrar los secretos:
|
||||
esa es la propiedad que da el diseño y conviene tenerla presente al decidir quién ve qué.
|
||||
|
||||
Para generar una clave nueva::
|
||||
|
||||
python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"
|
||||
"""
|
||||
|
||||
from cryptography.fernet import Fernet, InvalidToken
|
||||
|
||||
from core.config import settings
|
||||
|
||||
|
||||
class SecretsNotConfigured(Exception):
|
||||
"""No hay clave maestra configurada, así que no se puede cifrar ni descifrar."""
|
||||
|
||||
|
||||
class SecretDecryptionError(Exception):
|
||||
"""El valor guardado no se pudo descifrar con la clave maestra actual."""
|
||||
|
||||
|
||||
def _fernet() -> Fernet:
|
||||
clave = (settings.CSD_ENCRYPTION_KEY or "").strip()
|
||||
if not clave:
|
||||
raise SecretsNotConfigured(
|
||||
"Falta CSD_ENCRYPTION_KEY. Genérala con "
|
||||
'`python -c "from cryptography.fernet import Fernet; '
|
||||
'print(Fernet.generate_key().decode())"` y ponla en el .env.'
|
||||
)
|
||||
try:
|
||||
return Fernet(clave.encode("utf-8"))
|
||||
except (ValueError, TypeError) as exc:
|
||||
raise SecretsNotConfigured(
|
||||
"CSD_ENCRYPTION_KEY no es una clave Fernet válida (32 bytes en base64 url-safe)."
|
||||
) from exc
|
||||
|
||||
|
||||
def encrypt_secret(value: str) -> str:
|
||||
"""Cifra un secreto. Devuelve el token en texto, listo para guardar en una columna."""
|
||||
if not value:
|
||||
raise ValueError("no se cifra un secreto vacío")
|
||||
return _fernet().encrypt(value.encode("utf-8")).decode("ascii")
|
||||
|
||||
|
||||
def decrypt_secret(token: str) -> str:
|
||||
"""Recupera el secreto original.
|
||||
|
||||
Falla con ``SecretDecryptionError`` si la clave maestra cambió o el dato está corrupto.
|
||||
Se distingue de ``SecretsNotConfigured`` a propósito: una dice "configura la variable" y la
|
||||
otra "la variable no es la que cifró este dato", y confundirlas manda a buscar al lugar
|
||||
equivocado.
|
||||
"""
|
||||
if not token:
|
||||
raise SecretDecryptionError("no hay secreto guardado")
|
||||
try:
|
||||
return _fernet().decrypt(token.encode("ascii")).decode("utf-8")
|
||||
except InvalidToken as exc:
|
||||
raise SecretDecryptionError(
|
||||
"No pude descifrar el secreto guardado: la clave maestra no corresponde con la que "
|
||||
"se usó al guardarlo, o el dato está dañado. Hay que volver a capturarlo."
|
||||
) from exc
|
||||
|
||||
|
||||
def secrets_available() -> bool:
|
||||
"""``True`` si hay clave maestra utilizable. Para avisar en la UI antes de pedir el dato."""
|
||||
try:
|
||||
_fernet()
|
||||
return True
|
||||
except SecretsNotConfigured:
|
||||
return False
|
||||
301
backend/core/efc_client.py
Normal file
301
backend/core/efc_client.py
Normal file
@@ -0,0 +1,301 @@
|
||||
"""Cliente HTTP hacia EFC (carril de integración CRM Agentes de Carga -> EFC).
|
||||
|
||||
Clon del cliente del gateway de Anexo22, que es el carril de referencia ya en producción
|
||||
(``anexo22/backend/api/v1/modules/pedimentos/pedimento_gateway/client.py``), con las rutas
|
||||
cambiadas a ``.../integrations/crm/...``. No es una reinterpretación: el molde de reintentos, el
|
||||
corte en 4xx y la forma del error se conservan tal cual.
|
||||
|
||||
**Síncrono a propósito** (``httpx.Client``): el consumidor es el worker de Celery que drena el
|
||||
outbox, que corre en contexto sync. El único punto async del carril es el proxy de descarga de cara
|
||||
al usuario, y ése no usa este cliente.
|
||||
|
||||
Todos los endpoints se autentican con el header ``X-Api-Key``
|
||||
(``settings.EFC_API_KEY`` == ``CRM_INTEGRATION_API_KEY`` del lado de EFC).
|
||||
"""
|
||||
import logging
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from core.config import settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rutas de EFC (prefijo /api/v1/, ver config/urls.py del backend de EFC).
|
||||
_PATH_ORG_BUSCAR = "/api/v1/organization/integrations/crm/organizaciones/"
|
||||
_PATH_ORG_RESOLVER = "/api/v1/organization/integrations/crm/organizaciones/resolver/"
|
||||
_PATH_EXPEDIENTE = "/api/v1/customs/integrations/crm/expedientes/"
|
||||
_PATH_EXPEDIENTE_COMPLETAR = "/api/v1/customs/integrations/crm/expedientes/{folio}/completar/"
|
||||
_PATH_EXPEDIENTE_DETALLE = "/api/v1/customs/integrations/crm/expedientes/{folio}/"
|
||||
_PATH_DOCS = "/api/v1/record/integrations/crm/documentos/"
|
||||
_PATH_DOCS_LIST = "/api/v1/record/integrations/crm/documentos/list/"
|
||||
_PATH_DOC_DESCARGAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/descargar/"
|
||||
_PATH_DOC_ELIMINAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/eliminar/"
|
||||
_PATH_DOC_REEMPLAZAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/reemplazar/"
|
||||
|
||||
|
||||
class EfcClientError(Exception):
|
||||
"""Error de comunicación con EFC.
|
||||
|
||||
``retryable=True`` marca fallos transitorios (5xx/timeout/red) que el worker debe reintentar.
|
||||
``status_code``/``code`` exponen la respuesta de EFC para que el worker pueda ramificar
|
||||
(p. ej. 404 ``expediente_no_encontrado`` → ensure-then-upload).
|
||||
|
||||
El worker decide **por el campo ``retryable``**, nunca parseando el mensaje: un texto de error
|
||||
cambia con cualquier refactor del otro repo y con él se caería la política de reintentos sin que
|
||||
nada se vea roto.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, status_code: Optional[int] = None,
|
||||
code: Optional[str] = None, retryable: bool = False):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.code = code
|
||||
self.retryable = retryable
|
||||
|
||||
|
||||
class EfcClient:
|
||||
def __init__(
|
||||
self,
|
||||
base_url: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
timeout_ms: Optional[int] = None,
|
||||
upload_timeout_ms: Optional[int] = None,
|
||||
verify_ssl: Optional[bool] = None,
|
||||
retries: int = 2,
|
||||
transport: Optional[httpx.BaseTransport] = None,
|
||||
):
|
||||
self.base_url = (base_url if base_url is not None else settings.EFC_API_URL).rstrip("/")
|
||||
self.api_key = api_key if api_key is not None else settings.EFC_API_KEY
|
||||
self.timeout_s = max(0.1, float(timeout_ms or settings.EFC_API_TIMEOUT_MS) / 1000.0)
|
||||
# Timeout aparte para las subidas: los 8 s de los metadatos no alcanzan para un archivo de
|
||||
# 25 MB. Tiene que quedar POR DEBAJO del proxy_read_timeout del nginx de EFC — si el CRM
|
||||
# esperara más, vería un 504 opaco sin saber si el documento entró. Fallando primero de este
|
||||
# lado, el reintento con el mismo efc_document_ref es limpio.
|
||||
self.upload_timeout_s = max(
|
||||
0.1, float(upload_timeout_ms or settings.EFC_UPLOAD_TIMEOUT_MS) / 1000.0
|
||||
)
|
||||
self.verify_ssl = settings.EFC_API_VERIFY_SSL if verify_ssl is None else verify_ssl
|
||||
self.retries = max(0, int(retries))
|
||||
self.transport = transport
|
||||
# mTLS (scaffolding pre-prod): si hay CA se usa para verificar; si hay par cert/key se
|
||||
# presenta como certificado de cliente. Vacío = TLS normal.
|
||||
self._ca_path = settings.EFC_MTLS_CA_PATH or ""
|
||||
self._cert_path = settings.EFC_MTLS_CERT_PATH or ""
|
||||
self._key_path = settings.EFC_MTLS_KEY_PATH or ""
|
||||
|
||||
def _client_kwargs(self, timeout_s: Optional[float] = None) -> dict:
|
||||
"""``verify``/``cert`` para httpx según config mTLS (o TLS normal si no hay mTLS)."""
|
||||
verify = self._ca_path if self._ca_path else self.verify_ssl
|
||||
kwargs = {
|
||||
"timeout": timeout_s or self.timeout_s,
|
||||
"verify": verify,
|
||||
"transport": self.transport,
|
||||
}
|
||||
if self._cert_path and self._key_path:
|
||||
kwargs["cert"] = (self._cert_path, self._key_path)
|
||||
return kwargs
|
||||
|
||||
@property
|
||||
def is_configured(self) -> bool:
|
||||
"""``False`` = integración deshabilitada (best-effort): sin URL o sin key."""
|
||||
return bool(self.base_url and self.api_key)
|
||||
|
||||
# ── HTTP interno ──────────────────────────────────────────────────────────
|
||||
|
||||
def _request(self, method: str, path: str, *, json: Any = None,
|
||||
params: dict = None, files: dict = None, data: dict = None,
|
||||
stream: bool = False, timeout_s: Optional[float] = None):
|
||||
if not self.is_configured:
|
||||
raise EfcClientError("EFC no configurado (EFC_API_URL/EFC_API_KEY vacíos).", retryable=False)
|
||||
|
||||
url = f"{self.base_url}{path}"
|
||||
headers = {"X-Api-Key": self.api_key}
|
||||
last_error: Optional[Exception] = None
|
||||
|
||||
for attempt in range(self.retries + 1):
|
||||
try:
|
||||
client = httpx.Client(**self._client_kwargs(timeout_s))
|
||||
try:
|
||||
response = client.request(method, url, headers=headers, json=json,
|
||||
params=params, files=files, data=data)
|
||||
except Exception:
|
||||
client.close()
|
||||
raise
|
||||
|
||||
if 200 <= response.status_code < 300:
|
||||
if stream:
|
||||
# El caller lee response.content y cierra el cliente.
|
||||
return response, client
|
||||
client.close()
|
||||
return response
|
||||
|
||||
# 5xx: transitorio, reintentar.
|
||||
if response.status_code >= 500 and attempt < self.retries:
|
||||
client.close()
|
||||
time.sleep(0.15 * (attempt + 1))
|
||||
continue
|
||||
|
||||
# 4xx u otro: no reintentar. Extraer code/mensaje de EFC.
|
||||
code, message = _parse_error_body(response)
|
||||
client.close()
|
||||
raise EfcClientError(
|
||||
message or f"EFC respondió {response.status_code}",
|
||||
status_code=response.status_code,
|
||||
code=code,
|
||||
retryable=response.status_code >= 500,
|
||||
)
|
||||
|
||||
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||
last_error = exc
|
||||
if attempt >= self.retries:
|
||||
break
|
||||
time.sleep(0.15 * (attempt + 1))
|
||||
except EfcClientError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001 — cualquier fallo inesperado es no-retryable
|
||||
raise EfcClientError(str(exc), retryable=False) from exc
|
||||
|
||||
raise EfcClientError(
|
||||
f"EFC inaccesible tras {self.retries + 1} intentos: {last_error}",
|
||||
retryable=True,
|
||||
)
|
||||
|
||||
# ── Organización ──────────────────────────────────────────────────────────
|
||||
|
||||
def buscar_organizaciones(self, q: str) -> list:
|
||||
"""Búsqueda por texto, para el alta manual desde una pantalla de administración."""
|
||||
return self._request("GET", _PATH_ORG_BUSCAR, params={"q": q}).json()
|
||||
|
||||
def resolve_organizacion(self, tenant_slug: str, tenant_name: Optional[str] = None) -> dict:
|
||||
payload = {"tenant_slug": tenant_slug}
|
||||
if tenant_name:
|
||||
payload["tenant_name"] = tenant_name
|
||||
return self._request("POST", _PATH_ORG_RESOLVER, json=payload).json()
|
||||
|
||||
# ── Expediente (pedimento provisional en EFC) ──────────────────────────────
|
||||
|
||||
def ingest_expediente(self, payload: dict) -> dict:
|
||||
"""Crea el pedimento provisional del expediente. 201 si es nuevo, 200 si ya existía."""
|
||||
return self._request("POST", _PATH_EXPEDIENTE, json=payload).json()
|
||||
|
||||
def completar_expediente(self, folio: str, payload: dict) -> dict:
|
||||
"""Completa un provisional con la data aduanera real. Ningún archivo se mueve."""
|
||||
path = _PATH_EXPEDIENTE_COMPLETAR.format(folio=folio)
|
||||
return self._request("POST", path, json=payload).json()
|
||||
|
||||
def get_expediente(self, folio: str, organizacion_id: str) -> dict:
|
||||
path = _PATH_EXPEDIENTE_DETALLE.format(folio=folio)
|
||||
return self._request("GET", path, params={"organizacion_id": str(organizacion_id)}).json()
|
||||
|
||||
# ── Documentos ────────────────────────────────────────────────────────────
|
||||
|
||||
def upload_documento(self, organizacion_id: str, crm_company_id: int, crm_expediente_id: int,
|
||||
tipo: str, filename: str, content: bytes,
|
||||
content_type: str = "application/octet-stream",
|
||||
crm_document_ref: Optional[str] = None) -> dict:
|
||||
"""Sube un documento al expediente. Multipart, nunca base64.
|
||||
|
||||
``crm_document_ref`` es **el handle de NUESTRO registro de origen**, y es lo que después
|
||||
permite recuperar el archivo sin guardar de este lado ningún identificador de EFC. EFC lo
|
||||
guarda junto al documento con un UNIQUE parcial por ``(organizacion, ref)``, así que una
|
||||
entrega repetida devuelve el documento que ya existía (200) en vez de crear otro (201).
|
||||
|
||||
Es la cuarta capa de idempotencia del carril y la única que garantiza la base: la entrega la
|
||||
hace un worker con reintentos, así que un timeout ambiguo —EFC commiteó y contestó tarde—
|
||||
duplicaría el documento sin esto.
|
||||
"""
|
||||
files = {"file": (filename, content, content_type)}
|
||||
data = {
|
||||
"organizacion_id": str(organizacion_id),
|
||||
"crm_company_id": str(int(crm_company_id)),
|
||||
"crm_expediente_id": str(int(crm_expediente_id)),
|
||||
"tipo": tipo,
|
||||
}
|
||||
if crm_document_ref:
|
||||
data["crm_document_ref"] = crm_document_ref
|
||||
return self._request(
|
||||
"POST", _PATH_DOCS, files=files, data=data, timeout_s=self.upload_timeout_s
|
||||
).json()
|
||||
|
||||
def list_documentos(self, organizacion_id: str, crm_expediente_id: int, *,
|
||||
tipo: Optional[str] = None,
|
||||
crm_document_ref: Optional[str] = None) -> list:
|
||||
"""Documentos del expediente. ``tipo`` y ``crm_document_ref`` son filtros OPCIONALES.
|
||||
|
||||
Preguntar por ``crm_document_ref`` es lo que permite recuperar ``efc_document_id`` si el
|
||||
cache local se perdió, sin guardar identificadores ajenos como handle.
|
||||
"""
|
||||
params = {
|
||||
"organizacion_id": str(organizacion_id),
|
||||
"crm_expediente_id": str(int(crm_expediente_id)),
|
||||
}
|
||||
if tipo:
|
||||
params["tipo"] = tipo
|
||||
if crm_document_ref:
|
||||
params["crm_document_ref"] = crm_document_ref
|
||||
return self._request("GET", _PATH_DOCS_LIST, params=params).json()
|
||||
|
||||
def replace_documento(self, organizacion_id: str, doc_id: str, filename: str, content: bytes,
|
||||
content_type: str = "application/octet-stream") -> dict:
|
||||
"""Sustituye el CONTENIDO de un documento conservando su fila (mismo id, mismo tipo).
|
||||
|
||||
EFC sube primero y borra el viejo al final, así que una subida fallida deja el anterior
|
||||
intacto y descargable.
|
||||
"""
|
||||
files = {"file": (filename, content, content_type)}
|
||||
data = {"organizacion_id": str(organizacion_id)}
|
||||
path = _PATH_DOC_REEMPLAZAR.format(doc_id=doc_id)
|
||||
return self._request(
|
||||
"PUT", path, files=files, data=data, timeout_s=self.upload_timeout_s
|
||||
).json()
|
||||
|
||||
def download_documento(self, organizacion_id: str, doc_id: str) -> tuple[bytes, str]:
|
||||
path = _PATH_DOC_DESCARGAR.format(doc_id=doc_id)
|
||||
params = {"organizacion_id": str(organizacion_id)}
|
||||
response, client = self._request("GET", path, params=params, stream=True)
|
||||
try:
|
||||
content = response.content
|
||||
filename = _filename_from_response(response, default=str(doc_id))
|
||||
finally:
|
||||
client.close()
|
||||
return content, filename
|
||||
|
||||
def download_url(self, doc_id: str) -> str:
|
||||
"""URL absoluta del endpoint de descarga de EFC, para el proxy async de la fase 7.
|
||||
|
||||
El proxy no puede usar ``download_documento``: éste es síncrono y bufferiza el archivo
|
||||
entero. Lo que necesita es la URL y el header, y hace su propio streaming.
|
||||
"""
|
||||
return f"{self.base_url}{_PATH_DOC_DESCARGAR.format(doc_id=doc_id)}"
|
||||
|
||||
@property
|
||||
def auth_headers(self) -> dict:
|
||||
"""El header de autenticación, para el proxy async que no pasa por ``_request``."""
|
||||
return {"X-Api-Key": self.api_key}
|
||||
|
||||
|
||||
def _parse_error_body(response) -> tuple[Optional[str], Optional[str]]:
|
||||
"""Extrae ``(code, message)`` del cuerpo de error estructurado de EFC
|
||||
(``{"error": {"code", "message"}}``) sin reventar si no es JSON."""
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
return None, None
|
||||
err = body.get("error") if isinstance(body, dict) else None
|
||||
if isinstance(err, dict):
|
||||
return err.get("code"), err.get("message")
|
||||
return None, None
|
||||
|
||||
|
||||
def _filename_from_response(response, default: str) -> str:
|
||||
disp = response.headers.get("Content-Disposition", "")
|
||||
if "filename=" in disp:
|
||||
return disp.split("filename=")[-1].strip().strip('"') or default
|
||||
return default
|
||||
|
||||
|
||||
# Instancia por defecto (lee settings). Los tests inyectan su propio transport construyendo
|
||||
# EfcClient(transport=httpx.MockTransport(...)).
|
||||
efc_client = EfcClient()
|
||||
@@ -285,6 +285,55 @@ def doda_report_pdf_key(
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}doda/{did}/report/doda_report.pdf"
|
||||
|
||||
|
||||
def invoice_stamp_xml_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
invoice_id: int,
|
||||
uuid: str,
|
||||
) -> str:
|
||||
"""
|
||||
XML timbrado bajo ``.../fin-invoices/{invoice_id}/stamps/{uuid}.xml``.
|
||||
|
||||
La clave lleva el UUID y no un timestamp: el UUID identifica el comprobante ante el SAT y
|
||||
no cambia, así que la clave es estable y un retimbrado accidental no puede sobrescribir el
|
||||
XML de otro comprobante.
|
||||
"""
|
||||
iid = _segment(invoice_id, "invoice_id")
|
||||
# El UUID va por _segment para que no pueda colar separadores de ruta.
|
||||
u = _segment(uuid, "uuid")
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}fin-invoices/{iid}/stamps/{u}.xml"
|
||||
|
||||
|
||||
STAMP_XML_KINDS = ("request", "response")
|
||||
|
||||
|
||||
def invoice_stamp_attempt_xml_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
invoice_id: int,
|
||||
attempt_id: int,
|
||||
kind: str,
|
||||
) -> str:
|
||||
"""
|
||||
XML de un **intento** de timbrado, bajo
|
||||
``.../fin-invoices/{invoice_id}/stamps/attempts/{attempt_id}-{kind}.xml``.
|
||||
|
||||
``kind`` es ``request`` (lo que se transmitió al PAC) o ``response`` (lo que contestó).
|
||||
|
||||
La clave va por ``attempt_id`` y no por UUID porque un intento rechazado no tiene UUID, y
|
||||
es justo el rechazado el que hay que poder reconstruir: sin el par enviado/recibido, un
|
||||
error del PAC no se puede diagnosticar después de que termine la petición.
|
||||
"""
|
||||
if kind not in STAMP_XML_KINDS:
|
||||
raise ValueError(f"kind inválido: {kind!r}. Sólo se admiten {STAMP_XML_KINDS}.")
|
||||
iid = _segment(invoice_id, "invoice_id")
|
||||
aid = _segment(attempt_id, "attempt_id")
|
||||
return (
|
||||
f"{tenant_company_prefix(tenant_id, company_id)}"
|
||||
f"fin-invoices/{iid}/stamps/attempts/{aid}-{kind}.xml"
|
||||
)
|
||||
|
||||
|
||||
def company_certificate_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
|
||||
@@ -51,6 +51,15 @@ celery==5.3.6
|
||||
redis==5.0.1
|
||||
flower==2.0.1
|
||||
|
||||
# CFDI / timbrado
|
||||
# lxml: la cadena original del SAT sólo se obtiene aplicando el XSLT 1.0 oficial;
|
||||
# xml.etree de la stdlib no hace XSLT y no hay otra implementación madura en Python.
|
||||
# cryptography: lee el .key del CSD (PKCS#8 DER cifrado) y el .cer (X.509 DER), y firma
|
||||
# RSA-SHA256. Ya entraba de forma transitiva por python-jose[cryptography]; aquí pasa a
|
||||
# ser dependencia directa, así que se declara.
|
||||
lxml==5.3.0
|
||||
cryptography==43.0.3
|
||||
|
||||
# Barcode
|
||||
pdf417gen==0.8.1
|
||||
asgiref==3.8.1
|
||||
|
||||
@@ -40,9 +40,14 @@ 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
|
||||
import api.v1.modules.fin.stamping.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.
|
||||
@@ -78,6 +83,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:
|
||||
|
||||
182
backend/tests/contracts/efc_crm_contract.json
Normal file
182
backend/tests/contracts/efc_crm_contract.json
Normal file
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"_meta": {
|
||||
"nombre": "Contrato del carril CRM Agentes de Carga <-> EFC",
|
||||
"ticket": "T2026-08-046",
|
||||
"version": 1,
|
||||
"por_que_existe": "CRM y EFC son dos repos con despliegue independiente. Nada obliga a que sus dos mitades del carril evolucionen juntas, y una ruta renombrada o una clave de payload que cambia solo se descubre en produccion, cuando un documento deja de llegar al expediente. Este archivo es la unica forma de que un cambio unilateral salga rojo en CI.",
|
||||
"como_se_usa": "Cada repo afirma su lado contra ESTE archivo. El CRM en backend/tests/test_contrato_efc.py. EFC debe afirmar el suyo cuando aterricen sus fases 1-4, contra una copia identica byte a byte de este JSON; si las dos copias divergen, el contrato deja de servir para lo unico que sirve.",
|
||||
"regla": "Cambiar algo aqui es cambiar el contrato: obliga a un PR en los DOS repos."
|
||||
},
|
||||
|
||||
"carril_efc": {
|
||||
"_nota": "Endpoints maquina-a-maquina que EXPONE EFC y CONSUME el CRM. Header obligatorio en todas: X-Api-Key.",
|
||||
"header_autenticacion": "X-Api-Key",
|
||||
"endpoints": {
|
||||
"organizaciones_buscar": {
|
||||
"metodo": "GET",
|
||||
"path": "/api/v1/organization/integrations/crm/organizaciones/"
|
||||
},
|
||||
"organizaciones_resolver": {
|
||||
"metodo": "POST",
|
||||
"path": "/api/v1/organization/integrations/crm/organizaciones/resolver/",
|
||||
"request_claves": ["tenant_slug", "tenant_name"],
|
||||
"response_claves": ["id", "nombre", "rfc", "hub_tenant_slug", "is_active", "created"]
|
||||
},
|
||||
"expediente_crear": {
|
||||
"metodo": "POST",
|
||||
"path": "/api/v1/customs/integrations/crm/expedientes/",
|
||||
"request_claves": [
|
||||
"crm_tenant_slug",
|
||||
"crm_company_id",
|
||||
"crm_expediente_id",
|
||||
"folio",
|
||||
"storage_token"
|
||||
],
|
||||
"status_exito": [200, 201],
|
||||
"_nota_status": "201 si el provisional es nuevo, 200 si ya existia. Las dos son exito: el carril es idempotente."
|
||||
},
|
||||
"expediente_completar": {
|
||||
"metodo": "POST",
|
||||
"path": "/api/v1/customs/integrations/crm/expedientes/{folio}/completar/"
|
||||
},
|
||||
"expediente_detalle": {
|
||||
"metodo": "GET",
|
||||
"path": "/api/v1/customs/integrations/crm/expedientes/{folio}/"
|
||||
},
|
||||
"documento_subir": {
|
||||
"metodo": "POST",
|
||||
"path": "/api/v1/record/integrations/crm/documentos/",
|
||||
"content_type": "multipart/form-data",
|
||||
"_nota_content_type": "Multipart, nunca base64: EFC declara parser_classes = [MultiPartParser].",
|
||||
"form_claves": [
|
||||
"organizacion_id",
|
||||
"crm_company_id",
|
||||
"crm_expediente_id",
|
||||
"tipo",
|
||||
"crm_document_ref"
|
||||
],
|
||||
"archivo_campo": "file",
|
||||
"status_exito": [200, 201]
|
||||
},
|
||||
"documentos_listar": {
|
||||
"metodo": "GET",
|
||||
"path": "/api/v1/record/integrations/crm/documentos/list/"
|
||||
},
|
||||
"documento_descargar": {
|
||||
"metodo": "GET",
|
||||
"path": "/api/v1/record/integrations/crm/documentos/{doc_id}/descargar/"
|
||||
},
|
||||
"documento_eliminar": {
|
||||
"metodo": "DELETE",
|
||||
"path": "/api/v1/record/integrations/crm/documentos/{doc_id}/eliminar/"
|
||||
},
|
||||
"documento_reemplazar": {
|
||||
"metodo": "PUT",
|
||||
"path": "/api/v1/record/integrations/crm/documentos/{doc_id}/reemplazar/"
|
||||
}
|
||||
},
|
||||
|
||||
"formato_error": {
|
||||
"forma": {"error": {"code": "<string>", "message": "<string>"}},
|
||||
"_nota": "El worker del CRM ramifica por error.code, NUNCA por el texto del mensaje: un mensaje cambia con cualquier refactor del otro repo y con el se caeria la politica de reintentos sin que nada se vea roto."
|
||||
},
|
||||
|
||||
"codigos_error": {
|
||||
"400": [
|
||||
"payload_invalido",
|
||||
"pedimento_app_reservado",
|
||||
"storage_token_invalido",
|
||||
"efc_pedimento_app_incompleto",
|
||||
"tipo_invalido",
|
||||
"archivo_faltante",
|
||||
"extension_no_permitida",
|
||||
"archivo_demasiado_grande",
|
||||
"espacio_insuficiente"
|
||||
],
|
||||
"403": ["documento_no_eliminable"],
|
||||
"404": ["expediente_no_encontrado", "documento_no_encontrado"],
|
||||
"409": [
|
||||
"organizacion_no_utilizable",
|
||||
"licencia_sin_espacio",
|
||||
"expediente_ya_completado",
|
||||
"pedimento_real_ya_existe",
|
||||
"conflicto"
|
||||
],
|
||||
"502": ["error_storage"]
|
||||
},
|
||||
|
||||
"codigos_con_significado_para_el_crm": {
|
||||
"expediente_no_encontrado": {
|
||||
"http": 404,
|
||||
"efecto": "dispara el ensure-then-upload: el CRM crea el provisional y reintenta la subida UNA vez",
|
||||
"_nota": "Si EFC renombra este code, el CRM deja de recuperarse solo y los documentos se quedan pendientes para siempre sin que nada falle a gritos. Es el code mas fragil del carril."
|
||||
}
|
||||
},
|
||||
|
||||
"reintentos": {
|
||||
"reintentables": ["5xx", "timeout", "error_de_red"],
|
||||
"no_reintentables": ["4xx"],
|
||||
"_nota": "Un 4xx reintentado tres veces es tres veces el mismo error mas latencia. El corte esta en 500, no en 400."
|
||||
}
|
||||
},
|
||||
|
||||
"api_crm": {
|
||||
"_nota": "Endpoints de usuario que expone el CRM. Todos con company_id obligatorio en query y usuario autenticado.",
|
||||
"query_obligatorio": "company_id",
|
||||
"endpoints": [
|
||||
{"metodo": "GET", "path": "/expedientes"},
|
||||
{"metodo": "GET", "path": "/expedientes/{expediente_id}"},
|
||||
{"metodo": "POST", "path": "/expedientes/ensure"},
|
||||
{"metodo": "POST", "path": "/expedientes/{expediente_id}/completar"},
|
||||
{"metodo": "DELETE", "path": "/expedientes/{expediente_id}"},
|
||||
{"metodo": "GET", "path": "/expedientes/{expediente_id}/documentos"},
|
||||
{"metodo": "POST", "path": "/expedientes/{expediente_id}/documentos"},
|
||||
{"metodo": "GET", "path": "/expedientes/{expediente_id}/documentos/{document_id}/archivo"},
|
||||
{"metodo": "DELETE", "path": "/expedientes/{expediente_id}/documentos/{document_id}"},
|
||||
{"metodo": "GET", "path": "/expediente-gateway/outbox"},
|
||||
{"metodo": "POST", "path": "/expediente-gateway/outbox/{outbox_id}/retry"},
|
||||
{"metodo": "GET", "path": "/expediente-gateway/metrics"}
|
||||
],
|
||||
|
||||
"documento_response_prohibido": ["file_key", "file_url"],
|
||||
"_nota_prohibido": "La copia local es de transito y se borra al confirmar la entrega a EFC. Exponerla invitaria al frontend a guardarse una referencia que va a dejar de existir; para abrir el archivo esta el proxy de descarga.",
|
||||
|
||||
"documento_response_claves_minimas": [
|
||||
"expediente_id",
|
||||
"doc_type",
|
||||
"name",
|
||||
"content_type",
|
||||
"size_bytes",
|
||||
"efc_sync_state",
|
||||
"efc_document_ref"
|
||||
],
|
||||
|
||||
"estados_sincronizacion": ["PENDING", "SYNCED", "FAILED"],
|
||||
|
||||
"descarga_documento": {
|
||||
"tipo_respuesta": "streaming",
|
||||
"traduccion_errores": {
|
||||
"404_de_efc": 404,
|
||||
"cualquier_otro_fallo_de_efc": 502,
|
||||
"documento_de_otro_tenant": 404,
|
||||
"documento_aun_no_entregado": 409
|
||||
},
|
||||
"_nota": "La asimetria es deliberada: un 404 de EFC significa que el documento realmente no esta; cualquier otro fallo es de la integracion, no del usuario. Y la traduccion tiene que ocurrir ANTES de que la respuesta empiece a salir, o llega tarde."
|
||||
},
|
||||
|
||||
"reintento_outbox": {
|
||||
"exito": {"status": "requeued", "id": "<int>"},
|
||||
"fila_inexistente_o_de_otro_tenant": 404,
|
||||
"_nota": "404 y no un 200 silencioso: es contrato con el frontend, que distingue 'no se pudo reencolar' de 'reencolado'."
|
||||
},
|
||||
|
||||
"metricas_outbox_claves": ["pending", "sent", "failed"],
|
||||
|
||||
"folio": {
|
||||
"formato": "EXP{YYYY}-{MM}-{NNN}",
|
||||
"alcance_consecutivo": ["tenant", "company", "mes"],
|
||||
"reinicia": "cada mes",
|
||||
"_nota": "Al pasar de 999 crece a 4 digitos en vez de desbordar."
|
||||
}
|
||||
}
|
||||
}
|
||||
296
backend/tests/test_contrato_efc.py
Normal file
296
backend/tests/test_contrato_efc.py
Normal file
@@ -0,0 +1,296 @@
|
||||
"""Afirmación del lado CRM del contrato con EFC.
|
||||
|
||||
``tests/contracts/efc_crm_contract.json`` es el contrato; esto comprueba que **este** repo lo
|
||||
cumple. EFC debe afirmar su mitad contra una copia idéntica del mismo archivo.
|
||||
|
||||
Por qué existe, y por qué no basta con las otras pruebas: CRM y EFC se despliegan por separado. Las
|
||||
pruebas de `test_efc_client.py` verifican que el cliente se comporta bien contra el EFC que el
|
||||
cliente **cree** que existe; si EFC renombra una ruta o una clave del payload, esas pruebas siguen
|
||||
verdes y el carril se rompe en producción. Lo único que atrapa esa deriva es un contrato escrito
|
||||
aparte y afirmado desde los dos lados.
|
||||
|
||||
Nada aquí toca la red: las peticiones se capturan con ``httpx.MockTransport``.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from core.efc_client import EfcClient
|
||||
|
||||
CONTRATO = json.loads(
|
||||
(Path(__file__).parent / "contracts" / "efc_crm_contract.json").read_text(encoding="utf-8")
|
||||
)
|
||||
CARRIL = CONTRATO["carril_efc"]
|
||||
API_CRM = CONTRATO["api_crm"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def capturadas():
|
||||
"""Cliente contra EFC simulado que va guardando las peticiones que salen."""
|
||||
peticiones: list[httpx.Request] = []
|
||||
|
||||
def handler(request):
|
||||
peticiones.append(request)
|
||||
if request.method == "GET" and request.url.path.endswith("/list/"):
|
||||
return httpx.Response(200, json=[])
|
||||
return httpx.Response(200, json={"id": "org-1"})
|
||||
|
||||
cliente = EfcClient(
|
||||
base_url="https://efc.example.test",
|
||||
api_key="llave-de-prueba",
|
||||
timeout_ms=500,
|
||||
upload_timeout_ms=500,
|
||||
verify_ssl=False,
|
||||
transport=httpx.MockTransport(handler),
|
||||
)
|
||||
return cliente, peticiones
|
||||
|
||||
|
||||
# ── Las rutas del carril ─────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"nombre,llamada",
|
||||
[
|
||||
("organizaciones_buscar", lambda c: c.buscar_organizaciones("temex")),
|
||||
("organizaciones_resolver", lambda c: c.resolve_organizacion("temex", "TEMEX")),
|
||||
("expediente_crear", lambda c: c.ingest_expediente({"folio": "EXP2026-08-001"})),
|
||||
("expediente_completar", lambda c: c.completar_expediente("EXP2026-08-001", {})),
|
||||
("expediente_detalle", lambda c: c.get_expediente("EXP2026-08-001", "org-1")),
|
||||
("documentos_listar", lambda c: c.list_documentos("org-1", 42)),
|
||||
],
|
||||
)
|
||||
def test_cada_llamada_del_cliente_pega_en_la_ruta_del_contrato(capturadas, nombre, llamada):
|
||||
cliente, peticiones = capturadas
|
||||
esperado = CARRIL["endpoints"][nombre]
|
||||
|
||||
llamada(cliente)
|
||||
|
||||
peticion = peticiones[-1]
|
||||
assert peticion.method == esperado["metodo"]
|
||||
assert peticion.url.path == _resolver(esperado["path"])
|
||||
|
||||
|
||||
def test_la_descarga_apunta_a_la_ruta_del_contrato(capturadas):
|
||||
"""``download_url`` la arma a mano para el proxy async, así que se comprueba aparte."""
|
||||
cliente, _ = capturadas
|
||||
esperado = CARRIL["endpoints"]["documento_descargar"]
|
||||
|
||||
url = httpx.URL(cliente.download_url("doc-1"))
|
||||
assert url.path == _resolver(esperado["path"], doc_id="doc-1")
|
||||
|
||||
|
||||
def test_la_subida_de_documento_pega_en_su_ruta_y_va_en_multipart(capturadas):
|
||||
cliente, peticiones = capturadas
|
||||
esperado = CARRIL["endpoints"]["documento_subir"]
|
||||
|
||||
cliente.upload_documento("org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4", "application/pdf")
|
||||
|
||||
peticion = peticiones[-1]
|
||||
assert peticion.method == esperado["metodo"]
|
||||
assert peticion.url.path == esperado["path"]
|
||||
assert esperado["content_type"] in peticion.headers["content-type"]
|
||||
|
||||
|
||||
def test_el_reemplazo_de_documento_pega_en_su_ruta(capturadas):
|
||||
cliente, peticiones = capturadas
|
||||
esperado = CARRIL["endpoints"]["documento_reemplazar"]
|
||||
|
||||
cliente.replace_documento("org-1", "doc-1", "guia.pdf", b"%PDF-1.4")
|
||||
|
||||
peticion = peticiones[-1]
|
||||
assert peticion.method == esperado["metodo"]
|
||||
assert peticion.url.path == _resolver(esperado["path"], doc_id="doc-1")
|
||||
|
||||
|
||||
def test_el_cliente_NO_sabe_borrar_documentos_en_efc():
|
||||
"""El endpoint de borrado existe en EFC y el CRM **deliberadamente no lo llama**.
|
||||
|
||||
``record.Document`` no tiene vigencia ni purga, así que la política implícita del sistema es
|
||||
conservar, y un documento que mañana puede ser parte del expediente de un pedimento real es
|
||||
riesgo de retención fiscal. La baja en el CRM es lógica. Que el método no exista es lo que
|
||||
impide que alguien lo llame "porque estaba ahí": esta prueba se pone roja si aparece.
|
||||
"""
|
||||
metodos = {m for m in dir(EfcClient) if "elimin" in m or "delete" in m or "borrar" in m}
|
||||
assert metodos == set(), f"apareció una operación de borrado hacia EFC: {metodos}"
|
||||
assert "documento_eliminar" in CARRIL["endpoints"], "el endpoint existe del lado de EFC"
|
||||
|
||||
|
||||
def _resolver(plantilla: str, **valores) -> str:
|
||||
"""Rellena los marcadores de la plantilla con los valores que usan las pruebas."""
|
||||
defaults = {"folio": "EXP2026-08-001", "doc_id": "doc-1"}
|
||||
defaults.update(valores)
|
||||
return plantilla.format(**defaults)
|
||||
|
||||
|
||||
# ── Las formas de los payloads ───────────────────────────────────────────────
|
||||
|
||||
def test_el_alta_de_expediente_manda_exactamente_las_claves_del_contrato(capturadas):
|
||||
"""Ni una de más ni una de menos.
|
||||
|
||||
Una clave de menos y EFC responde ``payload_invalido``; una de más y el serializer de EFC la
|
||||
ignora en silencio, que es peor: el dato se cree enviado y no lo está.
|
||||
"""
|
||||
cliente, peticiones = capturadas
|
||||
esperadas = set(CARRIL["endpoints"]["expediente_crear"]["request_claves"])
|
||||
|
||||
cliente.ingest_expediente({clave: "x" for clave in esperadas})
|
||||
|
||||
assert set(json.loads(peticiones[-1].content)) == esperadas
|
||||
|
||||
|
||||
def test_la_subida_manda_los_campos_de_formulario_del_contrato(capturadas):
|
||||
cliente, peticiones = capturadas
|
||||
esperados = set(CARRIL["endpoints"]["documento_subir"]["form_claves"])
|
||||
archivo = CARRIL["endpoints"]["documento_subir"]["archivo_campo"]
|
||||
|
||||
cliente.upload_documento(
|
||||
"org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4", "application/pdf",
|
||||
crm_document_ref="CRMDOC-1-7",
|
||||
)
|
||||
|
||||
cuerpo = peticiones[-1].content.decode("latin-1")
|
||||
faltantes = [c for c in esperados if f'name="{c}"' not in cuerpo]
|
||||
assert faltantes == [], f"el multipart no lleva {faltantes}"
|
||||
assert f'name="{archivo}"' in cuerpo
|
||||
|
||||
|
||||
def test_toda_peticion_del_carril_lleva_el_header_de_autenticacion(capturadas):
|
||||
cliente, peticiones = capturadas
|
||||
header = CARRIL["header_autenticacion"]
|
||||
|
||||
cliente.resolve_organizacion("temex")
|
||||
cliente.ingest_expediente({"folio": "EXP2026-08-001"})
|
||||
cliente.upload_documento("org-1", 1, 42, "MBL", "g.pdf", b"x")
|
||||
|
||||
assert peticiones, "no salió ninguna petición"
|
||||
for peticion in peticiones:
|
||||
assert peticion.headers.get(header) == "llave-de-prueba"
|
||||
|
||||
|
||||
# ── El catálogo de errores ───────────────────────────────────────────────────
|
||||
|
||||
def test_el_code_que_dispara_el_ensure_then_upload_esta_en_el_catalogo():
|
||||
"""Si EFC renombra este code, el CRM deja de recuperarse solo y los documentos se quedan
|
||||
pendientes para siempre **sin que nada falle a gritos**. Es el code más frágil del carril."""
|
||||
critico = CARRIL["codigos_con_significado_para_el_crm"]["expediente_no_encontrado"]
|
||||
assert critico["http"] == 404
|
||||
assert "expediente_no_encontrado" in CARRIL["codigos_error"]["404"]
|
||||
|
||||
|
||||
def test_el_gateway_ramifica_por_el_code_exacto_del_contrato():
|
||||
"""El código del CRM tiene ese ``code`` escrito literal. Que coincida con el contrato es lo que
|
||||
esta prueba fija; que el contrato coincida con EFC lo fija la suite del otro repo."""
|
||||
from pathlib import Path as _Path
|
||||
|
||||
fuente = (
|
||||
_Path(__file__).parent.parent
|
||||
/ "api" / "v1" / "modules" / "crm" / "expediente_gateway" / "service.py"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
assert '"expediente_no_encontrado"' in fuente
|
||||
|
||||
|
||||
def test_el_cliente_extrae_el_code_del_formato_de_error_del_contrato():
|
||||
forma = CARRIL["formato_error"]["forma"]
|
||||
assert set(forma) == {"error"}
|
||||
assert set(forma["error"]) == {"code", "message"}
|
||||
|
||||
def handler(request):
|
||||
return httpx.Response(400, json={"error": {"code": "espacio_insuficiente", "message": "m"}})
|
||||
|
||||
from core.efc_client import EfcClientError
|
||||
|
||||
cliente = EfcClient(
|
||||
base_url="https://efc.example.test", api_key="k", timeout_ms=200,
|
||||
verify_ssl=False, transport=httpx.MockTransport(handler),
|
||||
)
|
||||
with pytest.raises(EfcClientError) as exc:
|
||||
cliente.resolve_organizacion("temex")
|
||||
|
||||
assert exc.value.code == "espacio_insuficiente"
|
||||
assert exc.value.code in CARRIL["codigos_error"]["400"]
|
||||
assert exc.value.retryable is False
|
||||
|
||||
|
||||
# ── El API de usuario del CRM ────────────────────────────────────────────────
|
||||
|
||||
def test_las_rutas_registradas_del_crm_son_las_del_contrato():
|
||||
"""Cubre las dos direcciones: ninguna del contrato sin registrar, y ninguna registrada de más
|
||||
en estos dos routers. Un endpoint que aparece sin estar en el contrato es un endpoint que
|
||||
nadie del otro lado sabe que existe."""
|
||||
from api.v1.modules.crm.expediente_gateway.routes import router as gateway_router
|
||||
from api.v1.modules.crm.expedientes.routes import router as expedientes_router
|
||||
|
||||
registradas = set()
|
||||
for router in (expedientes_router, gateway_router):
|
||||
for ruta in router.routes:
|
||||
# ``ruta.path`` ya trae el prefijo del router aplicado: concatenarlo lo duplicaría.
|
||||
for metodo in ruta.methods:
|
||||
if metodo in ("HEAD", "OPTIONS"):
|
||||
continue
|
||||
registradas.add((metodo, ruta.path))
|
||||
|
||||
del_contrato = {(e["metodo"], e["path"]) for e in API_CRM["endpoints"]}
|
||||
|
||||
assert del_contrato - registradas == set(), "el contrato declara rutas que no existen"
|
||||
assert registradas - del_contrato == set(), "hay rutas fuera del contrato"
|
||||
|
||||
|
||||
def test_la_respuesta_de_un_documento_nunca_expone_la_copia_local():
|
||||
"""La copia local se borra al confirmar la entrega a EFC: una referencia expuesta al frontend
|
||||
es una referencia que va a dejar de existir."""
|
||||
from api.v1.modules.crm.expedientes.dto import ExpedienteDocumentResponse
|
||||
|
||||
campos = set(ExpedienteDocumentResponse.model_fields)
|
||||
|
||||
for prohibido in API_CRM["documento_response_prohibido"]:
|
||||
assert prohibido not in campos, f"la respuesta expone '{prohibido}'"
|
||||
faltantes = [c for c in API_CRM["documento_response_claves_minimas"] if c not in campos]
|
||||
assert faltantes == [], f"la respuesta no lleva {faltantes}"
|
||||
|
||||
|
||||
def test_los_estados_de_sincronizacion_son_los_del_contrato():
|
||||
from api.v1.modules.crm.expediente_gateway.models import (
|
||||
STATUS_FAILED,
|
||||
STATUS_PENDING,
|
||||
STATUS_SENT,
|
||||
)
|
||||
|
||||
# Los del outbox son en minúsculas; los que ve el frontend en el documento, en mayúsculas.
|
||||
assert {STATUS_PENDING, STATUS_SENT, STATUS_FAILED} == {"pending", "sent", "failed"}
|
||||
assert set(API_CRM["estados_sincronizacion"]) == {"PENDING", "SYNCED", "FAILED"}
|
||||
|
||||
|
||||
def test_las_metricas_del_outbox_tienen_las_claves_del_contrato(db):
|
||||
from api.v1.modules.crm.expediente_gateway import service as gateway
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
metricas = gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID)
|
||||
|
||||
assert set(API_CRM["metricas_outbox_claves"]).issubset(set(metricas))
|
||||
|
||||
|
||||
def test_el_formato_del_folio_es_el_del_contrato(db):
|
||||
"""El folio es lo que el usuario ve al guardar y lo que enlaza al expediente con EFC: su forma
|
||||
es contrato, no detalle."""
|
||||
import re
|
||||
|
||||
# El generador es el del CRM (crm/common/folios.py), no uno del carril: el expediente y su
|
||||
# consecutivo son del CRM y esta prueba solo afirma que la FORMA que produce es la que EFC
|
||||
# espera. Por eso se llama a la implementación real y no se reimplementa el formato aquí.
|
||||
from api.v1.modules.crm.common.folios import next_folio
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
# with_direction=False: el expediente no lleva sufijo I/E, a diferencia de la solicitud.
|
||||
folio = next_folio(db, TENANT_ID, COMPANY_ID, "EXP", None, with_direction=False)
|
||||
|
||||
patron = (
|
||||
API_CRM["folio"]["formato"]
|
||||
.replace("{YYYY}", r"\d{4}")
|
||||
.replace("{MM}", r"\d{2}")
|
||||
.replace("{NNN}", r"\d{3,}")
|
||||
)
|
||||
assert re.fullmatch(patron, folio), f"{folio} no cumple {API_CRM['folio']['formato']}"
|
||||
85
backend/tests/test_doc_types_paridad.py
Normal file
85
backend/tests/test_doc_types_paridad.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Paridad del catálogo de tipos de documento entre el CRM y EFC.
|
||||
|
||||
``EFC_DOC_TYPES`` del CRM tiene que ser **exactamente** el juego de claves de
|
||||
``TIPOS_DOCUMENTO_CRM`` de ``api/record/views_integrations_crm.py`` en EFC. La lista está duplicada
|
||||
a mano en dos repos con despliegue independiente, y este archivo es lo único que la mantiene
|
||||
honesta: si alguien agrega un tipo de un solo lado, esto se pone rojo **antes** de que un documento
|
||||
se rechace en producción con ``tipo_invalido``.
|
||||
|
||||
El juego esperado va escrito **literal** aquí y no derivado de ``doc_types.py``, porque una prueba
|
||||
que se lo pregunte al mismo módulo que valida no prueba nada: pasaría con cualquier cambio.
|
||||
"""
|
||||
|
||||
from api.v1.modules.crm.expediente_gateway.doc_types import EFC_DOC_TYPES, is_valid_doc_type
|
||||
|
||||
# Copia literal de las claves de TIPOS_DOCUMENTO_CRM (EFC, fase 3 del ticket T2026-08-046).
|
||||
# Al cambiar EFC, se cambia AQUÍ y el rojo obliga a mirar los dos lados.
|
||||
CLAVES_EN_EFC = {
|
||||
# crm.documents — DOC_TYPES de frontend/src/lib/api/crm/format.ts
|
||||
"constancia_fiscal",
|
||||
"acta_constitutiva",
|
||||
"identificacion",
|
||||
"comprobante_domicilio",
|
||||
"contrato",
|
||||
"presentacion",
|
||||
"certificacion",
|
||||
"licencia",
|
||||
"convenio",
|
||||
"tarifario",
|
||||
# ops.shipment_documents — SHIPMENT_DOC_TYPES del mismo archivo
|
||||
"MBL",
|
||||
"HBL",
|
||||
"MAWB",
|
||||
"HAWB",
|
||||
"CMR",
|
||||
"factura_comercial",
|
||||
"packing_list",
|
||||
"carta_encomienda",
|
||||
"carta_garantia",
|
||||
"certificado_permiso",
|
||||
# fin.invoices
|
||||
"factura_venta",
|
||||
# 'otro' existe en AMBAS listas del CRM y significa lo mismo: una sola entrada
|
||||
"otro",
|
||||
}
|
||||
|
||||
|
||||
def test_el_catalogo_del_crm_es_identico_al_de_efc():
|
||||
faltan_en_crm = CLAVES_EN_EFC - EFC_DOC_TYPES
|
||||
sobran_en_crm = EFC_DOC_TYPES - CLAVES_EN_EFC
|
||||
assert not faltan_en_crm, f"EFC acepta tipos que el CRM no conoce: {sorted(faltan_en_crm)}"
|
||||
assert not sobran_en_crm, (
|
||||
f"El CRM mandaría tipos que EFC va a rechazar con tipo_invalido: {sorted(sobran_en_crm)}"
|
||||
)
|
||||
|
||||
|
||||
def test_son_exactamente_veintidos():
|
||||
"""El número está en el ticket. Si cambia, es un cambio de contrato entre dos repos."""
|
||||
assert len(EFC_DOC_TYPES) == 22
|
||||
|
||||
|
||||
def test_no_hay_duplicados_entre_las_tres_fuentes():
|
||||
"""``otro`` está en las dos listas del CRM y debe colapsar a UNA entrada.
|
||||
|
||||
Un ``frozenset`` lo colapsa solo; la prueba está para que una futura refactorización a lista o
|
||||
a tupla no reintroduzca el duplicado en silencio.
|
||||
"""
|
||||
from api.v1.modules.crm.expediente_gateway import doc_types
|
||||
|
||||
todas = (
|
||||
doc_types._TIPOS_DOCUMENTOS_CLIENTE
|
||||
+ doc_types._TIPOS_DOCUMENTOS_EMBARQUE
|
||||
+ doc_types._TIPOS_FACTURACION
|
||||
+ doc_types._TIPOS_COMUNES
|
||||
)
|
||||
assert len(todas) == len(set(todas))
|
||||
|
||||
|
||||
def test_un_tipo_fuera_del_catalogo_se_rechaza():
|
||||
"""El CRM valida ANTES de gastar un viaje de red, y evita que un typo cree un DocumentType
|
||||
basura en el catálogo GLOBAL de EFC, que comparten todas las organizaciones."""
|
||||
assert is_valid_doc_type("MBL") is True
|
||||
assert is_valid_doc_type("mbl") is False # sensible a mayúsculas, como el catálogo de EFC
|
||||
assert is_valid_doc_type("factura_de_venta") is False # typo de 'factura_venta'
|
||||
assert is_valid_doc_type("") is False
|
||||
assert is_valid_doc_type(None) is False
|
||||
248
backend/tests/test_efc_client.py
Normal file
248
backend/tests/test_efc_client.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""Pruebas del cliente HTTP hacia EFC.
|
||||
|
||||
**Existen porque el carril de referencia no las tiene.** Verificado: en el gateway de Anexo22 no hay
|
||||
ni una prueba de ``EfcClient._request``, así que su bucle de reintentos, su backoff, su corte en 4xx
|
||||
y su header nunca se ejercitan. Ese hueco no se clona.
|
||||
|
||||
Todo va contra ``httpx.MockTransport`` por el parámetro ``transport``, que existe justamente para
|
||||
esto: **ninguna de estas pruebas toca la red**.
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from core.efc_client import EfcClient, EfcClientError
|
||||
|
||||
BASE = "https://efc.example.test"
|
||||
KEY = "llave-de-prueba"
|
||||
|
||||
|
||||
def _client(handler, **kwargs) -> EfcClient:
|
||||
return EfcClient(
|
||||
base_url=kwargs.pop("base_url", BASE),
|
||||
api_key=kwargs.pop("api_key", KEY),
|
||||
timeout_ms=kwargs.pop("timeout_ms", 500),
|
||||
upload_timeout_ms=kwargs.pop("upload_timeout_ms", 500),
|
||||
verify_ssl=False,
|
||||
transport=httpx.MockTransport(handler),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def test_reintenta_un_500_y_devuelve_el_exito():
|
||||
intentos = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
intentos["n"] += 1
|
||||
if intentos["n"] == 1:
|
||||
return httpx.Response(500, json={"detail": "boom"})
|
||||
return httpx.Response(200, json={"id": "org-1"})
|
||||
|
||||
resp = _client(handler).resolve_organizacion("temex")
|
||||
assert resp == {"id": "org-1"}
|
||||
assert intentos["n"] == 2
|
||||
|
||||
|
||||
def test_un_500_permanente_hace_exactamente_tres_intentos_y_es_retryable():
|
||||
"""``retries = 2`` significa 3 intentos: el original + 2. Ni 2 ni 4."""
|
||||
intentos = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
intentos["n"] += 1
|
||||
return httpx.Response(500, json={"detail": "boom"})
|
||||
|
||||
with pytest.raises(EfcClientError) as exc:
|
||||
_client(handler).resolve_organizacion("temex")
|
||||
|
||||
assert intentos["n"] == 3
|
||||
assert exc.value.retryable is True
|
||||
|
||||
|
||||
def test_un_timeout_permanente_hace_tres_intentos_y_es_retryable():
|
||||
intentos = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
intentos["n"] += 1
|
||||
raise httpx.ConnectTimeout("se acabó el tiempo", request=request)
|
||||
|
||||
with pytest.raises(EfcClientError) as exc:
|
||||
_client(handler).resolve_organizacion("temex")
|
||||
|
||||
assert intentos["n"] == 3
|
||||
assert exc.value.retryable is True
|
||||
|
||||
|
||||
def test_un_400_no_se_reintenta_y_extrae_el_code_del_cuerpo():
|
||||
"""El corte en 4xx es lo que evita machacar a EFC con una petición que nunca va a pasar.
|
||||
|
||||
Y el ``code`` extraído es lo que permite al worker ramificar **por campo**, nunca parseando el
|
||||
texto del mensaje: un texto cambia con cualquier refactor del otro repo.
|
||||
"""
|
||||
intentos = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
intentos["n"] += 1
|
||||
return httpx.Response(
|
||||
400,
|
||||
json={"error": {"code": "espacio_insuficiente", "message": "La licencia no tiene espacio"}},
|
||||
)
|
||||
|
||||
with pytest.raises(EfcClientError) as exc:
|
||||
_client(handler).resolve_organizacion("temex")
|
||||
|
||||
assert intentos["n"] == 1
|
||||
assert exc.value.status_code == 400
|
||||
assert exc.value.code == "espacio_insuficiente"
|
||||
assert exc.value.retryable is False
|
||||
assert "La licencia no tiene espacio" in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status_code", [401, 403])
|
||||
def test_401_y_403_no_se_reintentan(status_code):
|
||||
"""Una key mal configurada no mejora insistiendo: reintentarla solo gasta cuota y llena logs."""
|
||||
intentos = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
intentos["n"] += 1
|
||||
return httpx.Response(status_code)
|
||||
|
||||
with pytest.raises(EfcClientError) as exc:
|
||||
_client(handler).resolve_organizacion("temex")
|
||||
|
||||
assert intentos["n"] == 1
|
||||
assert exc.value.retryable is False
|
||||
|
||||
|
||||
def test_un_cuerpo_de_error_que_no_es_json_no_revienta():
|
||||
def handler(request):
|
||||
return httpx.Response(400, text="<html>502 Bad Gateway</html>")
|
||||
|
||||
with pytest.raises(EfcClientError) as exc:
|
||||
_client(handler).resolve_organizacion("temex")
|
||||
|
||||
assert exc.value.code is None
|
||||
assert exc.value.status_code == 400
|
||||
|
||||
|
||||
def test_toda_llamada_manda_el_header_x_api_key():
|
||||
visto = {}
|
||||
|
||||
def handler(request):
|
||||
visto["key"] = request.headers.get("X-Api-Key")
|
||||
return httpx.Response(200, json={"id": "org-1"})
|
||||
|
||||
_client(handler).resolve_organizacion("temex")
|
||||
assert visto["key"] == KEY
|
||||
|
||||
|
||||
def test_sin_url_configurada_no_toca_la_red_y_el_error_no_es_retryable():
|
||||
"""``is_configured is False`` es lo que hace que todo el carril sea best-effort.
|
||||
|
||||
Si esto tocara la red, cada operación del CRM con EFC apagado pagaría un timeout.
|
||||
"""
|
||||
llamado = {"n": 0}
|
||||
|
||||
def handler(request):
|
||||
llamado["n"] += 1
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
client = _client(handler, base_url="")
|
||||
assert client.is_configured is False
|
||||
|
||||
with pytest.raises(EfcClientError) as exc:
|
||||
client.resolve_organizacion("temex")
|
||||
|
||||
assert llamado["n"] == 0
|
||||
assert exc.value.retryable is False
|
||||
|
||||
|
||||
def test_sin_api_key_tampoco_esta_configurado():
|
||||
def handler(request):
|
||||
return httpx.Response(200, json={})
|
||||
|
||||
assert _client(handler, api_key="").is_configured is False
|
||||
|
||||
|
||||
def test_la_base_url_con_y_sin_barra_final_dan_la_misma_url():
|
||||
urls = []
|
||||
|
||||
def handler(request):
|
||||
urls.append(str(request.url))
|
||||
return httpx.Response(200, json={"id": "org-1"})
|
||||
|
||||
_client(handler, base_url=BASE).resolve_organizacion("temex")
|
||||
_client(handler, base_url=BASE + "/").resolve_organizacion("temex")
|
||||
|
||||
assert urls[0] == urls[1]
|
||||
assert "//organization" not in urls[0]
|
||||
|
||||
|
||||
def test_la_subida_va_multipart_y_lleva_el_crm_document_ref():
|
||||
"""El ref es la tercera capa de idempotencia: EFC devuelve 200 con el que ya existía."""
|
||||
visto = {}
|
||||
|
||||
def handler(request):
|
||||
visto["content_type"] = request.headers.get("Content-Type", "")
|
||||
visto["body"] = request.content
|
||||
return httpx.Response(201, json={"id": "doc-1"})
|
||||
|
||||
resp = _client(handler).upload_documento(
|
||||
"org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4 contenido", "application/pdf",
|
||||
crm_document_ref="SHPDOC-1-4471",
|
||||
)
|
||||
|
||||
assert resp == {"id": "doc-1"}
|
||||
assert visto["content_type"].startswith("multipart/form-data")
|
||||
assert b"SHPDOC-1-4471" in visto["body"]
|
||||
assert b"%PDF-1.4 contenido" in visto["body"]
|
||||
# Nada de base64: el archivo viaja crudo dentro del multipart.
|
||||
assert b"base64" not in visto["body"]
|
||||
|
||||
|
||||
def test_la_subida_usa_el_timeout_largo_y_los_metadatos_el_corto():
|
||||
"""Los 8 s de los metadatos no alcanzan para un archivo de 25 MB, y un timeout de subida
|
||||
demasiado largo haría que el CRM vea un 504 opaco de nginx sin saber si el documento entró."""
|
||||
client = _client(handler=lambda r: httpx.Response(200, json={}), timeout_ms=8000, upload_timeout_ms=55000)
|
||||
assert client.timeout_s == 8.0
|
||||
assert client.upload_timeout_s == 55.0
|
||||
assert client.upload_timeout_s > client.timeout_s
|
||||
|
||||
|
||||
def test_ensure_then_upload_puede_ramificar_por_el_code_del_404():
|
||||
"""El 404 del expediente tiene que llegar al worker con su ``code`` y su ``status_code``.
|
||||
|
||||
Es lo que dispara el ensure-then-upload; sin el code, el worker tendría que adivinar de qué es
|
||||
el 404 y crearía provisionales por cualquier ausencia.
|
||||
"""
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
404, json={"error": {"code": "expediente_no_encontrado", "message": "no está"}}
|
||||
)
|
||||
|
||||
with pytest.raises(EfcClientError) as exc:
|
||||
_client(handler).upload_documento("org-1", 1, 42, "MBL", "g.pdf", b"x")
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.code == "expediente_no_encontrado"
|
||||
assert exc.value.retryable is False
|
||||
|
||||
|
||||
def test_la_descarga_devuelve_contenido_y_nombre_del_content_disposition():
|
||||
def handler(request):
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=b"contenido binario",
|
||||
headers={"Content-Disposition": 'attachment; filename="factura.pdf"'},
|
||||
)
|
||||
|
||||
contenido, nombre = _client(handler).download_documento("org-1", "doc-1")
|
||||
assert contenido == b"contenido binario"
|
||||
assert nombre == "factura.pdf"
|
||||
|
||||
|
||||
def test_la_descarga_sin_content_disposition_cae_al_id_del_documento():
|
||||
def handler(request):
|
||||
return httpx.Response(200, content=b"x")
|
||||
|
||||
_contenido, nombre = _client(handler).download_documento("org-1", "doc-9")
|
||||
assert nombre == "doc-9"
|
||||
350
backend/tests/test_efc_entrega_documento.py
Normal file
350
backend/tests/test_efc_entrega_documento.py
Normal file
@@ -0,0 +1,350 @@
|
||||
"""Pruebas de la entrega de un archivo al expediente de EFC.
|
||||
|
||||
Cubren las tres cosas que hacen que este carril no pierda ni duplique archivos: el
|
||||
**ensure-then-upload** cuando el provisional todavía no existe allá, el **corte directo**
|
||||
(``delete_local``) que borra la copia local solo al confirmar, y la idempotencia por
|
||||
``crm_document_ref`` cuando un timeout ambiguo hace reintentar.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from api.v1.modules.crm.expediente_gateway import service as gateway
|
||||
from api.v1.modules.crm.expediente_gateway.models import (
|
||||
FILE_KIND_DOCUMENTO,
|
||||
MAX_ATTEMPTS,
|
||||
SOURCE_CRM_DOCUMENTS,
|
||||
STATUS_FAILED,
|
||||
STATUS_PENDING,
|
||||
STATUS_SENT,
|
||||
EfcFileOutbox,
|
||||
)
|
||||
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||
from api.v1.modules.crm.documents.models import Document
|
||||
from api.v1.modules.crm.service_requests import service as sr_service
|
||||
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
||||
from core.efc_client import EfcClientError
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
CONTENIDO = b"%PDF-1.4 guia madre"
|
||||
S3_KEY = "tenants/1/companies/1/expedientes/1/guia.pdf"
|
||||
|
||||
|
||||
class _ClienteFalso:
|
||||
"""Doble del cliente de EFC. Registra qué se le pidió, para poder afirmarlo."""
|
||||
|
||||
is_configured = True
|
||||
|
||||
def __init__(self, *, upload_falla_con=None, falla_solo_la_primera=True):
|
||||
self.uploads = []
|
||||
self.ingests = []
|
||||
self._upload_falla_con = upload_falla_con
|
||||
self._falla_solo_la_primera = falla_solo_la_primera
|
||||
|
||||
def ingest_expediente(self, payload):
|
||||
self.ingests.append(payload)
|
||||
return {"status": "created", "efc": {"pedimento_id": "ped-1"}}
|
||||
|
||||
def upload_documento(self, org_id, company_id, expediente_id, tipo, filename, content,
|
||||
content_type="application/octet-stream", crm_document_ref=None):
|
||||
primera = not self.uploads
|
||||
self.uploads.append({
|
||||
"org_id": org_id, "company_id": company_id, "expediente_id": expediente_id,
|
||||
"tipo": tipo, "filename": filename, "content": content,
|
||||
"content_type": content_type, "crm_document_ref": crm_document_ref,
|
||||
})
|
||||
if self._upload_falla_con is not None and (primera or not self._falla_solo_la_primera):
|
||||
raise self._upload_falla_con
|
||||
return {"id": f"doc-{len(self.uploads)}"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def entorno(db, monkeypatch):
|
||||
"""EFC encendido, storage simulado y organización ya resuelta."""
|
||||
from core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
||||
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
|
||||
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
|
||||
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
|
||||
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
|
||||
|
||||
borrados = []
|
||||
import core.storage_s3 as storage
|
||||
|
||||
monkeypatch.setattr(storage, "get_object_bytes", lambda key: CONTENIDO)
|
||||
monkeypatch.setattr(storage, "delete_object_if_exists", lambda key: borrados.append(key))
|
||||
|
||||
solicitud = sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||
)
|
||||
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
return {"db": db, "expediente": expediente, "borrados": borrados}
|
||||
|
||||
|
||||
def _documento_local(db, expediente) -> Document:
|
||||
doc = Document(
|
||||
doc_type="MBL",
|
||||
name="guia.pdf",
|
||||
file_key=S3_KEY,
|
||||
content_type="application/pdf",
|
||||
size_bytes=len(CONTENIDO),
|
||||
expediente_id=expediente.id,
|
||||
efc_sync_state="PENDING",
|
||||
tenant_id=TENANT_ID,
|
||||
company_id=COMPANY_ID,
|
||||
)
|
||||
db.add(doc)
|
||||
db.flush()
|
||||
doc.efc_document_ref = f"CRMDOC-{COMPANY_ID}-{doc.id}"
|
||||
db.commit()
|
||||
return doc
|
||||
|
||||
|
||||
def _fila(db, expediente, documento, **kwargs) -> EfcFileOutbox:
|
||||
row = EfcFileOutbox(
|
||||
kind=FILE_KIND_DOCUMENTO,
|
||||
s3_key=S3_KEY,
|
||||
file_name="guia.pdf",
|
||||
content_type="application/pdf",
|
||||
efc_tipo="MBL",
|
||||
source_table=SOURCE_CRM_DOCUMENTS,
|
||||
source_id=documento.id,
|
||||
crm_document_ref=documento.efc_document_ref,
|
||||
expediente_ref=expediente.id,
|
||||
delete_local=kwargs.pop("delete_local", True),
|
||||
status=kwargs.pop("status", STATUS_PENDING),
|
||||
tenant_id=TENANT_ID,
|
||||
company_id=COMPANY_ID,
|
||||
**kwargs,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
# ── Camino feliz ─────────────────────────────────────────────────────────────
|
||||
|
||||
def test_entrega_feliz_marca_la_fila_y_el_documento(entorno):
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento)
|
||||
cliente = _ClienteFalso()
|
||||
|
||||
gateway.deliver_file_row(db, row, cliente)
|
||||
|
||||
assert row.status == STATUS_SENT
|
||||
assert row.efc_document_id == "doc-1"
|
||||
assert row.sent_at is not None
|
||||
assert documento.efc_sync_state == "SYNCED"
|
||||
assert documento.efc_document_id == "doc-1"
|
||||
assert documento.efc_synced_at is not None
|
||||
|
||||
|
||||
def test_la_subida_lleva_el_crm_document_ref_y_el_contenido_leido_de_minio(entorno):
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento)
|
||||
cliente = _ClienteFalso()
|
||||
|
||||
gateway.deliver_file_row(db, row, cliente)
|
||||
|
||||
assert len(cliente.uploads) == 1
|
||||
subida = cliente.uploads[0]
|
||||
assert subida["crm_document_ref"] == documento.efc_document_ref
|
||||
assert subida["content"] == CONTENIDO
|
||||
assert subida["tipo"] == "MBL"
|
||||
assert subida["expediente_id"] == expediente.id
|
||||
|
||||
|
||||
# ── Ensure-then-upload ───────────────────────────────────────────────────────
|
||||
|
||||
def test_un_404_de_expediente_crea_el_provisional_y_reintenta_una_vez(entorno):
|
||||
"""La creación del provisional y la subida son colas distintas: la subida puede llegar antes.
|
||||
|
||||
Sin esto, el primer documento de cada expediente fallaría y esperaría al barrido.
|
||||
"""
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento)
|
||||
cliente = _ClienteFalso(
|
||||
upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado")
|
||||
)
|
||||
|
||||
gateway.deliver_file_row(db, row, cliente)
|
||||
|
||||
assert len(cliente.ingests) == 1
|
||||
assert cliente.ingests[0]["folio"] == expediente.folio
|
||||
assert cliente.ingests[0]["storage_token"] == expediente.efc_storage_token
|
||||
assert len(cliente.uploads) == 2 # el que falló + UNO de reintento
|
||||
assert row.status == STATUS_SENT
|
||||
|
||||
|
||||
def test_un_404_con_OTRO_code_no_dispara_el_ensure(entorno):
|
||||
"""El ensure se dispara por el ``code``, no por el 404 a secas.
|
||||
|
||||
Si se disparara por cualquier 404, un documento no encontrado crearía provisionales espurios.
|
||||
"""
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento)
|
||||
cliente = _ClienteFalso(
|
||||
upload_falla_con=EfcClientError("otro", status_code=404, code="documento_no_encontrado"),
|
||||
falla_solo_la_primera=False,
|
||||
)
|
||||
|
||||
gateway.deliver_file_row(db, row, cliente)
|
||||
|
||||
assert cliente.ingests == []
|
||||
assert len(cliente.uploads) == 1
|
||||
assert row.attempts == 1
|
||||
|
||||
|
||||
def test_el_ensure_reintenta_UNA_vez_y_no_entra_en_bucle(entorno):
|
||||
"""Si el reintento vuelve a dar 404, se registra el fallo. No se reintenta indefinidamente."""
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento)
|
||||
cliente = _ClienteFalso(
|
||||
upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado"),
|
||||
falla_solo_la_primera=False,
|
||||
)
|
||||
|
||||
gateway.deliver_file_row(db, row, cliente)
|
||||
|
||||
assert len(cliente.ingests) == 1
|
||||
assert len(cliente.uploads) == 2
|
||||
assert row.attempts == 1
|
||||
assert row.status == STATUS_FAILED # un 404 no es retryable
|
||||
|
||||
|
||||
# ── Corte directo (delete_local) ─────────────────────────────────────────────
|
||||
|
||||
def test_al_confirmar_se_borra_la_copia_local(entorno):
|
||||
"""«EFC es la fuente única» se cumple así: el objeto local se borra AL CONFIRMAR, no antes."""
|
||||
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento, delete_local=True)
|
||||
|
||||
gateway.deliver_file_row(db, row, _ClienteFalso())
|
||||
|
||||
assert borrados == [S3_KEY]
|
||||
# La key local se limpia: dejarla apuntaría a un objeto que ya no existe y la descarga se
|
||||
# ramificaría por el camino equivocado.
|
||||
assert documento.file_key is None
|
||||
|
||||
|
||||
def test_con_delete_local_en_false_no_se_borra_nada(entorno):
|
||||
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento, delete_local=False)
|
||||
|
||||
gateway.deliver_file_row(db, row, _ClienteFalso())
|
||||
|
||||
assert borrados == []
|
||||
assert documento.file_key == S3_KEY
|
||||
assert row.status == STATUS_SENT
|
||||
|
||||
|
||||
def test_si_el_borrado_local_falla_la_entrega_sigue_siendo_valida(entorno, monkeypatch):
|
||||
"""El archivo YA está en EFC. No poder borrar la copia local no invalida la entrega, y volver a
|
||||
intentarla subiría el mismo documento otra vez."""
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
import core.storage_s3 as storage
|
||||
|
||||
def _revienta(key):
|
||||
raise RuntimeError("MinIO no responde")
|
||||
|
||||
monkeypatch.setattr(storage, "delete_object_if_exists", _revienta)
|
||||
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento, delete_local=True)
|
||||
|
||||
gateway.deliver_file_row(db, row, _ClienteFalso())
|
||||
|
||||
assert row.status == STATUS_SENT
|
||||
assert documento.efc_sync_state == "SYNCED"
|
||||
|
||||
|
||||
def test_el_borrado_local_ocurre_ANTES_de_marcar_enviada_pero_no_antes_de_subir(entorno):
|
||||
"""Nunca se borra el original antes de confirmar la subida: si se borrara primero y la subida
|
||||
fallara, el archivo se habría perdido."""
|
||||
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento, delete_local=True)
|
||||
cliente = _ClienteFalso(
|
||||
upload_falla_con=EfcClientError("500", status_code=500, retryable=True),
|
||||
falla_solo_la_primera=False,
|
||||
)
|
||||
|
||||
gateway.deliver_file_row(db, row, cliente)
|
||||
|
||||
assert borrados == [] # la subida falló: el original sigue ahí
|
||||
assert row.status == STATUS_PENDING
|
||||
assert documento.file_key == S3_KEY
|
||||
|
||||
|
||||
# ── Fallos ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_un_fallo_de_efc_no_propaga_y_se_refleja_en_el_documento(entorno):
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento, attempts=MAX_ATTEMPTS - 1)
|
||||
cliente = _ClienteFalso(
|
||||
upload_falla_con=EfcClientError("EFC caído", status_code=503, retryable=True),
|
||||
falla_solo_la_primera=False,
|
||||
)
|
||||
|
||||
gateway.deliver_file_row(db, row, cliente) # no lanza
|
||||
|
||||
assert row.status == STATUS_FAILED
|
||||
assert documento.efc_sync_state == "FAILED"
|
||||
assert "EFC caído" in documento.efc_error_detail
|
||||
assert documento.efc_attempts == MAX_ATTEMPTS
|
||||
|
||||
|
||||
def test_una_fila_ya_enviada_no_vuelve_a_subir_el_archivo(entorno):
|
||||
"""Segunda guarda de idempotencia. Un re-despacho tras un timeout ambiguo no duplica."""
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento, status=STATUS_SENT)
|
||||
cliente = _ClienteFalso()
|
||||
|
||||
gateway.deliver_file_row(db, row, cliente)
|
||||
|
||||
assert cliente.uploads == []
|
||||
|
||||
|
||||
def test_un_expediente_borrado_deja_la_fila_reintentable(entorno):
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento)
|
||||
row.expediente_ref = 999999
|
||||
db.commit()
|
||||
|
||||
gateway.deliver_file_row(db, row, _ClienteFalso())
|
||||
|
||||
assert row.status == STATUS_PENDING # retryable: el expediente puede reaparecer
|
||||
assert row.attempts == 1
|
||||
|
||||
|
||||
def test_un_reintento_tras_timeout_manda_el_mismo_ref_y_no_duplica(entorno):
|
||||
"""El ``crm_document_ref`` es estable entre reintentos: EFC devuelve 200 con el que ya existía.
|
||||
|
||||
Es la tercera capa de idempotencia y la que cubre el timeout ambiguo —EFC commiteó y contestó
|
||||
tarde—, donde el CRM no puede saber si el documento entró.
|
||||
"""
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
documento = _documento_local(db, expediente)
|
||||
row = _fila(db, expediente, documento)
|
||||
|
||||
primer_cliente = _ClienteFalso(
|
||||
upload_falla_con=EfcClientError("timeout", retryable=True), falla_solo_la_primera=False
|
||||
)
|
||||
gateway.deliver_file_row(db, row, primer_cliente)
|
||||
assert row.status == STATUS_PENDING
|
||||
|
||||
segundo_cliente = _ClienteFalso()
|
||||
gateway.deliver_file_row(db, row, segundo_cliente)
|
||||
|
||||
assert primer_cliente.uploads[0]["crm_document_ref"] == segundo_cliente.uploads[0]["crm_document_ref"]
|
||||
assert row.status == STATUS_SENT
|
||||
386
backend/tests/test_efc_outbox.py
Normal file
386
backend/tests/test_efc_outbox.py
Normal file
@@ -0,0 +1,386 @@
|
||||
"""Pruebas de la máquina de reintentos del outbox hacia EFC.
|
||||
|
||||
Lo que se fija aquí es la capa 2 de las tres del carril: el worker **nunca lanza**, registra el
|
||||
fallo en la propia fila, y decide reintentar o rendirse por el campo ``retryable`` —nunca parseando
|
||||
el texto del error—.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from api.v1.modules.crm.expediente_gateway import service as gateway
|
||||
from api.v1.modules.crm.expediente_gateway.models import (
|
||||
FILE_KIND_DOCUMENTO,
|
||||
KIND_EXPEDIENTE,
|
||||
MAX_ATTEMPTS,
|
||||
SOURCE_CRM_DOCUMENTS,
|
||||
SOURCE_OPS_SHIPMENT_DOCUMENTS,
|
||||
STATUS_FAILED,
|
||||
STATUS_PENDING,
|
||||
STATUS_SENT,
|
||||
EfcFileOutbox,
|
||||
EfcSyncOutbox,
|
||||
)
|
||||
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||
from api.v1.modules.crm.service_requests import service as sr_service
|
||||
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
||||
from core.efc_client import EfcClientError
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
OTRO_TENANT = 99
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def efc_encendido(monkeypatch):
|
||||
"""Enciende la integración y evita que el encolado toque el broker o el tenant real."""
|
||||
from core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
||||
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
|
||||
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
|
||||
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
|
||||
return settings
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def efc_apagado(monkeypatch):
|
||||
from core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False)
|
||||
return settings
|
||||
|
||||
|
||||
def _expediente(db):
|
||||
solicitud = sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||
)
|
||||
return expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
|
||||
def _fila_sync(db, expediente, **kwargs):
|
||||
row = EfcSyncOutbox(
|
||||
kind=kwargs.pop("kind", KIND_EXPEDIENTE),
|
||||
payload=kwargs.pop("payload", {"folio": expediente.folio}),
|
||||
expediente_ref=expediente.id,
|
||||
status=kwargs.pop("status", STATUS_PENDING),
|
||||
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
||||
company_id=kwargs.pop("company_id", COMPANY_ID),
|
||||
**kwargs,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
def _fila_archivo(db, expediente, **kwargs):
|
||||
row = EfcFileOutbox(
|
||||
kind=kwargs.pop("kind", FILE_KIND_DOCUMENTO),
|
||||
s3_key=kwargs.pop("s3_key", "tenants/1/companies/1/expedientes/1/guia.pdf"),
|
||||
file_name=kwargs.pop("file_name", "guia.pdf"),
|
||||
content_type="application/pdf",
|
||||
efc_tipo=kwargs.pop("efc_tipo", "MBL"),
|
||||
source_table=kwargs.pop("source_table", SOURCE_CRM_DOCUMENTS),
|
||||
source_id=kwargs.pop("source_id", 1),
|
||||
crm_document_ref=kwargs.pop("crm_document_ref", "CRMDOC-1-1"),
|
||||
expediente_ref=expediente.id,
|
||||
status=kwargs.pop("status", STATUS_PENDING),
|
||||
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
||||
company_id=kwargs.pop("company_id", COMPANY_ID),
|
||||
**kwargs,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
# ── _register_failure ────────────────────────────────────────────────────────
|
||||
|
||||
def test_un_fallo_retryable_suma_un_intento_y_deja_la_fila_pendiente(db, efc_encendido):
|
||||
expediente = _expediente(db)
|
||||
row = _fila_sync(db, expediente)
|
||||
|
||||
gateway._register_failure(db, row, EfcClientError("EFC no responde", retryable=True), True)
|
||||
|
||||
assert row.attempts == 1
|
||||
assert row.status == STATUS_PENDING
|
||||
assert "EFC no responde" in row.last_error
|
||||
|
||||
|
||||
def test_un_fallo_no_retryable_marca_failed_de_inmediato(db, efc_encendido):
|
||||
"""Un 400 no mejora insistiendo: reintentarlo ocho veces solo retrasa que alguien lo vea."""
|
||||
expediente = _expediente(db)
|
||||
row = _fila_sync(db, expediente)
|
||||
|
||||
gateway._register_failure(db, row, EfcClientError("tipo inválido", retryable=False), False)
|
||||
|
||||
assert row.attempts == 1
|
||||
assert row.status == STATUS_FAILED
|
||||
|
||||
|
||||
def test_al_llegar_a_max_attempts_la_fila_queda_failed(db, efc_encendido):
|
||||
expediente = _expediente(db)
|
||||
row = _fila_sync(db, expediente, attempts=MAX_ATTEMPTS - 1)
|
||||
|
||||
gateway._register_failure(db, row, EfcClientError("otra vez", retryable=True), True)
|
||||
|
||||
assert row.attempts == MAX_ATTEMPTS
|
||||
assert row.status == STATUS_FAILED
|
||||
|
||||
|
||||
def test_el_ultimo_error_se_trunca_a_2000_caracteres(db, efc_encendido):
|
||||
"""``last_error`` es Text, pero un traceback de 5000 caracteres por fila llena la tabla de ruido."""
|
||||
expediente = _expediente(db)
|
||||
row = _fila_sync(db, expediente)
|
||||
|
||||
gateway._register_failure(db, row, Exception("x" * 5000), True)
|
||||
|
||||
assert len(row.last_error) == 2000
|
||||
|
||||
|
||||
# ── deliver_row: no propaga ──────────────────────────────────────────────────
|
||||
|
||||
class _ClienteQueRevienta:
|
||||
is_configured = True
|
||||
|
||||
def __init__(self, exc):
|
||||
self._exc = exc
|
||||
self.llamadas = 0
|
||||
|
||||
def ingest_expediente(self, payload):
|
||||
self.llamadas += 1
|
||||
raise self._exc
|
||||
|
||||
def completar_expediente(self, folio, payload):
|
||||
self.llamadas += 1
|
||||
raise self._exc
|
||||
|
||||
|
||||
def test_deliver_row_no_propaga_la_excepcion_de_efc(db, efc_encendido, monkeypatch):
|
||||
"""Si esto propagara, un EFC caído mataría al worker y se perdería la cola entera."""
|
||||
expediente = _expediente(db)
|
||||
row = _fila_sync(db, expediente)
|
||||
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
|
||||
|
||||
gateway.deliver_row(db, row, _ClienteQueRevienta(EfcClientError("caído", retryable=True)))
|
||||
|
||||
assert row.status == STATUS_PENDING
|
||||
assert row.attempts == 1
|
||||
|
||||
|
||||
def test_deliver_row_tampoco_propaga_una_excepcion_inesperada(db, efc_encendido, monkeypatch):
|
||||
expediente = _expediente(db)
|
||||
row = _fila_sync(db, expediente)
|
||||
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
|
||||
|
||||
gateway.deliver_row(db, row, _ClienteQueRevienta(RuntimeError("algo raro")))
|
||||
|
||||
assert row.attempts == 1
|
||||
# Una excepción inesperada se trata como transitoria: no se sabe que sea permanente.
|
||||
assert row.status == STATUS_PENDING
|
||||
|
||||
|
||||
def test_una_fila_ya_enviada_no_vuelve_a_llamar_a_efc(db, efc_encendido):
|
||||
"""Segunda guarda de idempotencia. Sin ella, un re-despacho duplicaría el expediente en EFC."""
|
||||
expediente = _expediente(db)
|
||||
row = _fila_sync(db, expediente, status=STATUS_SENT)
|
||||
cliente = _ClienteQueRevienta(EfcClientError("no debería llamarse"))
|
||||
|
||||
gateway.deliver_row(db, row, cliente)
|
||||
|
||||
assert cliente.llamadas == 0
|
||||
assert row.status == STATUS_SENT
|
||||
|
||||
|
||||
# ── _ya_entregado: la ambigüedad de las dos secuencias ───────────────────────
|
||||
|
||||
def test_no_se_encola_dos_veces_el_mismo_archivo(db, efc_encendido):
|
||||
expediente = _expediente(db)
|
||||
_fila_archivo(db, expediente, source_id=7, status=STATUS_SENT)
|
||||
|
||||
assert gateway._ya_entregado(db, SOURCE_CRM_DOCUMENTS, 7, FILE_KIND_DOCUMENTO) is True
|
||||
|
||||
row = gateway.enqueue_file_best_effort(
|
||||
db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None,
|
||||
efc_tipo="MBL", source_table=SOURCE_CRM_DOCUMENTS, source_id=7,
|
||||
crm_document_ref="CRMDOC-1-7", expediente_ref=expediente.id,
|
||||
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||
)
|
||||
assert row is None
|
||||
|
||||
|
||||
def test_el_mismo_id_en_otra_tabla_de_origen_SI_se_encola(db, efc_encendido):
|
||||
"""La prueba de la ambigüedad de las dos secuencias.
|
||||
|
||||
``crm.documents.id = 7`` y ``ops.shipment_documents.id = 7`` son documentos DISTINTOS. Sin
|
||||
``source_table`` en la guarda, entregar el primero haría que el segundo se saltara para
|
||||
siempre — y nadie vería un error.
|
||||
"""
|
||||
expediente = _expediente(db)
|
||||
_fila_archivo(db, expediente, source_id=7, source_table=SOURCE_CRM_DOCUMENTS, status=STATUS_SENT)
|
||||
|
||||
assert gateway._ya_entregado(db, SOURCE_OPS_SHIPMENT_DOCUMENTS, 7, FILE_KIND_DOCUMENTO) is False
|
||||
|
||||
row = gateway.enqueue_file_best_effort(
|
||||
db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None,
|
||||
efc_tipo="MBL", source_table=SOURCE_OPS_SHIPMENT_DOCUMENTS, source_id=7,
|
||||
crm_document_ref="SHPDOC-1-7", expediente_ref=expediente.id,
|
||||
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||
)
|
||||
assert row is not None
|
||||
assert row.source_table == SOURCE_OPS_SHIPMENT_DOCUMENTS
|
||||
|
||||
|
||||
# ── retry ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_retry_resetea_la_fila_y_la_re_despacha(db, efc_encendido, monkeypatch):
|
||||
despachos = []
|
||||
monkeypatch.setattr(
|
||||
gateway, "_dispatch_file_delivery", lambda oid, t, c: despachos.append((oid, t, c))
|
||||
)
|
||||
expediente = _expediente(db)
|
||||
row = _fila_archivo(db, expediente, status=STATUS_FAILED, attempts=MAX_ATTEMPTS,
|
||||
last_error="se acabaron los intentos")
|
||||
|
||||
ok = gateway.retry_outbox_row(db, row.id, TENANT_ID, COMPANY_ID, "file")
|
||||
|
||||
assert ok is True
|
||||
assert row.status == STATUS_PENDING
|
||||
assert row.attempts == 0
|
||||
assert row.last_error is None
|
||||
assert despachos == [(row.id, TENANT_ID, COMPANY_ID)]
|
||||
|
||||
|
||||
def test_retry_de_otro_tenant_devuelve_false(db, efc_encendido):
|
||||
"""Devuelve False y el llamador lo traduce a 404: un 200 le haría creer al frontend que se
|
||||
reencoló algo que ni siquiera es suyo."""
|
||||
expediente = _expediente(db)
|
||||
row = _fila_archivo(db, expediente, status=STATUS_FAILED)
|
||||
|
||||
assert gateway.retry_outbox_row(db, row.id, OTRO_TENANT, COMPANY_ID, "file") is False
|
||||
assert row.status == STATUS_FAILED # intacta
|
||||
|
||||
|
||||
def test_retry_de_una_fila_inexistente_devuelve_false(db, efc_encendido):
|
||||
assert gateway.retry_outbox_row(db, 999999, TENANT_ID, COMPANY_ID, "file") is False
|
||||
|
||||
|
||||
# ── métricas y listado ───────────────────────────────────────────────────────
|
||||
|
||||
def test_las_metricas_cuentan_por_status_sumando_las_dos_tablas(db, efc_encendido):
|
||||
expediente = _expediente(db)
|
||||
_fila_sync(db, expediente, status=STATUS_SENT)
|
||||
_fila_archivo(db, expediente, source_id=1, status=STATUS_PENDING)
|
||||
_fila_archivo(db, expediente, source_id=2, status=STATUS_FAILED)
|
||||
_fila_archivo(db, expediente, source_id=3, status=STATUS_FAILED)
|
||||
|
||||
metricas = gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID)
|
||||
|
||||
# El alta del expediente encoló su propia fila pendiente al crearse la solicitud.
|
||||
assert metricas["failed"] == 2
|
||||
assert metricas["sent"] == 1
|
||||
assert metricas["pending"] >= 1
|
||||
|
||||
|
||||
def test_las_metricas_no_ven_las_filas_de_otro_tenant(db, efc_encendido):
|
||||
expediente = _expediente(db)
|
||||
_fila_archivo(db, expediente, source_id=5, status=STATUS_FAILED, tenant_id=OTRO_TENANT)
|
||||
|
||||
assert gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID)["failed"] == 0
|
||||
|
||||
|
||||
def test_el_listado_marca_de_que_tabla_viene_cada_fila(db, efc_encendido):
|
||||
expediente = _expediente(db)
|
||||
_fila_archivo(db, expediente, source_id=1)
|
||||
|
||||
filas = gateway.list_outbox(db, TENANT_ID, COMPANY_ID)
|
||||
tablas = {f["tabla"] for f in filas}
|
||||
assert tablas == {"sync", "file"}
|
||||
|
||||
solo_archivos = gateway.list_outbox(db, TENANT_ID, COMPANY_ID, tipo="file")
|
||||
assert {f["tabla"] for f in solo_archivos} == {"file"}
|
||||
|
||||
|
||||
# ── huecos ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def test_find_expediente_gaps_encuentra_los_que_no_tienen_fila(db, efc_apagado):
|
||||
"""Con EFC apagado no se encola nada, así que todos los expedientes son huecos.
|
||||
|
||||
Es exactamente el caso que el barrido cubre: lo creado ANTES de activar la integración.
|
||||
"""
|
||||
_expediente(db)
|
||||
_expediente(db)
|
||||
|
||||
huecos = gateway.find_expediente_gaps(db)
|
||||
assert len(huecos) == 2
|
||||
|
||||
|
||||
def test_un_expediente_con_fila_failed_NO_es_un_hueco(db, efc_encendido):
|
||||
"""Un ``failed`` existe como fila: es visible en el tablero y reintentable a mano.
|
||||
|
||||
Tratarlo como hueco lo re-encolaría en cada barrido y escondería el fallo.
|
||||
"""
|
||||
expediente = _expediente(db)
|
||||
fila = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.expediente_ref == expediente.id).first()
|
||||
assert fila is not None
|
||||
fila.status = STATUS_FAILED
|
||||
db.commit()
|
||||
|
||||
assert gateway.find_expediente_gaps(db) == []
|
||||
|
||||
|
||||
# ── best-effort ──────────────────────────────────────────────────────────────
|
||||
|
||||
def test_con_efc_apagado_no_se_encola_nada(db, efc_apagado):
|
||||
"""``EFC_API_URL`` vacía apaga el carril entero. El CRM sigue funcionando igual."""
|
||||
expediente = _expediente(db)
|
||||
|
||||
assert db.query(EfcSyncOutbox).count() == 0
|
||||
|
||||
fila = gateway.enqueue_file_best_effort(
|
||||
db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None,
|
||||
efc_tipo="MBL", source_table=SOURCE_CRM_DOCUMENTS, source_id=1,
|
||||
crm_document_ref="CRMDOC-1-1", expediente_ref=expediente.id,
|
||||
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||
)
|
||||
assert fila is None
|
||||
assert db.query(EfcFileOutbox).count() == 0
|
||||
|
||||
|
||||
def test_con_efc_encendido_crear_una_solicitud_encola_su_expediente(db, efc_encendido):
|
||||
expediente = _expediente(db)
|
||||
|
||||
filas = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.expediente_ref == expediente.id).all()
|
||||
assert len(filas) == 1
|
||||
assert filas[0].kind == KIND_EXPEDIENTE
|
||||
assert filas[0].status == STATUS_PENDING
|
||||
assert filas[0].payload["folio"] == expediente.folio
|
||||
assert filas[0].payload["storage_token"] == expediente.efc_storage_token
|
||||
|
||||
|
||||
def test_si_el_encolado_revienta_la_operacion_local_no_se_rompe(db, efc_encendido, monkeypatch):
|
||||
"""La integración NUNCA puede tumbar el alta de una solicitud del usuario."""
|
||||
def _revienta(*a, **k):
|
||||
raise RuntimeError("la tabla del outbox no existe")
|
||||
|
||||
monkeypatch.setattr(gateway, "_expediente_ya_encolado", _revienta)
|
||||
|
||||
solicitud = sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||
)
|
||||
|
||||
assert solicitud.id is not None
|
||||
assert expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) is not None
|
||||
|
||||
|
||||
def test_no_se_encola_dos_veces_el_mismo_expediente(db, efc_encendido):
|
||||
"""Primera guarda: ``ensure`` es idempotente y no debe generar una segunda réplica."""
|
||||
expediente = _expediente(db)
|
||||
solicitud_id = expediente.service_request_id
|
||||
|
||||
expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1")
|
||||
expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1")
|
||||
|
||||
filas = db.query(EfcSyncOutbox).filter(
|
||||
EfcSyncOutbox.expediente_ref == expediente.id,
|
||||
EfcSyncOutbox.kind == KIND_EXPEDIENTE,
|
||||
).all()
|
||||
assert len(filas) == 1
|
||||
141
backend/tests/test_fin_csd.py
Normal file
141
backend/tests/test_fin_csd.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Pruebas del cifrado de secretos y de la validación del CSD.
|
||||
|
||||
Nada sale a la red ni toca MinIO: la validación del par ``.cer``/``.key`` es criptografía
|
||||
pura, y es justo la parte que importa comprobar.
|
||||
"""
|
||||
|
||||
import datetime
|
||||
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.fernet import Fernet
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.v1.modules.fin.issuer import csd_service
|
||||
from core import crypto
|
||||
|
||||
CERT_NUMBER = "00001000000700000001"
|
||||
PASSWORD = "12345678a"
|
||||
|
||||
|
||||
def _par(cert_number: str = CERT_NUMBER, password: str = PASSWORD):
|
||||
"""Genera un CSD de juguete con el formato del SAT: (cer_der, key_der_cifrada)."""
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
nombre = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "EMISOR DE PRUEBA")])
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(nombre)
|
||||
.issuer_name(nombre)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(int.from_bytes(cert_number.encode("ascii"), "big"))
|
||||
.not_valid_before(datetime.datetime(2026, 1, 1))
|
||||
.not_valid_after(datetime.datetime(2035, 1, 1))
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
return (
|
||||
cert.public_bytes(serialization.Encoding.DER),
|
||||
key.private_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.BestAvailableEncryption(password.encode()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Cifrado de secretos
|
||||
# ---------------------------------------------------------------------------------------
|
||||
@pytest.fixture
|
||||
def clave_maestra(monkeypatch):
|
||||
clave = Fernet.generate_key().decode()
|
||||
monkeypatch.setattr(crypto.settings, "CSD_ENCRYPTION_KEY", clave, raising=False)
|
||||
return clave
|
||||
|
||||
|
||||
def test_cifrar_y_recuperar(clave_maestra):
|
||||
token = crypto.encrypt_secret(PASSWORD)
|
||||
assert token != PASSWORD, "el secreto no puede quedar en claro"
|
||||
assert crypto.decrypt_secret(token) == PASSWORD
|
||||
|
||||
|
||||
def test_dos_cifrados_del_mismo_valor_no_son_iguales(clave_maestra):
|
||||
"""Fernet incluye IV y timestamp: dos tokens distintos para el mismo dato.
|
||||
|
||||
Importa porque si fueran iguales, comparar columnas revelaría qué empresas comparten
|
||||
contraseña.
|
||||
"""
|
||||
assert crypto.encrypt_secret(PASSWORD) != crypto.encrypt_secret(PASSWORD)
|
||||
|
||||
|
||||
def test_sin_clave_maestra_no_se_cifra(monkeypatch):
|
||||
monkeypatch.setattr(crypto.settings, "CSD_ENCRYPTION_KEY", "", raising=False)
|
||||
assert crypto.secrets_available() is False
|
||||
with pytest.raises(crypto.SecretsNotConfigured):
|
||||
crypto.encrypt_secret(PASSWORD)
|
||||
|
||||
|
||||
def test_clave_maestra_invalida_se_detecta(monkeypatch):
|
||||
monkeypatch.setattr(crypto.settings, "CSD_ENCRYPTION_KEY", "no-es-una-clave", raising=False)
|
||||
with pytest.raises(crypto.SecretsNotConfigured):
|
||||
crypto.encrypt_secret(PASSWORD)
|
||||
|
||||
|
||||
def test_otra_clave_maestra_no_descifra(clave_maestra, monkeypatch):
|
||||
"""Rotar la clave maestra deja ilegibles los secretos: tiene que decirlo, no romperse raro."""
|
||||
token = crypto.encrypt_secret(PASSWORD)
|
||||
monkeypatch.setattr(
|
||||
crypto.settings, "CSD_ENCRYPTION_KEY", Fernet.generate_key().decode(), raising=False
|
||||
)
|
||||
with pytest.raises(crypto.SecretDecryptionError):
|
||||
crypto.decrypt_secret(token)
|
||||
|
||||
|
||||
def test_no_se_cifra_un_valor_vacio(clave_maestra):
|
||||
with pytest.raises(ValueError):
|
||||
crypto.encrypt_secret("")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Validación del par .cer / .key
|
||||
# ---------------------------------------------------------------------------------------
|
||||
def test_par_correcto_devuelve_numero_de_certificado():
|
||||
cer, key = _par()
|
||||
assert csd_service._verify_pair(cer, key, PASSWORD) == CERT_NUMBER
|
||||
|
||||
|
||||
def test_key_de_otro_certificado_se_rechaza():
|
||||
"""El caso que motiva la validación: dos CSD mezclados.
|
||||
|
||||
Sin esto, el error aparecería hasta que el PAC rechace el comprobante, con un mensaje que
|
||||
no menciona el certificado.
|
||||
"""
|
||||
cer, _ = _par()
|
||||
_, key_ajena = _par(cert_number="00001000000700000002")
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
csd_service._verify_pair(cer, key_ajena, PASSWORD)
|
||||
assert exc.value.status_code == 422
|
||||
assert "no corresponde" in str(exc.value.detail)
|
||||
|
||||
|
||||
def test_contrasena_incorrecta_se_rechaza():
|
||||
cer, key = _par()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
csd_service._verify_pair(cer, key, "incorrecta")
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
def test_certificado_que_no_es_x509_se_rechaza():
|
||||
_, key = _par()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
csd_service._verify_pair(b"esto no es un certificado", key, PASSWORD)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
def test_llave_que_no_es_una_llave_se_rechaza():
|
||||
cer, _ = _par()
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
csd_service._verify_pair(cer, b"esto no es una llave", PASSWORD)
|
||||
assert exc.value.status_code == 422
|
||||
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
|
||||
547
backend/tests/test_fin_stamping.py
Normal file
547
backend/tests/test_fin_stamping.py
Normal file
@@ -0,0 +1,547 @@
|
||||
"""Pruebas del timbrado de CFDI 4.0 de ingreso. Ninguna sale a la red.
|
||||
|
||||
El CSD es autofirmado y se genera en el fixture: para comprobar que *sellamos bien* basta con
|
||||
que el sello verifique contra la llave pública de su propio certificado, y así la suite no
|
||||
depende de descargar nada ni de que exista un CSD en disco. El CSD real de pruebas del SAT
|
||||
hace falta para timbrar contra el PAC de verdad, que es la prueba de integración aparte.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from cryptography import x509
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.x509.oid import NameOID
|
||||
from lxml import etree
|
||||
|
||||
from api.v1.modules.fin.stamping import cfdi_builder as B
|
||||
from api.v1.modules.fin.stamping import pac_comercio_digital as pac
|
||||
from api.v1.modules.fin.stamping import sealer, service
|
||||
from core import s3_keys
|
||||
|
||||
CFDI_NS = "http://www.sat.gob.mx/cfd/4"
|
||||
CSD_PASSWORD = "12345678a"
|
||||
# Número de serie con el formato del SAT: 20 dígitos cuyos bytes son sus caracteres ASCII.
|
||||
CERT_NUMBER = "00001000000700000001"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------------------
|
||||
@pytest.fixture(scope="module")
|
||||
def csd():
|
||||
"""CSD autofirmado con el formato del SAT: devuelve (cer_der, key_der_cifrada)."""
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
nombre = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "EMISOR DE PRUEBA")])
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(nombre)
|
||||
.issuer_name(nombre)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(int.from_bytes(CERT_NUMBER.encode("ascii"), "big"))
|
||||
.not_valid_before(datetime.datetime(2026, 1, 1))
|
||||
.not_valid_after(datetime.datetime(2035, 1, 1))
|
||||
.sign(key, hashes.SHA256())
|
||||
)
|
||||
return (
|
||||
cert.public_bytes(serialization.Encoding.DER),
|
||||
key.private_bytes(
|
||||
serialization.Encoding.DER,
|
||||
serialization.PrivateFormat.PKCS8,
|
||||
serialization.BestAvailableEncryption(CSD_PASSWORD.encode()),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _data(**overrides) -> B.CfdiData:
|
||||
"""CFDI de ingreso completo y válido; los kwargs sustituyen campos para los casos negativos."""
|
||||
base = dict(
|
||||
folio="1001",
|
||||
serie="A",
|
||||
date="2026-08-07T10:00:00",
|
||||
payment_form="03",
|
||||
payment_method="PUE",
|
||||
currency="MXN",
|
||||
exchange_rate=None,
|
||||
expedition_zip="64000",
|
||||
payment_conditions=None,
|
||||
issuer_rfc="EKU9003173C9",
|
||||
issuer_name="EMISOR DE PRUEBA",
|
||||
issuer_tax_regime="601",
|
||||
receiver_rfc="XAXX010101000",
|
||||
receiver_name="PUBLICO EN GENERAL",
|
||||
receiver_zip="64000",
|
||||
receiver_tax_regime="616",
|
||||
receiver_cfdi_use="S01",
|
||||
concepts=[
|
||||
B.ConceptLine(
|
||||
product_service_code="78101800",
|
||||
unit_code="E48",
|
||||
description="Flete maritimo",
|
||||
quantity=Decimal("1"),
|
||||
unit_price=Decimal("1000.00"),
|
||||
tax_object="02",
|
||||
taxes=[B.TaxLine(code="002", rate=Decimal("0.160000"), amount=Decimal("160.00"))],
|
||||
)
|
||||
],
|
||||
)
|
||||
base.update(overrides)
|
||||
return B.CfdiData(**base)
|
||||
|
||||
|
||||
def _xml(csd, data=None) -> bytes:
|
||||
numero, cert_b64 = sealer.read_certificate(csd[0])
|
||||
return B.build_xml(data or _data(), cert_number=numero, cert_b64=cert_b64)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Construcción del XML
|
||||
# ---------------------------------------------------------------------------------------
|
||||
def test_comprobante_tipo_ingreso_con_atributos_obligatorios(csd):
|
||||
root = etree.fromstring(_xml(csd))
|
||||
assert root.tag == f"{{{CFDI_NS}}}Comprobante"
|
||||
assert root.get("Version") == "4.0"
|
||||
assert root.get("TipoDeComprobante") == "I"
|
||||
assert root.get("Exportacion") == "01"
|
||||
assert root.get("SubTotal") == "1000.00"
|
||||
assert root.get("Total") == "1160.00"
|
||||
|
||||
|
||||
def test_declaracion_xml_con_comillas_dobles(csd):
|
||||
"""Comercio Digital compara la cadena literal version="1.0".
|
||||
|
||||
lxml emitiría comillas simples —válido según la especificación, pero su validador lo
|
||||
rechaza con el código 642 "la versión del XML no es 1.0"—, así que la declaración se
|
||||
antepone a mano y tiene que sobrevivir también al sellado, que es lo que se transmite.
|
||||
"""
|
||||
esperado = b'<?xml version="1.0" encoding="UTF-8"?>'
|
||||
xml = _xml(csd)
|
||||
assert xml.startswith(esperado)
|
||||
assert B.apply_seal(xml, "SELLO-DE-PRUEBA").startswith(esperado)
|
||||
|
||||
|
||||
def test_fecha_sin_desplazamiento_horario(csd):
|
||||
"""En CFDI 4.0 la fecha va sin offset; el '-06:00' era de 3.3 (CFDI.cs:12654)."""
|
||||
fecha = etree.fromstring(_xml(csd)).get("Fecha")
|
||||
assert fecha == "2026-08-07T10:00:00"
|
||||
assert "+" not in fecha and not fecha.endswith("Z")
|
||||
|
||||
|
||||
def test_orden_de_atributos_del_comprobante(csd):
|
||||
"""El orden importa: la cadena original —y con ella el sello— se calcula recorriéndolo."""
|
||||
root = etree.fromstring(_xml(csd))
|
||||
orden = [k for k in root.attrib if not k.startswith("{")]
|
||||
esperado = [
|
||||
"Version",
|
||||
"Serie",
|
||||
"Folio",
|
||||
"Fecha",
|
||||
"Sello",
|
||||
"FormaPago",
|
||||
"NoCertificado",
|
||||
"Certificado",
|
||||
"SubTotal",
|
||||
"Moneda",
|
||||
"Total",
|
||||
"TipoDeComprobante",
|
||||
"Exportacion",
|
||||
"MetodoPago",
|
||||
"LugarExpedicion",
|
||||
]
|
||||
assert orden == esperado
|
||||
|
||||
|
||||
def test_atributos_opcionales_se_omiten(csd):
|
||||
root = etree.fromstring(_xml(csd, _data(serie=None, folio=None, payment_conditions=None)))
|
||||
assert "Serie" not in root.attrib
|
||||
assert "CondicionesDePago" not in root.attrib
|
||||
# Moneda MXN: sin TipoCambio
|
||||
assert "TipoCambio" not in root.attrib
|
||||
|
||||
|
||||
def test_moneda_extranjera_exige_tipo_de_cambio():
|
||||
with pytest.raises(B.CfdiBuildError) as exc:
|
||||
B.build_xml(_data(currency="USD", exchange_rate=None))
|
||||
assert any("tipo de cambio" in m for m in exc.value.missing)
|
||||
|
||||
|
||||
def test_impuestos_del_concepto_y_totales(csd):
|
||||
root = etree.fromstring(_xml(csd))
|
||||
traslado = root.find(
|
||||
f"{{{CFDI_NS}}}Conceptos/{{{CFDI_NS}}}Concepto/{{{CFDI_NS}}}Impuestos"
|
||||
f"/{{{CFDI_NS}}}Traslados/{{{CFDI_NS}}}Traslado"
|
||||
)
|
||||
assert traslado.get("Base") == "1000.00"
|
||||
assert traslado.get("Impuesto") == "002"
|
||||
assert traslado.get("TipoFactor") == "Tasa"
|
||||
assert traslado.get("TasaOCuota") == "0.160000" # el SAT exige 6 decimales
|
||||
assert traslado.get("Importe") == "160.00"
|
||||
|
||||
totales = root.find(f"{{{CFDI_NS}}}Impuestos")
|
||||
assert totales.get("TotalImpuestosTrasladados") == "160.00"
|
||||
|
||||
|
||||
def test_retenciones_restan_del_total():
|
||||
data = _data()
|
||||
data.concepts[0].taxes.append(
|
||||
B.TaxLine(
|
||||
code="001", rate=Decimal("0.100000"), amount=Decimal("100.00"), is_withholding=True
|
||||
)
|
||||
)
|
||||
# 1000 + 160 - 100
|
||||
assert data.total == Decimal("1060.00")
|
||||
|
||||
|
||||
def test_partida_objeto_de_impuesto_sin_impuestos_es_error():
|
||||
"""ObjetoImp '02' obliga al desglose. No se inventa una tasa por defecto."""
|
||||
data = _data()
|
||||
data.concepts[0].taxes = []
|
||||
with pytest.raises(B.CfdiBuildError) as exc:
|
||||
B.build_xml(data)
|
||||
assert any("objeto de impuesto" in m for m in exc.value.missing)
|
||||
|
||||
|
||||
def test_validacion_reporta_todos_los_faltantes_juntos():
|
||||
"""Quien captura la factura necesita la lista completa, no descubrirlos de uno en uno."""
|
||||
data = _data(issuer_rfc="", receiver_rfc="", payment_form="", expedition_zip="")
|
||||
with pytest.raises(B.CfdiBuildError) as exc:
|
||||
B.build_xml(data)
|
||||
assert len(exc.value.missing) >= 4
|
||||
|
||||
|
||||
def test_importes_con_decimal_no_arrastran_error_de_punto_flotante():
|
||||
data = _data()
|
||||
data.concepts[0].quantity = Decimal("3")
|
||||
data.concepts[0].unit_price = Decimal("0.10")
|
||||
assert data.concepts[0].amount == Decimal("0.30")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Cadena original y sello
|
||||
# ---------------------------------------------------------------------------------------
|
||||
def test_cadena_original_delimitada(csd):
|
||||
cadena = sealer.build_original_string(_xml(csd))
|
||||
assert cadena.startswith("||") and cadena.endswith("||")
|
||||
assert "|4.0|A|1001|" in cadena
|
||||
|
||||
|
||||
def test_cadena_original_no_descarga_nada():
|
||||
"""Los includes del XSLT tienen que ser locales: libxslt sale a la red si son URLs.
|
||||
|
||||
Sin esto, la cadena original —el dato que se firma— vendría de una descarga no verificada
|
||||
en tiempo de ejecución, y el timbrado dependería de que sat.gob.mx responda.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
xslt = Path(sealer._XSLT_CADENA)
|
||||
contenido = xslt.read_text(encoding="utf-8")
|
||||
assert 'href="http' not in contenido, "el XSLT conserva includes remotos"
|
||||
|
||||
|
||||
def test_numero_de_certificado_entra_en_la_cadena(csd):
|
||||
"""Si NoCertificado se rellenara después de firmar, el sello no verificaría."""
|
||||
numero, _ = sealer.read_certificate(csd[0])
|
||||
assert numero in sealer.build_original_string(_xml(csd))
|
||||
|
||||
|
||||
def test_certificado_da_numero_de_20_digitos_y_base64(csd):
|
||||
numero, cert_b64 = sealer.read_certificate(csd[0])
|
||||
assert numero == CERT_NUMBER
|
||||
assert len(numero) == 20 and numero.isdigit()
|
||||
assert base64.b64decode(cert_b64) == csd[0]
|
||||
|
||||
|
||||
def test_sello_verifica_contra_la_llave_publica_del_certificado(csd):
|
||||
"""La prueba fuerte del sellado: si esto pasa, sellamos como espera el SAT."""
|
||||
cer_der, key_der = csd
|
||||
xml = _xml(csd)
|
||||
cadena = sealer.build_original_string(xml)
|
||||
sello = sealer.sign(cadena, sealer.load_private_key(key_der, CSD_PASSWORD))
|
||||
|
||||
x509.load_der_x509_certificate(cer_der).public_key().verify(
|
||||
base64.b64decode(sello), cadena.encode("utf-8"), padding.PKCS1v15(), hashes.SHA256()
|
||||
)
|
||||
|
||||
|
||||
def test_sello_no_verifica_si_la_cadena_cambia(csd):
|
||||
"""Contraparte de la anterior: un verde que no se ve fallar no vale."""
|
||||
cer_der, key_der = csd
|
||||
cadena = sealer.build_original_string(_xml(csd))
|
||||
sello = sealer.sign(cadena, sealer.load_private_key(key_der, CSD_PASSWORD))
|
||||
|
||||
with pytest.raises(Exception):
|
||||
x509.load_der_x509_certificate(cer_der).public_key().verify(
|
||||
base64.b64decode(sello),
|
||||
(cadena + " ").encode("utf-8"),
|
||||
padding.PKCS1v15(),
|
||||
hashes.SHA256(),
|
||||
)
|
||||
|
||||
|
||||
def test_sellar_no_altera_la_cadena_original(csd):
|
||||
"""El Sello no entra en la cadena: insertarlo no puede cambiarla."""
|
||||
xml = _xml(csd)
|
||||
antes = sealer.build_original_string(xml)
|
||||
firmado = B.apply_seal(xml, "SELLO-DE-PRUEBA")
|
||||
assert sealer.build_original_string(firmado) == antes
|
||||
assert etree.fromstring(firmado).get("Sello") == "SELLO-DE-PRUEBA"
|
||||
|
||||
|
||||
def test_contrasena_incorrecta_da_error_claro(csd):
|
||||
with pytest.raises(sealer.SealingError) as exc:
|
||||
sealer.load_private_key(csd[1], "incorrecta")
|
||||
assert "contraseña" in str(exc.value).lower()
|
||||
|
||||
|
||||
def test_sin_contrasena_no_se_intenta_firmar(csd):
|
||||
with pytest.raises(sealer.SealingError):
|
||||
sealer.load_private_key(csd[1], "")
|
||||
|
||||
|
||||
def test_sellar_sin_numero_de_certificado_es_error():
|
||||
xml = B.build_xml(_data(), cert_number="", cert_b64="")
|
||||
with pytest.raises(B.CfdiBuildError):
|
||||
B.apply_seal(xml, "SELLO")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Cliente del PAC — resolución de host
|
||||
# ---------------------------------------------------------------------------------------
|
||||
HOST_TEST = "pruebas.comercio-digital.mx"
|
||||
HOST_PROD = "ws.comercio-digital.mx"
|
||||
|
||||
|
||||
def test_host_se_deriva_del_modo():
|
||||
assert pac.resolve_host("pruebas", HOST_TEST, HOST_PROD) == HOST_TEST
|
||||
assert pac.resolve_host("produccion", HOST_TEST, HOST_PROD) == HOST_PROD
|
||||
|
||||
|
||||
@pytest.mark.parametrize("modo", ["", "prod", "PRUEBAS", "producción", None])
|
||||
def test_modo_invalido_no_cae_a_ningun_host(modo):
|
||||
"""El legado, con host vacío, caía silenciosamente a pruebas (CFDI.cs:19288)."""
|
||||
with pytest.raises(pac.PacConfigError):
|
||||
pac.resolve_host(modo, HOST_TEST, HOST_PROD)
|
||||
|
||||
|
||||
def test_el_host_no_se_puede_inyectar_por_http():
|
||||
"""`stamp` no acepta host ni URL: sólo el modo. Es la regla 1 de §4.5 del plan."""
|
||||
import inspect
|
||||
|
||||
params = set(inspect.signature(pac.stamp).parameters)
|
||||
assert "url" not in params
|
||||
assert params & {"host_test", "host_prod"} == {"host_test", "host_prod"}
|
||||
# host_test/host_prod son configuración del servidor, no entrada de la petición: el
|
||||
# endpoint los toma de settings y nunca del cuerpo (ver routes.stamp_invoice).
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Cliente del PAC — casos de error, contra un doble
|
||||
# ---------------------------------------------------------------------------------------
|
||||
XML_VALIDO = b"<x>" + b"a" * 300 + b"</x>"
|
||||
|
||||
|
||||
def _stamp(monkeypatch, *, respuesta=None, excepcion=None, **kwargs):
|
||||
"""Ejecuta pac.stamp con httpx.post sustituido por un doble."""
|
||||
|
||||
def falso_post(url, content=None, headers=None, timeout=None):
|
||||
falso_post.llamadas.append({"url": url, "headers": headers, "content": content})
|
||||
if excepcion:
|
||||
raise excepcion
|
||||
return respuesta
|
||||
|
||||
falso_post.llamadas = []
|
||||
monkeypatch.setattr(httpx, "post", falso_post)
|
||||
opciones = dict(
|
||||
mode="pruebas",
|
||||
user="SCT050708AD1",
|
||||
password="secreto",
|
||||
host_test=HOST_TEST,
|
||||
host_prod=HOST_PROD,
|
||||
)
|
||||
opciones.update(kwargs)
|
||||
return pac.stamp(XML_VALIDO, **opciones), falso_post.llamadas
|
||||
|
||||
|
||||
def _respuesta(status_code=200, headers=None, text="<cfdi/>"):
|
||||
return httpx.Response(
|
||||
status_code=status_code,
|
||||
headers=headers or {},
|
||||
text=text,
|
||||
request=httpx.Request("POST", "https://x/timbre4/timbrarV5"),
|
||||
)
|
||||
|
||||
|
||||
def test_error_701_usuario_invalido(monkeypatch):
|
||||
res, llamadas = _stamp(monkeypatch, user="corto")
|
||||
assert res.ok is False and res.code == 701
|
||||
assert llamadas == [], "no debe salir a la red con el usuario inválido"
|
||||
|
||||
|
||||
def test_error_702_password_vacio(monkeypatch):
|
||||
res, llamadas = _stamp(monkeypatch, password="")
|
||||
assert res.ok is False and res.code == 702
|
||||
assert llamadas == []
|
||||
|
||||
|
||||
def test_error_711_xml_demasiado_corto(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "post", lambda *a, **k: pytest.fail("no debe llamar al PAC"))
|
||||
res = pac.stamp(
|
||||
b"<x/>",
|
||||
mode="pruebas",
|
||||
user="SCT050708AD1",
|
||||
password="x",
|
||||
host_test=HOST_TEST,
|
||||
host_prod=HOST_PROD,
|
||||
)
|
||||
assert res.ok is False and res.code == 711
|
||||
|
||||
|
||||
def test_error_833_fallo_de_red(monkeypatch):
|
||||
res, _ = _stamp(monkeypatch, excepcion=httpx.ConnectError("sin ruta al host"))
|
||||
assert res.ok is False and res.code == 833
|
||||
|
||||
|
||||
def test_error_998_http_distinto_de_200(monkeypatch):
|
||||
res, _ = _stamp(monkeypatch, respuesta=_respuesta(status_code=500, text="<fault/>"))
|
||||
# El cuerpo se conserva: es lo que se guarda como XML de respuesta del intento.
|
||||
assert res.xml == "<fault/>"
|
||||
assert res.ok is False and res.code == 998
|
||||
|
||||
|
||||
def test_timbrado_correcto_lee_codigo_y_saldo(monkeypatch):
|
||||
"""Los dos valores que el legado perdía siempre (CFDI.cs:19324-19336)."""
|
||||
res, llamadas = _stamp(
|
||||
monkeypatch,
|
||||
respuesta=_respuesta(
|
||||
headers={"uuid": "ABC-123", "codigo": "0", "saldo": "4821", "errmsg": ""}
|
||||
),
|
||||
)
|
||||
assert res.ok is True
|
||||
assert res.uuid == "ABC-123"
|
||||
assert res.code == 0, "el código del PAC no se está leyendo"
|
||||
assert res.balance == 4821, "el saldo de folios no se está leyendo"
|
||||
assert llamadas[0]["url"] == f"https://{HOST_TEST}/timbre4/timbrarV5"
|
||||
|
||||
|
||||
def test_errmsg_con_contenido_es_error(monkeypatch):
|
||||
res, _ = _stamp(
|
||||
monkeypatch,
|
||||
respuesta=_respuesta(headers={"errmsg": "307 CFDI previamente timbrado", "codigo": "307"}),
|
||||
)
|
||||
assert res.ok is False
|
||||
assert res.code == 307
|
||||
assert "307" in res.error_message
|
||||
|
||||
|
||||
def test_respuesta_sin_uuid_no_es_exito(monkeypatch):
|
||||
"""200 sin errmsg y sin UUID no es un comprobante timbrado."""
|
||||
res, _ = _stamp(monkeypatch, respuesta=_respuesta(headers={"errmsg": "", "codigo": "0"}))
|
||||
assert res.ok is False
|
||||
assert "UUID" in res.error_message
|
||||
|
||||
|
||||
def test_cabecera_no_numerica_no_rompe_el_timbrado(monkeypatch):
|
||||
res, _ = _stamp(
|
||||
monkeypatch,
|
||||
respuesta=_respuesta(headers={"uuid": "U-1", "codigo": "n/d", "saldo": ""}),
|
||||
)
|
||||
assert res.ok is True
|
||||
assert res.code is None and res.balance is None
|
||||
|
||||
|
||||
def test_cabeceras_y_cuerpo_de_la_peticion(monkeypatch):
|
||||
res, llamadas = _stamp(
|
||||
monkeypatch,
|
||||
respuesta=_respuesta(headers={"uuid": "U-1"}),
|
||||
email="Avisos@Ejemplo.MX",
|
||||
)
|
||||
enviado = llamadas[0]
|
||||
assert enviado["headers"]["tipo"] == "XML"
|
||||
assert enviado["headers"]["Content-Type"] == "text/plain"
|
||||
assert enviado["headers"]["email"] == "avisos@ejemplo.mx" # el PAC lo exige en minúsculas
|
||||
assert enviado["content"] == XML_VALIDO, "el XML va en crudo, ni base64 ni SOAP"
|
||||
|
||||
|
||||
def test_modo_produccion_apunta_al_host_de_produccion(monkeypatch):
|
||||
"""Se ejercita SOLO contra el doble: ninguna prueba transmite a producción."""
|
||||
_, llamadas = _stamp(
|
||||
monkeypatch, mode="produccion", respuesta=_respuesta(headers={"uuid": "U-1"})
|
||||
)
|
||||
assert llamadas[0]["url"] == f"https://{HOST_PROD}/timbre4/timbrarV5"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------------------
|
||||
# Rastro del intento: XML enviado y recibido
|
||||
# ---------------------------------------------------------------------------------------
|
||||
class _StampFalso:
|
||||
"""Lo mínimo que _store_attempt_xml necesita de un InvoiceStamp, sin tocar la BD."""
|
||||
|
||||
def __init__(self):
|
||||
self.tenant_id, self.company_id, self.invoice_id, self.id = 7, 3, 41, 9
|
||||
self.request_xml_file_key = None
|
||||
self.response_xml_file_key = None
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def subidas(monkeypatch):
|
||||
"""Captura lo que se sube al almacenamiento en vez de escribir en MinIO."""
|
||||
from core import storage_s3
|
||||
|
||||
hechas: list[tuple[str, bytes]] = []
|
||||
|
||||
def falso_put(key, body, content_type=None):
|
||||
hechas.append((key, body))
|
||||
|
||||
monkeypatch.setattr(storage_s3, "put_object_bytes", falso_put)
|
||||
return hechas
|
||||
|
||||
|
||||
def test_clave_del_xml_del_intento_lleva_el_id_del_intento():
|
||||
"""Por id de intento y no por UUID: un intento rechazado no tiene UUID."""
|
||||
key = s3_keys.invoice_stamp_attempt_xml_key(7, 3, 41, 9, "request")
|
||||
assert key.endswith("fin-invoices/41/stamps/attempts/9-request.xml")
|
||||
|
||||
|
||||
def test_clave_del_xml_del_intento_rechaza_tipos_desconocidos():
|
||||
with pytest.raises(ValueError):
|
||||
s3_keys.invoice_stamp_attempt_xml_key(7, 3, 41, 9, "borrador")
|
||||
|
||||
|
||||
def test_se_guardan_los_dos_xml_del_intento(subidas):
|
||||
stamp = _StampFalso()
|
||||
service._store_attempt_xml(stamp, b"<enviado/>", "<recibido/>")
|
||||
|
||||
assert [cuerpo for _, cuerpo in subidas] == [b"<enviado/>", b"<recibido/>"]
|
||||
assert stamp.request_xml_file_key.endswith("9-request.xml")
|
||||
assert stamp.response_xml_file_key.endswith("9-response.xml")
|
||||
|
||||
|
||||
def test_sin_respuesta_del_pac_se_guarda_al_menos_lo_enviado(subidas):
|
||||
"""Un fallo de red corta antes de que el PAC conteste: el envío sigue siendo el dato útil."""
|
||||
stamp = _StampFalso()
|
||||
service._store_attempt_xml(stamp, b"<enviado/>", "")
|
||||
|
||||
assert [cuerpo for _, cuerpo in subidas] == [b"<enviado/>"]
|
||||
assert stamp.request_xml_file_key is not None
|
||||
assert stamp.response_xml_file_key is None
|
||||
|
||||
|
||||
def test_fallo_del_almacenamiento_no_tumba_el_timbrado(monkeypatch):
|
||||
"""El rastro es para diagnóstico: perderlo no puede invalidar un timbre que el SAT ya dio
|
||||
por bueno, ni tapar el error del PAC con uno de almacenamiento."""
|
||||
from core import storage_s3
|
||||
|
||||
def revienta(*_a, **_k):
|
||||
raise RuntimeError("MinIO no responde")
|
||||
|
||||
monkeypatch.setattr(storage_s3, "put_object_bytes", revienta)
|
||||
|
||||
stamp = _StampFalso()
|
||||
service._store_attempt_xml(stamp, b"<enviado/>", "<recibido/>") # no propaga
|
||||
|
||||
assert stamp.request_xml_file_key is None
|
||||
assert stamp.response_xml_file_key is None
|
||||
134
backend/tests/test_gateway_rutas.py
Normal file
134
backend/tests/test_gateway_rutas.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""Contrato del tablero de ops del carril CRM -> EFC.
|
||||
|
||||
Lo que se fija aquí es lo que el frontend espera recibir: el 404 del reintento sobre una fila que no
|
||||
existe (y **no** un 200 silencioso), la forma exacta de la respuesta de éxito, y el aislamiento por
|
||||
tenant/company.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.v1.modules.crm.expediente_gateway import routes
|
||||
from api.v1.modules.crm.expediente_gateway import service as gateway
|
||||
from api.v1.modules.crm.expediente_gateway.models import (
|
||||
FILE_KIND_DOCUMENTO,
|
||||
SOURCE_CRM_DOCUMENTS,
|
||||
STATUS_FAILED,
|
||||
STATUS_PENDING,
|
||||
STATUS_SENT,
|
||||
EfcFileOutbox,
|
||||
)
|
||||
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||
from api.v1.modules.crm.service_requests import service as sr_service
|
||||
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
OTRO_TENANT = 99
|
||||
OTRA_COMPANY = 77
|
||||
USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def entorno(db, monkeypatch):
|
||||
from core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
||||
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
|
||||
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
|
||||
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
|
||||
|
||||
solicitud = sr_service.create_service_request(
|
||||
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||
)
|
||||
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
return {"db": db, "expediente": expediente}
|
||||
|
||||
|
||||
def _fila_archivo(db, expediente, **kwargs) -> EfcFileOutbox:
|
||||
row = EfcFileOutbox(
|
||||
kind=FILE_KIND_DOCUMENTO,
|
||||
s3_key="k",
|
||||
file_name="guia.pdf",
|
||||
content_type="application/pdf",
|
||||
efc_tipo="MBL",
|
||||
source_table=SOURCE_CRM_DOCUMENTS,
|
||||
source_id=kwargs.pop("source_id", 1),
|
||||
crm_document_ref="CRMDOC-1-1",
|
||||
expediente_ref=expediente.id,
|
||||
status=kwargs.pop("status", STATUS_PENDING),
|
||||
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
||||
company_id=kwargs.pop("company_id", COMPANY_ID),
|
||||
**kwargs,
|
||||
)
|
||||
db.add(row)
|
||||
db.commit()
|
||||
return row
|
||||
|
||||
|
||||
def test_retry_de_una_fila_inexistente_da_404_con_mensaje_especifico(entorno):
|
||||
"""**Es contrato con el frontend.** Un 200 le haría pintar «reencolado» cuando no hay nada que
|
||||
entregar, y el usuario esperaría un badge que nunca va a cambiar."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
routes.retry_outbox(999999, COMPANY_ID, "file", USUARIO, entorno["db"])
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.detail == "Fila de outbox no encontrada"
|
||||
|
||||
|
||||
def test_retry_exitoso_devuelve_requeued_con_el_id(entorno):
|
||||
row = _fila_archivo(entorno["db"], entorno["expediente"], status=STATUS_FAILED)
|
||||
|
||||
resp = routes.retry_outbox(row.id, COMPANY_ID, "file", USUARIO, entorno["db"])
|
||||
|
||||
assert resp == {"status": "requeued", "id": row.id}
|
||||
assert row.status == STATUS_PENDING
|
||||
|
||||
|
||||
def test_retry_de_una_fila_de_otra_company_da_404(entorno):
|
||||
"""No se filtra la existencia: para ese usuario la fila simplemente no existe."""
|
||||
row = _fila_archivo(entorno["db"], entorno["expediente"], company_id=OTRA_COMPANY)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
routes.retry_outbox(row.id, COMPANY_ID, "file", USUARIO, entorno["db"])
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_metrics_cuenta_por_status(entorno):
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
_fila_archivo(db, expediente, source_id=1, status=STATUS_FAILED)
|
||||
_fila_archivo(db, expediente, source_id=2, status=STATUS_SENT)
|
||||
|
||||
metricas = routes.metrics(COMPANY_ID, USUARIO, db)
|
||||
|
||||
assert set(metricas) == {"pending", "sent", "failed"}
|
||||
assert metricas["failed"] == 1
|
||||
assert metricas["sent"] == 1
|
||||
|
||||
|
||||
def test_metrics_no_ve_otro_tenant(entorno):
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
_fila_archivo(db, expediente, source_id=3, status=STATUS_FAILED, tenant_id=OTRO_TENANT)
|
||||
|
||||
assert routes.metrics(COMPANY_ID, USUARIO, db)["failed"] == 0
|
||||
|
||||
|
||||
def test_el_listado_solo_devuelve_lo_del_tenant_y_la_company(entorno):
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
_fila_archivo(db, expediente, source_id=1)
|
||||
_fila_archivo(db, expediente, source_id=2, tenant_id=OTRO_TENANT)
|
||||
_fila_archivo(db, expediente, source_id=3, company_id=OTRA_COMPANY)
|
||||
|
||||
filas = routes.list_outbox(COMPANY_ID, "file", None, 100, USUARIO, db)
|
||||
|
||||
assert len(filas) == 1
|
||||
assert filas[0]["source_id"] == 1
|
||||
|
||||
|
||||
def test_el_listado_filtra_por_status(entorno):
|
||||
db, expediente = entorno["db"], entorno["expediente"]
|
||||
_fila_archivo(db, expediente, source_id=1, status=STATUS_FAILED)
|
||||
_fila_archivo(db, expediente, source_id=2, status=STATUS_SENT)
|
||||
|
||||
fallidas = routes.list_outbox(COMPANY_ID, "file", STATUS_FAILED, 100, USUARIO, db)
|
||||
|
||||
assert [f["source_id"] for f in fallidas] == [1]
|
||||
@@ -5,7 +5,13 @@ from api.v1.modules.crm.accounts.dto import AccountCreate
|
||||
from api.v1.modules.crm.quotes import service as quotes_service
|
||||
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate
|
||||
from api.v1.modules.fin.invoices import service
|
||||
from api.v1.modules.fin.invoices.dto import InvoiceCreate, InvoiceItemCreate, PaymentCreate
|
||||
from api.v1.modules.fin.invoices.dto import (
|
||||
InvoiceCreate,
|
||||
InvoiceItemCreate,
|
||||
InvoiceItemUpdate,
|
||||
InvoiceUpdate,
|
||||
PaymentCreate,
|
||||
)
|
||||
from api.v1.modules.ops.shipments import service as shipments_service
|
||||
from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentCreate
|
||||
|
||||
@@ -55,3 +61,116 @@ def test_generate_from_shipment_copies_quote_items(db):
|
||||
items = service.get_items(db, inv.id, T, C)
|
||||
assert len(items) == 1 and float(items[0].unit_amount) == 1500.0
|
||||
assert float(inv.subtotal) == 1500.0
|
||||
|
||||
|
||||
# ----- Impuestos derivados por partida (para el CFDI) -----
|
||||
# El CFDI exige el desglose por partida, pero la factura captura un % global. El traslado de
|
||||
# IVA se deriva de ese %; estas pruebas fijan que la derivación no invente ni borre nada.
|
||||
|
||||
def _iva_de(db, item_id):
|
||||
from api.v1.modules.fin.invoices.models import InvoiceItemTax
|
||||
return db.query(InvoiceItemTax).filter(
|
||||
InvoiceItemTax.invoice_item_id == item_id, InvoiceItemTax.deleted_at.is_(None)
|
||||
).all()
|
||||
|
||||
|
||||
def _obj_imp(db, code):
|
||||
from api.v1.modules.fin.catalogs.models import TaxObject
|
||||
return db.query(TaxObject).filter(TaxObject.code == code).first().id
|
||||
|
||||
|
||||
def test_iva_se_deriva_cuando_la_partida_es_objeto_de_impuesto(db):
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-IVA", tax_rate=Decimal("16")), T, C)
|
||||
item = service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1,
|
||||
unit_amount=1000, tax_object_id=_obj_imp(db, "02")),
|
||||
T, C,
|
||||
)
|
||||
taxes = _iva_de(db, item.id)
|
||||
assert len(taxes) == 1
|
||||
assert float(taxes[0].rate) == 0.16
|
||||
assert float(taxes[0].amount) == 160.0
|
||||
assert taxes[0].is_withholding is False
|
||||
|
||||
|
||||
def test_sin_objeto_de_impuesto_no_se_deriva_nada(db):
|
||||
"""ObjetoImp 01 con nodo de impuestos es motivo de rechazo del SAT."""
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-NOOBJ", tax_rate=Decimal("16")), T, C)
|
||||
item = service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
|
||||
tax_object_id=_obj_imp(db, "01")),
|
||||
T, C,
|
||||
)
|
||||
assert _iva_de(db, item.id) == []
|
||||
|
||||
|
||||
def test_cambiar_el_porcentaje_recalcula_las_partidas(db):
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-REC", tax_rate=Decimal("16")), T, C)
|
||||
item = service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
|
||||
tax_object_id=_obj_imp(db, "02")),
|
||||
T, C,
|
||||
)
|
||||
service.update_invoice(db, inv.id, InvoiceUpdate(tax_rate=Decimal("8")), T, C)
|
||||
taxes = _iva_de(db, item.id)
|
||||
assert float(taxes[0].rate) == 0.08
|
||||
assert float(taxes[0].amount) == 80.0
|
||||
|
||||
|
||||
def test_cambiar_el_importe_recalcula_el_iva(db):
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-IMP", tax_rate=Decimal("16")), T, C)
|
||||
item = service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
|
||||
tax_object_id=_obj_imp(db, "02")),
|
||||
T, C,
|
||||
)
|
||||
service.update_item(db, item.id, InvoiceItemUpdate(unit_amount=Decimal("2000")), T, C)
|
||||
assert float(_iva_de(db, item.id)[0].amount) == 320.0
|
||||
|
||||
|
||||
def test_quitar_el_objeto_de_impuesto_retira_el_traslado(db):
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-QUITA", tax_rate=Decimal("16")), T, C)
|
||||
item = service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
|
||||
tax_object_id=_obj_imp(db, "02")),
|
||||
T, C,
|
||||
)
|
||||
assert len(_iva_de(db, item.id)) == 1
|
||||
service.update_item(db, item.id, InvoiceItemUpdate(tax_object_id=_obj_imp(db, "01")), T, C)
|
||||
assert _iva_de(db, item.id) == []
|
||||
|
||||
|
||||
def test_la_derivacion_no_pisa_una_retencion_capturada(db):
|
||||
"""Ajustar los impuestos a mano desactiva el automatismo para esa partida.
|
||||
|
||||
Es la diferencia entre un valor por defecto útil y un automatismo que borra trabajo ajeno.
|
||||
"""
|
||||
from api.v1.modules.fin.catalogs.models import Tax
|
||||
from api.v1.modules.fin.invoices import taxes_service
|
||||
|
||||
inv = service.create_invoice(db, InvoiceCreate(reference="F-RET", tax_rate=Decimal("16")), T, C)
|
||||
item = service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
|
||||
tax_object_id=_obj_imp(db, "02")),
|
||||
T, C,
|
||||
)
|
||||
isr = db.query(Tax).filter(Tax.code == "001").first()
|
||||
taxes_service.set_item_tax(db, item.id, isr.id, Decimal("0.10"), True, T, C)
|
||||
|
||||
iva = db.query(Tax).filter(Tax.code == "002").first()
|
||||
|
||||
# Cambiar el % ya no debe tocar ESTA partida: ni la retención capturada ni el IVA, que se
|
||||
# queda con la tasa que tenía cuando se intervino a mano.
|
||||
service.update_invoice(db, inv.id, InvoiceUpdate(tax_rate=Decimal("8")), T, C)
|
||||
taxes = {t.tax_id: t for t in _iva_de(db, item.id)}
|
||||
assert len(taxes) == 2, "se perdió un impuesto capturado a mano"
|
||||
assert float(taxes[isr.id].amount) == 100.0, "se pisó la retención"
|
||||
# Éste es el assert que distingue: sin el guard, el IVA habría bajado a 0.08 / 80.0.
|
||||
assert float(taxes[iva.id].rate) == 0.16, "el automatismo recalculó una partida intervenida"
|
||||
assert float(taxes[iva.id].amount) == 160.0
|
||||
|
||||
85
backend/tests/test_uploads_alcance.py
Normal file
85
backend/tests/test_uploads_alcance.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Alcance de ``GET /uploads/url``: qué objetos puede firmar este endpoint y cuáles no.
|
||||
|
||||
**Cierra una fuga real.** Antes bastaba con que la key empezara por
|
||||
``tenants/{tid}/companies/{cid}/`` para firmar una URL de lectura, lo que permitía firmar
|
||||
**cualquier** objeto de esa company —incluidos los certificados de la FIEL— con solo el permiso de
|
||||
módulo ``crm.access``. El alcance de este endpoint es «los archivos que el CRM subió», no «todo el
|
||||
almacén de la company».
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.v1.modules.crm.uploads.routes import get_upload_url, validar_extension
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"}
|
||||
PREFIJO = f"tenants/{TENANT_ID}/companies/{COMPANY_ID}/"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _sin_s3(monkeypatch):
|
||||
import api.v1.modules.crm.uploads.routes as uploads
|
||||
|
||||
monkeypatch.setattr(uploads, "presigned_get_url", lambda key, **k: f"https://firmada/{key}")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sufijo",
|
||||
[
|
||||
"certificates/fiel_20260101.key", # llave privada de la FIEL
|
||||
"certificates/fiel_20260101.cer",
|
||||
"invoices/9/cove/cove.xml",
|
||||
"imports/csv/invoice/job-1.csv",
|
||||
"branding/logo.png",
|
||||
"doda/1/report/doda_report.pdf",
|
||||
"signatures/1/photo_x.png",
|
||||
],
|
||||
)
|
||||
def test_no_se_puede_firmar_nada_fuera_de_los_documentos_del_crm(sufijo):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"sufijo",
|
||||
[
|
||||
"crm-docs/abc123/contrato.pdf",
|
||||
"expedientes/1/documents/abc123_guia.pdf",
|
||||
],
|
||||
)
|
||||
def test_los_documentos_del_crm_si_se_pueden_firmar(sufijo):
|
||||
resp = get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO)
|
||||
assert resp["url"].endswith(sufijo)
|
||||
|
||||
|
||||
def test_no_se_puede_firmar_nada_de_otro_tenant_ni_de_otra_company():
|
||||
"""El aislamiento previo sigue en pie: es una guarda adicional, no un reemplazo."""
|
||||
for key in (
|
||||
"tenants/999/companies/1/crm-docs/a/b.pdf",
|
||||
f"tenants/{TENANT_ID}/companies/999/crm-docs/a/b.pdf",
|
||||
):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_upload_url(key, COMPANY_ID, USUARIO)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_una_key_que_solo_CONTIENE_el_prefijo_no_pasa():
|
||||
"""La comprobación es de prefijo, no de subcadena: ``startswith`` y no ``in``."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
get_upload_url(f"otro/{PREFIJO}crm-docs/a/b.pdf", COMPANY_ID, USUARIO)
|
||||
assert exc.value.status_code == 403
|
||||
|
||||
|
||||
def test_la_allowlist_de_extensiones_rechaza_lo_ejecutable():
|
||||
for nombre in ("virus.exe", "script.sh", "macro.bat", "lib.dll", "sin_extension"):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
validar_extension(nombre)
|
||||
assert exc.value.status_code == 422
|
||||
assert exc.value.detail == "Ese tipo de archivo no está permitido."
|
||||
|
||||
|
||||
def test_la_allowlist_acepta_los_formatos_de_documento():
|
||||
for nombre in ("guia.pdf", "factura.XML", "foto.JPG", "hoja.xlsx", "carta.docx", "paquete.zip"):
|
||||
validar_extension(nombre) # no lanza
|
||||
@@ -7,12 +7,28 @@
|
||||
# - Sin `container_name` ni `restart` (efímero, COMPOSE_PROJECT_NAME aísla los nombres).
|
||||
# - Sin `celery_worker` / `celery_beat` (no se ejercitan en los specs actuales).
|
||||
# - Imágenes vía env vars: la del frontend se rebuildea localmente con
|
||||
# VITE_API_URL=http://localhost:8000/api/ (la prod tiene la URL de dev bakeada).
|
||||
# - Puertos fijos: frontend 5173 / backend 8000 (URIs ya registradas en Workspace).
|
||||
# VITE_API_URL=http://localhost:3468/api/ (la prod tiene la URL de dev bakeada).
|
||||
#
|
||||
# PUERTOS DEL HOST — 2026-08-10
|
||||
# frontend 5173 NO SE MUEVE. Está registrado en Workspace: `http://localhost:5173/auth/callback`
|
||||
# es la redirect URI permitida del cliente OIDC, y es además el `baseURL` de
|
||||
# Playwright, el `CORS_ORIGINS` y el `APP_PUBLIC_URL` del backend. Moverlo rompe
|
||||
# el login antes de que corra el primer spec.
|
||||
# backend 3468 Movido desde 8000. En la máquina de desarrollo el 8000 del host lo ocupa
|
||||
# `EFC_backend_dev` (stack de EFC), así que este servicio no arrancaba:
|
||||
# "port is already allocated". 3468 es vecino del 3467 de docker-compose.prod.yml,
|
||||
# para que los puertos del CRM se mantengan en un mismo rango reconocible.
|
||||
# Dentro del contenedor sigue siendo 8000: el bind de gunicorn, el healthcheck
|
||||
# y `INTERNAL_API_URL=http://backend:8000` NO cambian, son de la red interna.
|
||||
#
|
||||
# ⚠️ SI SE REACTIVA EL STAGE E2E DEL JENKINSFILE: la imagen del frontend hay que reconstruirla
|
||||
# con VITE_API_URL=http://localhost:3468/api/. Si se hornea con 8000, el navegador de Playwright
|
||||
# pegaría contra el backend de EFC y los specs pasarían contra el sistema equivocado — no falla,
|
||||
# miente. Es el modo de fallo caro de este cambio.
|
||||
#
|
||||
# Variables requeridas en el entorno al invocar docker compose:
|
||||
# E2E_BACKEND_IMAGE — imagen del backend recién pusheada a Harbor
|
||||
# E2E_FRONTEND_IMAGE — imagen temporal del frontend con VITE_API_URL=localhost
|
||||
# E2E_FRONTEND_IMAGE — imagen temporal del frontend con VITE_API_URL=localhost:3468
|
||||
|
||||
services:
|
||||
postgres-app:
|
||||
@@ -95,7 +111,9 @@ services:
|
||||
- S3_USE_SSL=false
|
||||
- S3_FILE_STORAGE=true
|
||||
ports:
|
||||
- "8000:8000"
|
||||
# 3468 en el HOST, 8000 dentro del contenedor. Ver la nota de puertos del encabezado:
|
||||
# el 8000 del host lo ocupa EFC_backend_dev en la maquina de desarrollo.
|
||||
- "3468:8000"
|
||||
depends_on:
|
||||
postgres-app:
|
||||
condition: service_healthy
|
||||
@@ -131,7 +149,11 @@ services:
|
||||
- NODE_ENV=production
|
||||
# VITE_API_URL ya está bakeada en E2E_FRONTEND_IMAGE; este valor solo sirve
|
||||
# para fallback server-side en frontend/src/lib/server/api.ts.
|
||||
- VITE_API_URL=http://localhost:8000/api/
|
||||
#
|
||||
# ⚠️ El puerto del host es 3468, no 8000 (ver el encabezado). Y como el valor que usa
|
||||
# el NAVEGADOR va bakeado en la imagen, cambiar solo esta línea NO basta: quien
|
||||
# reconstruya E2E_FRONTEND_IMAGE tiene que hornear la misma URL.
|
||||
- VITE_API_URL=http://localhost:3468/api/
|
||||
- INTERNAL_API_URL=http://backend:8000/api/
|
||||
- INTERNAL_HUB_URL=https://workspace.aduanasoft.com
|
||||
- HUB_URL=https://workspace.aduanasoft.com
|
||||
|
||||
@@ -84,6 +84,31 @@ services:
|
||||
- S3_USE_SSL=${S3_USE_SSL:-false}
|
||||
- S3_FILE_STORAGE=${S3_FILE_STORAGE:-true}
|
||||
- S3_PRESIGNED_EXPIRES_SECONDS=${S3_PRESIGNED_EXPIRES_SECONDS:-3600}
|
||||
|
||||
# ── Carril hacia EFC, el expediente electronico (T2026-08-046) ──────────────────────
|
||||
# EFC_API_URL VACIA = CARRIL APAGADO. No es un error de configuracion: con la URL vacia
|
||||
# `EfcClient.is_configured` es False, todo el enganche hace no-op y el CRM guarda sus
|
||||
# archivos solo en su propio MinIO. Encenderlo exige las dos primeras.
|
||||
#
|
||||
# EFC_API_KEY debe ser IDENTICA a CRM_INTEGRATION_API_KEY del lado de EFC. Alla el permiso
|
||||
# es fail-closed: si no coinciden, EFC responde 403 "API key invalida o no configurada" y
|
||||
# esa respuesta NO distingue entre clave equivocada y clave ausente, a proposito.
|
||||
#
|
||||
# Las cuatro ultimas van declaradas aunque hoy queden vacias. La razon no es simetria: si
|
||||
# una variable no se declara aqui, editarla en el .env NO surte efecto y el sintoma parece
|
||||
# un problema de red. Ya paso con EFC_API_VERIFY_SSL en el compose de desarrollo.
|
||||
- EFC_API_URL=${EFC_API_URL:-}
|
||||
- EFC_API_KEY=${EFC_API_KEY:-}
|
||||
- EFC_API_VERIFY_SSL=${EFC_API_VERIFY_SSL:-true}
|
||||
- EFC_API_TIMEOUT_MS=${EFC_API_TIMEOUT_MS:-8000}
|
||||
# Las subidas van aparte: 8 s no alcanzan para un archivo de 25 MB. Debe quedar POR DEBAJO
|
||||
# del proxy_read_timeout del nginx de EFC.
|
||||
- EFC_UPLOAD_TIMEOUT_MS=${EFC_UPLOAD_TIMEOUT_MS:-55000}
|
||||
# Scaffolding de mTLS: activarlo es configuracion, no codigo. Es el endurecimiento que la
|
||||
# deuda de la API key estatica pide antes de produccion real.
|
||||
- EFC_MTLS_CA_PATH=${EFC_MTLS_CA_PATH:-}
|
||||
- EFC_MTLS_CERT_PATH=${EFC_MTLS_CERT_PATH:-}
|
||||
- EFC_MTLS_KEY_PATH=${EFC_MTLS_KEY_PATH:-}
|
||||
ports:
|
||||
- "3467:8000"
|
||||
depends_on:
|
||||
@@ -148,6 +173,19 @@ services:
|
||||
- S3_REGION=${S3_REGION:-us-east-1}
|
||||
- S3_USE_SSL=${S3_USE_SSL:-false}
|
||||
- S3_FILE_STORAGE=${S3_FILE_STORAGE:-true}
|
||||
|
||||
# Carril hacia EFC (T2026-08-046). EL WORKER LAS NECESITA IGUAL QUE EL BACKEND: la entrega
|
||||
# de expedientes y archivos la hace `deliver_*_row` AQUI, no en el proceso web. Si solo el
|
||||
# backend las tuviera, el encolado se veria perfecto y nada se entregaria nunca.
|
||||
# Ver la nota completa en el servicio `backend`.
|
||||
- EFC_API_URL=${EFC_API_URL:-}
|
||||
- EFC_API_KEY=${EFC_API_KEY:-}
|
||||
- EFC_API_VERIFY_SSL=${EFC_API_VERIFY_SSL:-true}
|
||||
- EFC_API_TIMEOUT_MS=${EFC_API_TIMEOUT_MS:-8000}
|
||||
- EFC_UPLOAD_TIMEOUT_MS=${EFC_UPLOAD_TIMEOUT_MS:-55000}
|
||||
- EFC_MTLS_CA_PATH=${EFC_MTLS_CA_PATH:-}
|
||||
- EFC_MTLS_CERT_PATH=${EFC_MTLS_CERT_PATH:-}
|
||||
- EFC_MTLS_KEY_PATH=${EFC_MTLS_KEY_PATH:-}
|
||||
depends_on:
|
||||
- backend
|
||||
- valkey
|
||||
@@ -186,6 +224,20 @@ services:
|
||||
- S3_REGION=${S3_REGION:-us-east-1}
|
||||
- S3_USE_SSL=${S3_USE_SSL:-false}
|
||||
- S3_FILE_STORAGE=${S3_FILE_STORAGE:-true}
|
||||
|
||||
# Carril hacia EFC (T2026-08-046). El BEAT las necesita porque de aqui salen los tres
|
||||
# barridos —`sweep_outbox`, `sweep_file_outbox` y `sweep_expediente_gaps`—, que son la red
|
||||
# que atrapa lo que el despacho inmediato no alcanzo a entregar. Sin ellas el carril
|
||||
# depende solo del despacho inmediato y una caida de EFC deja la cola detenida para
|
||||
# siempre. Ver la nota completa en el servicio `backend`.
|
||||
- EFC_API_URL=${EFC_API_URL:-}
|
||||
- EFC_API_KEY=${EFC_API_KEY:-}
|
||||
- EFC_API_VERIFY_SSL=${EFC_API_VERIFY_SSL:-true}
|
||||
- EFC_API_TIMEOUT_MS=${EFC_API_TIMEOUT_MS:-8000}
|
||||
- EFC_UPLOAD_TIMEOUT_MS=${EFC_UPLOAD_TIMEOUT_MS:-55000}
|
||||
- EFC_MTLS_CA_PATH=${EFC_MTLS_CA_PATH:-}
|
||||
- EFC_MTLS_CERT_PATH=${EFC_MTLS_CERT_PATH:-}
|
||||
- EFC_MTLS_KEY_PATH=${EFC_MTLS_KEY_PATH:-}
|
||||
depends_on:
|
||||
- backend
|
||||
- valkey
|
||||
|
||||
694
docs/prompts/SM-XXX_timbrado_cfdi_ingreso_comercio_digital.md
Normal file
694
docs/prompts/SM-XXX_timbrado_cfdi_ingreso_comercio_digital.md
Normal file
@@ -0,0 +1,694 @@
|
||||
# Prompt — Timbrado de CFDI 4.0 de ingreso vía PAC Comercio Digital (modo pruebas)
|
||||
|
||||
> Doc **Prompt** del ticket Kanban SM. Plan asincrónico para ejecución autónoma en ventana Sundown.
|
||||
> Tipo de actividad IA a registrar: **DISEÑO DE PROMPTS IA** (elaboración de este plan) y
|
||||
> **REVISION DE CODIGO IA** + **PRUEBAS SOBRE CAMBIOS IA** en el Sunrise siguiente.
|
||||
|
||||
| Campo | Valor |
|
||||
|---|---|
|
||||
| Ticket | SM-XXX *(asignar antes de disparar)* |
|
||||
| Repo | `CRM_AGENTES_CARGA` |
|
||||
| Rama base | `feature/crm-cumplimiento-pdf` *(confirmar — ver Bloqueante B3)* |
|
||||
| Rama de trabajo | `feature/AS-XXX-timbrado-cfdi-ingreso` |
|
||||
| **Ventana** | **Lunes 10 de agosto de 2026, 17:00 → martes 11, 07:00.** Ciclos cada 30 min (~28) |
|
||||
| Entregable | Commits locales en la rama de trabajo. **Sin push.** El PR lo abre una persona en el Sunrise |
|
||||
| Sunrise | Martes 11 de agosto de 2026, primera hora — ver `automatizacion/SUNRISE.md` |
|
||||
| Estimación | 6–7 h de trabajo efectivo |
|
||||
| Riesgo | Medio-bajo — módulo nuevo, sin migraciones a prod, sin tocar flujo de facturación existente |
|
||||
|
||||
> **Operación autónoma.** Este plan se ejecuta en una ventana nocturna sin supervisión. El mecanismo
|
||||
> (corredor, preflight, instrucción, Sunrise) está montado en [`automatizacion/`](../../automatizacion/)
|
||||
> y se describe en §10. **La instrucción que recibe el agente cada ciclo es
|
||||
> `automatizacion/instruccion-nocturna.md`**, que remite a este documento como especificación.
|
||||
|
||||
---
|
||||
|
||||
## 1. Objetivo
|
||||
|
||||
Agregar al backend del CRM la capacidad de **generar, sellar y timbrar un CFDI 4.0 de tipo
|
||||
ingreso (`TipoDeComprobante = "I"`)** a partir de una factura existente en `fin.invoices`,
|
||||
usando el PAC **Comercio Digital** en su **entorno de pruebas**.
|
||||
|
||||
El proceso replica el que hoy vive en la solución legada WinForms `CFDI.sln`
|
||||
(.NET Framework 4.8, `CFDI/CFDI.cs`), pero reimplementado en Python/FastAPI siguiendo las
|
||||
convenciones del CRM. La solución legada es **fuente de verdad del contrato con el PAC**, no
|
||||
un modelo de arquitectura a copiar.
|
||||
|
||||
### Fuera de alcance (NO hacer)
|
||||
|
||||
- Frontend SvelteKit. Este Sundown es backend puro.
|
||||
- Tipos de comprobante distintos de `I` (egreso `E`, traslado `T`, pago `P`, retenciones).
|
||||
- Complementos (Carta Porte, Comercio Exterior, INE, Notarios, Pagos 2.0).
|
||||
- Cancelación, consulta de estatus, recuperación de XML, consulta de saldo del PAC.
|
||||
- Timbrado contra el entorno de **producción** de Comercio Digital.
|
||||
- Generación del PDF/representación impresa del comprobante timbrado y del código QR.
|
||||
- Migraciones aplicadas a bases de datos productivas.
|
||||
|
||||
---
|
||||
|
||||
## 2. Estado actual del CRM (verificado)
|
||||
|
||||
Ya existe y **se debe reutilizar, no duplicar**:
|
||||
|
||||
| Pieza | Ruta | Qué aporta |
|
||||
|---|---|---|
|
||||
| Catálogos SAT | `backend/api/v1/modules/fin/catalogs/` | Schema `sat`: `tax_regimes`, `taxes`, `payment_forms`, `payment_methods`, `voucher_types`, `units_of_measure`, `products_services`, `tax_objects`. Con `seed_data.py`. |
|
||||
| Datos del emisor | `backend/api/v1/modules/fin/issuer/` | `fin.issuer_settings`: `legal_name`, `rfc`, `tax_regime_id`, `zip_code`. Único vigente por empresa. |
|
||||
| Facturas | `backend/api/v1/modules/fin/invoices/` | `fin.invoices`, `fin.invoice_items`, `fin.invoice_item_taxes`, `fin.payments`. Ya tienen FK a los catálogos SAT (`voucher_type_id`, `payment_form_id`, `payment_method_id`, `expedition_zip_code`, `product_service_id`, `unit_of_measure_id`, `tax_object_id`). |
|
||||
| Conceptos | `backend/api/v1/modules/fin/concepts/` | Catálogo interno de conceptos facturables. |
|
||||
| Claves MinIO | `backend/core/s3_keys.py:288` | `company_certificate_key(tenant_id, company_id, certificate_type, timestamp, file_ext)` → `tenants/{tid}/companies/{cid}/certificates/{tipo}_{ts}.{cer\|key}`. **El CSD ya tiene dónde vivir.** |
|
||||
| Config | `backend/core/config.py` | `Settings` (pydantic-settings). Aquí se agregan las variables del PAC. |
|
||||
| Tests | `backend/tests/` | Convención `test_<modulo>.py`. Referencias cercanas: `test_fin_sat_catalogs.py`, `test_invoices.py`. |
|
||||
|
||||
**Lo que NO existe todavía:** ningún módulo de certificados/CSD, ningún cliente de PAC,
|
||||
ninguna generación de XML CFDI. Este ticket lo crea.
|
||||
|
||||
---
|
||||
|
||||
## 3. Contrato con el PAC — extraído del legado (fuente de verdad)
|
||||
|
||||
Todo lo de esta sección está verificado en
|
||||
`C:\Users\Jair Cedillo\Documents\Visual Studio 2022\Projects\Proyectos\CFDI CP y 4.0\CFDI\CFDI\CFDI.cs`
|
||||
(21,127 líneas; el agente NO necesita abrir el archivo, la información relevante está aquí).
|
||||
|
||||
### 3.1 Endpoint de timbrado — `timbrarV5Xml` (`CFDI.cs:19274-19346`)
|
||||
|
||||
```
|
||||
POST https://{host}/timbre4/timbrarV5
|
||||
|
||||
host pruebas : pruebas.comercio-digital.mx
|
||||
host producción : ws.comercio-digital.mx
|
||||
|
||||
Content-Type : text/plain
|
||||
Timeout : 15 s
|
||||
TLS : 1.2 (el legado fuerza SecurityProtocolType 3072)
|
||||
|
||||
Headers de petición:
|
||||
usrws : usuario del web service (longitud válida 12–13 caracteres)
|
||||
pwdws : password del web service
|
||||
tipo : "XML"
|
||||
email : opcional, en minúsculas; se omite el header si va vacío
|
||||
|
||||
Body: bytes crudos del XML CFDI **ya sellado**, en UTF-8. NO va en base64,
|
||||
NO va envuelto en SOAP.
|
||||
|
||||
Respuesta:
|
||||
body : XML del CFDI timbrado (con el nodo tfd:TimbreFiscalDigital incorporado)
|
||||
headers : uuid, errmsg, saldo, erremail, codigo
|
||||
```
|
||||
|
||||
Validaciones previas que el legado hace antes de salir a la red (replicarlas):
|
||||
|
||||
| Condición | Código | Mensaje |
|
||||
|---|---|---|
|
||||
| `len(usrws)` fuera de 12–13 | 701 | Usuario/password inválido |
|
||||
| `pwdws` vacío | 702 | Usuario/password inválido |
|
||||
| XML nulo o < 200 bytes | 711 | Contenido XML vacío |
|
||||
| Excepción de red | 833 | Error de transmisión |
|
||||
| HTTP != 200 | 998 | Error HTTP |
|
||||
|
||||
**Criterio de éxito del legado:** el header `errmsg` viene vacío. Si trae contenido, es error
|
||||
(`ComDig_Exception`, `CFDI.cs:16516-16519`).
|
||||
|
||||
### 3.2 Defectos del legado que NO se deben replicar
|
||||
|
||||
Al portar `timbrarV5Xml` hay que corregir, no copiar:
|
||||
|
||||
1. **`codigo` nunca recibe el valor del header `codigo`.** En `CFDI.cs:19331-19336` el valor se
|
||||
lee en la variable local `cod2`, que se descarta. El parámetro de salida `codigo` termina
|
||||
siempre en `999` o `991`. → En Python, el código del PAC **sí** se debe leer y persistir.
|
||||
2. **`saldo` nunca se asigna.** Mismo patrón (`CFDI.cs:19324-19327`): se lee en `cod2` y se
|
||||
pierde. El parámetro de salida queda siempre en `0`. → Leerlo y persistirlo como entero.
|
||||
3. **`GetResponseHeader` devuelve `""` y no `null`** cuando el header no existe, así que la
|
||||
rama `if (... == null) codigo = 991` prácticamente nunca se cumple. → En Python usar
|
||||
presencia real en el diccionario de headers.
|
||||
4. **Los errores se tragan con `MessageBox`** y el flujo continúa. → En el CRM, error del PAC
|
||||
se traduce a excepción de dominio y respuesta HTTP con detalle, nunca a un `except: pass`.
|
||||
|
||||
### 3.3 RFC del proveedor de certificación (`CFDI.cs:16665`)
|
||||
|
||||
| Entorno | RFC |
|
||||
|---|---|
|
||||
| Pruebas | `SPR190613I52` |
|
||||
| Producción | `SCD110105654` |
|
||||
|
||||
Sirve para **verificar** que el `RfcProvCertif` del timbre recibido corresponde al entorno
|
||||
esperado. Si no coincide, es error: se timbró contra el entorno equivocado.
|
||||
|
||||
### 3.4 Cadena original y sello (`CFDI.cs:8295-8402`, `CFDI.cs:8150-8210`)
|
||||
|
||||
- La **cadena original** se obtiene aplicando la transformación XSLT oficial del SAT
|
||||
`cadenaoriginal_4_0.xslt` al XML del comprobante **sin sello**, y decodificando las
|
||||
entidades HTML del resultado.
|
||||
- El **sello** es `RSA` + `SHA256` sobre los bytes UTF-8 de la cadena original, resultado en
|
||||
base64. (El legado usa SHA1 solo para retenciones con otro PAC — para CFDI de ingreso con
|
||||
Comercio Digital es **SHA256**.)
|
||||
- La llave privada `.key` del CSD es **PKCS#8 DER cifrada con contraseña**; el certificado
|
||||
`.cer` es **X.509 DER**.
|
||||
- El atributo `Certificado` del comprobante es el **base64 del DER** del `.cer`.
|
||||
- El atributo `NoCertificado` es el número de serie del certificado del SAT, que viene
|
||||
codificado de forma que sus bytes son directamente los 20 caracteres ASCII del número.
|
||||
|
||||
⚠️ **Lección de seguridad del legado:** el XSLT del SAT hace `xsl:include` de otros archivos.
|
||||
El legado tuvo que introducir un resolver que no descarga recursos externos (ver el bloque
|
||||
comentado en `CFDI.cs:8333-8363`). En el CRM, los XSLT se versionan en el repo y la
|
||||
transformación se ejecuta **sin acceso a red y sin habilitar scripts ni `document()`**.
|
||||
|
||||
### 3.5 Detalles del XML CFDI 4.0 verificados en el legado
|
||||
|
||||
- **Orden de atributos del nodo `cfdi:Comprobante`** (`CFDI.cs:14340-14394`):
|
||||
`Version`, `Serie`, `Folio`, `Fecha`, `Sello`, `FormaPago`, `NoCertificado`, `Certificado`,
|
||||
`CondicionesDePago`, `SubTotal`, `Descuento`, `Moneda`, `TipoCambio`, `Total`,
|
||||
`TipoDeComprobante`, `Exportacion`, `MetodoPago`, `LugarExpedicion`.
|
||||
- `Serie` y `CondicionesDePago` se omiten si vienen vacíos; `Descuento` solo si es > 0;
|
||||
`TipoCambio` solo si la moneda no es MXN.
|
||||
- **`Fecha` en CFDI 4.0 va SIN offset de zona horaria** (`CFDI.cs:12654`: el `-06:00` es
|
||||
exclusivo de CFDI 3.3). Formato `YYYY-MM-DDTHH:MM:SS`.
|
||||
- `Exportacion` es obligatorio en 4.0. Para factura de ingreso nacional: `"01"` (no aplica).
|
||||
- `TipoDeComprobante = "I"` es el caso por defecto en el legado (`CFDI.cs:8966`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Diseño a implementar
|
||||
|
||||
### 4.1 Estructura de archivos
|
||||
|
||||
Módulo nuevo por dominio, siguiendo la convención de `fin/`:
|
||||
|
||||
```
|
||||
backend/api/v1/modules/fin/stamping/
|
||||
├── __init__.py
|
||||
├── models.py # InvoiceStamp → fin.invoice_stamps
|
||||
├── dto.py # Pydantic v2
|
||||
├── routes.py # endpoints
|
||||
├── service.py # orquestación del flujo
|
||||
├── cfdi_builder.py # construcción del XML CFDI 4.0 tipo I
|
||||
├── sealer.py # cadena original (XSLT) + sello RSA-SHA256
|
||||
├── pac_comercio_digital.py # cliente HTTP del PAC
|
||||
└── xslt/ # XSLT oficiales del SAT, versionados
|
||||
├── cadenaoriginal_4_0.xslt
|
||||
└── utilerias.xslt # (y cualquier otro archivo incluido por el anterior)
|
||||
```
|
||||
|
||||
Los XSLT se descargan **una sola vez durante el desarrollo** desde `www.sat.gob.mx` y se
|
||||
commitean. En tiempo de ejecución no se descarga nada.
|
||||
|
||||
### 4.2 Modelo de datos
|
||||
|
||||
#### Campo nuevo en `fin.invoices`
|
||||
|
||||
El modo de timbrado **se decide por factura**, no por entorno:
|
||||
|
||||
```
|
||||
stamping_mode VARCHAR(12) NOT NULL DEFAULT 'pruebas' -- 'pruebas' | 'produccion'
|
||||
```
|
||||
|
||||
- Editable desde el CRUD de facturas (`InvoiceCreate` / `InvoiceUpdate` / `InvoiceResponse`).
|
||||
- Al crear una factura sin el campo, toma el valor de `PAC_DEFAULT_MODE` (§4.5); si tampoco
|
||||
está, `'pruebas'`.
|
||||
- **Inmutable una vez timbrada**: si la factura ya tiene un `invoice_stamp` en estado
|
||||
`timbrado`, un `PATCH` que intente cambiar `stamping_mode` se rechaza con 409. Cambiarlo
|
||||
después del hecho falsearía el registro de con qué intención se emitió.
|
||||
- Validado como enum cerrado en el DTO Pydantic. Un valor distinto de los dos permitidos es
|
||||
error de validación (422), nunca un default silencioso.
|
||||
|
||||
Este campo es la **única** fuente del modo. No se acepta modo ni host por body, query string ni
|
||||
cabecera en el endpoint de timbrado.
|
||||
|
||||
#### Tabla nueva `fin.invoice_stamps`
|
||||
|
||||
Una fila por intento de timbrado (incluidos los fallidos, para trazabilidad). Usa
|
||||
`TenantScopedMixin` + `TimestampMixin` como el resto del módulo.
|
||||
|
||||
Campos mínimos:
|
||||
|
||||
- `id`, `invoice_id` (FK `fin.invoices.id`, indexado)
|
||||
- `mode` — `pruebas` | `produccion`
|
||||
- `status` — `pendiente` | `timbrado` | `error`
|
||||
- `uuid` (36, nullable, indexado)
|
||||
- `stamped_at` (datetime, nullable) — `FechaTimbrado` del TFD
|
||||
- `pac_rfc` (13, nullable) — `RfcProvCertif` recibido
|
||||
- `sat_cert_number` (20, nullable) — `NoCertificadoSAT`
|
||||
- `sat_seal` / `cfd_seal` (Text, nullable) — `SelloSAT` / `SelloCFD`
|
||||
- `pac_code` (int, nullable), `pac_balance` (int, nullable) — headers `codigo` y `saldo`
|
||||
- `error_message` (Text, nullable) — header `errmsg`
|
||||
- `xml_file_key` (512, nullable) — clave MinIO del XML timbrado
|
||||
|
||||
El campo `mode` se copia desde `invoices.stamping_mode` **en el momento del timbrado** y queda
|
||||
congelado en la fila: es el registro de contra qué entorno se transmitió realmente.
|
||||
|
||||
Índice único parcial sobre `uuid` (donde `deleted_at IS NULL`) para impedir UUID duplicado.
|
||||
|
||||
**Migración Alembic** en `backend/alembic/versions/`, siguiendo el estilo de las existentes.
|
||||
Se aplica solo en local/testing. **No tocar producción.**
|
||||
|
||||
### 4.3 Flujo del servicio
|
||||
|
||||
`stamp_invoice(db, invoice_id, tenant_id, company_id, user_id)`:
|
||||
|
||||
1. **Cargar y validar** — la factura existe, pertenece al tenant/company, no está cancelada y
|
||||
**no tiene ya un timbre en estado `timbrado`** (idempotencia: si lo tiene, devolver el
|
||||
existente sin volver a timbrar; timbrar dos veces cuesta folios y genera un CFDI duplicado
|
||||
ante el SAT).
|
||||
2. **Validar completitud fiscal** — emisor configurado (`fin.issuer_settings`), receptor con
|
||||
RFC / razón social / régimen / domicilio fiscal, partidas con `product_service_id`,
|
||||
`unit_of_measure_id` y `tax_object_id`, forma y método de pago. Cada faltante se acumula y
|
||||
se devuelve como lista, no se falla en el primero.
|
||||
3. **Construir el XML** sin sello (`cfdi_builder`).
|
||||
4. **Calcular cadena original** por XSLT y **sellar** con el CSD (`sealer`).
|
||||
5. **Insertar `Sello`, `NoCertificado` y `Certificado`** en el comprobante.
|
||||
6. **Transmitir al PAC** (`pac_comercio_digital.stamp`), con el modo leído de
|
||||
`invoice.stamping_mode` — que determina el host, ver §4.5.
|
||||
7. **Verificar el timbre recibido** — el nodo `tfd:TimbreFiscalDigital` existe y trae `UUID`, y
|
||||
el `RfcProvCertif` corresponde al modo solicitado (§3.3). Si no corresponde, es error grave:
|
||||
se timbró contra un entorno distinto del pedido.
|
||||
8. **Persistir** el registro en `fin.invoice_stamps` y subir el XML timbrado a MinIO.
|
||||
9. **Devolver** el resumen (uuid, fecha, modo, clave del archivo).
|
||||
|
||||
Si algo falla en 6–7: se persiste la fila con `status = "error"`, `pac_code` y `error_message`,
|
||||
y se responde con error HTTP. **No se marca la factura como timbrada.**
|
||||
|
||||
### 4.4 Endpoints
|
||||
|
||||
```
|
||||
POST /api/v1/fin/invoices/{invoice_id}/stamp?company_id={cid}
|
||||
GET /api/v1/fin/invoices/{invoice_id}/stamp?company_id={cid}
|
||||
GET /api/v1/fin/invoices/{invoice_id}/stamp/xml-url?company_id={cid}
|
||||
```
|
||||
|
||||
Mismo patrón de dependencias que `invoices/routes.py`: `company_id: int = Query(...)`,
|
||||
`current_user: dict = Depends(get_current_user)`, `db: Session = Depends(get_core_db)`.
|
||||
|
||||
### 4.5 Configuración
|
||||
|
||||
En `backend/core/config.py`, agregar al `Settings`:
|
||||
|
||||
```python
|
||||
# ----- PAC Comercio Digital (timbrado CFDI) -----
|
||||
# El modo NO se configura aquí: vive en invoices.stamping_mode (§4.2). Esta variable
|
||||
# solo define con qué valor nacen las facturas nuevas que no lo especifican.
|
||||
PAC_DEFAULT_MODE: str = "pruebas" # pruebas | produccion
|
||||
PAC_HOST_TEST: str = "pruebas.comercio-digital.mx"
|
||||
PAC_HOST_PROD: str = "ws.comercio-digital.mx"
|
||||
PAC_USER: str = "" # header usrws
|
||||
PAC_PASSWORD: str = "" # header pwdws
|
||||
PAC_TIMEOUT_SECONDS: int = 15
|
||||
PAC_NOTIFICATION_EMAIL: str = "" # header email, opcional
|
||||
CSD_PASSWORD: str = "" # contraseña de la llave .key
|
||||
```
|
||||
|
||||
El andamiaje ya está hecho: el passthrough existe en `docker-compose.yml` (bloque
|
||||
`backend.environment`) y los nombres están documentados en `.env.example`. Los **valores** viven
|
||||
solo en `.env`, que está en `.gitignore` (línea 28). Falta únicamente declarar los campos en
|
||||
`Settings`.
|
||||
|
||||
⚠️ **`docker-compose.yml` no está versionado** (`.gitignore` línea 77): cada quien tiene el suyo.
|
||||
El passthrough ya está aplicado en el compose local de este clon, que es donde corre la ventana
|
||||
nocturna, así que el agente lo hereda. Pero **no intentes commitearlo ni versionarlo**: git lo
|
||||
ignora y forzarlo rompería la convención del proyecto. Si otro entorno necesita estas variables,
|
||||
se documentan en `.env.example` — que sí está versionado — y cada quien las añade a su compose.
|
||||
|
||||
Reglas duras:
|
||||
|
||||
- **Ningún valor real** (usuario, password, RFC, contraseña de CSD) se escribe en el código,
|
||||
en tests, en fixtures ni en `.env.example`. Solo variables de entorno con default vacío.
|
||||
- Si faltan `PAC_USER` o `PAC_PASSWORD`, el servicio falla con mensaje claro en vez de intentar
|
||||
timbrar con cadenas vacías. (El legado ya lo hace con los códigos 701/702 — §3.1.)
|
||||
|
||||
#### Resolución del host — regla única
|
||||
|
||||
El host **se deriva exclusivamente del enum** `invoice.stamping_mode`, en el servidor:
|
||||
|
||||
| `stamping_mode` | Host |
|
||||
|---|---|
|
||||
| `pruebas` | `PAC_HOST_TEST` → `pruebas.comercio-digital.mx` |
|
||||
| `produccion` | `PAC_HOST_PROD` → `ws.comercio-digital.mx` |
|
||||
|
||||
Tres reglas que el cliente del PAC debe cumplir:
|
||||
|
||||
1. **Nunca aceptar un host, una URL ni un modo por parámetro de la petición HTTP.** El modo se
|
||||
lee de la factura y punto. Es el defecto de diseño del legado, donde `CFDIPacUrl` es un
|
||||
campo mutable que cualquier rama del código puede reasignar (§3.1).
|
||||
2. **Fallar de entrada** ante un `stamping_mode` que no sea exactamente `"pruebas"` o
|
||||
`"produccion"`. Nada de default silencioso. Ojo con el legado: en `CFDI.cs:19288` un host
|
||||
vacío cae a `pruebas.comercio-digital.mx` — comportamiento implícito que aquí no se replica.
|
||||
3. **Verificar el timbre recibido** contra el modo solicitado. Si se pidió `pruebas` y el
|
||||
`RfcProvCertif` devuelto es el de producción (`SCD110105654`), o al revés, se trata como
|
||||
error grave: se registra y la operación **no** se da por exitosa.
|
||||
|
||||
⚠️ **Contexto para quien lea esto después.** Las credenciales configuradas son de la cuenta de
|
||||
**producción** del PAC (§8, B1), y por decisión del responsable del proyecto **no** existe un
|
||||
veto de entorno: una factura con `stamping_mode = "produccion"` timbra un CFDI con validez
|
||||
fiscal real ante el SAT desde cualquier entorno donde estén esas credenciales, incluido
|
||||
desarrollo. Deshacerlo requiere cancelar el comprobante ante el SAT. Las tres reglas de arriba
|
||||
son, por tanto, la única barrera técnica que queda: no se relajan sin decisión explícita.
|
||||
|
||||
**Restricción para este Sundown:** todas las facturas que el agente cree o use para pruebas
|
||||
llevan `stamping_mode = "pruebas"`. El agente **no** debe timbrar en `produccion` bajo ninguna
|
||||
circunstancia (ya está en Fuera de alcance, §1).
|
||||
|
||||
### 4.6 Dependencias nuevas — justificación obligatoria
|
||||
|
||||
Dos, ambas necesarias y sin alternativa razonable en la stdlib:
|
||||
|
||||
| Paquete | Por qué |
|
||||
|---|---|
|
||||
| `lxml` | La cadena original del SAT **solo** se puede calcular aplicando el XSLT 1.0 oficial. `xml.etree` de la stdlib no hace XSLT. Es la única implementación de XSLT 1.0 madura en Python. |
|
||||
| `cryptography` | Lectura del `.key` (PKCS#8 DER cifrado) y del `.cer` (X.509 DER), y firma RSA-SHA256. Ya entra de forma transitiva por `python-jose[cryptography]`; aquí se declara explícita porque pasa a ser dependencia directa. |
|
||||
|
||||
Agregar a `backend/requirements.txt` con versión fijada, en una sección comentada
|
||||
`# CFDI / timbrado`, respetando el estilo del archivo.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pruebas
|
||||
|
||||
### 5.1 Unitarias — sin red (`backend/tests/test_fin_stamping.py`)
|
||||
|
||||
- **Constructor de XML**: dada una factura de prueba, el XML resultante valida contra el XSD
|
||||
`cfdv40.xsd`, tiene los atributos en el orden correcto, `TipoDeComprobante="I"`,
|
||||
`Exportacion="01"`, y `Fecha` sin offset de zona horaria.
|
||||
- **Omisión de atributos opcionales**: sin serie → no aparece `Serie`; descuento en 0 → no
|
||||
aparece `Descuento`; moneda MXN → no aparece `TipoCambio`.
|
||||
- **Cadena original**: la transformación XSLT produce una cadena que empieza con `||` y
|
||||
termina con `||`, y no intenta ninguna descarga externa.
|
||||
- **Sello**: firmando con el CSD público de pruebas del SAT, el sello resultante **verifica**
|
||||
contra la llave pública del `.cer`. Es la prueba fuerte del sellado.
|
||||
- **Cliente del PAC contra un doble**: valida los cinco casos de error previos (701, 702, 711,
|
||||
833, 998) y el camino feliz, comprobando que `codigo` y `saldo` **sí** se extraen de los
|
||||
headers — el defecto corregido de la sección 3.2.
|
||||
- **Idempotencia**: timbrar dos veces la misma factura no genera un segundo llamado al PAC.
|
||||
- **Validación de completitud**: una factura incompleta devuelve **todos** los faltantes.
|
||||
- **Resolución del host por modo** (§4.5): una factura con `stamping_mode = "pruebas"` produce
|
||||
una URL contra `pruebas.comercio-digital.mx`, y una con `"produccion"` contra
|
||||
`ws.comercio-digital.mx`. Ambos casos se prueban contra el doble del PAC, sin red.
|
||||
- **Modo inválido**: `stamping_mode = "prod"`, `""` o `None` es error de validación, y en
|
||||
ningún caso termina resolviendo a un host.
|
||||
- **El modo no se puede inyectar por HTTP**: mandar `mode` / `host` / `url` en el body o el
|
||||
query string de `POST /stamp` no altera el host usado. Es la prueba de la regla 1 de §4.5.
|
||||
- **`stamping_mode` inmutable tras timbrar**: `PATCH` sobre una factura ya timbrada devuelve
|
||||
409 y no modifica el valor.
|
||||
- **Verificación de `RfcProvCertif`**: un doble que responde con el RFC del entorno equivocado
|
||||
deja la fila en `status = "error"` y no marca la factura como timbrada.
|
||||
|
||||
### 5.2 Integración — timbrado real en pruebas (`test_fin_stamping_pac.py`)
|
||||
|
||||
Marcada con `@pytest.mark.skipif` sobre la ausencia de `PAC_USER`/`PAC_PASSWORD` en el entorno,
|
||||
para que la suite normal siga siendo verde sin credenciales.
|
||||
|
||||
- Timbra un CFDI de ingreso completo contra `pruebas.comercio-digital.mx`.
|
||||
- Verifica: `errmsg` vacío, `uuid` con formato UUID válido, `RfcProvCertif == "SPR190613I52"`,
|
||||
el XML devuelto contiene `tfd:TimbreFiscalDigital` y la fila en `fin.invoice_stamps` queda en
|
||||
`status = "timbrado"`.
|
||||
|
||||
### 5.3 CSD de pruebas
|
||||
|
||||
Se usa el **CSD público de demostración que publica el SAT** (RFC de prueba). No es dato
|
||||
sensible y puede vivir en `backend/tests/fixtures/csd/`. **Bajo ninguna circunstancia** se
|
||||
commitea un CSD real de un cliente.
|
||||
|
||||
Datos dummy autorizados para el resto de las pruebas: RFC `XAXX010101000`,
|
||||
pedimento `0000-0000000`.
|
||||
|
||||
---
|
||||
|
||||
## 6. Criterios de aceptación
|
||||
|
||||
Verificables uno por uno en el Sunrise:
|
||||
|
||||
1. `POST /api/v1/fin/invoices/{id}/stamp` sobre una factura completa con
|
||||
`stamping_mode = "pruebas"` devuelve 200 con `uuid`, `stamped_at`, `mode: "pruebas"` y
|
||||
`xml_file_key`.
|
||||
2. El XML timbrado queda en MinIO bajo una clave obtenida de `core/s3_keys.py` (función nueva,
|
||||
no ruta construida a mano en la ruta HTTP).
|
||||
3. Existe una fila en `fin.invoice_stamps` con `status = "timbrado"` y `pac_rfc = "SPR190613I52"`.
|
||||
4. Un segundo `POST` sobre la misma factura devuelve el timbre existente **sin** llamar al PAC.
|
||||
5. Una factura sin `issuer_settings` o con partidas sin clave de producto/servicio devuelve 422
|
||||
con la lista completa de faltantes.
|
||||
6. Un error del PAC deja fila con `status = "error"`, `pac_code` y `error_message` poblados, y
|
||||
la factura **no** queda marcada como timbrada.
|
||||
7. `pytest backend/tests/test_fin_stamping.py` pasa completo sin acceso a red.
|
||||
8. `black`, `flake8` y `mypy` pasan sobre el módulo nuevo.
|
||||
9. La migración Alembic sube y baja limpio (`upgrade head` / `downgrade -1`) en local, e
|
||||
incluye tanto `fin.invoice_stamps` como la columna `fin.invoices.stamping_mode`.
|
||||
10. No hay credenciales, RFC reales ni CSD de cliente en el diff. Verificable con
|
||||
`git diff --stat` + inspección de los archivos nuevos.
|
||||
11. `stamping_mode` es editable vía `POST`/`PATCH` de facturas, se refleja en
|
||||
`InvoiceResponse`, y una factura ya timbrada rechaza su modificación con 409.
|
||||
12. Con `stamping_mode = "produccion"` el cliente resuelve `ws.comercio-digital.mx` — probado
|
||||
**solo contra el doble del PAC**. Ninguna prueba transmite a producción.
|
||||
|
||||
---
|
||||
|
||||
## 7. Convenciones obligatorias
|
||||
|
||||
- **Nombres de código en inglés**; comentarios y docstrings en **español** para la lógica
|
||||
fiscal/aduanera, igual que en `fin/issuer/models.py` y `fin/catalogs/models.py`.
|
||||
- **FastAPI**: Pydantic v2, routers por dominio, DTOs separados de los modelos.
|
||||
- **Sin `print` de depuración. Sin `except: pass`.** Todo error se maneja o se propaga.
|
||||
- Conventional commits (`feat(fin): ...`, `test(fin): ...`, `chore(deps): ...`).
|
||||
- Rama `feature/AS-XXX-timbrado-cfdi-ingreso`. **PR, nunca push directo** a main/master/
|
||||
develop/release.
|
||||
- No inventar tablas, campos ni endpoints fuera de los especificados aquí. Si hace falta algo
|
||||
que no está descrito, se documenta como PENDIENTE DECISIÓN.
|
||||
|
||||
### Ambigüedad durante la ejecución autónoma
|
||||
|
||||
**No adivinar.** Cualquier decisión no cubierta por este documento se implementa con la opción
|
||||
más conservadora y se documenta en el cuerpo del PR bajo un encabezado
|
||||
`## PENDIENTE DECISIÓN`, con: qué se asumió, qué alternativas había, y qué se necesita para
|
||||
resolverlo. Es la misma convención que ya usa `fin/catalogs/seed_data.py`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Bloqueantes a resolver ANTES de disparar
|
||||
|
||||
El DoR **no se cumple** hasta que estos tres estén cerrados:
|
||||
|
||||
- **B1 — Credenciales del PAC. RESUELTO, con reserva.** Se reutilizan las credenciales de
|
||||
Comercio Digital que hoy usa la solución legada, apuntando al host de pruebas. Van en `.env`
|
||||
como `PAC_USER` / `PAC_PASSWORD`; el andamiaje ya está listo (§4.5). **No se pegan en este
|
||||
documento, en el chat ni en ningún archivo versionado.**
|
||||
→ Reserva: son credenciales de **producción** — en el legado las ramas de pruebas y
|
||||
producción del ternario son idénticas (§9). Mientras eso siga así, la única barrera entre un
|
||||
timbre de prueba y un CFDI fiscal real es el host, y por eso la salvaguarda de §4.5 no es
|
||||
opcional. Conviene solicitar a Comercio Digital un usuario de pruebas independiente y
|
||||
sustituirlo en `.env` cuando exista; no bloquea este Sundown.
|
||||
→ Nota: el `usrws` es de 12–13 caracteres (formato de RFC), así que el RFC del emisor en el
|
||||
CFDI de prueba debe ser coherente con la cuenta del PAC. Si el CSD de demostración del SAT
|
||||
resulta incompatible con esa cuenta, el agente lo documenta como PENDIENTE DECISIÓN en el PR
|
||||
en vez de inventar un RFC.
|
||||
|
||||
- **B2 — Trabajo previo sin commitear.** `backend/api/v1/modules/fin/catalogs/` aparece como
|
||||
no rastreado en git. Este ticket **depende** de esos catálogos. Hay que commitearlo (o
|
||||
fusionarlo) antes de disparar; si no, el agente arranca sobre una base que no está en el
|
||||
árbol y el PR saldrá mezclando dos trabajos.
|
||||
|
||||
- **B3 — Rama base del PR.** El repo tiene `feature/crm-cumplimiento-pdf` como rama principal
|
||||
de integración, no `main`. Confirmar que el PR va contra ella.
|
||||
|
||||
- **B4 — CLI de Claude Code. RESUELTO.** Instalado en WSL (`~/.local/bin/claude`, versión
|
||||
`2.1.224`) y verificado también por la ruta que usa el corredor (PowerShell → `wsl.exe` →
|
||||
`bash -lc`). El ensayo de extremo a extremo corre y devuelve `ENSAYO-OK` — ver §10.3.
|
||||
|
||||
- **B5 — La tarea programada no está registrada.** Requiere PowerShell **como administrador**; un
|
||||
agente no puede ni debe registrarse una tarea del sistema. El bloque listo para pegar está en
|
||||
§10.6. Al registrarla, verificar que `NextRunTime` **no** salga vacío: una tarea con hora de
|
||||
arranque ya pasada nunca dispara y en la interfaz se ve perfectamente bien.
|
||||
|
||||
> **Alcance de la noche: COMPLETO, en una sola ventana.** Se planteó partirlo en dos —el mecanismo
|
||||
> primero, el timbrado real después—, siguiendo la recomendación del kit de que la primera ventana
|
||||
> lleve un entregable pequeño. **Decisión del responsable del proyecto: se ejecutan los cinco
|
||||
> entregables de §4 en la ventana del lunes 10.** No se reserva nada para una segunda noche.
|
||||
>
|
||||
> Consecuencia práctica para el agente: la escalera de `instruccion-nocturna.md` se recorre entera y
|
||||
> el entregable 5 (timbrado real contra `pruebas.comercio-digital.mx`) **sí** entra en el alcance,
|
||||
> siempre que `PAC_USER` y `PAC_PASSWORD` estén en el entorno. Si faltan, se salta ese entregable y
|
||||
> se sigue con la escalera — no es motivo para detener la noche.
|
||||
|
||||
---
|
||||
|
||||
## 9. Hallazgo de seguridad — acción separada de este ticket
|
||||
|
||||
Durante el análisis del legado se encontraron **credenciales de ambos PAC en texto plano dentro
|
||||
del código fuente** de `CFDI.cs`. Ubicaciones exactas:
|
||||
|
||||
| Líneas | PAC | Contenido |
|
||||
|---|---|---|
|
||||
| 2709, 2711 | Edicom | usuario y password (2710: password anterior, comentado) |
|
||||
| 2720, 2721 | Comercio Digital | usuario y password |
|
||||
| 6858, 6860 | Edicom | duplicado exacto del bloque 2709 |
|
||||
| 6863, 6864 | Comercio Digital | duplicado exacto del bloque 2720 |
|
||||
| 7210, 7212 | Edicom | tercer duplicado |
|
||||
| 7216, 7217 | Comercio Digital | tercer duplicado |
|
||||
| 21061, 21063 | Edicom | dentro de `tst_xml_ine()` (línea 20910), más un `Emisor_Rfc` fijo |
|
||||
| 20833, 20834 | Edicom | dentro de `tst_xml()` (línea 20695), comentado |
|
||||
|
||||
Agravantes:
|
||||
|
||||
1. **El "modo pruebas" de Comercio Digital usa credenciales de producción.** Los ternarios de
|
||||
las líneas 2720-2721 (y sus dos duplicados) tienen **ambas ramas idénticas**:
|
||||
`CFDIMod == "Prueba" ? X : X`. Lo único que realmente cambia entre entornos es la URL
|
||||
(línea 2722).
|
||||
2. **`tst_xml_ine()` no se invoca desde ningún lado, pero se compila.** Las credenciales quedan
|
||||
en el ejecutable distribuido, y `Costura.Fody` embebe todo en un único `.exe`: son
|
||||
recuperables con cualquier decompilador de .NET.
|
||||
3. El password de Edicom de la línea 2711 tiene forma de contraseña corporativa, con riesgo de
|
||||
estar reutilizada en otros sistemas.
|
||||
|
||||
Todo está commiteado en el repositorio `CFDI` (rama `main`, con remoto configurado).
|
||||
|
||||
**Recomendación:** rotar esas credenciales con Comercio Digital y moverlas a configuración
|
||||
externa en la solución legada. Es un ticket aparte — **no** forma parte de este Sundown y el
|
||||
agente autónomo **no** debe tocar la solución C#.
|
||||
|
||||
---
|
||||
|
||||
## 10. Operación de la ventana nocturna
|
||||
|
||||
El plan se ejecuta sin supervisión entre las 17:00 del lunes 10 y las 07:00 del martes 11 de agosto
|
||||
de 2026, en ciclos de 30 minutos disparados por el Programador de tareas de Windows. Cada ciclo es
|
||||
un proceso nuevo **sin memoria del anterior**: la única continuidad es la bitácora.
|
||||
|
||||
### 10.1 Las piezas
|
||||
|
||||
| Archivo | Dónde corre | Qué hace |
|
||||
|---|---|---|
|
||||
| `automatizacion/ventana-nocturna.ps1` | Windows | El corredor. Vigila la ventana, el candado, el árbol de procesos y la cuota |
|
||||
| `automatizacion/ciclo-noche.sh` / `ciclo-ensayo.sh` | WSL | Envoltorios **sin argumentos**. Existen para que ningún argumento lleve espacios — ver §10.2 |
|
||||
| `automatizacion/ciclo-wsl.sh` | WSL | Un ciclo: mete la instrucción por stdin y lanza el agente |
|
||||
| `automatizacion/cpu_arbol.py` | WSL | Mide si el ciclo trabaja o está muerto de pie |
|
||||
| `automatizacion/instruccion-nocturna.md` | — | **La instrucción del agente.** Es el 80% del valor del montaje |
|
||||
| `automatizacion/instruccion-ensayo.txt` | — | Instrucción de una línea para el ensayo del mecanismo |
|
||||
| `automatizacion/preflight.py` | WSL | Comprueba, antes de irse, que la ventana *puede* funcionar |
|
||||
| `automatizacion/SUNRISE.md` | — | Qué revisar el martes por la mañana, en orden |
|
||||
|
||||
Nada de esto se commitea desde un ciclo nocturno: la bitácora vive en `~/.ventana/`, y el log del
|
||||
corredor en `%LOCALAPPDATA%\ventana-nocturna\`, ambos fuera del árbol versionado.
|
||||
|
||||
### 10.2 La adaptación a WSL, y por qué no es cosmética
|
||||
|
||||
El kit de operación nocturna asume Windows de punta a punta. Aquí el proyecto vive en WSL2, así que
|
||||
el corredor sigue en Windows pero cada ciclo entra a la distro con `wsl.exe`. Tres consecuencias que
|
||||
**no se deben "simplificar"**:
|
||||
|
||||
1. **La CPU se mide dentro de Linux, no desde Windows.** En WSL2 el trabajo ocurre en una VM ligera:
|
||||
`Get-Process` y `Win32_Process` solo ven `wsl.exe`, un cliente delgado con CPU casi nula. Medir
|
||||
desde Windows daría "mudo" en **todos** los ciclos, incluidos los sanos, y el corte por silencio
|
||||
apagaría la ventana en el primer disparo. Verificado: un árbol que trabaja de verdad mide 8.00 s
|
||||
con `cpu_arbol.py` y **0.00 s** si solo se mira la raíz.
|
||||
2. **Matar `wsl.exe` no mata al agente.** El árbol hay que terminarlo también del lado Linux
|
||||
(`pkill`), que es el que consume cuota.
|
||||
3. **`bash -lc`, nunca `bash -c`.** El CLI vive en `~/.local/bin`, que solo entra al `PATH` en un
|
||||
shell de *login*. Sin el `-l`, el ciclo muere con «command not found» y cero caracteres de salida
|
||||
— indistinguible de un cuelgue. `ciclo-wsl.sh` además asegura ese `PATH` por su cuenta, para que
|
||||
un ensayo lanzado a mano se comporte igual que el ciclo real.
|
||||
4. **Ningún argumento puede contener espacios**, y por eso existen `ciclo-noche.sh` y
|
||||
`ciclo-ensayo.sh`. `Start-Process -ArgumentList` no entrecomilla: el elemento
|
||||
`"bash '<ruta>' noche"` llegaba partido y `bash -lc bash <ruta> noche` ejecutaba `bash` a secas,
|
||||
que salía en el acto sin trabajo y sin error. El modo va codificado en la **ruta** del envoltorio,
|
||||
no como argumento. Es el defecto 1 del kit reaparecido una capa más abajo.
|
||||
5. **El `.out`/`.err` del ciclo anterior se borra ANTES de lanzar.** Si no, un ciclo que no produce
|
||||
nada reporta la salida del ciclo previo como suya. Costó un falso `ENSAYO-OK` en la única
|
||||
comprobación que existe para detectar que la instrucción no llega.
|
||||
|
||||
### 10.3 Verificación ya ejecutada (no leída)
|
||||
|
||||
| Comprobación | Resultado |
|
||||
|---|---|
|
||||
| El corredor parsea en PowerShell 5.1 | **LIMPIO** |
|
||||
| El corredor es ASCII puro | **OK**, 20,110 bytes |
|
||||
| `cpu_arbol.py` suma la CPU de un **nieto** | **8.00 s** en árbol ocupado, **0.00 s** en dormido |
|
||||
| La misma prueba con el recorrido de descendientes mutilado | **ROJA** — la prueba sí detecta el defecto |
|
||||
| Interop PowerShell → `wsl.exe` → `git` / `python3` | Responde correctamente |
|
||||
| `ciclo-wsl.sh` sin CLI instalado | Aborta con código 4 y mensaje reconocible, no en silencio |
|
||||
| CLI de Claude Code en WSL | `2.1.224`, en `~/.local/bin/claude`, visible también por interop |
|
||||
| **Ensayo de extremo a extremo desde el corredor** | **`ENSAYO-OK`**, 9 caracteres, 4.3 s |
|
||||
| Marcas `8< / >8` alrededor de la salida del agente | Presentes en el log |
|
||||
| Limpieza del `finally` | Sin temporales en `~/.ventana/` ni locks en `%LOCALAPPDATA%` |
|
||||
| Respaldo del corredor fuera del repo | Creado |
|
||||
| Suite de pruebas del proyecto | **98 pruebas en 6.1 s** |
|
||||
|
||||
### 10.4 El falso verde que apareció al verificar, y cómo se cazó
|
||||
|
||||
La primera corrida del ensayo reportó `ENSAYO-OK` con 9 caracteres y **cerró en menos de un
|
||||
segundo**. Un `claude --print` real no es tan rápido: esa era la única señal de que algo iba mal.
|
||||
|
||||
Al borrar el estado y repetir, la salida fue **0 caracteres**. El corredor había estado leyendo el
|
||||
`.out` que dejó un ensayo manual previo. Debajo había dos defectos encadenados —los puntos 4 y 5 de
|
||||
§10.2— y el segundo enmascaraba al primero.
|
||||
|
||||
Es exactamente lo que advierte el kit: *un corredor que se lee correcto y no se corrió nunca es un
|
||||
corredor que no funciona*, y *una herramienta que nombra una causa falsa es peor que no tener
|
||||
ninguna*. Tras corregir ambos, el ensayo tarda 4.3 s y el `ENSAYO-OK` es real.
|
||||
|
||||
### 10.5 Antes de irse el lunes
|
||||
|
||||
1. `python3 automatizacion/preflight.py` y que salga **verde**.
|
||||
2. Ensayo de extremo a extremo (§4.2 del kit), y que la salida del agente diga `ENSAYO-OK`:
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "<ruta>\ventana-nocturna.ps1" -Ensayo
|
||||
```
|
||||
3. Dejar el checkout en `feature/AS-XXX-timbrado-cfdi-ingreso`, **no** en la rama base.
|
||||
4. Dejar el árbol limpio (o saber qué queda sin commitear).
|
||||
5. Dejar los contenedores arriba: `docker compose up -d`.
|
||||
|
||||
### 10.6 Lo que solo puede hacer una persona
|
||||
|
||||
Registrar la tarea programada y decidir con qué nivel de autorización corre el agente durante
|
||||
catorce horas. Un agente no puede —ni debe— registrarse una tarea del sistema ni autorizarse
|
||||
herramientas por adelantado.
|
||||
|
||||
Está automatizado en `automatizacion/instalar-tarea.ps1`. **En una terminal de PowerShell normal**
|
||||
(no hace falta administrador, ver más abajo):
|
||||
|
||||
```powershell
|
||||
powershell -NoProfile -ExecutionPolicy Bypass -File "\\wsl.localhost\Debian\home\jcedillo\Projects\CRM_AGENTES_CARGA\automatizacion\instalar-tarea.ps1"
|
||||
```
|
||||
|
||||
Tiene que terminar con `OK: primer disparo el 10/08/2026 17:05:00`. Si dice que `NextRunTime` está
|
||||
vacío, la tarea **no va a disparar** y hay que corregir la fecha.
|
||||
|
||||
Tres decisiones que se apartan del bloque genérico del kit, y por qué:
|
||||
|
||||
1. **El corredor se copia a `%LOCALAPPDATA%\ventana-nocturna\` y la tarea invoca esa copia**, no el
|
||||
archivo del repo. Una ruta `\\wsl.localhost\` solo responde con el servicio de WSL arriba: si la
|
||||
máquina arranca y la tarea dispara antes, el ciclo se pierde sin más rastro que un código de
|
||||
error. Y como el agente cambia de rama durante la noche, invocar el archivo dentro del árbol de
|
||||
git haría correr una versión vieja del corredor (defecto 4 del kit). Fuera del repo, el problema
|
||||
desaparece de raíz.
|
||||
→ **Si editas `ventana-nocturna.ps1`, vuelve a correr `instalar-tarea.ps1`.** El preflight
|
||||
compara ambas copias y avisa si se separaron.
|
||||
2. **Disparo `-Once` con fecha explícita** (`2026-08-10 17:05`), no `-Daily`. Con `-Daily`,
|
||||
registrarla un viernes por la tarde la dispararía *ese mismo día*.
|
||||
3. **Sin `-RunLevel Highest`.** Elevar no aporta nada —lanzar `wsl.exe` no necesita privilegios— y
|
||||
sí puede estorbar: una tarea elevada corre en otro contexto, donde el acceso al perfil de WSL y a
|
||||
las credenciales del agente en `~/.claude` puede no resolverse igual. Efecto secundario útil: no
|
||||
hace falta abrir PowerShell como administrador.
|
||||
|
||||
⚠️ **La tarea corre como tu usuario y solo con la sesión iniciada.** El agente necesita tu perfil
|
||||
para llegar a WSL, a docker y a sus credenciales; como SYSTEM no funcionaría. **Deja la sesión de
|
||||
Windows iniciada el lunes** — bloquear la pantalla está bien; cerrar sesión o apagar, no.
|
||||
|
||||
- Para quitarla: `Unregister-ScheduledTask -TaskName 'VentanaNocturna' -Confirm:$false`
|
||||
- Para probarla sin esperar: `Start-ScheduledTask -TaskName 'VentanaNocturna'` y mirar el log.
|
||||
Ojo: fuera de la ventana el ciclo registrará `fuera de la ventana; no se lanza nada`, que es la
|
||||
respuesta correcta. Para probar el mecanismo completo, usa el ensayo de §10.5.
|
||||
|
||||
### 10.7 Las reglas que cambian de día a noche
|
||||
|
||||
| De día, supervisado | De noche, autónomo |
|
||||
|---|---|
|
||||
| Una duda se pregunta | **Nada espera a nadie.** Solo abrir o mergear un PR requiere una persona |
|
||||
| Un bloqueo se plantea | Se anota como BLOQUEADO con su razón en una línea y **se pasa al siguiente** |
|
||||
| Un servicio caído se reporta | **Se levanta.** Es la primera tarea, no un bloqueo |
|
||||
| El push pide confirmación | **Esta noche no hay push.** El trabajo se queda en commits locales |
|
||||
| La prueba manual la hace una persona | Se cubre con pruebas automáticas y se deja escrito el guion para la mañana |
|
||||
| Una revisión con varios subagentes vale la pena | **Cuidado con la cuota:** es la misma bolsa para los ~28 ciclos |
|
||||
|
||||
Y dos que no se negocian:
|
||||
|
||||
- **Un cambio sin regresión en verde no se sube. Nunca.** Levantar el ambiente sirve para poder
|
||||
*verificar*, no para saltarse la verificación.
|
||||
- **Ninguna hora se escribe de memoria.** El agente no tiene reloj: si la infiere del trabajo hecho,
|
||||
siempre sale mal. Se lee de `date` o de la fecha de un commit, o no se escribe.
|
||||
@@ -31,8 +31,11 @@ export interface Account {
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
commercial_observations: 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;
|
||||
|
||||
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,11 @@
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export * from './catalogs';
|
||||
export * from './concepts';
|
||||
export * from './issuer';
|
||||
export * from './stamping';
|
||||
|
||||
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
|
||||
|
||||
export interface Invoice {
|
||||
@@ -33,6 +38,13 @@ 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;
|
||||
/** Modo de timbrado de ESTA factura: 'produccion' emite un CFDI con validez fiscal real. */
|
||||
stamping_mode: 'pruebas' | 'produccion';
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
created_at: string;
|
||||
@@ -43,17 +55,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 {
|
||||
|
||||
87
frontend/src/lib/api/fin/issuer.ts
Normal file
87
frontend/src/lib/api/fin/issuer.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 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;
|
||||
// ----- Estado del CSD -----
|
||||
// El backend expone si hay certificado y cuál, nunca su contenido ni la contraseña.
|
||||
csd_cert_number: string | null;
|
||||
csd_uploaded_at: string | null;
|
||||
has_csd: boolean;
|
||||
}
|
||||
|
||||
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!;
|
||||
},
|
||||
|
||||
/**
|
||||
* Sube el par del CSD y su contraseña. El backend valida que la llave corresponda al
|
||||
* certificado antes de guardar nada, y la contraseña queda cifrada: no vuelve a salir.
|
||||
*/
|
||||
async uploadCsd(
|
||||
cer: File,
|
||||
key: File,
|
||||
password: string,
|
||||
companyId: number
|
||||
): Promise<IssuerSettings> {
|
||||
const fd = new FormData();
|
||||
fd.append('cer', cer);
|
||||
fd.append('key', key);
|
||||
fd.append('password', password);
|
||||
const res = await api.request<IssuerSettings>(
|
||||
`/v1/fin/settings/issuer/csd?company_id=${companyId}`,
|
||||
{ method: 'POST', body: fd }
|
||||
);
|
||||
if (res.error) throw new Error(typeof res.error === 'string' ? res.error : 'No se pudo cargar el CSD');
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
/** Desvincula el CSD y borra sus archivos del almacenamiento. */
|
||||
async deleteCsd(companyId: number): Promise<IssuerSettings> {
|
||||
const res = await api.delete<IssuerSettings>(
|
||||
`/v1/fin/settings/issuer/csd?company_id=${companyId}`
|
||||
);
|
||||
if (res.error) throw new Error(typeof res.error === 'string' ? res.error : 'No se pudo quitar el CSD');
|
||||
return res.data!;
|
||||
}
|
||||
};
|
||||
88
frontend/src/lib/api/fin/stamping.ts
Normal file
88
frontend/src/lib/api/fin/stamping.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Cliente API — Timbrado de CFDI ante el PAC.
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export type StampingMode = 'pruebas' | 'produccion';
|
||||
export type StampStatus = 'pendiente' | 'timbrado' | 'error';
|
||||
|
||||
export interface InvoiceStamp {
|
||||
id: number;
|
||||
invoice_id: number;
|
||||
mode: StampingMode;
|
||||
status: StampStatus;
|
||||
uuid: string | null;
|
||||
stamped_at: string | null;
|
||||
pac_rfc: string | null;
|
||||
sat_cert_number: string | null;
|
||||
pac_code: number | null;
|
||||
/** Folios que le quedan a la cuenta del PAC según la última respuesta. */
|
||||
pac_balance: number | null;
|
||||
error_message: string | null;
|
||||
xml_file_key: string | null;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* El backend responde 422 con la lista COMPLETA de datos fiscales que faltan, en vez de
|
||||
* fallar en el primero. Se conserva como error propio para poder pintarla: aplanarla a un
|
||||
* string obligaría a capturar los faltantes de uno en uno.
|
||||
*/
|
||||
export class MissingFiscalDataError extends Error {
|
||||
readonly missing: string[];
|
||||
constructor(message: string, missing: string[]) {
|
||||
super(message);
|
||||
this.name = 'MissingFiscalDataError';
|
||||
this.missing = missing;
|
||||
}
|
||||
}
|
||||
|
||||
function qp(companyId: number): string {
|
||||
return new URLSearchParams({ company_id: String(companyId) }).toString();
|
||||
}
|
||||
|
||||
/** Extrae el error del cliente base, que para 422 entrega un objeto y no una cadena. */
|
||||
function toError(raw: unknown, fallback: string): Error {
|
||||
if (raw && typeof raw === 'object') {
|
||||
const o = raw as { message?: unknown; missing?: unknown; pac_error?: unknown };
|
||||
if (Array.isArray(o.missing)) {
|
||||
return new MissingFiscalDataError(
|
||||
typeof o.message === 'string' ? o.message : 'Faltan datos fiscales para timbrar',
|
||||
o.missing.map(String)
|
||||
);
|
||||
}
|
||||
// Rechazo del PAC: el mensaje útil es el suyo, no el genérico.
|
||||
if (typeof o.pac_error === 'string' && o.pac_error) return new Error(o.pac_error);
|
||||
if (typeof o.message === 'string' && o.message) return new Error(o.message);
|
||||
}
|
||||
return new Error(typeof raw === 'string' && raw ? raw : fallback);
|
||||
}
|
||||
|
||||
export const stampingAPI = {
|
||||
/**
|
||||
* Timbra la factura. Es idempotente en el backend: si ya tiene timbre lo devuelve sin
|
||||
* volver a llamar al PAC.
|
||||
*/
|
||||
stamp: async (id: number, companyId: number): Promise<InvoiceStamp> => {
|
||||
const res = await api.post<InvoiceStamp>(`/v1/fin/invoices/${id}/stamp?${qp(companyId)}`, {});
|
||||
if (res.error) throw toError(res.error, 'No se pudo timbrar la factura');
|
||||
return res.data as InvoiceStamp;
|
||||
},
|
||||
|
||||
/** Timbre vigente, o `null` si la factura no está timbrada (404 esperado). */
|
||||
get: async (id: number, companyId: number): Promise<InvoiceStamp | null> => {
|
||||
const res = await api.get<InvoiceStamp>(`/v1/fin/invoices/${id}/stamp?${qp(companyId)}`);
|
||||
if (res.error) {
|
||||
if (res.status === 404) return null;
|
||||
throw toError(res.error, 'No se pudo consultar el timbre');
|
||||
}
|
||||
return res.data as InvoiceStamp;
|
||||
},
|
||||
|
||||
/** URL firmada del XML timbrado. Caduca, así que se pide en el momento de abrirlo. */
|
||||
xmlUrl: async (id: number, companyId: number): Promise<string> => {
|
||||
const res = await api.get<{ url: string }>(`/v1/fin/invoices/${id}/stamp/xml-url?${qp(companyId)}`);
|
||||
if (res.error) throw toError(res.error, 'No se pudo obtener el XML');
|
||||
return (res.data as { url: string }).url;
|
||||
}
|
||||
};
|
||||
@@ -1,11 +1,44 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import type { AccountInput } from '$lib/api/crm';
|
||||
import { satCatalogsAPI, type SatCatalogItem, type SatTaxRegime } from '$lib/api/fin';
|
||||
import { ACCOUNT_TYPES } from '$lib/components/crm/format';
|
||||
import { crmCatalogs } from '$lib/stores/crm-catalogs.svelte';
|
||||
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';
|
||||
@@ -50,8 +83,29 @@
|
||||
</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><select class={inputCls} bind:value={form.tax_regime}><option value={undefined}>—</option>{#each crmCatalogs.options('regimen_fiscal') as r (r.value)}<option value={r.value}>{r.label}</option>{/each}</select></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}><option value={undefined}>—</option>{#each crmCatalogs.options('uso_cfdi') as u (u.value)}<option value={u.value}>{u.value} — {u.label}</option>{/each}</select></label>
|
||||
<!-- Régimen fiscal y uso de CFDI salen del catálogo del SAT, no del catálogo configurable
|
||||
del CRM: son las claves que viajan en el CFDI y el PAC las valida contra c_RegimenFiscal
|
||||
y c_UsoCFDI. El resto sí son catálogos del CRM. -->
|
||||
<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><select class={inputCls} bind:value={form.payment_method}><option value={undefined}>—</option>{#each crmCatalogs.options('metodo_pago') as m (m.value)}<option value={m.value}>{m.value} — {m.label}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><select class={inputCls} bind:value={form.payment_form}><option value={undefined}>—</option>{#each crmCatalogs.options('forma_pago') as f (f.value)}<option value={f.value}>{f.value} — {f.label}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><select class={inputCls} bind:value={form.currency}><option value={undefined}>—</option>{#each crmCatalogs.options('moneda') as c (c.value)}<option value={c.value}>{c.value} — {c.label}</option>{/each}</select></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>
|
||||
@@ -73,6 +73,7 @@ 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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -97,6 +98,7 @@ export function getNavMain(): NavMainItem[] {
|
||||
items: [
|
||||
{ title: 'General', url: '/dashboard/settings/general' },
|
||||
{ title: 'Formato de cotización', url: '/dashboard/settings/cotizacion' },
|
||||
{ title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -108,7 +108,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>
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Receipt, Plus, Trash2, Send, FileCheck, X, FileText, Check, ClipboardCheck } from '@lucide/svelte';
|
||||
import { ArrowLeft, Receipt, Plus, Trash2, Send, FileCheck, X, FileText, Check, ClipboardCheck, Stamp, FileCode, AlertTriangle } from '@lucide/svelte';
|
||||
import { page } from '$app/state';
|
||||
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 {
|
||||
invoicesAPI, invoiceItemsAPI, paymentsAPI,
|
||||
type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
|
||||
invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI, stampingAPI, satCatalogsAPI, MissingFiscalDataError,
|
||||
type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput, type InvoiceStamp,
|
||||
type SatCatalogItem, type SatUnitOfMeasure
|
||||
} 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 +21,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,19 +33,45 @@
|
||||
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');
|
||||
/** Timbre vigente de la factura; `null` mientras no esté timbrada. */
|
||||
let stamp = $state<InvoiceStamp | null>(null);
|
||||
let stamping = $state(false);
|
||||
/** Datos fiscales que el backend reportó como faltantes en el último intento. */
|
||||
let missing = $state<string[]>([]);
|
||||
// Catálogos SAT para los selectores fiscales. Se cargan una vez con la factura.
|
||||
let paymentForms = $state<SatCatalogItem[]>([]);
|
||||
let paymentMethods = $state<SatCatalogItem[]>([]);
|
||||
let taxObjects = $state<SatCatalogItem[]>([]);
|
||||
let unitsOfMeasure = $state<SatUnitOfMeasure[]>([]);
|
||||
let productsServices = $state<SatCatalogItem[]>([]);
|
||||
/** Partida cuyas claves fiscales se están editando. */
|
||||
let fiscalItem = $state<InvoiceItem | null>(null);
|
||||
let fiscalForm = $state<{ product_service_id: number | null; unit_of_measure_id: number | null; tax_object_id: number | null }>({
|
||||
product_service_id: null, unit_of_measure_id: null, tax_object_id: null
|
||||
});
|
||||
|
||||
const activeConcepts = $derived(concepts.filter((c) => c.is_active));
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
const id = invoiceId;
|
||||
if (!cid || !id) return;
|
||||
void load(cid, id);
|
||||
void loadStamp(cid, id);
|
||||
});
|
||||
|
||||
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,
|
||||
paymentForms, paymentMethods, taxObjects, unitsOfMeasure, productsServices] = await Promise.all([
|
||||
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid),
|
||||
accountsAPI.list(cid), conceptsAPI.list(cid),
|
||||
satCatalogsAPI.paymentForms(cid), satCatalogsAPI.paymentMethods(cid),
|
||||
satCatalogsAPI.taxObjects(cid), satCatalogsAPI.unitsOfMeasure(cid),
|
||||
satCatalogsAPI.productsServices(cid)
|
||||
]);
|
||||
form = { ...invoice };
|
||||
} catch (e) {
|
||||
@@ -127,7 +157,111 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startItem() { newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; addingItem = true; }
|
||||
async function loadStamp(cid: number, id: number) {
|
||||
try {
|
||||
stamp = await stampingAPI.get(id, cid);
|
||||
} catch {
|
||||
// Consultar el timbre es informativo: si falla, la pantalla sigue siendo usable.
|
||||
stamp = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function doStamp() {
|
||||
if (!companyId || !invoice) return;
|
||||
if (invoice.stamping_mode === 'produccion') {
|
||||
// Producción emite un CFDI con validez fiscal real ante el SAT y deshacerlo obliga a
|
||||
// cancelarlo: no puede dispararse con un clic distraído.
|
||||
const ok = window.confirm(
|
||||
'Esta factura está en modo PRODUCCIÓN.\n\n' +
|
||||
'Se emitirá un CFDI con validez fiscal real ante el SAT y para deshacerlo habrá que ' +
|
||||
'cancelarlo.\n\n¿Continuar?'
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
missing = [];
|
||||
stamping = true;
|
||||
try {
|
||||
stamp = await stampingAPI.stamp(invoice.id, companyId);
|
||||
toast.success(`Comprobante timbrado — UUID ${stamp.uuid}`);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
if (e instanceof MissingFiscalDataError) {
|
||||
// Se pintan en la pantalla, no en un toast: son varios y hay que ir a capturarlos.
|
||||
missing = e.missing;
|
||||
toast.error(e.message);
|
||||
} else {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo timbrar');
|
||||
}
|
||||
} finally {
|
||||
stamping = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openStampXml() {
|
||||
if (!companyId || !invoice) return;
|
||||
try {
|
||||
const url = await stampingAPI.xmlUrl(invoice.id, companyId);
|
||||
window.open(url, '_blank', 'noopener');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el XML');
|
||||
}
|
||||
}
|
||||
|
||||
function startFiscal(it: InvoiceItem) {
|
||||
fiscalItem = it;
|
||||
fiscalForm = {
|
||||
product_service_id: it.product_service_id ?? null,
|
||||
unit_of_measure_id: it.unit_of_measure_id ?? null,
|
||||
tax_object_id: it.tax_object_id ?? null
|
||||
};
|
||||
}
|
||||
|
||||
async function saveFiscal() {
|
||||
if (!companyId || !fiscalItem) return;
|
||||
busy = true;
|
||||
try {
|
||||
// El backend deriva el IVA de la partida al guardar: si el objeto de impuesto pasa a
|
||||
// '02', el traslado aparece solo con el % de la factura.
|
||||
await invoiceItemsAPI.update(fiscalItem.id, fiscalForm, companyId);
|
||||
fiscalItem = null;
|
||||
await reload();
|
||||
toast.success('Claves fiscales actualizadas');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar las claves');
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
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'); }
|
||||
@@ -180,10 +314,56 @@
|
||||
<Button size="sm" variant="outline" onclick={() => clientDecision(true)} disabled={busy}><Check class="mr-1 h-4 w-4 text-emerald-600" /> Cliente aprueba</Button>
|
||||
<Button size="sm" variant="outline" onclick={() => clientDecision(false)} disabled={busy}><X class="mr-1 h-4 w-4 text-destructive" /> Con observaciones</Button>
|
||||
{/if}
|
||||
{#if invoice.status !== 'borrador' && invoice.status !== 'cancelada' && !stamp}
|
||||
<Button size="sm" onclick={doStamp} disabled={stamping || busy}>
|
||||
<Stamp class="mr-1 h-4 w-4" /> {stamping ? 'Timbrando…' : 'Timbrar'}
|
||||
</Button>
|
||||
{/if}
|
||||
{#if stamp?.xml_file_key}<Button size="sm" variant="outline" onclick={openStampXml}><FileCode class="mr-1 h-4 w-4" /> XML timbrado</Button>{/if}
|
||||
{#if invoice.status !== 'cancelada' && invoice.status !== 'pagada'}<Button size="sm" variant="outline" onclick={() => doAction('cancel')} disabled={busy}><X class="mr-1 h-4 w-4" /> Cancelar</Button>{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stamp}
|
||||
<!-- Comprobante ya timbrado: el UUID es el identificador ante el SAT. -->
|
||||
<div class="rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950">
|
||||
<p class="flex flex-wrap items-center gap-2 font-medium text-emerald-900 dark:text-emerald-100">
|
||||
<Stamp class="h-4 w-4" /> Comprobante timbrado
|
||||
{#if stamp.mode === 'pruebas'}
|
||||
<span class="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-semibold text-amber-900 dark:bg-amber-900 dark:text-amber-100">
|
||||
PRUEBAS — sin validez fiscal
|
||||
</span>
|
||||
{/if}
|
||||
</p>
|
||||
<dl class="mt-2 grid gap-x-6 gap-y-1 text-xs text-emerald-900 sm:grid-cols-2 dark:text-emerald-200">
|
||||
<div><dt class="inline font-medium">UUID:</dt> <dd class="inline font-mono">{stamp.uuid}</dd></div>
|
||||
<div><dt class="inline font-medium">Fecha de timbrado:</dt> <dd class="inline">{stamp.stamped_at ?? '—'}</dd></div>
|
||||
<div><dt class="inline font-medium">PAC:</dt> <dd class="inline">{stamp.pac_rfc ?? '—'}</dd></div>
|
||||
{#if stamp.pac_balance !== null}
|
||||
<div><dt class="inline font-medium">Folios restantes:</dt> <dd class="inline">{stamp.pac_balance}</dd></div>
|
||||
{/if}
|
||||
</dl>
|
||||
{#if stamp.error_message}
|
||||
<p class="mt-2 text-xs text-amber-800 dark:text-amber-200">Aviso: {stamp.error_message}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if missing.length}
|
||||
<!-- El backend devuelve TODOS los faltantes de una vez, para capturarlos en una pasada. -->
|
||||
<div class="rounded-md border border-destructive/40 bg-destructive/5 p-3 text-sm">
|
||||
<p class="flex items-center gap-2 font-medium text-destructive">
|
||||
<AlertTriangle class="h-4 w-4" /> Faltan datos fiscales para timbrar
|
||||
</p>
|
||||
<ul class="mt-2 list-disc space-y-0.5 pl-6 text-xs text-muted-foreground">
|
||||
{#each missing as m (m)}<li>{m}</li>{/each}
|
||||
</ul>
|
||||
<p class="mt-2 text-xs text-muted-foreground">
|
||||
Captúralos en la factura, en sus partidas o en la ficha del cliente y vuelve a intentar.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if invoice.client_reviewed_at}
|
||||
<p class="text-xs text-muted-foreground">Revisión del cliente: {invoice.client_approved ? 'aprobada' : 'con observaciones'}{#if invoice.review_notes} — {invoice.review_notes}{/if}</p>
|
||||
{/if}
|
||||
@@ -207,26 +387,118 @@
|
||||
<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>
|
||||
<!-- Claves del SAT de la partida. Vienen del concepto del catálogo cuando lo hay,
|
||||
y se pueden ajustar aquí; sin ellas no se puede timbrar. -->
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Clave producto/servicio (SAT)</span>
|
||||
<select class={inputCls} bind:value={newItem.product_service_id}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each productsServices as ps (ps.id)}<option value={ps.id}>{ps.code} — {ps.description}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Clave de unidad (SAT)</span>
|
||||
<select class={inputCls} bind:value={newItem.unit_of_measure_id}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each unitsOfMeasure as u (u.id)}<option value={u.id}>{u.code} — {u.description}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Objeto de impuesto (SAT)</span>
|
||||
<select class={inputCls} bind:value={newItem.tax_object_id}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each taxObjects as o (o.id)}<option value={o.id}>{o.code} — {o.description}</option>{/each}
|
||||
</select>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Con «02 — Sí objeto de impuesto» el IVA se calcula solo, con el % de la factura.
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex justify-end gap-2 sm:col-span-2"><Button variant="outline" size="sm" onclick={() => (addingItem = false)}>Cancelar</Button><Button size="sm" onclick={saveItem}>Guardar</Button></div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if fiscalItem}
|
||||
<!-- Claves del SAT de una partida ya creada: es lo que faltaba para poder timbrar
|
||||
partidas dadas de alta antes de que existieran estos campos. -->
|
||||
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||
<p class="text-sm font-medium sm:col-span-2">
|
||||
Claves fiscales de «{itemConceptLabel(fiscalItem)}»
|
||||
</p>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Clave producto/servicio (SAT)</span>
|
||||
<select class={inputCls} bind:value={fiscalForm.product_service_id}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each productsServices as ps (ps.id)}<option value={ps.id}>{ps.code} — {ps.description}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Clave de unidad (SAT)</span>
|
||||
<select class={inputCls} bind:value={fiscalForm.unit_of_measure_id}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each unitsOfMeasure as u (u.id)}<option value={u.id}>{u.code} — {u.name}</option>{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Objeto de impuesto (SAT)</span>
|
||||
<select class={inputCls} bind:value={fiscalForm.tax_object_id}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each taxObjects as o (o.id)}<option value={o.id}>{o.code} — {o.description}</option>{/each}
|
||||
</select>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Con «02 — Sí objeto de impuesto» el IVA se calcula solo, usando el {invoice.tax_rate}%
|
||||
de la factura.
|
||||
</span>
|
||||
</label>
|
||||
<div class="flex justify-end gap-2 sm:col-span-2">
|
||||
<Button variant="outline" size="sm" onclick={() => (fiscalItem = null)}>Cancelar</Button>
|
||||
<Button size="sm" onclick={saveFiscal} disabled={busy}>Guardar claves</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if items.length === 0}
|
||||
<p class="text-sm text-muted-foreground">Sin conceptos.</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head class="text-right">Cant.</Table.Head><Table.Head class="text-right">Unitario</Table.Head><Table.Head class="text-right">Importe</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||
<Table.Header><Table.Row><Table.Head>Concepto</Table.Head><Table.Head>Claves SAT</Table.Head><Table.Head class="text-right">Cant.</Table.Head><Table.Head class="text-right">Unitario</Table.Head><Table.Head class="text-right">Importe</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||
<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-xs">
|
||||
{#if it.product_service_id && it.unit_of_measure_id && it.tax_object_id}
|
||||
<span class="text-emerald-600">completas</span>
|
||||
{:else}
|
||||
<button type="button" class="text-destructive underline" onclick={() => startFiscal(it)}>faltan claves</button>
|
||||
{/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>
|
||||
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeItem(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<Button variant="ghost" size="sm" onclick={() => startFiscal(it)} aria-label="Claves fiscales"><Receipt class="h-4 w-4" /></Button>
|
||||
<Button variant="ghost" size="sm" onclick={() => removeItem(it)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
@@ -269,6 +541,47 @@
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">% Impuesto</span><input type="number" min="0" max="100" step="0.01" class={inputCls} bind:value={form.tax_rate} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Emisión</span><input type="date" class={inputCls} bind:value={form.issue_date} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Vencimiento</span><input type="date" class={inputCls} bind:value={form.due_date} /></label>
|
||||
<!-- Claves fiscales del comprobante. Sin ellas el timbrado se detiene en la
|
||||
validación, antes de llegar al PAC. -->
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Forma de pago (SAT) *</span>
|
||||
<select class={inputCls} bind:value={form.payment_form_id}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each paymentForms as f (f.id)}<option value={f.id}>{f.code} — {f.description}</option>{/each}
|
||||
</select>
|
||||
<span class="text-xs text-muted-foreground">Con qué se paga: efectivo, transferencia…</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Método de pago (SAT) *</span>
|
||||
<select class={inputCls} bind:value={form.payment_method_id}>
|
||||
<option value={null}>Selecciona…</option>
|
||||
{#each paymentMethods as m (m.id)}<option value={m.id}>{m.code} — {m.description}</option>{/each}
|
||||
</select>
|
||||
<span class="text-xs text-muted-foreground">PUE en una exhibición, PPD en parcialidades.</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">CP del lugar de expedición</span>
|
||||
<input class={inputCls} maxlength="5" inputmode="numeric" bind:value={form.expedition_zip_code} />
|
||||
<span class="text-xs text-muted-foreground">Si se deja vacío se usa el del emisor.</span>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Modo de timbrado</span>
|
||||
<select class={inputCls} bind:value={form.stamping_mode} disabled={!!stamp}>
|
||||
<option value="pruebas">Pruebas — el comprobante no tiene validez fiscal</option>
|
||||
<option value="produccion">Producción — emite un CFDI real ante el SAT</option>
|
||||
</select>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
{#if stamp}
|
||||
La factura ya está timbrada: el modo no se puede cambiar.
|
||||
{:else}
|
||||
Determina a qué entorno del PAC se transmite. En producción el comprobante tiene
|
||||
validez fiscal y deshacerlo obliga a cancelarlo ante el SAT.
|
||||
{/if}
|
||||
</span>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Datos bancarios</span><textarea rows="2" class={inputCls} bind:value={form.bank_info}></textarea></label>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2"><span class="font-medium">Notas</span><textarea rows="2" class={inputCls} bind:value={form.notes}></textarea></label>
|
||||
</div>
|
||||
|
||||
354
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
354
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
@@ -0,0 +1,354 @@
|
||||
<script lang="ts">
|
||||
import { Receipt, ShieldCheck, Upload, Trash2, AlertTriangle } 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 IssuerSettings,
|
||||
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('');
|
||||
/** Estado del CSD cargado; null mientras no haya datos fiscales. */
|
||||
let issuer = $state<IssuerSettings | null>(null);
|
||||
let cerFile = $state<File | null>(null);
|
||||
let keyFile = $state<File | null>(null);
|
||||
let csdPassword = $state('');
|
||||
let uploadingCsd = $state(false);
|
||||
|
||||
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;
|
||||
issuer = settings;
|
||||
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 {
|
||||
issuer = 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;
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCsd(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const cid = companyId;
|
||||
if (!cid || !cerFile || !keyFile) return;
|
||||
uploadingCsd = true;
|
||||
try {
|
||||
issuer = await issuerAPI.uploadCsd(cerFile, keyFile, csdPassword, cid);
|
||||
// La contraseña no se conserva en la pantalla: ya está cifrada en el servidor y
|
||||
// dejarla en memoria del navegador no aporta nada.
|
||||
csdPassword = '';
|
||||
cerFile = null;
|
||||
keyFile = null;
|
||||
toast.success('Certificado cargado y verificado');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el certificado');
|
||||
} finally {
|
||||
uploadingCsd = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function removeCsd() {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
if (!window.confirm('Se quitará el certificado y la empresa dejará de poder timbrar. ¿Continuar?'))
|
||||
return;
|
||||
uploadingCsd = true;
|
||||
try {
|
||||
issuer = await issuerAPI.deleteCsd(cid);
|
||||
toast.success('Certificado retirado');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo quitar el certificado');
|
||||
} finally {
|
||||
uploadingCsd = 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>
|
||||
|
||||
<!-- ===== Certificado de Sello Digital ===== -->
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<ShieldCheck class="h-5 w-5" /> Certificado de Sello Digital (CSD)
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
Es lo que firma los comprobantes ante el SAT. Sin él no se puede timbrar.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if isNew}
|
||||
<p class="py-4 text-sm text-muted-foreground">
|
||||
Primero guarda los datos fiscales del emisor: el certificado se asocia a ellos.
|
||||
</p>
|
||||
{:else}
|
||||
{#if issuer?.has_csd}
|
||||
<div class="mb-4 rounded-md border border-emerald-200 bg-emerald-50 p-3 text-sm dark:border-emerald-900 dark:bg-emerald-950">
|
||||
<p class="flex items-center gap-2 font-medium text-emerald-900 dark:text-emerald-100">
|
||||
<ShieldCheck class="h-4 w-4" /> Certificado cargado
|
||||
</p>
|
||||
<dl class="mt-2 grid gap-x-6 gap-y-1 text-xs text-emerald-900 sm:grid-cols-2 dark:text-emerald-200">
|
||||
<div>
|
||||
<dt class="inline font-medium">No. de certificado:</dt>
|
||||
<dd class="inline font-mono">{issuer.csd_cert_number}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="inline font-medium">Cargado el:</dt>
|
||||
<dd class="inline">{issuer.csd_uploaded_at}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{#if canEdit}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
class="mt-3"
|
||||
onclick={removeCsd}
|
||||
disabled={uploadingCsd}
|
||||
>
|
||||
<Trash2 class="mr-1 h-4 w-4" /> Quitar certificado
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if canEdit}
|
||||
<form class="grid max-w-2xl gap-4 sm:grid-cols-2" onsubmit={uploadCsd}>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Certificado (.cer) *</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".cer"
|
||||
class={inputCls}
|
||||
required
|
||||
onchange={(e) => (cerFile = e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Llave privada (.key) *</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".key"
|
||||
class={inputCls}
|
||||
required
|
||||
onchange={(e) => (keyFile = e.currentTarget.files?.[0] ?? null)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Contraseña de la llave privada *</span>
|
||||
<input
|
||||
type="password"
|
||||
class={inputCls}
|
||||
bind:value={csdPassword}
|
||||
required
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span class="text-xs text-muted-foreground">
|
||||
Se guarda cifrada y no se vuelve a mostrar. Al subir, se verifica que la llave
|
||||
corresponda al certificado antes de guardar nada.
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<p class="flex items-start gap-2 rounded-md border border-amber-200 bg-amber-50 p-3 text-xs text-amber-900 sm:col-span-2 dark:border-amber-900 dark:bg-amber-950 dark:text-amber-100">
|
||||
<AlertTriangle class="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>
|
||||
Usa el <strong>CSD</strong>, no la FIEL: son certificados distintos y la FIEL no
|
||||
sirve para timbrar. Con la llave privada se puede firmar a nombre de la empresa
|
||||
ante el SAT, así que trátala como una credencial.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div class="flex justify-end sm:col-span-2">
|
||||
<Button type="submit" disabled={uploadingCsd || !cerFile || !keyFile || !companyId}>
|
||||
<Upload class="mr-1 h-4 w-4" />
|
||||
{uploadingCsd ? 'Verificando…' : issuer?.has_csd ? 'Reemplazar certificado' : 'Cargar certificado'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{:else if !issuer?.has_csd}
|
||||
<p class="py-4 text-sm text-muted-foreground">
|
||||
Esta empresa no tiene certificado cargado. Se requiere el permiso de edición para
|
||||
subirlo.
|
||||
</p>
|
||||
{/if}
|
||||
{/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>
|
||||
|
||||
Reference in New Issue
Block a user