Compare commits
10 Commits
feature/T2
...
f4ef6a037d
| Author | SHA1 | Date | |
|---|---|---|---|
| f4ef6a037d | |||
| 15717314fd | |||
| ce09e0d30a | |||
| ae0664e987 | |||
| cb2acb11fc | |||
| 5a112a0171 | |||
| 8d9db3505d | |||
| b8b8311ece | |||
| 9cf142add6 | |||
| e24435c74b |
20
.env.example
20
.env.example
@@ -112,23 +112,3 @@ SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
||||
|
||||
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
|
||||
SPOKE_URLS=""
|
||||
|
||||
# ── EFC (expediente electronico) ─────────────────────────────────────────────
|
||||
# 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.
|
||||
#
|
||||
# EFC_API_URL VACIA = integracion 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_KEY debe coincidir con CRM_INTEGRATION_API_KEY del lado de EFC.
|
||||
EFC_API_URL=
|
||||
EFC_API_KEY=
|
||||
EFC_API_VERIFY_SSL=true
|
||||
# Metadatos: resolver organizacion, crear expediente, completar.
|
||||
EFC_API_TIMEOUT_MS=8000
|
||||
# Subidas. Debe quedar POR DEBAJO del proxy_read_timeout del nginx de EFC: si el CRM
|
||||
# esperara mas, veria un 504 opaco y no sabria si el documento entro.
|
||||
EFC_UPLOAD_TIMEOUT_MS=55000
|
||||
# Scaffolding de mTLS (pre-produccion). Vacio = TLS normal.
|
||||
EFC_MTLS_CA_PATH=
|
||||
EFC_MTLS_CERT_PATH=
|
||||
EFC_MTLS_KEY_PATH=
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
"""Expediente del CRM y su espejo en EFC: crm.expedientes, el contador de folios y las columnas
|
||||
del espejo en las dos tablas de documentos.
|
||||
|
||||
Revision ID: e6f7a8b9c0d1
|
||||
Revises: d5e6f7a8b9c0
|
||||
Create Date: 2026-08-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "e6f7a8b9c0d1"
|
||||
down_revision: Union[str, None] = "d5e6f7a8b9c0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
# Las diez columnas del espejo en EFC. Van idénticas en crm.documents y en ops.shipment_documents
|
||||
# porque las dos alimentan el mismo expediente electrónico: si divergieran, la UI pintaría un badge
|
||||
# distinto según de dónde viniera el documento. Se define una vez aquí y se aplica en bucle, para
|
||||
# que no se puedan desalinear al editar la migración.
|
||||
def _columnas_espejo_efc() -> list[sa.Column]:
|
||||
return [
|
||||
sa.Column("expediente_id", sa.Integer(), nullable=True),
|
||||
sa.Column("efc_document_ref", sa.String(length=64), nullable=True),
|
||||
sa.Column("efc_document_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("efc_sync_state", sa.String(length=20), nullable=True, server_default=sa.text("'PENDING'")),
|
||||
sa.Column("efc_synced_at", sa.DateTime(), nullable=True),
|
||||
sa.Column("efc_error_code", sa.String(length=60), nullable=True),
|
||||
sa.Column("efc_error_detail", sa.Text(), nullable=True),
|
||||
sa.Column("efc_attempts", sa.Integer(), nullable=True, server_default=sa.text("0")),
|
||||
sa.Column("content_sha256", sa.String(length=64), nullable=True),
|
||||
]
|
||||
|
||||
|
||||
_TABLAS_CON_ESPEJO = (
|
||||
("documents", "crm"),
|
||||
("shipment_documents", "ops"),
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- crm.expedientes ----------
|
||||
op.create_table(
|
||||
"expedientes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("folio", sa.String(length=20), nullable=False),
|
||||
sa.Column("period_year", sa.Integer(), nullable=False),
|
||||
sa.Column("period_month", sa.Integer(), nullable=False),
|
||||
sa.Column("sequence", sa.Integer(), nullable=False),
|
||||
sa.Column("service_request_id", sa.Integer(), nullable=True),
|
||||
sa.Column("account_id", sa.Integer(), nullable=True),
|
||||
sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'abierto'")),
|
||||
sa.Column("efc_organizacion_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("efc_pedimento_id", sa.String(length=36), nullable=True),
|
||||
sa.Column("efc_storage_token", sa.String(length=25), nullable=True),
|
||||
sa.Column("efc_link_state", sa.String(length=20), nullable=False, server_default=sa.text("'PENDING'")),
|
||||
sa.Column("efc_error_code", sa.String(length=60), nullable=True),
|
||||
sa.Column("efc_error_detail", sa.Text(), nullable=True),
|
||||
sa.Column("patente", sa.String(length=20), nullable=True),
|
||||
sa.Column("aduana", sa.String(length=10), nullable=True),
|
||||
sa.Column("numero_pedimento", sa.String(length=20), nullable=True),
|
||||
sa.Column("anio", sa.Integer(), nullable=True),
|
||||
sa.Column("clave_pedimento", sa.String(length=10), nullable=True),
|
||||
sa.Column("regimen", sa.String(length=10), nullable=True),
|
||||
sa.Column("fecha_pago", sa.Date(), nullable=True),
|
||||
sa.Column("rfc_importador", sa.String(length=20), nullable=True),
|
||||
sa.Column("rfc_agente_aduanal", sa.String(length=100), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), 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"),
|
||||
# Las dos redes de seguridad del folio: si el contador se corrompe, un duplicado falla
|
||||
# ruidosamente en vez de mezclar dos hilos documentales.
|
||||
sa.UniqueConstraint("tenant_id", "company_id", "folio", name="uq_crm_expedientes_folio"),
|
||||
sa.UniqueConstraint(
|
||||
"tenant_id", "company_id", "period_year", "period_month", "sequence",
|
||||
name="uq_crm_expedientes_periodo_seq",
|
||||
),
|
||||
schema="crm",
|
||||
)
|
||||
op.create_index("ix_crm_expedientes_id", "expedientes", ["id"], schema="crm")
|
||||
op.create_index("ix_crm_expedientes_folio", "expedientes", ["folio"], schema="crm")
|
||||
op.create_index("ix_crm_expedientes_status", "expedientes", ["status"], schema="crm")
|
||||
op.create_index("ix_crm_expedientes_tenant_id", "expedientes", ["tenant_id"], schema="crm")
|
||||
op.create_index("ix_crm_expedientes_company_id", "expedientes", ["company_id"], schema="crm")
|
||||
op.create_index(
|
||||
"ix_crm_expedientes_service_request_id", "expedientes", ["service_request_id"], schema="crm"
|
||||
)
|
||||
op.create_index("ix_crm_expedientes_account_id", "expedientes", ["account_id"], schema="crm")
|
||||
op.create_foreign_key(
|
||||
"fk_crm_expedientes_tenant_id", "expedientes", "tenants",
|
||||
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_crm_expedientes_service_request_id", "expedientes", "service_requests",
|
||||
["service_request_id"], ["id"], source_schema="crm", referent_schema="crm",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_crm_expedientes_account_id", "expedientes", "accounts",
|
||||
["account_id"], ["id"], source_schema="crm", referent_schema="crm",
|
||||
)
|
||||
|
||||
# ---------- crm.expediente_folio_counters ----------
|
||||
# PK compuesta (tenant, company, period): es la fila sobre la que serializa el
|
||||
# INSERT ... ON CONFLICT DO UPDATE del asignador. Sin esa PK el upsert no tiene sobre qué
|
||||
# detectar el conflicto y dos altas simultáneas darían el mismo folio.
|
||||
op.create_table(
|
||||
"expediente_folio_counters",
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("period", sa.String(length=7), nullable=False),
|
||||
sa.Column("last_seq", sa.Integer(), nullable=False, server_default=sa.text("0")),
|
||||
sa.PrimaryKeyConstraint("tenant_id", "company_id", "period"),
|
||||
schema="crm",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_crm_expediente_folio_counters_tenant_id", "expediente_folio_counters", "tenants",
|
||||
["tenant_id"], ["id"], source_schema="crm", referent_schema="core",
|
||||
)
|
||||
|
||||
# ---------- Espejo de EFC en las dos tablas de documentos ----------
|
||||
for tabla, schema in _TABLAS_CON_ESPEJO:
|
||||
for columna in _columnas_espejo_efc():
|
||||
op.add_column(tabla, columna, schema=schema)
|
||||
op.create_index(
|
||||
f"ix_{schema}_{tabla}_expediente_id", tabla, ["expediente_id"], schema=schema
|
||||
)
|
||||
op.create_index(
|
||||
f"ix_{schema}_{tabla}_efc_document_ref", tabla, ["efc_document_ref"], schema=schema
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
for tabla, schema in reversed(_TABLAS_CON_ESPEJO):
|
||||
op.drop_index(f"ix_{schema}_{tabla}_efc_document_ref", table_name=tabla, schema=schema)
|
||||
op.drop_index(f"ix_{schema}_{tabla}_expediente_id", table_name=tabla, schema=schema)
|
||||
for columna in reversed(_columnas_espejo_efc()):
|
||||
op.drop_column(tabla, columna.name, schema=schema)
|
||||
|
||||
op.drop_constraint(
|
||||
"fk_crm_expediente_folio_counters_tenant_id", "expediente_folio_counters",
|
||||
schema="crm", type_="foreignkey",
|
||||
)
|
||||
op.drop_table("expediente_folio_counters", schema="crm")
|
||||
|
||||
op.drop_constraint("fk_crm_expedientes_account_id", "expedientes", schema="crm", type_="foreignkey")
|
||||
op.drop_constraint("fk_crm_expedientes_service_request_id", "expedientes", schema="crm", type_="foreignkey")
|
||||
op.drop_constraint("fk_crm_expedientes_tenant_id", "expedientes", schema="crm", type_="foreignkey")
|
||||
op.drop_index("ix_crm_expedientes_account_id", table_name="expedientes", schema="crm")
|
||||
op.drop_index("ix_crm_expedientes_service_request_id", table_name="expedientes", schema="crm")
|
||||
op.drop_index("ix_crm_expedientes_company_id", table_name="expedientes", schema="crm")
|
||||
op.drop_index("ix_crm_expedientes_tenant_id", table_name="expedientes", schema="crm")
|
||||
op.drop_index("ix_crm_expedientes_status", table_name="expedientes", schema="crm")
|
||||
op.drop_index("ix_crm_expedientes_folio", table_name="expedientes", schema="crm")
|
||||
op.drop_index("ix_crm_expedientes_id", table_name="expedientes", schema="crm")
|
||||
op.drop_table("expedientes", schema="crm")
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Catálogos SAT (schema sat), conceptos de facturación, datos fiscales del emisor
|
||||
y amarre de facturas y partidas a los catálogos.
|
||||
|
||||
Revision ID: e6f7a8b9c0d1
|
||||
Revises: d5e6f7a8b9c0
|
||||
Create Date: 2026-08-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs
|
||||
|
||||
revision: str = "e6f7a8b9c0d1"
|
||||
down_revision: Union[str, None] = "d5e6f7a8b9c0"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
# Índices únicos parciales: la baja lógica (deleted_at) libera la clave.
|
||||
_ALIVE = "deleted_at IS NULL"
|
||||
|
||||
# Catálogos del SAT: (tabla, longitud de code, columnas propias del catálogo).
|
||||
_SAT_CATALOGS: list[tuple[str, int, list[sa.Column]]] = [
|
||||
("tax_regimes", 3, [
|
||||
sa.Column("applies_to_individual", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("applies_to_legal_entity", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
]),
|
||||
("taxes", 3, [
|
||||
sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("is_transferred", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("is_local", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
]),
|
||||
("payment_forms", 2, []),
|
||||
("units_of_measure", 20, [
|
||||
sa.Column("name", sa.String(length=255), nullable=False),
|
||||
sa.Column("symbol", sa.String(length=20), nullable=True),
|
||||
]),
|
||||
("products_services", 8, []),
|
||||
("voucher_types", 1, []),
|
||||
("payment_methods", 3, []),
|
||||
("tax_objects", 2, []),
|
||||
]
|
||||
|
||||
# units_of_measure guarda el nombre corto aparte, así que su description es opcional.
|
||||
_NULLABLE_DESCRIPTION = {"units_of_measure"}
|
||||
|
||||
|
||||
def _timestamp_columns(with_soft_delete: bool) -> list[sa.Column]:
|
||||
columns = [
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
]
|
||||
if with_soft_delete:
|
||||
columns.append(sa.Column("deleted_at", sa.DateTime(), nullable=True))
|
||||
return columns
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- Schema y catálogos globales del SAT ----------
|
||||
op.execute("CREATE SCHEMA IF NOT EXISTS sat")
|
||||
|
||||
for table, code_length, extra_columns in _SAT_CATALOGS:
|
||||
op.create_table(
|
||||
table,
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(length=code_length), nullable=False),
|
||||
sa.Column(
|
||||
"description",
|
||||
sa.String(length=500),
|
||||
nullable=table in _NULLABLE_DESCRIPTION,
|
||||
),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
*extra_columns,
|
||||
*_timestamp_columns(with_soft_delete=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema="sat",
|
||||
)
|
||||
op.create_index(f"ix_sat_{table}_id", table, ["id"], schema="sat")
|
||||
# La clave oficial del SAT es única dentro de su catálogo.
|
||||
op.create_index(f"ix_sat_{table}_code", table, ["code"], unique=True, schema="sat")
|
||||
|
||||
# Semillas de los catálogos (idempotente: puede volver a correrse sin duplicar).
|
||||
sync_catalogs(op.get_bind())
|
||||
|
||||
# ---------- fin.concepts ----------
|
||||
op.create_table(
|
||||
"concepts",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(length=40), nullable=False),
|
||||
sa.Column("description", sa.String(length=500), nullable=False),
|
||||
sa.Column("product_service_id", sa.Integer(), nullable=False),
|
||||
sa.Column("unit_of_measure_id", sa.Integer(), nullable=True),
|
||||
sa.Column("tax_object_id", sa.Integer(), nullable=True),
|
||||
sa.Column("unit_price", sa.Numeric(precision=14, scale=2), nullable=True),
|
||||
sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("notes", sa.Text(), nullable=True),
|
||||
sa.Column("created_by", sa.String(length=64), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
*_timestamp_columns(with_soft_delete=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_concepts_tenant_id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["product_service_id"], ["sat.products_services.id"], name="fk_fin_concepts_product_service_id"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["unit_of_measure_id"], ["sat.units_of_measure.id"], name="fk_fin_concepts_unit_of_measure_id"
|
||||
),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tax_object_id"], ["sat.tax_objects.id"], name="fk_fin_concepts_tax_object_id"
|
||||
),
|
||||
schema="fin",
|
||||
)
|
||||
op.create_index("ix_fin_concepts_id", "concepts", ["id"], schema="fin")
|
||||
op.create_index("ix_fin_concepts_tenant_id", "concepts", ["tenant_id"], schema="fin")
|
||||
op.create_index("ix_fin_concepts_company_id", "concepts", ["company_id"], schema="fin")
|
||||
op.create_index("ix_fin_concepts_product_service_id", "concepts", ["product_service_id"], schema="fin")
|
||||
# La clave interna del concepto es única por empresa.
|
||||
op.create_index(
|
||||
"uq_fin_concepts_code", "concepts", ["tenant_id", "company_id", "code"],
|
||||
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||
)
|
||||
# Relación 1:1 con c_ClaveProdServ: una clave del SAT no puede repetirse entre
|
||||
# los conceptos vigentes de la misma empresa.
|
||||
op.create_index(
|
||||
"uq_fin_concepts_product_service", "concepts", ["tenant_id", "company_id", "product_service_id"],
|
||||
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||
)
|
||||
|
||||
# ---------- fin.issuer_settings ----------
|
||||
op.create_table(
|
||||
"issuer_settings",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("legal_name", sa.String(length=255), nullable=False),
|
||||
sa.Column("rfc", sa.String(length=13), nullable=False),
|
||||
sa.Column("tax_regime_id", sa.Integer(), nullable=False),
|
||||
sa.Column("zip_code", sa.String(length=5), nullable=True),
|
||||
sa.Column("updated_by", sa.String(length=64), nullable=True),
|
||||
*_timestamp_columns(with_soft_delete=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_issuer_settings_tenant_id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["tax_regime_id"], ["sat.tax_regimes.id"], name="fk_fin_issuer_settings_tax_regime_id"
|
||||
),
|
||||
schema="fin",
|
||||
)
|
||||
op.create_index("ix_fin_issuer_settings_id", "issuer_settings", ["id"], schema="fin")
|
||||
op.create_index("ix_fin_issuer_settings_tenant_id", "issuer_settings", ["tenant_id"], schema="fin")
|
||||
op.create_index("ix_fin_issuer_settings_company_id", "issuer_settings", ["company_id"], schema="fin")
|
||||
op.create_index("ix_fin_issuer_settings_tax_regime_id", "issuer_settings", ["tax_regime_id"], schema="fin")
|
||||
# Una sola configuración fiscal vigente por empresa.
|
||||
op.create_index(
|
||||
"uq_fin_issuer_settings_company", "issuer_settings", ["tenant_id", "company_id"],
|
||||
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||
)
|
||||
|
||||
# ---------- fin.invoice_item_taxes ----------
|
||||
op.create_table(
|
||||
"invoice_item_taxes",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("tenant_id", sa.Integer(), nullable=False),
|
||||
sa.Column("company_id", sa.Integer(), nullable=False),
|
||||
sa.Column("invoice_item_id", sa.Integer(), nullable=False),
|
||||
sa.Column("tax_id", sa.Integer(), nullable=False),
|
||||
sa.Column("is_withholding", sa.Boolean(), nullable=False, server_default=sa.text("false")),
|
||||
sa.Column("rate", sa.Numeric(precision=8, scale=6), nullable=True),
|
||||
sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=False, server_default=sa.text("0")),
|
||||
*_timestamp_columns(with_soft_delete=True),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"], name="fk_fin_invoice_item_taxes_tenant_id"),
|
||||
sa.ForeignKeyConstraint(
|
||||
["invoice_item_id"], ["fin.invoice_items.id"], name="fk_fin_invoice_item_taxes_invoice_item_id"
|
||||
),
|
||||
sa.ForeignKeyConstraint(["tax_id"], ["sat.taxes.id"], name="fk_fin_invoice_item_taxes_tax_id"),
|
||||
schema="fin",
|
||||
)
|
||||
op.create_index("ix_fin_invoice_item_taxes_id", "invoice_item_taxes", ["id"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_item_taxes_tenant_id", "invoice_item_taxes", ["tenant_id"], schema="fin")
|
||||
op.create_index("ix_fin_invoice_item_taxes_company_id", "invoice_item_taxes", ["company_id"], schema="fin")
|
||||
op.create_index(
|
||||
"ix_fin_invoice_item_taxes_invoice_item_id", "invoice_item_taxes", ["invoice_item_id"], schema="fin"
|
||||
)
|
||||
# Un mismo impuesto no puede declararse dos veces con el mismo rol en la partida.
|
||||
op.create_index(
|
||||
"uq_fin_invoice_item_taxes", "invoice_item_taxes", ["invoice_item_id", "tax_id", "is_withholding"],
|
||||
unique=True, schema="fin", postgresql_where=sa.text(_ALIVE),
|
||||
)
|
||||
|
||||
# ---------- fin.invoices: claves fiscales del comprobante ----------
|
||||
# Todas nullable: las facturas ya emitidas no tienen estos datos.
|
||||
op.add_column("invoices", sa.Column("voucher_type_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("payment_form_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("payment_method_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoices", sa.Column("expedition_zip_code", sa.String(length=5), nullable=True), schema="fin")
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoices_voucher_type_id", "invoices", "voucher_types",
|
||||
["voucher_type_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoices_payment_form_id", "invoices", "payment_forms",
|
||||
["payment_form_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoices_payment_method_id", "invoices", "payment_methods",
|
||||
["payment_method_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
|
||||
# ---------- fin.invoice_items: claves fiscales de la partida ----------
|
||||
# La columna de texto libre `concept` se conserva intacta y obligatoria: la usa el
|
||||
# PDF actual de la factura.
|
||||
op.add_column("invoice_items", sa.Column("concept_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoice_items", sa.Column("product_service_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoice_items", sa.Column("unit_of_measure_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.add_column("invoice_items", sa.Column("tax_object_id", sa.Integer(), nullable=True), schema="fin")
|
||||
op.create_index("ix_fin_invoice_items_concept_id", "invoice_items", ["concept_id"], schema="fin")
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoice_items_concept_id", "invoice_items", "concepts",
|
||||
["concept_id"], ["id"], source_schema="fin", referent_schema="fin",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoice_items_product_service_id", "invoice_items", "products_services",
|
||||
["product_service_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoice_items_unit_of_measure_id", "invoice_items", "units_of_measure",
|
||||
["unit_of_measure_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_fin_invoice_items_tax_object_id", "invoice_items", "tax_objects",
|
||||
["tax_object_id"], ["id"], source_schema="fin", referent_schema="sat",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# fin.invoice_items
|
||||
for constraint in (
|
||||
"fk_fin_invoice_items_tax_object_id",
|
||||
"fk_fin_invoice_items_unit_of_measure_id",
|
||||
"fk_fin_invoice_items_product_service_id",
|
||||
"fk_fin_invoice_items_concept_id",
|
||||
):
|
||||
op.drop_constraint(constraint, "invoice_items", schema="fin", type_="foreignkey")
|
||||
op.drop_index("ix_fin_invoice_items_concept_id", table_name="invoice_items", schema="fin")
|
||||
for column in ("tax_object_id", "unit_of_measure_id", "product_service_id", "concept_id"):
|
||||
op.drop_column("invoice_items", column, schema="fin")
|
||||
|
||||
# fin.invoices
|
||||
for constraint in (
|
||||
"fk_fin_invoices_payment_method_id",
|
||||
"fk_fin_invoices_payment_form_id",
|
||||
"fk_fin_invoices_voucher_type_id",
|
||||
):
|
||||
op.drop_constraint(constraint, "invoices", schema="fin", type_="foreignkey")
|
||||
for column in ("expedition_zip_code", "payment_method_id", "payment_form_id", "voucher_type_id"):
|
||||
op.drop_column("invoices", column, schema="fin")
|
||||
|
||||
# Tablas nuevas (los índices caen con la tabla).
|
||||
op.drop_table("invoice_item_taxes", schema="fin")
|
||||
op.drop_table("issuer_settings", schema="fin")
|
||||
op.drop_table("concepts", schema="fin")
|
||||
|
||||
# Catálogos del SAT: se va el schema completo.
|
||||
op.execute("DROP SCHEMA IF EXISTS sat CASCADE")
|
||||
@@ -1,113 +0,0 @@
|
||||
"""Outbox del carril CRM -> EFC: crm.efc_sync_outbox (expedientes) y crm.efc_file_outbox (archivos).
|
||||
|
||||
Revision ID: f7a8b9c0d1e2
|
||||
Revises: e6f7a8b9c0d1
|
||||
Create Date: 2026-08-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "f7a8b9c0d1e2"
|
||||
down_revision: Union[str, None] = "e6f7a8b9c0d1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- 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",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_crm_efc_file_outbox_expediente_ref", "efc_file_outbox", "expedientes",
|
||||
["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")
|
||||
@@ -0,0 +1,84 @@
|
||||
"""Catálogo c_UsoCFDI y claves fiscales del receptor en crm.accounts.
|
||||
|
||||
Cierra las decisiones pendientes 1 y 5 del ticket de catálogos SAT: agrega
|
||||
``sat.cfdi_uses`` y amarra el régimen fiscal y el uso de CFDI de la cuenta a los
|
||||
catálogos, conservando las columnas de texto libre que ya existían.
|
||||
|
||||
Revision ID: f7a8b9c0d1e2
|
||||
Revises: e6f7a8b9c0d1
|
||||
Create Date: 2026-08-07 00:00:00.000000
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs
|
||||
|
||||
revision: str = "f7a8b9c0d1e2"
|
||||
down_revision: Union[str, None] = "e6f7a8b9c0d1"
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# ---------- sat.cfdi_uses ----------
|
||||
op.create_table(
|
||||
"cfdi_uses",
|
||||
sa.Column("id", sa.Integer(), nullable=False),
|
||||
sa.Column("code", sa.String(length=4), nullable=False),
|
||||
sa.Column("description", sa.String(length=500), nullable=False),
|
||||
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")),
|
||||
sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
schema="sat",
|
||||
)
|
||||
op.create_index("ix_sat_cfdi_uses_id", "cfdi_uses", ["id"], schema="sat")
|
||||
op.create_index("ix_sat_cfdi_uses_code", "cfdi_uses", ["code"], unique=True, schema="sat")
|
||||
|
||||
# sync_catalogs es idempotente: siembra c_UsoCFDI y deja intactos los catálogos
|
||||
# que ya sembró la migración anterior.
|
||||
sync_catalogs(op.get_bind())
|
||||
|
||||
# ---------- crm.accounts: claves fiscales del receptor ----------
|
||||
# Nullables: las cuentas existentes solo tienen el texto libre.
|
||||
op.add_column("accounts", sa.Column("tax_regime_id", sa.Integer(), nullable=True), schema="crm")
|
||||
op.add_column("accounts", sa.Column("cfdi_use_id", sa.Integer(), nullable=True), schema="crm")
|
||||
op.create_foreign_key(
|
||||
"fk_crm_accounts_tax_regime_id", "accounts", "tax_regimes",
|
||||
["tax_regime_id"], ["id"], source_schema="crm", referent_schema="sat",
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_crm_accounts_cfdi_use_id", "accounts", "cfdi_uses",
|
||||
["cfdi_use_id"], ["id"], source_schema="crm", referent_schema="sat",
|
||||
)
|
||||
|
||||
# Backfill conservador: solo resuelve lo inequívoco. Se compara el texto libre
|
||||
# contra la clave del catálogo (p. ej. "601", "G03") y contra la descripción
|
||||
# exacta, sin distinguir mayúsculas ni espacios sobrantes. Lo que no case así se
|
||||
# queda en NULL para que lo revise el usuario: adivinar el régimen de un receptor
|
||||
# a partir de texto libre provoca CFDI rechazados.
|
||||
for column, catalog in [("tax_regime", "tax_regimes"), ("cfdi_use", "cfdi_uses")]:
|
||||
op.execute(
|
||||
f"""
|
||||
UPDATE crm.accounts AS a
|
||||
SET {column}_id = c.id
|
||||
FROM sat.{catalog} AS c
|
||||
WHERE a.{column}_id IS NULL
|
||||
AND a.{column} IS NOT NULL
|
||||
AND (
|
||||
upper(btrim(a.{column})) = upper(c.code)
|
||||
OR upper(btrim(a.{column})) = upper(c.description)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint("fk_crm_accounts_cfdi_use_id", "accounts", schema="crm", type_="foreignkey")
|
||||
op.drop_constraint("fk_crm_accounts_tax_regime_id", "accounts", schema="crm", type_="foreignkey")
|
||||
op.drop_column("accounts", "cfdi_use_id", schema="crm")
|
||||
op.drop_column("accounts", "tax_regime_id", schema="crm")
|
||||
op.drop_table("cfdi_uses", schema="sat")
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
@@ -31,39 +31,3 @@ class TenantScopedMixin:
|
||||
|
||||
tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True)
|
||||
company_id: Mapped[int] = mapped_column(Integer, nullable=False, index=True)
|
||||
|
||||
|
||||
class EfcDocumentRefMixin:
|
||||
"""Columnas del espejo de un documento en EFC. Se aplica a ``crm.documents`` y a
|
||||
``ops.shipment_documents``.
|
||||
|
||||
``efc_document_ref`` es el handle AUTORITATIVO —el CRM lo construye y EFC lo guarda—;
|
||||
``efc_document_id`` es solo un CACHE de la resolución, recuperable por el endpoint de lista si
|
||||
se pierde. Es el mismo principio que aplica el gateway de Anexo22: el sistema de origen conserva
|
||||
el registro de SU dato, y con eso pide el archivo de vuelta, en lugar de guardar identificadores
|
||||
ajenos en columnas propias.
|
||||
|
||||
``efc_sync_state`` es el estado del ESPEJO (lo que pinta la UI: badge, botón reintentar). La
|
||||
cola de trabajo vive aparte, en ``crm.efc_file_outbox``. No son redundantes: el outbox es
|
||||
indexable por su propio ciclo de vida y sobrevive a un borrado cuya fila ya no está.
|
||||
|
||||
Es un mixin y no diez columnas copiadas en dos modelos porque las dos tablas tienen que
|
||||
describir el mismo espejo: si divergen, la UI pinta un badge distinto según de dónde venga el
|
||||
documento y nadie entiende por qué.
|
||||
"""
|
||||
|
||||
expediente_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
|
||||
# {TABLA}-{company_id}-{row_id}, p. ej. SHPDOC-1-4471. Texto y no un entero porque el CRM tiene
|
||||
# dos tablas de documentos con secuencias independientes: crm.documents.id = 5 y
|
||||
# ops.shipment_documents.id = 5 coexisten, así que un entero solo sería ambiguo entre ellas.
|
||||
efc_document_ref: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
efc_document_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
# PENDING | SYNCED | FAILED
|
||||
efc_sync_state: Mapped[str | None] = mapped_column(
|
||||
String(20), nullable=True, server_default=text("'PENDING'")
|
||||
)
|
||||
efc_synced_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
efc_error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
efc_attempts: Mapped[int | None] = mapped_column(Integer, nullable=True, server_default=text("0"))
|
||||
content_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
@@ -25,6 +25,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)
|
||||
@@ -65,6 +68,8 @@ class AccountUpdate(BaseModel):
|
||||
website: str | None = Field(None, max_length=255)
|
||||
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
|
||||
|
||||
|
||||
@@ -47,8 +48,17 @@ class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
# ----- 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,20 +1,15 @@
|
||||
from sqlalchemy import ForeignKey, Integer, String
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import EfcDocumentRefMixin, TenantScopedMixin, TimestampMixin
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Document(Base, TenantScopedMixin, TimestampMixin, EfcDocumentRefMixin):
|
||||
class Document(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Documento de un cliente (``account_id``) o proveedor (``supplier_id``).
|
||||
|
||||
Guarda los metadatos y una referencia al archivo (``file_key`` en MinIO/S3 o
|
||||
``file_url`` externa). La subida binaria se hace vía la capa de storage.
|
||||
|
||||
Con ``EfcDocumentRefMixin`` la fila además refleja el estado del documento en el expediente
|
||||
electrónico de EFC. Un documento del CRM puede vivir en tres modos y la descarga se ramifica por
|
||||
ellos: ``efc_document_id`` (está en EFC → proxy), ``file_key`` (solo local → URL firmada) o
|
||||
``file_url`` (externa).
|
||||
"""
|
||||
|
||||
__tablename__ = "documents"
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
@@ -9,8 +8,6 @@ from ..suppliers.models import Supplier
|
||||
from .dto import DocumentCreate, DocumentUpdate
|
||||
from .models import Document
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _validate_owner(db: Session, account_id: int | None, supplier_id: int | None, tenant_id: int, company_id: int) -> None:
|
||||
"""Un documento debe pertenecer a exactamente un cliente o proveedor existente."""
|
||||
@@ -98,30 +95,6 @@ def update_document(
|
||||
|
||||
|
||||
def delete_document(db: Session, document_id: int, tenant_id: int, company_id: int) -> None:
|
||||
"""Baja lógica del documento. Si estaba en un expediente, lo **desasocia**; no lo destruye.
|
||||
|
||||
El CRM no llama al DELETE de EFC, y es deliberado: el gateway de Anexo22 tampoco lo hace
|
||||
—verificado, ese método no existe en su cliente— y ``record.Document`` en EFC no tiene vigencia
|
||||
ni purga, así que la política implícita del sistema es conservar. Un documento que mañana puede
|
||||
ser parte del expediente de un pedimento real es riesgo de retención fiscal.
|
||||
|
||||
El objeto local sí se limpia cuando todavía existe: si ya se entregó a EFC, ``delete_local`` lo
|
||||
borró al confirmar y ``file_key`` está en ``None``.
|
||||
"""
|
||||
document = get_document(db, document_id, tenant_id, company_id)
|
||||
document.deleted_at = datetime.now(timezone.utc)
|
||||
document.expediente_id = None
|
||||
if document.file_key:
|
||||
try:
|
||||
from core.storage_s3 import delete_object_if_exists
|
||||
|
||||
delete_object_if_exists(document.file_key)
|
||||
except Exception:
|
||||
# El borrado del objeto es una consecuencia de la baja, no parte de ella: dejar un
|
||||
# objeto huérfano es preferible a no poder dar de baja el documento.
|
||||
logger.warning(
|
||||
"documents: no se pudo borrar el objeto local %s del documento %s",
|
||||
document.file_key, document.id, exc_info=True,
|
||||
)
|
||||
document.file_key = None
|
||||
db.commit()
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
"""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.expedientes.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.expedientes.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)
|
||||
@@ -1,58 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,731 +0,0 @@
|
||||
"""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 ..expedientes.models import Expediente
|
||||
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: Expediente) -> 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: Expediente) -> 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
|
||||
# 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.folio,
|
||||
"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: Expediente, 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.folio,
|
||||
"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(Expediente).filter(Expediente.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(Expediente).filter(Expediente.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.folio,
|
||||
"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 == Expediente.id)
|
||||
return (
|
||||
db.query(Expediente)
|
||||
.filter(Expediente.deleted_at.is_(None), ~ya_encolado)
|
||||
.order_by(Expediente.id.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
@@ -1,122 +0,0 @@
|
||||
"""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
|
||||
|
||||
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
|
||||
@@ -1,64 +0,0 @@
|
||||
"""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
|
||||
@@ -1,106 +0,0 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ExpedienteBase(BaseModel):
|
||||
service_request_id: int | None = None
|
||||
account_id: int | None = None
|
||||
status: str = Field("abierto", max_length=20)
|
||||
|
||||
|
||||
class ExpedienteCreate(BaseModel):
|
||||
"""Alta explícita de un expediente.
|
||||
|
||||
No lleva ``folio``: lo asigna el servidor con el contador de ``folio.py``. Aceptarlo del cliente
|
||||
permitiría pisar el consecutivo de otro expediente.
|
||||
"""
|
||||
|
||||
service_request_id: int | None = None
|
||||
account_id: int | None = None
|
||||
|
||||
|
||||
class ExpedienteUpdate(BaseModel):
|
||||
account_id: int | None = None
|
||||
status: str | None = Field(None, max_length=20)
|
||||
|
||||
|
||||
class ExpedienteCompleteInput(BaseModel):
|
||||
"""Data aduanera real con la que se completa un expediente provisional."""
|
||||
|
||||
patente: str = Field(..., max_length=20)
|
||||
aduana: str = Field(..., max_length=10)
|
||||
numero_pedimento: str = Field(..., max_length=20)
|
||||
anio: int = Field(..., ge=1900, le=2999)
|
||||
clave_pedimento: str | None = Field(None, max_length=10)
|
||||
regimen: str | None = Field(None, max_length=10)
|
||||
fecha_pago: date | None = None
|
||||
rfc_importador: str | None = Field(None, max_length=20)
|
||||
rfc_agente_aduanal: str | None = Field(None, max_length=100)
|
||||
|
||||
|
||||
class ExpedienteResponse(ExpedienteBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
folio: str
|
||||
period_year: int
|
||||
period_month: int
|
||||
sequence: int
|
||||
|
||||
efc_organizacion_id: str | None = None
|
||||
efc_pedimento_id: str | None = None
|
||||
efc_storage_token: str | None = None
|
||||
efc_link_state: str
|
||||
efc_error_code: str | None = None
|
||||
efc_error_detail: str | None = None
|
||||
|
||||
patente: str | None = None
|
||||
aduana: str | None = None
|
||||
numero_pedimento: str | None = None
|
||||
anio: int | None = None
|
||||
clave_pedimento: str | None = None
|
||||
regimen: str | None = None
|
||||
fecha_pago: date | None = None
|
||||
rfc_importador: str | None = None
|
||||
rfc_agente_aduanal: str | None = None
|
||||
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ExpedienteEnsureInput(BaseModel):
|
||||
"""Entrada de ``POST /expedientes/ensure``: la solicitud a la que colgar el expediente."""
|
||||
|
||||
service_request_id: int
|
||||
|
||||
|
||||
class ExpedienteDocumentResponse(BaseModel):
|
||||
"""Documento de un expediente, tal como lo ve el frontend.
|
||||
|
||||
**No lleva ``file_key`` ni ``file_url`` a propósito.** La copia local es de tránsito y se borra
|
||||
al confirmar la entrega a EFC, así que exponerla invitaría al frontend a guardarse una
|
||||
referencia que va a dejar de existir. Para abrir el archivo está el proxy de descarga.
|
||||
"""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
expediente_id: int | None = None
|
||||
doc_type: str
|
||||
name: str
|
||||
content_type: str | None = None
|
||||
size_bytes: int | None = None
|
||||
# Lo que pinta el badge de la ficha: PENDING | SYNCED | FAILED
|
||||
efc_sync_state: str | None = None
|
||||
efc_document_ref: str | None = None
|
||||
efc_document_id: str | None = None
|
||||
efc_error_code: str | None = None
|
||||
efc_attempts: int | None = None
|
||||
uploaded_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@@ -1,95 +0,0 @@
|
||||
"""Asignador del folio de expediente: ``EXP{YYYY}-{MM}-{NNN}``.
|
||||
|
||||
Un consecutivo por ``(tenant, company, mes)`` que reinicia cada mes. La disciplina es la misma del
|
||||
asignador de folios de Anexo22 (``catalogos/customs_brokers/folios.py``): validar antes de tocar el
|
||||
contador, **no commitear dentro del asignador**, y fallar cerrado ante ambigüedad.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import ExpedienteFolioCounter
|
||||
|
||||
# Ancho del consecutivo dentro del folio. Al pasar de 999 el folio crece a 4 dígitos en vez de
|
||||
# truncarse o reiniciar: un folio ya comunicado al cliente no puede cambiar de forma.
|
||||
_SEQ_WIDTH = 3
|
||||
|
||||
|
||||
def format_folio(year: int, month: int, sequence: int) -> str:
|
||||
"""``(2026, 8, 1)`` → ``"EXP2026-08-001"``. Única fuente del formato del folio."""
|
||||
return f"EXP{year:04d}-{month:02d}-{sequence:0{_SEQ_WIDTH}d}"
|
||||
|
||||
|
||||
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.
|
||||
|
||||
Cabe en los 25 caracteres de ``Pedimento.pedimento_app`` mientras el consecutivo no pase de 4
|
||||
dígitos y el ``company_id`` de 7: ``CRM-`` (4) + company + ``-`` + ``EXP2026-08-001`` (14).
|
||||
"""
|
||||
return f"CRM-{company_id}-{folio}"
|
||||
|
||||
|
||||
def next_folio(
|
||||
db: Session, tenant_id: int, company_id: int, on: date | None = None
|
||||
) -> tuple[str, int, int, int]:
|
||||
"""Reserva el siguiente consecutivo del mes y devuelve ``(folio, year, month, sequence)``.
|
||||
|
||||
Una sola sentencia atómica, sin read-modify-write: el ``INSERT ... ON CONFLICT DO UPDATE``
|
||||
serializa sobre la fila de ese ``(tenant, company, mes)`` y devuelve el valor ya incrementado.
|
||||
Un ``SELECT max(sequence) + 1`` es exactamente la carrera que hay que evitar, y un
|
||||
``SELECT ... FOR UPDATE`` también sirve pero son dos viajes.
|
||||
|
||||
NO hace commit: opera sobre la sesión que recibe, para que un fallo posterior en la creación del
|
||||
expediente pueda hacer rollback sin quemar el folio.
|
||||
|
||||
Un rollback deja HUECO en la secuencia. Los huecos son aceptables; los duplicados no.
|
||||
"""
|
||||
today = on or date.today()
|
||||
period = f"{today.year:04d}-{today.month:02d}"
|
||||
|
||||
# El constructor de upsert es por dialecto: PostgreSQL en producción, SQLite en las pruebas
|
||||
# unitarias (tests/conftest.py). Se usa el constructor de SQLAlchemy y no SQL crudo porque el
|
||||
# `schema_translate_map` de las pruebas solo traduce el schema `crm` si la tabla viaja como
|
||||
# objeto; en un `text()` el nombre del schema queda escrito a mano y rompe en SQLite.
|
||||
dialect = db.get_bind().dialect.name
|
||||
if dialect == "postgresql":
|
||||
from sqlalchemy.dialects.postgresql import insert as _insert
|
||||
else:
|
||||
from sqlalchemy.dialects.sqlite import insert as _insert
|
||||
|
||||
table = ExpedienteFolioCounter.__table__
|
||||
stmt = _insert(table).values(
|
||||
tenant_id=tenant_id, company_id=company_id, period=period, last_seq=1
|
||||
)
|
||||
stmt = stmt.on_conflict_do_update(
|
||||
index_elements=["tenant_id", "company_id", "period"],
|
||||
set_={"last_seq": table.c.last_seq + 1},
|
||||
).returning(table.c.last_seq)
|
||||
|
||||
sequence = db.execute(stmt).scalar_one()
|
||||
return format_folio(today.year, today.month, sequence), today.year, today.month, sequence
|
||||
|
||||
|
||||
def peek_last_sequence(db: Session, tenant_id: int, company_id: int, on: date | None = None) -> int:
|
||||
"""El último consecutivo entregado en ese mes, o ``0`` si todavía no hay ninguno.
|
||||
|
||||
Solo lectura y sin efecto sobre el contador: existe para diagnóstico y para las pruebas. Quien
|
||||
necesite un folio usa :func:`next_folio`.
|
||||
"""
|
||||
today = on or date.today()
|
||||
period = f"{today.year:04d}-{today.month:02d}"
|
||||
value = db.execute(
|
||||
select(ExpedienteFolioCounter.last_seq).where(
|
||||
ExpedienteFolioCounter.tenant_id == tenant_id,
|
||||
ExpedienteFolioCounter.company_id == company_id,
|
||||
ExpedienteFolioCounter.period == period,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
return int(value or 0)
|
||||
@@ -1,117 +0,0 @@
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import Date, ForeignKey, Integer, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Expediente(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Expediente del CRM: el hilo documental de una operación, de la RFQ a la factura.
|
||||
|
||||
El ancla es la solicitud de servicio (``crm.service_requests``): un expediente por hilo
|
||||
comercial, siguiendo la cadena que el CRM ya tiene. En EFC cada expediente se refleja como un
|
||||
*pedimento provisional* cuyo ``pedimento_app`` es el ``efc_storage_token``, y cuando llega la
|
||||
data aduanera real ese provisional se completa sin mover un solo archivo.
|
||||
|
||||
El folio va DESCOMPUESTO en ``period_year`` / ``period_month`` / ``sequence`` además de
|
||||
guardarse armado en ``folio``: así el consecutivo es un constraint real de la base y no un
|
||||
parse de string. ``uq_crm_expedientes_periodo_seq`` es la red de seguridad — si el contador se
|
||||
corrompe, un folio duplicado falla ruidosamente en vez de mezclar dos expedientes.
|
||||
|
||||
Los campos ``efc_*`` son un ESPEJO de lo que hay en EFC, nunca el handle. El handle que el CRM
|
||||
usa para hablar de este expediente es su ``folio`` y su ``id``: ``efc_pedimento_id`` es un cache
|
||||
de la resolución y ``pedimento_app`` del lado de EFC es mutable —se reescribe al completar—, así
|
||||
que apoyarse en él rompería en cuanto la data real llegue.
|
||||
|
||||
``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 hace que completar el pedimento no
|
||||
obligue a mover archivos.
|
||||
"""
|
||||
|
||||
__tablename__ = "expedientes"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "company_id", "folio", name="uq_crm_expedientes_folio"),
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"company_id",
|
||||
"period_year",
|
||||
"period_month",
|
||||
"sequence",
|
||||
name="uq_crm_expedientes_periodo_seq",
|
||||
),
|
||||
{"schema": "crm"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
|
||||
# ── Folio ──────────────────────────────────────────────────────────────────────────────
|
||||
folio: Mapped[str] = mapped_column(String(20), nullable=False, index=True) # EXP2026-08-001
|
||||
period_year: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
period_month: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
|
||||
# ── Anclas comerciales ─────────────────────────────────────────────────────────────────
|
||||
service_request_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.service_requests.id"), nullable=True, index=True
|
||||
)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
|
||||
# abierto | completado | cerrado
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, server_default=text("'abierto'"), index=True
|
||||
)
|
||||
|
||||
# ── Espejo de EFC ──────────────────────────────────────────────────────────────────────
|
||||
efc_organizacion_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
efc_pedimento_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
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'")
|
||||
)
|
||||
# El diagnóstico se guarda en la fila para que se vea en la ficha, sin obligar a ir a los logs.
|
||||
efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
efc_error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
# ── Data aduanera real: se llena al completar, no al crear ─────────────────────────────
|
||||
# Longitudes tomadas de api/customs/models.py::Pedimento en EFC, que es el destino de estos
|
||||
# datos: patente 20, aduana 10, regimen 10, clave_pedimento 10, RFC del agente 100.
|
||||
patente: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
aduana: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
numero_pedimento: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
anio: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
clave_pedimento: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
regimen: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
fecha_pago: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
rfc_importador: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
rfc_agente_aduanal: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class ExpedienteFolioCounter(Base):
|
||||
"""Contador de folios por ``(tenant, company, mes)``.
|
||||
|
||||
Tabla propia y no un ``max(sequence) + 1`` sobre ``crm.expedientes``: ese SELECT es exactamente
|
||||
la carrera que hay que evitar. Aquí el consecutivo se reserva con un solo
|
||||
``INSERT ... ON CONFLICT DO UPDATE ... RETURNING`` (ver ``folio.py``), que serializa sobre esta
|
||||
fila y devuelve el valor ya incrementado.
|
||||
|
||||
No lleva los mixins de tenant ni de timestamps a propósito: ``tenant_id`` y ``company_id`` son
|
||||
parte de la PK compuesta, y una fila de contador no tiene ciclo de vida propio que auditar.
|
||||
"""
|
||||
|
||||
__tablename__ = "expediente_folio_counters"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
tenant_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("core.tenants.id"), primary_key=True, nullable=False
|
||||
)
|
||||
company_id: Mapped[int] = mapped_column(Integer, primary_key=True, nullable=False)
|
||||
period: Mapped[str] = mapped_column(String(7), primary_key=True, nullable=False) # "2026-08"
|
||||
last_seq: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
@@ -1,182 +0,0 @@
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.efc_client import EfcClientError
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
ExpedienteCompleteInput,
|
||||
ExpedienteDocumentResponse,
|
||||
ExpedienteEnsureInput,
|
||||
ExpedienteResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/expedientes", response_model=list[ExpedienteResponse])
|
||||
def list_expedientes(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
service_request_id: int | None = Query(None, description="Filtrar por solicitud"),
|
||||
account_id: int | None = Query(None, description="Filtrar por cliente"),
|
||||
status_filter: str | None = Query(None, alias="status", description="abierto|completado|cerrado"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.list_expedientes(
|
||||
db, tenant_id, company_id, service_request_id, account_id, status_filter
|
||||
)
|
||||
|
||||
|
||||
@router.get("/expedientes/{expediente_id}", response_model=ExpedienteResponse)
|
||||
def get_expediente(
|
||||
expediente_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_expediente(db, expediente_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/expedientes/ensure", response_model=ExpedienteResponse)
|
||||
def ensure_expediente(
|
||||
payload: ExpedienteEnsureInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Devuelve el expediente de una solicitud, creándolo si hace falta. Idempotente.
|
||||
|
||||
Responde 200 y no 201 justamente porque es idempotente: el llamador no puede distinguir —ni le
|
||||
importa— si el expediente ya estaba.
|
||||
"""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
return service.ensure_expediente(db, payload.service_request_id, tenant_id, company_id, user_id)
|
||||
|
||||
|
||||
@router.post("/expedientes/{expediente_id}/completar", response_model=ExpedienteResponse)
|
||||
def complete_expediente(
|
||||
expediente_id: int,
|
||||
payload: ExpedienteCompleteInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
return service.complete_expediente(db, expediente_id, payload, tenant_id, company_id, user_id)
|
||||
|
||||
|
||||
@router.delete("/expedientes/{expediente_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_expediente(
|
||||
expediente_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_expediente(db, expediente_id, tenant_id, company_id)
|
||||
|
||||
|
||||
# ── Documentos del expediente ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get(
|
||||
"/expedientes/{expediente_id}/documentos",
|
||||
response_model=list[ExpedienteDocumentResponse],
|
||||
)
|
||||
def list_expediente_documents(
|
||||
expediente_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.list_expediente_documents(db, expediente_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/expedientes/{expediente_id}/documentos",
|
||||
response_model=ExpedienteDocumentResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def upload_expediente_document(
|
||||
expediente_id: int,
|
||||
file: UploadFile = File(...),
|
||||
doc_type: str = Form(...),
|
||||
name: str | None = Form(None),
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Subida de un paso: guarda, registra y encola la entrega a EFC.
|
||||
|
||||
Responde **201 aunque EFC esté caído**: el archivo ya está a salvo en el CRM y el carril lo
|
||||
entrega cuando EFC vuelva. Perder el trabajo del usuario porque un sistema de terceros no
|
||||
contesta sería el peor intercambio posible.
|
||||
"""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
user_id = current_user.get("sub") or current_user.get("id")
|
||||
return await service.attach_document(
|
||||
db, expediente_id, file, doc_type, tenant_id, company_id, name, user_id
|
||||
)
|
||||
|
||||
|
||||
@router.get("/expedientes/{expediente_id}/documentos/{document_id}/archivo")
|
||||
async def download_expediente_document(
|
||||
expediente_id: int,
|
||||
document_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Proxy de descarga hacia EFC, con streaming.
|
||||
|
||||
La traducción de errores es asimétrica **a propósito**: cualquier ``EfcClientError`` sale como
|
||||
502 —es un fallo de la integración, no del usuario—, salvo un 404 de EFC, que sale como 404
|
||||
porque significa que ese documento realmente no está.
|
||||
|
||||
Es ``async`` porque el servicio abre la conexión con EFC y **comprueba su status antes** de que
|
||||
esta función devuelva la ``StreamingResponse``: una vez devuelta, el status ya se envió y
|
||||
traducir el error sería tarde (ver ``service._abrir_upstream``).
|
||||
"""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
try:
|
||||
iterador, content_type, filename = await service.stream_document(
|
||||
db, expediente_id, document_id, tenant_id, company_id
|
||||
)
|
||||
except EfcClientError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND
|
||||
if exc.status_code == 404
|
||||
else status.HTTP_502_BAD_GATEWAY,
|
||||
detail="No se pudo obtener el archivo del expediente electrónico.",
|
||||
) from exc
|
||||
|
||||
return StreamingResponse(
|
||||
iterador,
|
||||
media_type=content_type,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/expedientes/{expediente_id}/documentos/{document_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
)
|
||||
def detach_expediente_document(
|
||||
expediente_id: int,
|
||||
document_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Desasocia el documento del CRM. **No lo destruye en EFC** (ver ``service.detach_document``)."""
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.detach_document(db, expediente_id, document_id, tenant_id, company_id)
|
||||
@@ -1,447 +0,0 @@
|
||||
"""Servicio del expediente del CRM.
|
||||
|
||||
Funciones libres que reciben ``db, tenant_id, company_id``, como el resto de los módulos del repo.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.s3_keys import expediente_document_key
|
||||
from core.storage_s3 import put_object_bytes
|
||||
|
||||
from ..documents.models import Document
|
||||
from ..expediente_gateway import service as gateway
|
||||
from ..expediente_gateway.models import FILE_KIND_DOCUMENTO, SOURCE_CRM_DOCUMENTS
|
||||
from ..service_requests.models import ServiceRequest
|
||||
from ..uploads.routes import leer_acotado, validar_extension
|
||||
from .doc_types import is_valid_doc_type
|
||||
from .dto import ExpedienteCompleteInput
|
||||
from .folio import next_folio, storage_token
|
||||
from .models import Expediente
|
||||
|
||||
|
||||
def _get_service_request(db: Session, service_request_id: int, tenant_id: int, company_id: int) -> ServiceRequest:
|
||||
obj = (
|
||||
db.query(ServiceRequest)
|
||||
.filter(
|
||||
ServiceRequest.id == service_request_id,
|
||||
ServiceRequest.tenant_id == tenant_id,
|
||||
ServiceRequest.company_id == company_id,
|
||||
ServiceRequest.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Solicitud no encontrada")
|
||||
return obj
|
||||
|
||||
|
||||
def get_expediente(db: Session, expediente_id: int, tenant_id: int, company_id: int) -> Expediente:
|
||||
obj = (
|
||||
db.query(Expediente)
|
||||
.filter(
|
||||
Expediente.id == expediente_id,
|
||||
Expediente.tenant_id == tenant_id,
|
||||
Expediente.company_id == company_id,
|
||||
Expediente.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Expediente no encontrado")
|
||||
return obj
|
||||
|
||||
|
||||
def list_expedientes(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
service_request_id: int | None = None,
|
||||
account_id: int | None = None,
|
||||
exp_status: str | None = None,
|
||||
) -> list[Expediente]:
|
||||
query = db.query(Expediente).filter(
|
||||
Expediente.tenant_id == tenant_id,
|
||||
Expediente.company_id == company_id,
|
||||
Expediente.deleted_at.is_(None),
|
||||
)
|
||||
if service_request_id is not None:
|
||||
query = query.filter(Expediente.service_request_id == service_request_id)
|
||||
if account_id is not None:
|
||||
query = query.filter(Expediente.account_id == account_id)
|
||||
if exp_status:
|
||||
query = query.filter(Expediente.status == exp_status)
|
||||
return query.order_by(Expediente.created_at.desc()).all()
|
||||
|
||||
|
||||
def find_by_service_request(
|
||||
db: Session, service_request_id: int, tenant_id: int, company_id: int
|
||||
) -> Expediente | None:
|
||||
return (
|
||||
db.query(Expediente)
|
||||
.filter(
|
||||
Expediente.service_request_id == service_request_id,
|
||||
Expediente.tenant_id == tenant_id,
|
||||
Expediente.company_id == company_id,
|
||||
Expediente.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def ensure_expediente_for_service_request(
|
||||
db: Session,
|
||||
service_request_id: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
user_id: str | None = None,
|
||||
account_id: int | None = None,
|
||||
) -> Expediente:
|
||||
"""Devuelve el expediente de esa solicitud, creándolo si todavía no existe. **Idempotente.**
|
||||
|
||||
**No hace commit**: hace ``flush`` sobre la sesión que recibe. La razón es que sus dos
|
||||
llamadores —``create_service_request`` y ``create_from_opportunity``— lo invocan ANTES de su
|
||||
propio commit, dentro de la misma transacción. Si esto commiteara por su cuenta, un fallo
|
||||
posterior en el alta de la solicitud dejaría un expediente huérfano con su folio ya quemado.
|
||||
Es la misma disciplina de ``next_folio`` y la del asignador de folios de Anexo22.
|
||||
"""
|
||||
existing = find_by_service_request(db, service_request_id, tenant_id, company_id)
|
||||
if existing is not None:
|
||||
return existing
|
||||
|
||||
folio, year, month, sequence = next_folio(db, tenant_id, company_id)
|
||||
expediente = Expediente(
|
||||
folio=folio,
|
||||
period_year=year,
|
||||
period_month=month,
|
||||
sequence=sequence,
|
||||
service_request_id=service_request_id,
|
||||
account_id=account_id,
|
||||
status="abierto",
|
||||
efc_storage_token=storage_token(company_id, folio),
|
||||
efc_link_state="PENDING",
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
created_by=user_id,
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(expediente)
|
||||
db.flush()
|
||||
# Réplica hacia EFC: best-effort y en la MISMA transacción. Si EFC está apagado
|
||||
# (``EFC_API_URL`` vacía) esto es un no-op y el expediente vive igual, solo en el CRM.
|
||||
gateway.replicate_expediente_best_effort(db, expediente)
|
||||
return expediente
|
||||
|
||||
|
||||
def ensure_expediente(
|
||||
db: Session,
|
||||
service_request_id: int,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> Expediente:
|
||||
"""Variante de cara al usuario: valida la solicitud, asegura el expediente y **sí** commitea.
|
||||
|
||||
La usa el endpoint ``POST /expedientes/ensure``, donde la transacción empieza y termina aquí.
|
||||
"""
|
||||
solicitud = _get_service_request(db, service_request_id, tenant_id, company_id)
|
||||
expediente = ensure_expediente_for_service_request(
|
||||
db, solicitud.id, tenant_id, company_id, user_id, account_id=solicitud.account_id
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(expediente)
|
||||
return expediente
|
||||
|
||||
|
||||
def _campos_para_efc(campos: dict) -> dict:
|
||||
"""Traduce los campos del expediente al vocabulario del contrato de EFC.
|
||||
|
||||
Las fechas van en ISO porque el payload del outbox se serializa a JSON, y un ``date`` de Python
|
||||
no es serializable: sin esto la fila se encolaría bien y **fallaría al entregar**, que es el peor
|
||||
momento para descubrirlo.
|
||||
"""
|
||||
salida = {}
|
||||
for clave, valor in campos.items():
|
||||
salida[clave] = valor.isoformat() if hasattr(valor, "isoformat") else valor
|
||||
return salida
|
||||
|
||||
|
||||
def complete_expediente(
|
||||
db: Session,
|
||||
expediente_id: int,
|
||||
payload: ExpedienteCompleteInput,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> Expediente:
|
||||
"""Registra en el CRM la data aduanera real de un expediente.
|
||||
|
||||
Solo toca la fila del CRM. Completar el pedimento provisional del lado de EFC es una operación
|
||||
aparte, del carril del gateway, porque puede fallar por causas de EFC —un pedimento real que ya
|
||||
existe con esa llave— y eso no debe impedir que el CRM guarde lo que el usuario capturó.
|
||||
"""
|
||||
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
|
||||
if expediente.status == "completado":
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT, detail="El expediente ya está completado"
|
||||
)
|
||||
|
||||
campos = payload.model_dump(exclude_unset=True)
|
||||
for field, value in campos.items():
|
||||
setattr(expediente, field, value)
|
||||
expediente.status = "completado"
|
||||
expediente.updated_by = user_id
|
||||
|
||||
# El completado del provisional en EFC va por el outbox, no inline: puede devolver 409 si allá
|
||||
# ya existe ese pedimento real, y eso no debe impedir que el CRM guarde lo que se capturó.
|
||||
gateway.enqueue_completar_best_effort(db, expediente, _campos_para_efc(campos))
|
||||
|
||||
db.commit()
|
||||
db.refresh(expediente)
|
||||
return expediente
|
||||
|
||||
|
||||
def delete_expediente(db: Session, expediente_id: int, tenant_id: int, company_id: int) -> None:
|
||||
"""Baja lógica. No propaga nada a EFC: el expediente electrónico se conserva."""
|
||||
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
|
||||
expediente.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ══ Documentos del expediente (fase 7) ══════════════════════════════════════
|
||||
|
||||
async def attach_document(
|
||||
db: Session,
|
||||
expediente_id: int,
|
||||
file: UploadFile,
|
||||
doc_type: str,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
name: str | None = None,
|
||||
user_id: str | None = None,
|
||||
) -> Document:
|
||||
"""Subida de UN paso: guarda el archivo, crea el documento y encola su entrega a EFC.
|
||||
|
||||
El orden importa y no es negociable:
|
||||
|
||||
1. Validar tipo y extensión **antes** de tocar el almacén, para no dejar un objeto huérfano.
|
||||
2. Leer el archivo **acotado**: nunca ``await file.read()`` completo (ver ``leer_acotado``).
|
||||
3. Escribir a MinIO.
|
||||
4. Crear la fila del documento y la del outbox **en una sola transacción**, de modo que no
|
||||
pueda existir un documento sin su intención de entrega ni al revés.
|
||||
5. Despachar best-effort y responder 201 **pase lo que pase con EFC**.
|
||||
|
||||
El paso 5 es lo que hace que un EFC caído no le cueste su trabajo al usuario: la subida tiene
|
||||
éxito y el documento queda en «Pendiente de enviar» hasta que el barrido lo entregue.
|
||||
"""
|
||||
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
|
||||
|
||||
if not is_valid_doc_type(doc_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Ese tipo de documento no está en el catálogo.",
|
||||
)
|
||||
validar_extension(file.filename)
|
||||
contenido = await leer_acotado(file)
|
||||
|
||||
key = expediente_document_key(
|
||||
tenant_id, company_id, expediente.id, uuid.uuid4().hex, file.filename or "archivo"
|
||||
)
|
||||
content_type = file.content_type or "application/octet-stream"
|
||||
put_object_bytes(key, contenido, content_type=content_type)
|
||||
|
||||
documento = Document(
|
||||
doc_type=doc_type,
|
||||
name=name or file.filename or "archivo",
|
||||
file_key=key,
|
||||
content_type=content_type,
|
||||
size_bytes=len(contenido),
|
||||
expediente_id=expediente.id,
|
||||
efc_sync_state="PENDING",
|
||||
content_sha256=hashlib.sha256(contenido).hexdigest(),
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
uploaded_by=user_id,
|
||||
)
|
||||
db.add(documento)
|
||||
db.flush()
|
||||
|
||||
documento.efc_document_ref = f"CRMDOC-{company_id}-{documento.id}"
|
||||
fila = gateway.enqueue_file_best_effort(
|
||||
db,
|
||||
kind=FILE_KIND_DOCUMENTO,
|
||||
s3_key=key,
|
||||
file_name=file.filename or "archivo",
|
||||
content_type=content_type,
|
||||
efc_tipo=doc_type,
|
||||
source_table=SOURCE_CRM_DOCUMENTS,
|
||||
source_id=documento.id,
|
||||
crm_document_ref=documento.efc_document_ref,
|
||||
expediente_ref=expediente.id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
delete_local=True,
|
||||
)
|
||||
db.commit()
|
||||
db.refresh(documento)
|
||||
|
||||
if fila is not None:
|
||||
gateway._dispatch_file_delivery(fila.id, tenant_id, company_id)
|
||||
return documento
|
||||
|
||||
|
||||
def get_expediente_document(
|
||||
db: Session, expediente_id: int, document_id: int, tenant_id: int, company_id: int
|
||||
) -> Document:
|
||||
"""El documento, validando que pertenezca a ESE expediente, tenant y company.
|
||||
|
||||
La validación de pertenencia va **antes** de tocar EFC. Sin ella, un ``document_id`` que
|
||||
coincidiera leería el expediente de otro tenant — y peor: el ``organizacion_id`` con el que el
|
||||
proxy pregunta se deriva del expediente, así que el CRM iría a preguntarle a la organización de
|
||||
otro cliente.
|
||||
"""
|
||||
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
|
||||
documento = (
|
||||
db.query(Document)
|
||||
.filter(
|
||||
Document.id == document_id,
|
||||
Document.expediente_id == expediente.id,
|
||||
Document.tenant_id == tenant_id,
|
||||
Document.company_id == company_id,
|
||||
Document.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not documento:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Documento no encontrado")
|
||||
return documento
|
||||
|
||||
|
||||
def list_expediente_documents(
|
||||
db: Session, expediente_id: int, tenant_id: int, company_id: int
|
||||
) -> list[Document]:
|
||||
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
|
||||
return (
|
||||
db.query(Document)
|
||||
.filter(
|
||||
Document.expediente_id == expediente.id,
|
||||
Document.tenant_id == tenant_id,
|
||||
Document.company_id == company_id,
|
||||
Document.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Document.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
async def _abrir_upstream(url: str, headers: dict, params: dict, verify: bool, timeout_s: float):
|
||||
"""Abre la descarga contra EFC y **valida el status antes de devolver el generador**.
|
||||
|
||||
Que la validación ocurra aquí y no dentro del generador **no es cosmético**: Starlette manda la
|
||||
línea de estado en cuanto construye la ``StreamingResponse``, o sea **antes** de pedir el primer
|
||||
trozo. Un 404 de EFC detectado ya dentro del generador llega tarde —la respuesta salió con 200—
|
||||
y el usuario recibe un archivo cortado en lugar del error. Detectándolo antes, la traducción
|
||||
404→404 / resto→502 que fija el ticket llega de verdad al cliente.
|
||||
|
||||
**El ``AsyncClient`` se crea aquí y se cierra en el ``finally`` del generador**, no en un
|
||||
``async with`` de fuera: FastAPI consume el generador después de que esta función retorna, así
|
||||
que un cierre anticipado mataría la descarga a media transferencia.
|
||||
|
||||
EFC nunca entrega una URL de MinIO, y reescribir el host de una URL ya firmada invalida su
|
||||
SigV4, así que el CRM tiene que hacer de segundo proxy: no hay atajo.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
client = httpx.AsyncClient(verify=verify, timeout=timeout_s)
|
||||
try:
|
||||
peticion = client.build_request("GET", url, headers=headers, params=params)
|
||||
upstream = await client.send(peticion, stream=True)
|
||||
except httpx.HTTPError as exc:
|
||||
# Un fallo de red es de la integración, no del usuario: 502, nunca un 500 opaco. Y el
|
||||
# cliente se cierra aquí porque todavía no hay generador que lo haga.
|
||||
await client.aclose()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_502_BAD_GATEWAY,
|
||||
detail="No se pudo obtener el archivo del expediente electrónico.",
|
||||
) from exc
|
||||
except BaseException:
|
||||
await client.aclose()
|
||||
raise
|
||||
|
||||
if upstream.status_code >= 400:
|
||||
codigo = upstream.status_code
|
||||
await upstream.aread()
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND
|
||||
if codigo == 404
|
||||
else status.HTTP_502_BAD_GATEWAY,
|
||||
detail="No se pudo obtener el archivo del expediente electrónico.",
|
||||
)
|
||||
|
||||
async def _generador():
|
||||
try:
|
||||
async for chunk in upstream.aiter_bytes():
|
||||
yield chunk
|
||||
finally:
|
||||
await upstream.aclose()
|
||||
await client.aclose()
|
||||
|
||||
return _generador()
|
||||
|
||||
|
||||
async def stream_document(
|
||||
db: Session, expediente_id: int, document_id: int, tenant_id: int, company_id: int
|
||||
) -> tuple:
|
||||
"""Prepara la descarga de un documento del expediente.
|
||||
|
||||
Devuelve ``(iterador, content_type, filename)``. Dos guardas, y las dos importan:
|
||||
|
||||
1. La pertenencia se valida **antes** de tocar EFC (ver ``get_expediente_document``).
|
||||
2. Se manda el ``organizacion_id`` del expediente, para que la verificación de EFC también
|
||||
dispare y no baste con acertar un id de documento.
|
||||
"""
|
||||
from core.efc_client import EfcClientError, efc_client
|
||||
|
||||
expediente = get_expediente(db, expediente_id, tenant_id, company_id)
|
||||
documento = get_expediente_document(db, expediente_id, document_id, tenant_id, company_id)
|
||||
|
||||
if not documento.efc_document_id:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="El documento todavía no llegó al expediente electrónico.",
|
||||
)
|
||||
if not efc_client.is_configured:
|
||||
raise EfcClientError("Integración con EFC no configurada.", retryable=False)
|
||||
|
||||
return (
|
||||
await _abrir_upstream(
|
||||
efc_client.download_url(documento.efc_document_id),
|
||||
efc_client.auth_headers,
|
||||
{"organizacion_id": str(expediente.efc_organizacion_id or "")},
|
||||
efc_client.verify_ssl,
|
||||
efc_client.upload_timeout_s,
|
||||
),
|
||||
documento.content_type or "application/octet-stream",
|
||||
documento.name or "documento",
|
||||
)
|
||||
|
||||
|
||||
def detach_document(
|
||||
db: Session, expediente_id: int, document_id: int, tenant_id: int, company_id: int
|
||||
) -> None:
|
||||
"""Baja lógica en el CRM. **NO destruye el documento en EFC.**
|
||||
|
||||
El gateway de Anexo22 nunca llama al DELETE de EFC —verificado: ese método no existe en su
|
||||
cliente—, y ``record.Document`` en EFC no tiene vigencia ni purga, así que la política implícita
|
||||
del sistema es **conservar**. Un documento que mañana puede ser parte del expediente de un
|
||||
pedimento real es riesgo de retención fiscal: desaparece de la vista del CRM y sigue en EFC.
|
||||
"""
|
||||
documento = get_expediente_document(db, expediente_id, document_id, tenant_id, company_id)
|
||||
documento.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
@@ -16,7 +16,6 @@ _ENTITIES = [
|
||||
("contact", "contactos"),
|
||||
("address", "direcciones"),
|
||||
("document", "documentos"),
|
||||
("expediente", "expedientes"),
|
||||
("service_request", "solicitudes de servicio"),
|
||||
("rate_request", "solicitudes de tarifa"),
|
||||
("quote", "cotizaciones"),
|
||||
|
||||
@@ -16,8 +16,6 @@ from .addresses.routes import router as addresses_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 .expedientes.routes import router as expedientes_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
|
||||
@@ -37,8 +35,6 @@ router.include_router(suppliers_router)
|
||||
router.include_router(contacts_router)
|
||||
router.include_router(addresses_router)
|
||||
router.include_router(documents_router)
|
||||
router.include_router(expedientes_router)
|
||||
router.include_router(expediente_gateway_router)
|
||||
router.include_router(service_requests_router)
|
||||
router.include_router(quotes_router)
|
||||
router.include_router(leads_router)
|
||||
|
||||
@@ -5,7 +5,6 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..catalogs.data import INCOTERM_CODES
|
||||
from ..expedientes import service as expedientes_service
|
||||
from ..opportunities.models import Opportunity
|
||||
from ..suppliers.models import Supplier
|
||||
from .dto import (
|
||||
@@ -50,24 +49,6 @@ def _validate_request_refs(db: Session, data: dict, tenant_id: int, company_id:
|
||||
)
|
||||
|
||||
|
||||
def _ensure_expediente(
|
||||
db: Session, obj: ServiceRequest, tenant_id: int, company_id: int, user_id: str | None
|
||||
) -> None:
|
||||
"""Le da su expediente —y con él su folio— a una solicitud recién creada.
|
||||
|
||||
Va DENTRO de la transacción del alta, no como un paso posterior best-effort: el folio del
|
||||
expediente es lo que el usuario ve en la pantalla en cuanto guarda, así que una solicitud sin
|
||||
expediente sería una solicitud a medias. Si esto falla, el alta entera se revierte y el usuario
|
||||
ve el error, que es preferible a una solicitud que nadie puede documentar.
|
||||
|
||||
Lo best-effort es la *réplica hacia EFC*, no esto: aquélla vive en el gateway y nunca rompe la
|
||||
operación local.
|
||||
"""
|
||||
expedientes_service.ensure_expediente_for_service_request(
|
||||
db, obj.id, tenant_id, company_id, user_id, account_id=obj.account_id
|
||||
)
|
||||
|
||||
|
||||
# ----- Service requests (RFQ) -----
|
||||
|
||||
def get_service_requests(
|
||||
@@ -123,11 +104,6 @@ def create_service_request(
|
||||
_validate_request_refs(db, data, tenant_id, company_id)
|
||||
obj = ServiceRequest(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||
db.add(obj)
|
||||
# flush y no commit: el expediente necesita el id de la solicitud, pero los dos tienen que
|
||||
# nacer en la MISMA transacción. Si el expediente se creara aparte y el alta fallara después,
|
||||
# quedaría un folio quemado colgando de una solicitud que no existe.
|
||||
db.flush()
|
||||
_ensure_expediente(db, obj, tenant_id, company_id, user_id)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
@@ -208,8 +184,6 @@ def create_from_opportunity(
|
||||
updated_by=user_id,
|
||||
)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
_ensure_expediente(db, obj, tenant_id, company_id, user_id)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
@@ -17,21 +17,6 @@ router = APIRouter()
|
||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
|
||||
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
||||
|
||||
# Trozo de lectura. No es crítico afinarlo: lo que importa es que la lectura sea POR PARTES y no de
|
||||
# golpe, para poder abortar en cuanto se pase del tope.
|
||||
_CHUNK_BYTES = 1 * 1024 * 1024
|
||||
|
||||
# Allowlist de extensiones, igual que la que ya tienen el avatar y el centro de ayuda. Coincide con
|
||||
# la que EFC aplica del otro lado del carril del expediente: si aquí se aceptara algo que allá se
|
||||
# rechaza, el archivo se guardaría y su entrega quedaría condenada a `failed`.
|
||||
ALLOWED_UPLOAD_EXTENSIONS = (
|
||||
".pdf", ".xml", ".png", ".jpg", ".jpeg", ".json", ".txt", ".zip", ".docx", ".xlsx",
|
||||
)
|
||||
|
||||
# Prefijos que este endpoint puede firmar. Es un subárbol EXPLÍCITO, no toda la company: ver la
|
||||
# nota de `get_upload_url`.
|
||||
_PREFIJOS_FIRMABLES = ("crm-docs/", "expedientes/")
|
||||
|
||||
|
||||
def _safe_filename(name: str | None) -> str:
|
||||
base = (name or "archivo").strip().replace(" ", "_")
|
||||
@@ -39,44 +24,6 @@ def _safe_filename(name: str | None) -> str:
|
||||
return base[:120]
|
||||
|
||||
|
||||
def extension_de(name: str | None) -> str:
|
||||
base = (name or "").rsplit("/", 1)[-1].rsplit("\\", 1)[-1]
|
||||
if "." not in base:
|
||||
return ""
|
||||
return "." + base.rsplit(".", 1)[1].lower()
|
||||
|
||||
|
||||
def validar_extension(name: str | None) -> None:
|
||||
if extension_de(name) not in ALLOWED_UPLOAD_EXTENSIONS:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="Ese tipo de archivo no está permitido.",
|
||||
)
|
||||
|
||||
|
||||
async def leer_acotado(file: UploadFile, max_bytes: int = MAX_UPLOAD_BYTES) -> bytes:
|
||||
"""Lee el archivo POR PARTES y aborta en cuanto pasa del tope.
|
||||
|
||||
``await file.read()`` a secas trae el archivo entero a memoria **antes** de que nadie pueda
|
||||
mirar su tamaño: un archivo de 2 GB se bufferiza completo solo para responder 422 después. Aquí
|
||||
el corte ocurre al superar el tope, así que el peor caso en RAM es el tope más un trozo.
|
||||
"""
|
||||
partes: list[bytes] = []
|
||||
total = 0
|
||||
while True:
|
||||
chunk = await file.read(_CHUNK_BYTES)
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="El archivo excede el tamaño máximo permitido.",
|
||||
)
|
||||
partes.append(chunk)
|
||||
return b"".join(partes)
|
||||
|
||||
|
||||
@router.post("/uploads")
|
||||
async def upload_file(
|
||||
file: UploadFile = File(...),
|
||||
@@ -84,8 +31,12 @@ async def upload_file(
|
||||
current_user: dict = Depends(get_current_user),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
validar_extension(file.filename)
|
||||
content = await leer_acotado(file)
|
||||
content = await file.read()
|
||||
if len(content) > MAX_UPLOAD_BYTES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="El archivo excede el tamaño máximo permitido (25 MB)",
|
||||
)
|
||||
filename = _safe_filename(file.filename)
|
||||
key = f"tenants/{tenant_id}/companies/{company_id}/crm-docs/{uuid.uuid4().hex}/{filename}"
|
||||
put_object_bytes(key, content, content_type=file.content_type or "application/octet-stream")
|
||||
@@ -109,13 +60,4 @@ def get_upload_url(
|
||||
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
|
||||
if not key.startswith(prefix):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
||||
|
||||
# Y SOLO dentro de los subárboles de documentos. Validar únicamente el prefijo de la company
|
||||
# permitía firmar una URL para CUALQUIER objeto suyo —`certificates/` (llaves privadas de la
|
||||
# FIEL), `fin-invoices/`, `imports/csv/`— con solo el permiso `crm.access`. El alcance de este
|
||||
# endpoint es "los archivos que el CRM subió", no "todo el almacén de la company".
|
||||
resto = key[len(prefix):]
|
||||
if not resto.startswith(_PREFIJOS_FIRMABLES):
|
||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
||||
|
||||
return {"url": presigned_get_url(key)}
|
||||
|
||||
1
backend/api/v1/modules/fin/catalogs/__init__.py
Normal file
1
backend/api/v1/modules/fin/catalogs/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Catálogos oficiales del SAT (schema ``sat``): globales y de solo lectura."""
|
||||
61
backend/api/v1/modules/fin/catalogs/dto.py
Normal file
61
backend/api/v1/modules/fin/catalogs/dto.py
Normal file
@@ -0,0 +1,61 @@
|
||||
"""Esquemas de respuesta de los catálogos del SAT (solo lectura)."""
|
||||
|
||||
from pydantic import BaseModel, ConfigDict
|
||||
|
||||
|
||||
class SatCatalogItem(BaseModel):
|
||||
"""Forma común de todo catálogo del SAT: clave + descripción."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
code: str
|
||||
description: str
|
||||
is_active: bool
|
||||
|
||||
|
||||
class TaxRegimeResponse(SatCatalogItem):
|
||||
"""``c_RegimenFiscal``: incluye a qué tipo de persona aplica el régimen."""
|
||||
|
||||
applies_to_individual: bool # persona física
|
||||
applies_to_legal_entity: bool # persona moral
|
||||
|
||||
|
||||
class TaxResponse(SatCatalogItem):
|
||||
"""``c_Impuesto``: indica si el impuesto puede retenerse o trasladarse."""
|
||||
|
||||
is_withholding: bool
|
||||
is_transferred: bool
|
||||
is_local: bool
|
||||
|
||||
|
||||
class UnitOfMeasureResponse(SatCatalogItem):
|
||||
"""``c_ClaveUnidad``: nombre corto, símbolo y nota larga del catálogo."""
|
||||
|
||||
description: str | None = None
|
||||
name: str
|
||||
symbol: str | None = None
|
||||
|
||||
|
||||
class PaymentFormResponse(SatCatalogItem):
|
||||
"""``c_FormaPago``."""
|
||||
|
||||
|
||||
class ProductServiceResponse(SatCatalogItem):
|
||||
"""``c_ClaveProdServ``."""
|
||||
|
||||
|
||||
class VoucherTypeResponse(SatCatalogItem):
|
||||
"""``c_TipoDeComprobante``."""
|
||||
|
||||
|
||||
class PaymentMethodResponse(SatCatalogItem):
|
||||
"""``c_MetodoPago``."""
|
||||
|
||||
|
||||
class TaxObjectResponse(SatCatalogItem):
|
||||
"""``c_ObjetoImp``."""
|
||||
|
||||
|
||||
class CfdiUseResponse(SatCatalogItem):
|
||||
"""``c_UsoCFDI``."""
|
||||
143
backend/api/v1/modules/fin/catalogs/models.py
Normal file
143
backend/api/v1/modules/fin/catalogs/models.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Modelos de los catálogos oficiales del SAT — schema ``sat``.
|
||||
|
||||
Son catálogos **globales**: los publica el SAT, valen igual para cualquier tenant y
|
||||
compañía, por eso no heredan ``TenantScopedMixin``. Tampoco se borran: cuando el SAT
|
||||
retira una clave, el registro se marca ``is_active = false`` para que las facturas
|
||||
históricas que la usan sigan resolviendo su descripción (de ahí que se use
|
||||
``BaseTimestampMixin``, sin ``deleted_at``).
|
||||
|
||||
La API los expone únicamente en modo lectura; el alta y la actualización pasan por
|
||||
``seed_data.sync_catalogs()``.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import BaseTimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class SatCatalogMixin(BaseTimestampMixin):
|
||||
"""Campos comunes a todo catálogo del SAT.
|
||||
|
||||
``code`` (la clave oficial) se declara en cada modelo porque su longitud
|
||||
cambia de catálogo en catálogo.
|
||||
"""
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||
|
||||
|
||||
class TaxRegime(Base, SatCatalogMixin):
|
||||
"""``c_RegimenFiscal`` — régimen fiscal del emisor y del receptor del CFDI.
|
||||
|
||||
Las banderas indican a qué tipo de persona aplica el régimen: una persona física
|
||||
no puede declararse en el 601 (General de Ley Personas Morales) y viceversa.
|
||||
"""
|
||||
|
||||
__tablename__ = "tax_regimes"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
|
||||
applies_to_individual: Mapped[bool] = mapped_column( # persona física
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
applies_to_legal_entity: Mapped[bool] = mapped_column( # persona moral
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
|
||||
|
||||
class Tax(Base, SatCatalogMixin):
|
||||
"""``c_Impuesto`` — impuestos federales que pueden trasladarse o retenerse."""
|
||||
|
||||
__tablename__ = "taxes"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
|
||||
is_withholding: Mapped[bool] = mapped_column( # puede retenerse
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
is_transferred: Mapped[bool] = mapped_column( # puede trasladarse
|
||||
Boolean, nullable=False, server_default=text("false")
|
||||
)
|
||||
# Los impuestos locales (ISH y similares) viajan en el complemento "Impuestos
|
||||
# Locales" con claves ajenas a c_Impuesto; la bandera queda disponible para
|
||||
# cuando el negocio defina ese catálogo.
|
||||
is_local: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
|
||||
class PaymentForm(Base, SatCatalogMixin):
|
||||
"""``c_FormaPago`` — con qué se pagó (efectivo, transferencia, tarjeta…)."""
|
||||
|
||||
__tablename__ = "payment_forms"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class UnitOfMeasure(Base, SatCatalogMixin):
|
||||
"""``c_ClaveUnidad`` — unidad de medida de la partida.
|
||||
|
||||
Único catálogo que separa nombre corto y definición: ``name`` es lo que se
|
||||
muestra al capturar y ``description`` la nota larga del SAT, que puede venir
|
||||
vacía.
|
||||
"""
|
||||
|
||||
__tablename__ = "units_of_measure"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(20), nullable=False, unique=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
symbol: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Se redeclara para permitir NULL: aquí la descripción es la nota del catálogo.
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
|
||||
|
||||
class ProductService(Base, SatCatalogMixin):
|
||||
"""``c_ClaveProdServ`` — clave de producto o servicio de la partida."""
|
||||
|
||||
__tablename__ = "products_services"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(8), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class VoucherType(Base, SatCatalogMixin):
|
||||
"""``c_TipoDeComprobante`` — I ingreso, E egreso, T traslado, N nómina, P pago."""
|
||||
|
||||
__tablename__ = "voucher_types"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(1), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class PaymentMethod(Base, SatCatalogMixin):
|
||||
"""``c_MetodoPago`` — PUE (una sola exhibición) o PPD (parcialidades/diferido)."""
|
||||
|
||||
__tablename__ = "payment_methods"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(3), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class TaxObject(Base, SatCatalogMixin):
|
||||
"""``c_ObjetoImp`` — si la partida es o no objeto de impuesto."""
|
||||
|
||||
__tablename__ = "tax_objects"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(2), nullable=False, unique=True, index=True)
|
||||
|
||||
|
||||
class CfdiUse(Base, SatCatalogMixin):
|
||||
"""``c_UsoCFDI`` — uso que el receptor le dará al comprobante.
|
||||
|
||||
Lo declara el receptor, no el emisor, y el SAT lo valida contra su régimen
|
||||
fiscal: por eso vive en la ficha del cliente (``crm.accounts.cfdi_use_id``).
|
||||
"""
|
||||
|
||||
__tablename__ = "cfdi_uses"
|
||||
__table_args__ = {"schema": "sat"}
|
||||
|
||||
code: Mapped[str] = mapped_column(String(4), nullable=False, unique=True, index=True)
|
||||
138
backend/api/v1/modules/fin/catalogs/routes.py
Normal file
138
backend/api/v1/modules/fin/catalogs/routes.py
Normal file
@@ -0,0 +1,138 @@
|
||||
"""Endpoints de los catálogos del SAT — **solo lectura**.
|
||||
|
||||
No se exponen POST/PUT/PATCH/DELETE a propósito: son catálogos fijos publicados por
|
||||
el SAT y se mantienen con ``seed_data.sync_catalogs()``, no por API.
|
||||
|
||||
Nota: aunque los catálogos son globales, el router del módulo exige ``fin.access``,
|
||||
permiso que se resuelve sobre una compañía; por eso las peticiones siguen llevando
|
||||
``company_id`` en la query string.
|
||||
"""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
CfdiUseResponse,
|
||||
PaymentFormResponse,
|
||||
PaymentMethodResponse,
|
||||
ProductServiceResponse,
|
||||
TaxObjectResponse,
|
||||
TaxRegimeResponse,
|
||||
TaxResponse,
|
||||
UnitOfMeasureResponse,
|
||||
VoucherTypeResponse,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
_SEARCH = Query(None, description="Búsqueda por clave o descripción")
|
||||
_ACTIVE_ONLY = Query(True, description="Solo claves vigentes")
|
||||
|
||||
|
||||
@router.get("/catalogs/tax-regimes", response_model=list[TaxRegimeResponse])
|
||||
def list_tax_regimes(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
person_type: Literal["fisica", "moral"] | None = Query(
|
||||
None, description="Acota al régimen de persona física o moral"
|
||||
),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_RegimenFiscal`` — régimen fiscal del emisor/receptor del CFDI."""
|
||||
return service.get_tax_regimes(db, search, active_only, person_type)
|
||||
|
||||
|
||||
@router.get("/catalogs/taxes", response_model=list[TaxResponse])
|
||||
def list_taxes(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_Impuesto`` — impuestos federales trasladados y retenidos."""
|
||||
return service.get_taxes(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/payment-forms", response_model=list[PaymentFormResponse])
|
||||
def list_payment_forms(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_FormaPago`` — medio con el que se liquidó el comprobante."""
|
||||
return service.get_payment_forms(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/units-of-measure", response_model=list[UnitOfMeasureResponse])
|
||||
def list_units_of_measure(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_ClaveUnidad`` — unidad de medida de la partida."""
|
||||
return service.get_units_of_measure(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/products-services", response_model=list[ProductServiceResponse])
|
||||
def list_products_services(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
limit: int = Query(50, ge=1, le=200, description="Máximo de claves devueltas"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_ClaveProdServ`` — clave de producto/servicio; pensado para autocompletado."""
|
||||
return service.get_products_services(db, search, active_only, limit)
|
||||
|
||||
|
||||
@router.get("/catalogs/voucher-types", response_model=list[VoucherTypeResponse])
|
||||
def list_voucher_types(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_TipoDeComprobante`` — ingreso, egreso, traslado, nómina o pago."""
|
||||
return service.get_voucher_types(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/payment-methods", response_model=list[PaymentMethodResponse])
|
||||
def list_payment_methods(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_MetodoPago`` — PUE o PPD."""
|
||||
return service.get_payment_methods(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/tax-objects", response_model=list[TaxObjectResponse])
|
||||
def list_tax_objects(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_ObjetoImp`` — si la partida es objeto de impuesto."""
|
||||
return service.get_tax_objects(db, search, active_only)
|
||||
|
||||
|
||||
@router.get("/catalogs/cfdi-uses", response_model=list[CfdiUseResponse])
|
||||
def list_cfdi_uses(
|
||||
search: str | None = _SEARCH,
|
||||
active_only: bool = _ACTIVE_ONLY,
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""``c_UsoCFDI`` — uso que el receptor le dará al comprobante."""
|
||||
return service.get_cfdi_uses(db, search, active_only)
|
||||
336
backend/api/v1/modules/fin/catalogs/seed_data.py
Normal file
336
backend/api/v1/modules/fin/catalogs/seed_data.py
Normal file
@@ -0,0 +1,336 @@
|
||||
"""Datos semilla de los catálogos del SAT y su sincronización idempotente.
|
||||
|
||||
Los catálogos viven aquí y no dentro de una migración concreta a propósito: cuando el
|
||||
SAT corrige una descripción o publica una clave nueva, basta editar estas listas y
|
||||
volver a correr :func:`sync_catalogs`, sin escribir una migración de esquema.
|
||||
|
||||
Las tablas se describen con ``sa.Table`` ligeros sobre un ``MetaData`` propio (no con
|
||||
los modelos ORM) para que la migración pueda importar este módulo sin acoplarse a la
|
||||
definición ORM, que sigue evolucionando.
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
|
||||
_metadata = sa.MetaData()
|
||||
|
||||
|
||||
def _catalog_table(name: str, *extra_columns: sa.Column) -> sa.Table:
|
||||
"""Tabla mínima de catálogo: las columnas que toca el upsert, nada más."""
|
||||
return sa.Table(
|
||||
name,
|
||||
_metadata,
|
||||
sa.Column("id", sa.Integer, primary_key=True),
|
||||
sa.Column("code", sa.String, nullable=False),
|
||||
sa.Column("description", sa.String),
|
||||
sa.Column("is_active", sa.Boolean),
|
||||
*extra_columns,
|
||||
schema="sat",
|
||||
)
|
||||
|
||||
|
||||
tax_regimes_table = _catalog_table(
|
||||
"tax_regimes",
|
||||
sa.Column("applies_to_individual", sa.Boolean),
|
||||
sa.Column("applies_to_legal_entity", sa.Boolean),
|
||||
)
|
||||
taxes_table = _catalog_table(
|
||||
"taxes",
|
||||
sa.Column("is_withholding", sa.Boolean),
|
||||
sa.Column("is_transferred", sa.Boolean),
|
||||
sa.Column("is_local", sa.Boolean),
|
||||
)
|
||||
payment_forms_table = _catalog_table("payment_forms")
|
||||
units_of_measure_table = _catalog_table(
|
||||
"units_of_measure",
|
||||
sa.Column("name", sa.String),
|
||||
sa.Column("symbol", sa.String),
|
||||
)
|
||||
products_services_table = _catalog_table("products_services")
|
||||
voucher_types_table = _catalog_table("voucher_types")
|
||||
payment_methods_table = _catalog_table("payment_methods")
|
||||
tax_objects_table = _catalog_table("tax_objects")
|
||||
cfdi_uses_table = _catalog_table("cfdi_uses")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_RegimenFiscal (CFDI 4.0)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _regime(code: str, description: str, individual: bool, legal_entity: bool) -> dict:
|
||||
return {
|
||||
"code": code,
|
||||
"description": description,
|
||||
"applies_to_individual": individual,
|
||||
"applies_to_legal_entity": legal_entity,
|
||||
"is_active": True,
|
||||
}
|
||||
|
||||
|
||||
TAX_REGIMES: list[dict] = [
|
||||
_regime("601", "General de Ley Personas Morales", False, True),
|
||||
_regime("603", "Personas Morales con Fines no Lucrativos", False, True),
|
||||
_regime("605", "Sueldos y Salarios e Ingresos Asimilados a Salarios", True, False),
|
||||
_regime("606", "Arrendamiento", True, False),
|
||||
_regime("607", "Régimen de Enajenación o Adquisición de Bienes", True, False),
|
||||
_regime("608", "Demás ingresos", True, False),
|
||||
_regime("610", "Residentes en el Extranjero sin Establecimiento Permanente en México", True, True),
|
||||
_regime("611", "Ingresos por Dividendos (socios y accionistas)", True, False),
|
||||
_regime("612", "Personas Físicas con Actividades Empresariales y Profesionales", True, False),
|
||||
_regime("614", "Ingresos por intereses", True, False),
|
||||
_regime("615", "Régimen de los ingresos por obtención de premios", True, False),
|
||||
_regime("616", "Sin obligaciones fiscales", True, False),
|
||||
_regime("620", "Sociedades Cooperativas de Producción que optan por diferir sus ingresos", False, True),
|
||||
_regime("621", "Incorporación Fiscal", True, False),
|
||||
_regime("622", "Actividades Agrícolas, Ganaderas, Silvícolas y Pesqueras", False, True),
|
||||
_regime("623", "Opcional para Grupos de Sociedades", False, True),
|
||||
_regime("624", "Coordinados", False, True),
|
||||
_regime("625", "Régimen de las Actividades Empresariales con ingresos a través de Plataformas Tecnológicas", True, False),
|
||||
_regime("626", "Régimen Simplificado de Confianza", True, True),
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_Impuesto
|
||||
# ---------------------------------------------------------------------------
|
||||
# is_local queda en false para los tres: los impuestos locales (ISH y similares)
|
||||
# se declaran en el complemento "Impuestos Locales" con claves que no pertenecen
|
||||
# a c_Impuesto. No se siembran registros locales inventados.
|
||||
|
||||
TAXES: list[dict] = [
|
||||
{"code": "001", "description": "ISR", "is_withholding": True, "is_transferred": False, "is_local": False, "is_active": True},
|
||||
{"code": "002", "description": "IVA", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True},
|
||||
{"code": "003", "description": "IEPS", "is_withholding": True, "is_transferred": True, "is_local": False, "is_active": True},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_FormaPago
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PAYMENT_FORMS: list[dict] = [
|
||||
{"code": code, "description": description, "is_active": True}
|
||||
for code, description in [
|
||||
("01", "Efectivo"),
|
||||
("02", "Cheque nominativo"),
|
||||
("03", "Transferencia electrónica de fondos"),
|
||||
("04", "Tarjeta de crédito"),
|
||||
("05", "Monedero electrónico"),
|
||||
("06", "Dinero electrónico"),
|
||||
("08", "Vales de despensa"),
|
||||
("12", "Dación en pago"),
|
||||
("13", "Pago por subrogación"),
|
||||
("14", "Pago por consignación"),
|
||||
("15", "Condonación"),
|
||||
("17", "Compensación"),
|
||||
("23", "Novación"),
|
||||
("24", "Confusión"),
|
||||
("25", "Remisión de deuda"),
|
||||
("26", "Prescripción o caducidad"),
|
||||
("27", "A satisfacción del acreedor"),
|
||||
("28", "Tarjeta de débito"),
|
||||
("29", "Tarjeta de servicios"),
|
||||
("30", "Aplicación de anticipos"),
|
||||
("31", "Intermediario pagos"),
|
||||
("99", "Por definir"),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_TipoDeComprobante
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
VOUCHER_TYPES: list[dict] = [
|
||||
{"code": code, "description": description, "is_active": True}
|
||||
for code, description in [
|
||||
("I", "Ingreso"),
|
||||
("E", "Egreso"),
|
||||
("T", "Traslado"),
|
||||
("N", "Nómina"),
|
||||
("P", "Pago"),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_MetodoPago
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PAYMENT_METHODS: list[dict] = [
|
||||
{"code": "PUE", "description": "Pago en una sola exhibición", "is_active": True},
|
||||
{"code": "PPD", "description": "Pago en parcialidades o diferido", "is_active": True},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_ObjetoImp
|
||||
# ---------------------------------------------------------------------------
|
||||
# Versiones posteriores del catálogo incorporan las claves 05–07; no se siembran
|
||||
# hasta que el área Fiscal confirme la versión vigente (ver PENDIENTE DECISIÓN).
|
||||
|
||||
TAX_OBJECTS: list[dict] = [
|
||||
{"code": "01", "description": "No objeto de impuesto", "is_active": True},
|
||||
{"code": "02", "description": "Sí objeto de impuesto", "is_active": True},
|
||||
{"code": "03", "description": "Sí objeto del impuesto y no obligado al desglose", "is_active": True},
|
||||
{"code": "04", "description": "Sí objeto del impuesto y no causa impuesto", "is_active": True},
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_ClaveUnidad — subset operativo
|
||||
# ---------------------------------------------------------------------------
|
||||
# description queda en NULL: es la nota larga del catálogo, que aquí no aporta.
|
||||
|
||||
UNITS_OF_MEASURE: list[dict] = [
|
||||
{"code": code, "name": name, "symbol": symbol, "description": None, "is_active": True}
|
||||
for code, name, symbol in [
|
||||
("H87", "Pieza", "pz"),
|
||||
("E48", "Unidad de servicio", None),
|
||||
("ACT", "Actividad", None),
|
||||
("C62", "Uno", None),
|
||||
("KGM", "Kilogramo", "kg"),
|
||||
("TNE", "Tonelada métrica", "t"),
|
||||
("GRM", "Gramo", "g"),
|
||||
("LTR", "Litro", "l"),
|
||||
("MTR", "Metro", "m"),
|
||||
("MTK", "Metro cuadrado", "m²"),
|
||||
("MTQ", "Metro cúbico", "m³"),
|
||||
("KMT", "Kilómetro", "km"),
|
||||
("CMT", "Centímetro", "cm"),
|
||||
("DAY", "Día", "d"),
|
||||
("HUR", "Hora", "h"),
|
||||
("MON", "Mes", None),
|
||||
("XBX", "Caja", None),
|
||||
("XPK", "Paquete", None),
|
||||
("XPX", "Paleta / tarima", None),
|
||||
("XLT", "Lote", None),
|
||||
("E51", "Trabajo", None),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_ClaveProdServ — subset de logística
|
||||
# ---------------------------------------------------------------------------
|
||||
# Subset inicial de c_ClaveProdServ para agente de carga — pendiente validación con
|
||||
# área Fiscal antes de producción. El catálogo completo son ~52,000 claves; aquí solo
|
||||
# se siembran las del giro. Si falta una clave para un caso de uso, se documenta como
|
||||
# PENDIENTE DECISIÓN: no se deduce ni se inventa.
|
||||
|
||||
PRODUCTS_SERVICES: list[dict] = [
|
||||
{"code": code, "description": description, "is_active": True}
|
||||
for code, description in [
|
||||
("78101500", "Transporte de carga por carretera"),
|
||||
("78101600", "Transporte de carga marítimo"),
|
||||
("78101700", "Transporte de carga por ferrocarril"),
|
||||
("78101800", "Transporte de carga aérea"),
|
||||
("78102200", "Servicios postales de paqueteo y courrier"),
|
||||
("78121600", "Embalaje"),
|
||||
("78131600", "Almacenaje"),
|
||||
("78141500", "Servicios de planificación logística"),
|
||||
("78141600", "Servicios de expedición de fletes"),
|
||||
("84131500", "Seguros de vida, salud y accidentes / seguros de carga"),
|
||||
("80101500", "Servicios de consultoría de negocios y administración corporativa"),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# c_UsoCFDI
|
||||
# ---------------------------------------------------------------------------
|
||||
# Catálogo del uso que el receptor da al comprobante. Se siembran clave y
|
||||
# descripción; **no** se cargan las banderas de persona física/moral ni la
|
||||
# compatibilidad por régimen fiscal, porque esa matriz cambia entre versiones del
|
||||
# catálogo y equivocarla provoca rechazos al timbrar.
|
||||
#
|
||||
# Pendiente validación con área Fiscal antes de producción, igual que el subset de
|
||||
# c_ClaveProdServ.
|
||||
|
||||
CFDI_USES: list[dict] = [
|
||||
{"code": code, "description": description, "is_active": True}
|
||||
for code, description in [
|
||||
("G01", "Adquisición de mercancías"),
|
||||
("G02", "Devoluciones, descuentos o bonificaciones"),
|
||||
("G03", "Gastos en general"),
|
||||
("I01", "Construcciones"),
|
||||
("I02", "Mobiliario y equipo de oficina por inversiones"),
|
||||
("I03", "Equipo de transporte"),
|
||||
("I04", "Equipo de cómputo y accesorios"),
|
||||
("I05", "Dados, troqueles, moldes, matrices y herramental"),
|
||||
("I06", "Comunicaciones telefónicas"),
|
||||
("I07", "Comunicaciones satelitales"),
|
||||
("I08", "Otra maquinaria y equipo"),
|
||||
("D01", "Honorarios médicos, dentales y gastos hospitalarios"),
|
||||
("D02", "Gastos médicos por incapacidad o discapacidad"),
|
||||
("D03", "Gastos funerales"),
|
||||
("D04", "Donativos"),
|
||||
("D05", "Intereses reales efectivamente pagados por créditos hipotecarios (casa habitación)"),
|
||||
("D06", "Aportaciones voluntarias al SAR"),
|
||||
("D07", "Primas por seguros de gastos médicos"),
|
||||
("D08", "Gastos de transportación escolar obligatoria"),
|
||||
("D09", "Depósitos en cuentas para el ahorro, primas que tengan como base planes de pensiones"),
|
||||
("D10", "Pagos por servicios educativos (colegiaturas)"),
|
||||
("S01", "Sin efectos fiscales"),
|
||||
("CP01", "Pagos"),
|
||||
("CN01", "Nómina"),
|
||||
]
|
||||
]
|
||||
|
||||
|
||||
# Orden estable de sincronización: (tabla, filas).
|
||||
CATALOGS: list[tuple[sa.Table, list[dict]]] = [
|
||||
(tax_regimes_table, TAX_REGIMES),
|
||||
(taxes_table, TAXES),
|
||||
(payment_forms_table, PAYMENT_FORMS),
|
||||
(units_of_measure_table, UNITS_OF_MEASURE),
|
||||
(products_services_table, PRODUCTS_SERVICES),
|
||||
(voucher_types_table, VOUCHER_TYPES),
|
||||
(payment_methods_table, PAYMENT_METHODS),
|
||||
(tax_objects_table, TAX_OBJECTS),
|
||||
(cfdi_uses_table, CFDI_USES),
|
||||
]
|
||||
|
||||
|
||||
def sync_catalogs(connection) -> dict[str, int]:
|
||||
"""Sincroniza los catálogos del SAT contra la base, de forma idempotente.
|
||||
|
||||
Inserta las claves que faltan y actualiza descripción y banderas de las que ya
|
||||
existen. **Nunca borra**: una clave retirada por el SAT se desactiva a mano para
|
||||
no romper los CFDI históricos que la referencian.
|
||||
|
||||
Devuelve un resumen ``{"sat.tabla": filas_insertadas}`` útil para la bitácora de
|
||||
la migración.
|
||||
|
||||
Los catálogos cuya tabla todavía no existe se omiten: al correr el historial de
|
||||
migraciones desde cero, una migración antigua invoca esta misma función cuando los
|
||||
catálogos agregados después aún no se han creado. Cada uno se siembra en la
|
||||
migración que lo crea.
|
||||
|
||||
Se usa contra el ``connection`` que da ``op.get_bind()`` en Alembic, o contra la
|
||||
conexión de una sesión en pruebas.
|
||||
"""
|
||||
inspector = sa.inspect(connection)
|
||||
# La inspección no aplica el schema_translate_map (las pruebas mapean sat -> None
|
||||
# sobre SQLite), así que se resuelve el schema efectivo a mano.
|
||||
schema_map = connection.get_execution_options().get("schema_translate_map") or {}
|
||||
|
||||
inserted: dict[str, int] = {}
|
||||
for table, rows in CATALOGS:
|
||||
effective_schema = schema_map.get(table.schema, table.schema)
|
||||
if not inspector.has_table(table.name, schema=effective_schema):
|
||||
continue
|
||||
key = f"sat.{table.name}"
|
||||
inserted[key] = 0
|
||||
for row in rows:
|
||||
existing = connection.execute(
|
||||
sa.select(table.c.id).where(table.c.code == row["code"])
|
||||
).scalar()
|
||||
values = {k: v for k, v in row.items() if k != "code"}
|
||||
if existing is None:
|
||||
connection.execute(table.insert().values(code=row["code"], **values))
|
||||
inserted[key] += 1
|
||||
else:
|
||||
connection.execute(
|
||||
table.update().where(table.c.id == existing).values(**values)
|
||||
)
|
||||
return inserted
|
||||
106
backend/api/v1/modules/fin/catalogs/service.py
Normal file
106
backend/api/v1/modules/fin/catalogs/service.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""Consultas de los catálogos del SAT.
|
||||
|
||||
Son globales (sin tenant_id / company_id) y de solo lectura: aquí no hay altas,
|
||||
cambios ni bajas, únicamente búsqueda para llenar los selectores de captura.
|
||||
"""
|
||||
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .models import (
|
||||
CfdiUse,
|
||||
PaymentForm,
|
||||
PaymentMethod,
|
||||
ProductService,
|
||||
Tax,
|
||||
TaxObject,
|
||||
TaxRegime,
|
||||
UnitOfMeasure,
|
||||
VoucherType,
|
||||
)
|
||||
|
||||
# Catálogos que además del código y la descripción buscan por nombre corto.
|
||||
_SEARCHABLE_EXTRA_FIELDS = {UnitOfMeasure: ("name",)}
|
||||
|
||||
|
||||
def search_catalog(
|
||||
db: Session,
|
||||
model,
|
||||
search: str | None = None,
|
||||
active_only: bool = True,
|
||||
limit: int | None = None,
|
||||
) -> list:
|
||||
"""Devuelve las claves de un catálogo, filtradas por texto libre.
|
||||
|
||||
``search`` compara contra la clave o la descripción sin distinguir mayúsculas.
|
||||
"""
|
||||
q = db.query(model)
|
||||
if active_only:
|
||||
q = q.filter(model.is_active.is_(True))
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
fields = [model.code, model.description]
|
||||
for extra in _SEARCHABLE_EXTRA_FIELDS.get(model, ()):
|
||||
fields.append(getattr(model, extra))
|
||||
q = q.filter(or_(*[f.ilike(term) for f in fields]))
|
||||
q = q.order_by(model.code.asc())
|
||||
if limit is not None:
|
||||
q = q.limit(limit)
|
||||
return q.all()
|
||||
|
||||
|
||||
def get_tax_regimes(
|
||||
db: Session,
|
||||
search: str | None = None,
|
||||
active_only: bool = True,
|
||||
person_type: str | None = None,
|
||||
) -> list[TaxRegime]:
|
||||
"""``c_RegimenFiscal``, opcionalmente acotado al tipo de persona.
|
||||
|
||||
``person_type='fisica'`` deja solo los regímenes que puede usar una persona
|
||||
física; ``'moral'``, los de persona moral.
|
||||
"""
|
||||
q = db.query(TaxRegime)
|
||||
if active_only:
|
||||
q = q.filter(TaxRegime.is_active.is_(True))
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
q = q.filter(or_(TaxRegime.code.ilike(term), TaxRegime.description.ilike(term)))
|
||||
if person_type == "fisica":
|
||||
q = q.filter(TaxRegime.applies_to_individual.is_(True))
|
||||
elif person_type == "moral":
|
||||
q = q.filter(TaxRegime.applies_to_legal_entity.is_(True))
|
||||
return q.order_by(TaxRegime.code.asc()).all()
|
||||
|
||||
|
||||
def get_taxes(db: Session, search=None, active_only=True) -> list[Tax]:
|
||||
return search_catalog(db, Tax, search, active_only)
|
||||
|
||||
|
||||
def get_payment_forms(db: Session, search=None, active_only=True) -> list[PaymentForm]:
|
||||
return search_catalog(db, PaymentForm, search, active_only)
|
||||
|
||||
|
||||
def get_units_of_measure(db: Session, search=None, active_only=True) -> list[UnitOfMeasure]:
|
||||
return search_catalog(db, UnitOfMeasure, search, active_only)
|
||||
|
||||
|
||||
def get_products_services(db: Session, search=None, active_only=True, limit=50) -> list[ProductService]:
|
||||
"""``c_ClaveProdServ``. Va paginado porque alimenta un autocompletado."""
|
||||
return search_catalog(db, ProductService, search, active_only, limit=limit)
|
||||
|
||||
|
||||
def get_voucher_types(db: Session, search=None, active_only=True) -> list[VoucherType]:
|
||||
return search_catalog(db, VoucherType, search, active_only)
|
||||
|
||||
|
||||
def get_payment_methods(db: Session, search=None, active_only=True) -> list[PaymentMethod]:
|
||||
return search_catalog(db, PaymentMethod, search, active_only)
|
||||
|
||||
|
||||
def get_tax_objects(db: Session, search=None, active_only=True) -> list[TaxObject]:
|
||||
return search_catalog(db, TaxObject, search, active_only)
|
||||
|
||||
|
||||
def get_cfdi_uses(db: Session, search=None, active_only=True) -> list[CfdiUse]:
|
||||
return search_catalog(db, CfdiUse, search, active_only)
|
||||
1
backend/api/v1/modules/fin/concepts/__init__.py
Normal file
1
backend/api/v1/modules/fin/concepts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Catálogo de conceptos de facturación por empresa."""
|
||||
55
backend/api/v1/modules/fin/concepts/dto.py
Normal file
55
backend/api/v1/modules/fin/concepts/dto.py
Normal file
@@ -0,0 +1,55 @@
|
||||
"""Esquemas del catálogo de conceptos de facturación."""
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from ..catalogs.dto import ProductServiceResponse, TaxObjectResponse, UnitOfMeasureResponse
|
||||
|
||||
|
||||
class ConceptBase(BaseModel):
|
||||
code: str = Field(..., min_length=1, max_length=40, description="Clave interna del concepto")
|
||||
description: str = Field(..., min_length=1, max_length=500)
|
||||
product_service_id: int = Field(..., description="Clave ProdServ del SAT (1:1 por empresa)")
|
||||
unit_of_measure_id: int | None = None
|
||||
tax_object_id: int | None = None
|
||||
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str = Field("MXN", min_length=3, max_length=3)
|
||||
is_active: bool = True
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ConceptCreate(ConceptBase):
|
||||
pass
|
||||
|
||||
|
||||
class ConceptUpdate(BaseModel):
|
||||
"""Actualización parcial: solo se tocan los campos enviados."""
|
||||
|
||||
code: str | None = Field(None, min_length=1, max_length=40)
|
||||
description: str | None = Field(None, min_length=1, max_length=500)
|
||||
product_service_id: int | None = None
|
||||
unit_of_measure_id: int | None = None
|
||||
tax_object_id: int | None = None
|
||||
unit_price: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str | None = Field(None, min_length=3, max_length=3)
|
||||
is_active: bool | None = None
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ConceptResponse(ConceptBase):
|
||||
"""Incluye los objetos del catálogo del SAT ya resueltos, para evitar N+1 en la UI."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
product_service: ProductServiceResponse | None = None
|
||||
unit_of_measure: UnitOfMeasureResponse | None = None
|
||||
tax_object: TaxObjectResponse | None = None
|
||||
created_by: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
67
backend/api/v1/modules/fin/concepts/models.py
Normal file
67
backend/api/v1/modules/fin/concepts/models.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Catálogo de conceptos de facturación — ``fin.concepts``.
|
||||
|
||||
A diferencia de los catálogos del SAT, este es **propio de cada empresa**: cada
|
||||
concepto que la empresa factura (flete internacional, despacho, almacenaje…) se
|
||||
registra una vez y queda amarrado a la clave de producto/servicio del SAT que le
|
||||
corresponde.
|
||||
|
||||
La relación con ``sat.products_services`` es **1:1 por empresa**: si dos conceptos
|
||||
compartieran la misma clave ProdServ, al timbrar no habría forma de saber cuál
|
||||
descripción corresponde a la clave, así que la unicidad se garantiza por índice y se
|
||||
valida además en el service para devolver un 409 con mensaje entendible.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Boolean, ForeignKey, Index, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure # noqa: F401 (resuelve las relaciones)
|
||||
|
||||
# Los índices son parciales (``WHERE deleted_at IS NULL``): un concepto dado de baja
|
||||
# lógica libera su clave y su código para uno nuevo.
|
||||
_ALIVE = text("deleted_at IS NULL")
|
||||
|
||||
|
||||
class Concept(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Concepto facturable de una empresa, ligado a una clave ProdServ del SAT."""
|
||||
|
||||
__tablename__ = "concepts"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_fin_concepts_code",
|
||||
"tenant_id", "company_id", "code",
|
||||
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
||||
),
|
||||
Index(
|
||||
"uq_fin_concepts_product_service",
|
||||
"tenant_id", "company_id", "product_service_id",
|
||||
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
||||
),
|
||||
{"schema": "fin"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
code: Mapped[str] = mapped_column(String(40), nullable=False) # clave interna del concepto
|
||||
description: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
product_service_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("sat.products_services.id"), nullable=False, index=True
|
||||
)
|
||||
unit_of_measure_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.units_of_measure.id"), nullable=True
|
||||
)
|
||||
tax_object_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.tax_objects.id"), nullable=True
|
||||
)
|
||||
unit_price: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("true"))
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
# Cargadas con selectinload para que el listado no dispare N+1 consultas.
|
||||
product_service: Mapped["ProductService"] = relationship("ProductService", lazy="selectin")
|
||||
unit_of_measure: Mapped["UnitOfMeasure | None"] = relationship("UnitOfMeasure", lazy="selectin")
|
||||
tax_object: Mapped["TaxObject | None"] = relationship("TaxObject", lazy="selectin")
|
||||
96
backend/api/v1/modules/fin/concepts/routes.py
Normal file
96
backend/api/v1/modules/fin/concepts/routes.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Endpoints del catálogo de conceptos de facturación (CRUD por empresa)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import ConceptCreate, ConceptResponse, ConceptUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _uid(current_user: dict) -> str | None:
|
||||
return current_user.get("sub") or current_user.get("id")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/concepts",
|
||||
response_model=list[ConceptResponse],
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
|
||||
)
|
||||
def list_concepts(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None, description="Búsqueda por clave o descripción"),
|
||||
active_only: bool | None = Query(None, description="Filtra por conceptos activos o inactivos"),
|
||||
product_service_id: int | None = Query(None, description="Filtra por clave ProdServ del SAT"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.get_concepts(
|
||||
db, current_user["tenant_id"], company_id, search, active_only, product_service_id
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/concepts/{concept_id}",
|
||||
response_model=ConceptResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.view"]))],
|
||||
)
|
||||
def get_concept(
|
||||
concept_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.get_concept(db, concept_id, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/concepts",
|
||||
response_model=ConceptResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.create"]))],
|
||||
)
|
||||
def create_concept(
|
||||
payload: ConceptCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.create_concept(db, payload, current_user["tenant_id"], company_id, _uid(current_user))
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/concepts/{concept_id}",
|
||||
response_model=ConceptResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.edit"]))],
|
||||
)
|
||||
def update_concept(
|
||||
concept_id: int,
|
||||
payload: ConceptUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
return service.update_concept(
|
||||
db, concept_id, payload, current_user["tenant_id"], company_id, _uid(current_user)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/concepts/{concept_id}",
|
||||
status_code=status.HTTP_204_NO_CONTENT,
|
||||
dependencies=[Depends(PermissionChecker(["fin.concept.delete"]))],
|
||||
)
|
||||
def delete_concept(
|
||||
concept_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Baja lógica del concepto (``deleted_at``)."""
|
||||
service.delete_concept(db, concept_id, current_user["tenant_id"], company_id)
|
||||
146
backend/api/v1/modules/fin/concepts/service.py
Normal file
146
backend/api/v1/modules/fin/concepts/service.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Lógica del catálogo de conceptos de facturación.
|
||||
|
||||
Todas las consultas filtran por ``tenant_id``, ``company_id`` y ``deleted_at IS NULL``:
|
||||
el catálogo es privado de cada empresa dentro de cada tenant.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..catalogs.models import ProductService, TaxObject, UnitOfMeasure
|
||||
from .dto import ConceptCreate, ConceptUpdate
|
||||
from .models import Concept
|
||||
|
||||
|
||||
def _check_sat_refs(db: Session, data: dict) -> None:
|
||||
"""Verifica que las claves del SAT referidas existan antes de guardar."""
|
||||
for field, model, msg in [
|
||||
("product_service_id", ProductService, "La clave de producto/servicio del SAT no existe"),
|
||||
("unit_of_measure_id", UnitOfMeasure, "La unidad de medida del SAT no existe"),
|
||||
("tax_object_id", TaxObject, "El objeto de impuesto del SAT no existe"),
|
||||
]:
|
||||
value = data.get(field)
|
||||
if field in data and value is not None:
|
||||
if db.query(model.id).filter(model.id == value).first() is None:
|
||||
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
||||
|
||||
|
||||
def _check_unique(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
code: str | None,
|
||||
product_service_id: int | None,
|
||||
exclude_id: int | None = None,
|
||||
) -> None:
|
||||
"""Aplica en el service las mismas reglas que los índices únicos parciales.
|
||||
|
||||
Sin esto el conflicto llegaría al cliente como un IntegrityError crudo; aquí se
|
||||
traduce a un 409 con mensaje en español.
|
||||
"""
|
||||
base = db.query(Concept).filter(
|
||||
Concept.tenant_id == tenant_id,
|
||||
Concept.company_id == company_id,
|
||||
Concept.deleted_at.is_(None),
|
||||
)
|
||||
if exclude_id is not None:
|
||||
base = base.filter(Concept.id != exclude_id)
|
||||
|
||||
if code is not None and base.filter(Concept.code == code).first() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Ya existe un concepto con la clave '{code}' en esta empresa",
|
||||
)
|
||||
# Regla 1:1 — una clave ProdServ no puede repetirse entre conceptos de la empresa.
|
||||
if product_service_id is not None and base.filter(
|
||||
Concept.product_service_id == product_service_id
|
||||
).first() is not None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail="La clave de producto/servicio del SAT ya está asignada a otro concepto de esta empresa",
|
||||
)
|
||||
|
||||
|
||||
def get_concepts(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
search: str | None = None,
|
||||
active_only: bool | None = None,
|
||||
product_service_id: int | None = None,
|
||||
) -> list[Concept]:
|
||||
q = db.query(Concept).filter(
|
||||
Concept.tenant_id == tenant_id,
|
||||
Concept.company_id == company_id,
|
||||
Concept.deleted_at.is_(None),
|
||||
)
|
||||
if active_only is not None:
|
||||
q = q.filter(Concept.is_active.is_(active_only))
|
||||
if product_service_id is not None:
|
||||
q = q.filter(Concept.product_service_id == product_service_id)
|
||||
if search:
|
||||
term = f"%{search.strip()}%"
|
||||
q = q.filter(or_(Concept.code.ilike(term), Concept.description.ilike(term)))
|
||||
return q.order_by(Concept.code.asc()).all()
|
||||
|
||||
|
||||
def get_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> Concept:
|
||||
obj = db.query(Concept).filter(
|
||||
Concept.id == concept_id,
|
||||
Concept.tenant_id == tenant_id,
|
||||
Concept.company_id == company_id,
|
||||
Concept.deleted_at.is_(None),
|
||||
).first()
|
||||
if not obj:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado")
|
||||
return obj
|
||||
|
||||
|
||||
def create_concept(
|
||||
db: Session, payload: ConceptCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
||||
) -> Concept:
|
||||
data = payload.model_dump()
|
||||
_check_sat_refs(db, data)
|
||||
_check_unique(db, tenant_id, company_id, data["code"], data["product_service_id"])
|
||||
obj = Concept(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||
db.add(obj)
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def update_concept(
|
||||
db: Session,
|
||||
concept_id: int,
|
||||
payload: ConceptUpdate,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> Concept:
|
||||
obj = get_concept(db, concept_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_check_sat_refs(db, data)
|
||||
_check_unique(
|
||||
db,
|
||||
tenant_id,
|
||||
company_id,
|
||||
data.get("code"),
|
||||
data.get("product_service_id"),
|
||||
exclude_id=obj.id,
|
||||
)
|
||||
for field, value in data.items():
|
||||
setattr(obj, field, value)
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
|
||||
|
||||
def delete_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> None:
|
||||
"""Baja lógica: libera la clave ProdServ y el código para un concepto nuevo."""
|
||||
obj = get_concept(db, concept_id, tenant_id, company_id)
|
||||
obj.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
@@ -10,7 +10,16 @@ class InvoiceClientReviewInput(BaseModel):
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class InvoiceItemBase(BaseModel):
|
||||
class InvoiceItemSatFields(BaseModel):
|
||||
"""Claves fiscales de la partida. Opcionales: las facturas previas no las tienen."""
|
||||
|
||||
concept_id: int | None = None
|
||||
product_service_id: int | None = None
|
||||
unit_of_measure_id: int | None = None
|
||||
tax_object_id: int | None = None
|
||||
|
||||
|
||||
class InvoiceItemBase(InvoiceItemSatFields):
|
||||
concept: str = Field(..., max_length=60)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
quantity: Decimal = Field(Decimal(1), ge=0, max_digits=12, decimal_places=2)
|
||||
@@ -19,9 +28,11 @@ class InvoiceItemBase(BaseModel):
|
||||
|
||||
class InvoiceItemCreate(InvoiceItemBase):
|
||||
invoice_id: int
|
||||
# Opcional solo si viene concept_id: el service copia la descripción del concepto.
|
||||
concept: str | None = Field(None, max_length=60)
|
||||
|
||||
|
||||
class InvoiceItemUpdate(BaseModel):
|
||||
class InvoiceItemUpdate(InvoiceItemSatFields):
|
||||
concept: str | None = Field(None, max_length=60)
|
||||
description: str | None = Field(None, max_length=255)
|
||||
quantity: Decimal | None = Field(None, ge=0, max_digits=12, decimal_places=2)
|
||||
@@ -76,6 +87,11 @@ class InvoiceBase(BaseModel):
|
||||
bank_info: str | None = None
|
||||
notes: str | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
# ----- Claves fiscales del CFDI (opcionales mientras no se timbre) -----
|
||||
voucher_type_id: int | None = None
|
||||
payment_form_id: int | None = None
|
||||
payment_method_id: int | None = None
|
||||
expedition_zip_code: str | None = Field(None, max_length=5)
|
||||
|
||||
|
||||
class InvoiceCreate(InvoiceBase):
|
||||
@@ -94,6 +110,10 @@ class InvoiceUpdate(BaseModel):
|
||||
bank_info: str | None = None
|
||||
notes: str | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
voucher_type_id: int | None = None
|
||||
payment_form_id: int | None = None
|
||||
payment_method_id: int | None = None
|
||||
expedition_zip_code: str | None = Field(None, max_length=5)
|
||||
|
||||
|
||||
class InvoiceResponse(InvoiceBase):
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Index, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
from ..catalogs.models import ( # noqa: F401 (registra los catálogos SAT referidos por las FK)
|
||||
PaymentForm,
|
||||
PaymentMethod,
|
||||
ProductService,
|
||||
Tax,
|
||||
TaxObject,
|
||||
UnitOfMeasure,
|
||||
VoucherType,
|
||||
)
|
||||
from ..concepts.models import Concept # noqa: F401
|
||||
|
||||
|
||||
class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Factura (Diagrama 4). Integra los costos de la operación para cobro al cliente."""
|
||||
@@ -50,6 +61,18 @@ class Invoice(Base, TenantScopedMixin, TimestampMixin):
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
# ----- Datos fiscales del CFDI (catálogos SAT) -----
|
||||
# Nullables: las facturas emitidas antes de existir los catálogos no los tienen.
|
||||
voucher_type_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.voucher_types.id"), nullable=True
|
||||
)
|
||||
payment_form_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.payment_forms.id"), nullable=True
|
||||
)
|
||||
payment_method_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.payment_methods.id"), nullable=True
|
||||
)
|
||||
expedition_zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||
|
||||
|
||||
class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
@@ -62,10 +85,54 @@ class InvoiceItem(Base, TenantScopedMixin, TimestampMixin):
|
||||
invoice_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fin.invoices.id"), nullable=False, index=True
|
||||
)
|
||||
# Texto libre histórico: lo consume el PDF actual y se conserva obligatorio.
|
||||
concept: Mapped[str] = mapped_column(String(60), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
quantity: Mapped[float] = mapped_column(Numeric(12, 2), nullable=False, server_default=text("1"))
|
||||
unit_amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
# ----- Datos fiscales de la partida (catálogos SAT) -----
|
||||
concept_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("fin.concepts.id"), nullable=True, index=True
|
||||
)
|
||||
product_service_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.products_services.id"), nullable=True
|
||||
)
|
||||
unit_of_measure_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.units_of_measure.id"), nullable=True
|
||||
)
|
||||
tax_object_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("sat.tax_objects.id"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class InvoiceItemTax(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Impuesto trasladado o retenido de una partida de la factura.
|
||||
|
||||
Es captura de detalle fiscal para el futuro CFDI: **no** interviene en el cálculo
|
||||
de subtotal/IVA/total de la factura, que sigue saliendo de ``invoices.tax_rate``.
|
||||
"""
|
||||
|
||||
__tablename__ = "invoice_item_taxes"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_fin_invoice_item_taxes",
|
||||
"invoice_item_id", "tax_id", "is_withholding",
|
||||
unique=True,
|
||||
postgresql_where=text("deleted_at IS NULL"),
|
||||
sqlite_where=text("deleted_at IS NULL"),
|
||||
),
|
||||
{"schema": "fin"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
invoice_item_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fin.invoice_items.id"), nullable=False, index=True
|
||||
)
|
||||
tax_id: Mapped[int] = mapped_column(Integer, ForeignKey("sat.taxes.id"), nullable=False)
|
||||
# false = trasladado (se cobra al cliente); true = retenido
|
||||
is_withholding: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
rate: Mapped[float | None] = mapped_column(Numeric(8, 6), nullable=True) # p. ej. 0.160000
|
||||
amount: Mapped[float] = mapped_column(Numeric(14, 2), nullable=False, server_default=text("0"))
|
||||
|
||||
|
||||
class Payment(Base, TenantScopedMixin, TimestampMixin):
|
||||
|
||||
@@ -9,6 +9,7 @@ from api.v1.modules.crm.accounts.models import Account
|
||||
from api.v1.modules.crm.quotes.models import Quote, QuoteItem
|
||||
from api.v1.modules.ops.shipments.models import Shipment
|
||||
|
||||
from ..concepts.models import Concept
|
||||
from .dto import (
|
||||
InvoiceClientReviewInput,
|
||||
InvoiceCreate,
|
||||
@@ -331,9 +332,50 @@ def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem:
|
||||
return obj
|
||||
|
||||
|
||||
# Claves del SAT que la partida hereda del concepto del catálogo cuando no se envían.
|
||||
_CONCEPT_INHERITED_FIELDS = ("product_service_id", "unit_of_measure_id", "tax_object_id")
|
||||
|
||||
|
||||
def _resolve_item_concept(db, data: dict, tenant_id, company_id) -> None:
|
||||
"""Completa la partida a partir del concepto del catálogo.
|
||||
|
||||
Hereda dos cosas cuando el cliente no las manda:
|
||||
|
||||
- ``concept``: el PDF de la factura sigue leyendo esa columna de texto libre, así
|
||||
que ahí va la descripción del concepto (recortada al largo de la columna).
|
||||
- Las claves fiscales (``product_service_id``, ``unit_of_measure_id``,
|
||||
``tax_object_id``): sin ellas la partida capturada por catálogo quedaría
|
||||
incompleta para el CFDI. Lo que el cliente sí envía manda sobre el catálogo,
|
||||
para poder facturar una partida con una unidad distinta a la del concepto.
|
||||
"""
|
||||
concept_id = data.get("concept_id")
|
||||
if concept_id is not None:
|
||||
catalog_concept = db.query(Concept).filter(
|
||||
Concept.id == concept_id, Concept.tenant_id == tenant_id,
|
||||
Concept.company_id == company_id, Concept.deleted_at.is_(None),
|
||||
).first()
|
||||
if not catalog_concept:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="El concepto del catálogo no existe en esta empresa",
|
||||
)
|
||||
if not data.get("concept"):
|
||||
data["concept"] = catalog_concept.description[:60]
|
||||
for field in _CONCEPT_INHERITED_FIELDS:
|
||||
if data.get(field) is None:
|
||||
data[field] = getattr(catalog_concept, field)
|
||||
if not data.get("concept"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="La partida requiere un concepto o una referencia al catálogo de conceptos",
|
||||
)
|
||||
|
||||
|
||||
def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem:
|
||||
invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id)
|
||||
item = InvoiceItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
data = payload.model_dump()
|
||||
_resolve_item_concept(db, data, tenant_id, company_id)
|
||||
item = InvoiceItem(**data, tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(item)
|
||||
db.flush()
|
||||
_recompute(db, invoice)
|
||||
@@ -344,7 +386,12 @@ def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> Invoic
|
||||
|
||||
def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem:
|
||||
item = _get_item(db, item_id, tenant_id, company_id)
|
||||
for f, v in payload.model_dump(exclude_unset=True).items():
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
# Cambiar el concepto del catálogo revalida la referencia y vuelve a heredar
|
||||
# descripción y claves fiscales del concepto nuevo.
|
||||
if data.get("concept_id") is not None:
|
||||
_resolve_item_concept(db, data, tenant_id, company_id)
|
||||
for f, v in data.items():
|
||||
setattr(item, f, v)
|
||||
db.flush()
|
||||
_recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id))
|
||||
|
||||
1
backend/api/v1/modules/fin/issuer/__init__.py
Normal file
1
backend/api/v1/modules/fin/issuer/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Datos fiscales del emisor por empresa."""
|
||||
60
backend/api/v1/modules/fin/issuer/dto.py
Normal file
60
backend/api/v1/modules/fin/issuer/dto.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Esquemas de los datos fiscales del emisor."""
|
||||
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||
|
||||
from ..catalogs.dto import TaxRegimeResponse
|
||||
|
||||
# RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave.
|
||||
RFC_PATTERN = re.compile(r"^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$")
|
||||
ZIP_PATTERN = re.compile(r"^\d{5}$")
|
||||
|
||||
|
||||
class IssuerSettingsInput(BaseModel):
|
||||
"""Alta o actualización de los datos fiscales del emisor."""
|
||||
|
||||
legal_name: str = Field(..., min_length=1, max_length=255, description="Razón social")
|
||||
rfc: str = Field(..., max_length=13, description="RFC del emisor")
|
||||
tax_regime_id: int = Field(..., description="Régimen fiscal (c_RegimenFiscal)")
|
||||
zip_code: str | None = Field(None, max_length=5, description="CP del lugar de expedición")
|
||||
|
||||
# mode="before": la normalización corre antes que el max_length del campo, para que
|
||||
# un RFC con espacios de sobra no se rechace por longitud antes de limpiarlo.
|
||||
@field_validator("rfc", mode="before")
|
||||
@classmethod
|
||||
def _validate_rfc(cls, value: str) -> str:
|
||||
"""Normaliza a mayúsculas sin espacios y valida el formato oficial del RFC."""
|
||||
if not isinstance(value, str):
|
||||
raise ValueError("El RFC debe ser texto")
|
||||
normalized = value.replace(" ", "").replace("-", "").upper()
|
||||
if not RFC_PATTERN.match(normalized):
|
||||
raise ValueError("El RFC no tiene un formato válido (ej. XAXX010101000)")
|
||||
return normalized
|
||||
|
||||
@field_validator("zip_code")
|
||||
@classmethod
|
||||
def _validate_zip(cls, value: str | None) -> str | None:
|
||||
if value is None or value == "":
|
||||
return None
|
||||
normalized = value.strip()
|
||||
if not ZIP_PATTERN.match(normalized):
|
||||
raise ValueError("El código postal debe tener 5 dígitos")
|
||||
return normalized
|
||||
|
||||
|
||||
class IssuerSettingsResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
legal_name: str
|
||||
rfc: str
|
||||
tax_regime_id: int
|
||||
tax_regime: TaxRegimeResponse | None = None
|
||||
zip_code: str | None = None
|
||||
updated_by: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
42
backend/api/v1/modules/fin/issuer/models.py
Normal file
42
backend/api/v1/modules/fin/issuer/models.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Datos fiscales del emisor — ``fin.issuer_settings``.
|
||||
|
||||
Es la identidad fiscal con la que la empresa emite CFDI: razón social, RFC, régimen
|
||||
fiscal y código postal del lugar de expedición. Hay **una sola configuración vigente
|
||||
por empresa**, garantizada con un índice único parcial.
|
||||
"""
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
from ..catalogs.models import TaxRegime # noqa: F401 (resuelve la relación)
|
||||
|
||||
_ALIVE = text("deleted_at IS NULL")
|
||||
|
||||
|
||||
class IssuerSettings(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Configuración fiscal del emisor de la empresa."""
|
||||
|
||||
__tablename__ = "issuer_settings"
|
||||
__table_args__ = (
|
||||
Index(
|
||||
"uq_fin_issuer_settings_company",
|
||||
"tenant_id", "company_id",
|
||||
unique=True, postgresql_where=_ALIVE, sqlite_where=_ALIVE,
|
||||
),
|
||||
{"schema": "fin"},
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
legal_name: Mapped[str] = mapped_column(String(255), nullable=False) # razón social
|
||||
rfc: Mapped[str] = mapped_column(String(13), nullable=False)
|
||||
tax_regime_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("sat.tax_regimes.id"), nullable=False, index=True
|
||||
)
|
||||
# CP del lugar de expedición del comprobante
|
||||
zip_code: Mapped[str | None] = mapped_column(String(5), nullable=True)
|
||||
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
tax_regime: Mapped["TaxRegime"] = relationship("TaxRegime", lazy="selectin")
|
||||
48
backend/api/v1/modules/fin/issuer/routes.py
Normal file
48
backend/api/v1/modules/fin/issuer/routes.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""Endpoints de los datos fiscales del emisor (una configuración por empresa)."""
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import IssuerSettingsInput, IssuerSettingsResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get(
|
||||
"/settings/issuer",
|
||||
response_model=IssuerSettingsResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.settings.view"]))],
|
||||
)
|
||||
def get_issuer_settings(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Devuelve 404 mientras la empresa no haya capturado sus datos fiscales."""
|
||||
return service.get_issuer_settings(db, current_user["tenant_id"], company_id)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/settings/issuer",
|
||||
response_model=IssuerSettingsResponse,
|
||||
dependencies=[Depends(PermissionChecker(["fin.settings.edit"]))],
|
||||
)
|
||||
def save_issuer_settings(
|
||||
payload: IssuerSettingsInput,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
"""Alta o actualización (upsert) de los datos fiscales del emisor."""
|
||||
return service.save_issuer_settings(
|
||||
db,
|
||||
payload,
|
||||
current_user["tenant_id"],
|
||||
company_id,
|
||||
current_user.get("sub") or current_user.get("id"),
|
||||
)
|
||||
58
backend/api/v1/modules/fin/issuer/service.py
Normal file
58
backend/api/v1/modules/fin/issuer/service.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Lógica de los datos fiscales del emisor.
|
||||
|
||||
Una empresa tiene, a lo más, una configuración vigente: el guardado es un upsert, no
|
||||
un alta que pueda duplicar filas.
|
||||
"""
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..catalogs.models import TaxRegime
|
||||
from .dto import IssuerSettingsInput
|
||||
from .models import IssuerSettings
|
||||
|
||||
|
||||
def _find(db: Session, tenant_id: int, company_id: int) -> IssuerSettings | None:
|
||||
return db.query(IssuerSettings).filter(
|
||||
IssuerSettings.tenant_id == tenant_id,
|
||||
IssuerSettings.company_id == company_id,
|
||||
IssuerSettings.deleted_at.is_(None),
|
||||
).first()
|
||||
|
||||
|
||||
def get_issuer_settings(db: Session, tenant_id: int, company_id: int) -> IssuerSettings:
|
||||
obj = _find(db, tenant_id, company_id)
|
||||
if not obj:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail="La empresa aún no tiene datos fiscales del emisor configurados",
|
||||
)
|
||||
return obj
|
||||
|
||||
|
||||
def save_issuer_settings(
|
||||
db: Session,
|
||||
payload: IssuerSettingsInput,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
user_id: str | None = None,
|
||||
) -> IssuerSettings:
|
||||
"""Crea la configuración la primera vez y la actualiza en adelante."""
|
||||
if db.query(TaxRegime.id).filter(TaxRegime.id == payload.tax_regime_id).first() is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail="El régimen fiscal indicado no existe en el catálogo del SAT",
|
||||
)
|
||||
|
||||
obj = _find(db, tenant_id, company_id)
|
||||
data = payload.model_dump()
|
||||
if obj is None:
|
||||
obj = IssuerSettings(**data, tenant_id=tenant_id, company_id=company_id, updated_by=user_id)
|
||||
db.add(obj)
|
||||
else:
|
||||
for field, value in data.items():
|
||||
setattr(obj, field, value)
|
||||
obj.updated_by = user_id
|
||||
db.commit()
|
||||
db.refresh(obj)
|
||||
return obj
|
||||
@@ -3,7 +3,7 @@
|
||||
from api.v1.modules.core.permissions.registry import registry
|
||||
|
||||
MODULE = "fin"
|
||||
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos")]
|
||||
_ENTITIES = [("invoice", "facturas"), ("payment", "pagos"), ("concept", "conceptos")]
|
||||
_ACTIONS = [("view", "Ver"), ("create", "Crear"), ("edit", "Editar"), ("delete", "Eliminar")]
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@ def register_permissions() -> None:
|
||||
for entity, label in _ENTITIES:
|
||||
for action, verb in _ACTIONS:
|
||||
registry.register(code=f"{MODULE}.{entity}.{action}", description=f"{verb} {label}", module=MODULE, action=action)
|
||||
# Datos fiscales del emisor: es configuración de la empresa, no una entidad con CRUD,
|
||||
# así que solo tiene ver/editar. Los catálogos del SAT no llevan permiso propio:
|
||||
# son globales y de solo lectura, basta con fin.access.
|
||||
registry.register(code=f"{MODULE}.settings.view", description="Ver datos fiscales del emisor", module=MODULE, action="view")
|
||||
registry.register(code=f"{MODULE}.settings.edit", description="Editar datos fiscales del emisor", module=MODULE, action="edit")
|
||||
|
||||
|
||||
register_permissions()
|
||||
|
||||
@@ -5,8 +5,14 @@ from fastapi import APIRouter, Depends
|
||||
from api.v1.modules.core.permissions.dependencies import PermissionChecker
|
||||
|
||||
from . import permissions # noqa: F401 (side-effect: registra permisos)
|
||||
from .catalogs.routes import router as catalogs_router
|
||||
from .concepts.routes import router as concepts_router
|
||||
from .invoices.routes import router as invoices_router
|
||||
from .issuer.routes import router as issuer_router
|
||||
|
||||
# Enforcement por área/carril (R-T-07): se exige fin.access para el módulo.
|
||||
router = APIRouter(dependencies=[Depends(PermissionChecker(["fin.access"]))])
|
||||
router.include_router(catalogs_router)
|
||||
router.include_router(concepts_router)
|
||||
router.include_router(issuer_router)
|
||||
router.include_router(invoices_router)
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import date, datetime
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import EfcDocumentRefMixin, TenantScopedMixin, TimestampMixin
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
@@ -92,12 +92,8 @@ class ShipmentEvent(Base, TenantScopedMixin, TimestampMixin):
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
|
||||
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin, EfcDocumentRefMixin):
|
||||
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.).
|
||||
|
||||
Comparte con ``crm.documents`` las columnas del espejo en EFC vía ``EfcDocumentRefMixin``: las
|
||||
dos tablas alimentan el mismo expediente electrónico y la UI las pinta con el mismo badge.
|
||||
"""
|
||||
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.)."""
|
||||
|
||||
__tablename__ = "shipment_documents"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
@@ -96,7 +96,6 @@ def _reset_rls_context_from_task(task_id=None, task=None, **_):
|
||||
celery_app.conf.update(
|
||||
include=[
|
||||
"api.v1.modules.core.help_center.tasks",
|
||||
"api.v1.modules.crm.expediente_gateway.tasks",
|
||||
# Agrega aquí las tareas de tu proyecto:
|
||||
# "api.v1.modules.example.tasks",
|
||||
]
|
||||
@@ -121,21 +120,6 @@ 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 reconciliacion. El reintento NO es exponencial a proposito —el
|
||||
# backoff corto vive en el cliente HTTP y el largo es este barrido de intervalo fijo.
|
||||
"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__":
|
||||
|
||||
@@ -62,7 +62,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", "EFC_API_URL", mode="before")
|
||||
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before")
|
||||
@classmethod
|
||||
def strip_quotes(cls, v: str) -> str:
|
||||
if v and isinstance(v, str):
|
||||
@@ -103,25 +103,6 @@ class Settings(BaseSettings):
|
||||
S3_FILE_STORAGE: bool = True
|
||||
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
|
||||
|
||||
# ── 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,
|
||||
|
||||
@@ -1,301 +0,0 @@
|
||||
"""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()
|
||||
@@ -250,30 +250,6 @@ def csv_import_key(
|
||||
)
|
||||
|
||||
|
||||
def expediente_document_key(
|
||||
tenant_id: Union[int, str],
|
||||
company_id: int,
|
||||
expediente_id: int,
|
||||
unique_token: str,
|
||||
original_filename: str,
|
||||
) -> str:
|
||||
"""
|
||||
Documento de un expediente del CRM, bajo
|
||||
``tenants/{tid}/companies/{cid}/expedientes/{expediente_id}/documents/{token}_{filename}``.
|
||||
|
||||
Es una **copia de tránsito**: el destino final del archivo es el expediente electrónico de EFC,
|
||||
y al confirmar la entrega esta copia se borra (``delete_local`` del outbox). Vive bajo el árbol
|
||||
por tenant/company igual que todo lo demás, para que el aislamiento sea el mismo.
|
||||
|
||||
NO confundir con ``expediente_archivo_document_key``: aquélla se refiere al expediente del
|
||||
**importador** de EFC, que cuelga de un RFC y es otro concepto. Es código muerto de la plantilla.
|
||||
"""
|
||||
eid = _segment(expediente_id, "expediente_id")
|
||||
token = _segment(unique_token, "unique_token")
|
||||
fn = safe_filename(original_filename)
|
||||
return f"{tenant_company_prefix(tenant_id, company_id)}expedientes/{eid}/documents/{token}_{fn}"
|
||||
|
||||
|
||||
def legacy_csv_import_key(job_type: str, job_id: str) -> str:
|
||||
"""Clave antigua sin tenant/company (solo migración / cleanup)."""
|
||||
_segment(job_id, "job_id")
|
||||
|
||||
@@ -82,31 +82,6 @@ def put_csv_object(key: str, body: bytes, content_type: str = "text/csv") -> Non
|
||||
put_object_bytes(key, body, content_type=content_type)
|
||||
|
||||
|
||||
def put_object_stream(key: str, fileobj, content_type: str = "application/octet-stream") -> None:
|
||||
"""Sube un objeto **sin materializarlo en memoria**, leyendo del descriptor por partes.
|
||||
|
||||
``put_object_bytes`` recibe los bytes ya completos, así que quien lo llama tuvo que
|
||||
bufferizar el archivo entero. Para una subida de usuario eso significa que un archivo de 2 GB
|
||||
ocupa 2 GB de RAM del proceso **antes** de que nadie valide su tamaño. ``upload_fileobj`` de
|
||||
boto3 lee del descriptor por partes y sube en multipart cuando hace falta.
|
||||
"""
|
||||
_client().upload_fileobj(
|
||||
fileobj,
|
||||
settings.S3_BUCKET,
|
||||
key,
|
||||
ExtraArgs={"ContentType": content_type},
|
||||
)
|
||||
|
||||
|
||||
def open_object_stream(key: str):
|
||||
"""Devuelve el cuerpo del objeto como flujo, para servirlo sin cargarlo entero en memoria.
|
||||
|
||||
El llamador es responsable de cerrarlo (``.close()``): un ``StreamingBody`` sin cerrar retiene
|
||||
la conexión del pool hasta que el recolector pase.
|
||||
"""
|
||||
return _client().get_object(Bucket=settings.S3_BUCKET, Key=key)["Body"]
|
||||
|
||||
|
||||
def get_object_bytes(key: str) -> bytes:
|
||||
resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||
return resp["Body"].read()
|
||||
|
||||
@@ -30,8 +30,6 @@ import api.v1.modules.crm.activities.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.addresses.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.contacts.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.documents.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.expediente_gateway.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.expedientes.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.leads.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.opportunities.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.pipelines.models # noqa: E402,F401
|
||||
@@ -39,9 +37,13 @@ import api.v1.modules.crm.quotes.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.service_requests.models # noqa: E402,F401
|
||||
import api.v1.modules.crm.suppliers.models # noqa: E402,F401
|
||||
import api.v1.modules.ops.shipments.models # noqa: E402,F401
|
||||
import api.v1.modules.fin.catalogs.models # noqa: E402,F401
|
||||
import api.v1.modules.fin.concepts.models # noqa: E402,F401
|
||||
import api.v1.modules.fin.issuer.models # noqa: E402,F401
|
||||
import api.v1.modules.fin.invoices.models # noqa: E402,F401
|
||||
from api.v1.modules.fin.catalogs.seed_data import sync_catalogs # noqa: E402
|
||||
|
||||
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None}
|
||||
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None, "sat": None}
|
||||
|
||||
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
|
||||
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
|
||||
@@ -77,6 +79,10 @@ def db():
|
||||
Base.metadata.create_all(engine)
|
||||
session_factory = sessionmaker(bind=engine, future=True)
|
||||
session = session_factory()
|
||||
# Los catálogos del SAT los siembra la migración en PostgreSQL; aquí se replica
|
||||
# con la misma función para que conceptos y emisor tengan claves que referenciar.
|
||||
sync_catalogs(session.connection())
|
||||
session.commit()
|
||||
try:
|
||||
yield session
|
||||
finally:
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
{
|
||||
"_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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,292 +0,0 @@
|
||||
"""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
|
||||
|
||||
from api.v1.modules.crm.expedientes.folio import next_folio
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
folio, _, _, _ = next_folio(db, TENANT_ID, COMPANY_ID)
|
||||
|
||||
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']}"
|
||||
@@ -1,85 +0,0 @@
|
||||
"""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.expedientes.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.expedientes 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
|
||||
@@ -1,248 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,350 +0,0 @@
|
||||
"""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
|
||||
@@ -1,386 +0,0 @@
|
||||
"""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
|
||||
@@ -1,332 +0,0 @@
|
||||
"""Pruebas del proxy de descarga de documentos del expediente.
|
||||
|
||||
EFC nunca entrega una URL de MinIO —reescribir el host de una URL ya firmada invalida su SigV4— así
|
||||
que el CRM tiene que hacer de segundo proxy. Lo que se fija aquí:
|
||||
|
||||
* La pertenencia se valida **antes** de tocar EFC, y el ``organizacion_id`` que se manda es el del
|
||||
expediente, no uno que venga del cliente.
|
||||
* La descarga es **streaming de verdad**: el cliente httpx sigue vivo mientras se consumen los
|
||||
trozos y se cierra al terminar, pase lo que pase.
|
||||
* La respuesta **no** filtra ninguna URL de almacenamiento, ni en el cuerpo ni en los headers.
|
||||
* La traducción de errores es la asimétrica del ticket: un 404 de EFC sale 404, todo lo demás 502.
|
||||
|
||||
Todo va contra ``httpx.MockTransport``: **ninguna de estas pruebas toca la red.**
|
||||
"""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
from api.v1.modules.crm.expediente_gateway import service as gateway
|
||||
from api.v1.modules.crm.expedientes import routes as expedientes_routes
|
||||
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
|
||||
ORGANIZACION = "11111111-2222-3333-4444-555555555555"
|
||||
DOC_EFC = "9001"
|
||||
PARTES = [b"%PDF-1.4 parte-1;", b"parte-2;", b"parte-3"]
|
||||
ARCHIVO = b"".join(PARTES)
|
||||
|
||||
# Un header de almacenamiento que EFC podría llegar a mandar. Está aquí para que la prueba muerda:
|
||||
# si alguien "simplificara" el proxy reenviando los headers del upstream tal cual, o devolviendo un
|
||||
# redirect a la URL firmada, la fuga se vería en la respuesta del CRM.
|
||||
URL_ALMACEN = "https://minio.efc.interno/bucket/doc.pdf?X-Amz-Signature=abc123"
|
||||
|
||||
USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"}
|
||||
|
||||
|
||||
class _ClienteEspia(httpx.AsyncClient):
|
||||
"""``AsyncClient`` con el transporte simulado inyectado, que además anota si lo cerraron.
|
||||
|
||||
El proxy construye su cliente **dentro** del generador, así que la única forma de alcanzarlo
|
||||
desde una prueba es sustituir la clase.
|
||||
"""
|
||||
|
||||
transporte = None
|
||||
instancias: list["_ClienteEspia"] = []
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
kwargs.pop("verify", None) # con transporte simulado no hay TLS que verificar
|
||||
kwargs["transport"] = _ClienteEspia.transporte
|
||||
super().__init__(*args, **kwargs)
|
||||
self.cerrado = False
|
||||
_ClienteEspia.instancias.append(self)
|
||||
|
||||
async def aclose(self):
|
||||
self.cerrado = True
|
||||
await super().aclose()
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def entorno(db, monkeypatch):
|
||||
"""Un expediente con un documento que **ya** vive en EFC, y EFC simulado."""
|
||||
from core.config import settings
|
||||
from core.efc_client import efc_client
|
||||
|
||||
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(efc_client, "base_url", "https://efc.example.test", raising=False)
|
||||
monkeypatch.setattr(efc_client, "api_key", "llave-de-prueba", raising=False)
|
||||
|
||||
subidos: dict = {}
|
||||
monkeypatch.setattr(
|
||||
expedientes_service, "put_object_bytes",
|
||||
lambda key, body, content_type="application/octet-stream": subidos.update({key: body}),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
expediente.efc_organizacion_id = ORGANIZACION
|
||||
|
||||
documento = _documento_en_efc(db, expediente)
|
||||
|
||||
peticiones: list[httpx.Request] = []
|
||||
_ClienteEspia.instancias = []
|
||||
monkeypatch.setattr(httpx, "AsyncClient", _ClienteEspia)
|
||||
|
||||
def usar(handler):
|
||||
def _registrar(request):
|
||||
peticiones.append(request)
|
||||
return handler(request)
|
||||
|
||||
_ClienteEspia.transporte = httpx.MockTransport(_registrar)
|
||||
|
||||
usar(_efc_entrega_el_archivo)
|
||||
|
||||
return {
|
||||
"db": db,
|
||||
"expediente": expediente,
|
||||
"documento": documento,
|
||||
"peticiones": peticiones,
|
||||
"usar": usar,
|
||||
}
|
||||
|
||||
|
||||
def _documento_en_efc(db, expediente):
|
||||
"""Documento ya replicado: el proxy solo entra en juego cuando hay ``efc_document_id``."""
|
||||
from api.v1.modules.crm.documents.models import Document
|
||||
|
||||
documento = Document(
|
||||
tenant_id=TENANT_ID,
|
||||
company_id=COMPANY_ID,
|
||||
expediente_id=expediente.id,
|
||||
name="guia.pdf",
|
||||
doc_type="MBL",
|
||||
content_type="application/pdf",
|
||||
efc_document_id=DOC_EFC,
|
||||
efc_sync_state="SYNCED",
|
||||
)
|
||||
db.add(documento)
|
||||
db.commit()
|
||||
db.refresh(documento)
|
||||
return documento
|
||||
|
||||
|
||||
def _efc_entrega_el_archivo(request):
|
||||
async def _por_partes():
|
||||
for parte in PARTES:
|
||||
yield parte
|
||||
|
||||
return httpx.Response(
|
||||
200,
|
||||
content=_por_partes(),
|
||||
headers={
|
||||
"Content-Type": "application/pdf",
|
||||
"X-Storage-Url": URL_ALMACEN,
|
||||
"Content-Disposition": 'attachment; filename="interno-de-efc.pdf"',
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
async def _descargar(entorno, **kwargs):
|
||||
return await expedientes_service.stream_document(
|
||||
entorno["db"],
|
||||
kwargs.pop("expediente_id", entorno["expediente"].id),
|
||||
kwargs.pop("document_id", entorno["documento"].id),
|
||||
kwargs.pop("tenant_id", TENANT_ID),
|
||||
kwargs.pop("company_id", COMPANY_ID),
|
||||
)
|
||||
|
||||
|
||||
async def _por_la_ruta(entorno, **kwargs):
|
||||
return await expedientes_routes.download_expediente_document(
|
||||
kwargs.pop("expediente_id", entorno["expediente"].id),
|
||||
kwargs.pop("document_id", entorno["documento"].id),
|
||||
company_id=kwargs.pop("company_id", COMPANY_ID),
|
||||
current_user=kwargs.pop("current_user", USUARIO),
|
||||
db=entorno["db"],
|
||||
)
|
||||
|
||||
|
||||
# ── Streaming ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_el_proxy_entrega_los_bytes_tal_cual_llegan_de_efc(entorno):
|
||||
iterador, content_type, filename = await _descargar(entorno)
|
||||
|
||||
assert b"".join([chunk async for chunk in iterador]) == ARCHIVO
|
||||
assert content_type == "application/pdf"
|
||||
assert filename == "guia.pdf"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_el_cliente_sigue_vivo_mientras_se_consumen_los_trozos_y_se_cierra_al_final(entorno):
|
||||
"""**El defecto que el ``finally`` del proxy cubre.**
|
||||
|
||||
Si el cliente se creara en un ``async with`` de fuera del generador, ese bloque lo cerraría en
|
||||
cuanto la función devuelve —FastAPI pide los trozos *después*— y la descarga moriría a medias
|
||||
sin error visible. Aquí se comprueba lo contrario: abierto durante, cerrado después.
|
||||
"""
|
||||
iterador, _, _ = await _descargar(entorno)
|
||||
generador = iterador.__aiter__()
|
||||
|
||||
primero = await generador.__anext__()
|
||||
assert primero
|
||||
cliente = _ClienteEspia.instancias[-1]
|
||||
assert cliente.cerrado is False, "el cliente se cerró antes de terminar de leer el archivo"
|
||||
|
||||
async for _ in generador:
|
||||
pass
|
||||
assert cliente.cerrado is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_el_cliente_se_cierra_aunque_efc_conteste_error(entorno):
|
||||
entorno["usar"](lambda request: httpx.Response(500, text="boom"))
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await _descargar(entorno)
|
||||
|
||||
assert _ClienteEspia.instancias[-1].cerrado is True
|
||||
|
||||
|
||||
# ── Las dos guardas de pertenencia ───────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_la_pertenencia_se_valida_ANTES_de_tocar_efc(entorno):
|
||||
"""Sin esto, acertar un ``document_id`` bastaría para leer el expediente de otro cliente — y
|
||||
peor: el ``organizacion_id`` se deriva del expediente, así que el proxy iría a preguntarle a la
|
||||
organización ajena."""
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _descargar(entorno, tenant_id=OTRO_TENANT)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert entorno["peticiones"] == [], "se llamó a EFC antes de validar la pertenencia"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_un_documento_de_otra_company_no_se_descarga(entorno):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _descargar(entorno, company_id=777)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert entorno["peticiones"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_el_proxy_manda_el_organizacion_id_del_expediente(entorno):
|
||||
iterador, _, _ = await _descargar(entorno)
|
||||
async for _ in iterador:
|
||||
pass
|
||||
|
||||
peticion = entorno["peticiones"][0]
|
||||
assert peticion.url.params["organizacion_id"] == ORGANIZACION
|
||||
assert DOC_EFC in str(peticion.url.path)
|
||||
assert peticion.headers["X-Api-Key"] == "llave-de-prueba"
|
||||
|
||||
|
||||
# ── Nada de URLs de almacenamiento hacia afuera ──────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_la_respuesta_no_expone_ninguna_url_de_almacenamiento(entorno):
|
||||
"""El proxy existe justamente para no entregar la URL firmada. Ni redirect, ni header
|
||||
reenviado, ni URL en el cuerpo."""
|
||||
respuesta = await _por_la_ruta(entorno)
|
||||
|
||||
assert respuesta.status_code == 200
|
||||
cabeceras = {k.lower(): v for k, v in respuesta.headers.items()}
|
||||
assert "location" not in cabeceras
|
||||
assert not any("x-amz" in v.lower() or "minio" in v.lower() for v in cabeceras.values())
|
||||
assert "x-storage-url" not in cabeceras
|
||||
|
||||
cuerpo = b"".join([chunk async for chunk in respuesta.body_iterator])
|
||||
assert cuerpo == ARCHIVO
|
||||
assert b"X-Amz-Signature" not in cuerpo
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_el_nombre_que_ve_el_usuario_es_el_del_crm_no_el_de_efc(entorno):
|
||||
respuesta = await _por_la_ruta(entorno)
|
||||
assert 'filename="guia.pdf"' in respuesta.headers["content-disposition"]
|
||||
|
||||
|
||||
# ── Traducción de errores: 404 pasa, el resto es 502 ─────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_un_404_de_efc_llega_al_usuario_como_404(entorno):
|
||||
"""Y **llega**: la traducción tiene que ocurrir antes de que la respuesta empiece a salir, o el
|
||||
navegador recibiría un 200 con el cuerpo cortado."""
|
||||
entorno["usar"](lambda request: httpx.Response(404, json={"detail": "no está"}))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _por_la_ruta(entorno)
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
assert exc.value.detail == "No se pudo obtener el archivo del expediente electrónico."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("codigo", [400, 403, 500, 503])
|
||||
async def test_cualquier_otro_error_de_efc_sale_como_502(entorno, codigo):
|
||||
entorno["usar"](lambda request: httpx.Response(codigo, text="boom"))
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _por_la_ruta(entorno)
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_un_timeout_contra_efc_sale_como_502_y_no_como_500(entorno):
|
||||
"""Un fallo de red es de la integración, no del usuario: 502. Dejarlo escapar sería un 500
|
||||
opaco y un cliente httpx sin cerrar."""
|
||||
def _revienta(request):
|
||||
raise httpx.ConnectTimeout("EFC no responde")
|
||||
|
||||
entorno["usar"](_revienta)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _por_la_ruta(entorno)
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
assert _ClienteEspia.instancias[-1].cerrado is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sin_integracion_configurada_la_ruta_responde_502(entorno, monkeypatch):
|
||||
from core.efc_client import efc_client
|
||||
|
||||
monkeypatch.setattr(efc_client, "base_url", "", raising=False)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _por_la_ruta(entorno)
|
||||
|
||||
assert exc.value.status_code == 502
|
||||
assert entorno["peticiones"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_un_documento_de_otro_tenant_por_la_ruta_da_404(entorno):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _por_la_ruta(entorno, current_user={"tenant_id": OTRO_TENANT, "sub": "user-2"})
|
||||
|
||||
assert exc.value.status_code == 404
|
||||
@@ -1,134 +0,0 @@
|
||||
"""Pruebas del asignador de folios de expediente.
|
||||
|
||||
El folio es lo primero que el usuario ve de un expediente y lo que comunica al cliente, así que un
|
||||
duplicado no es un detalle: dos expedientes con el mismo folio son dos hilos documentales que
|
||||
alguien va a mezclar. Estas pruebas fijan el formato, el reinicio mensual, el aislamiento por
|
||||
company y que un rollback deje hueco en vez de duplicar.
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
from api.v1.modules.crm.expedientes.folio import (
|
||||
format_folio,
|
||||
next_folio,
|
||||
peek_last_sequence,
|
||||
storage_token,
|
||||
)
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
|
||||
def test_formato_del_folio():
|
||||
assert format_folio(2026, 8, 1) == "EXP2026-08-001"
|
||||
assert format_folio(2026, 12, 42) == "EXP2026-12-042"
|
||||
|
||||
|
||||
def test_folio_de_cuatro_digitos_al_pasar_de_999():
|
||||
"""Al pasar de 999 el folio CRECE, no se trunca ni reinicia.
|
||||
|
||||
Truncar cambiaría la forma de un folio ya comunicado al cliente, y reiniciar duplicaría uno
|
||||
anterior del mismo mes.
|
||||
"""
|
||||
assert format_folio(2026, 8, 1000) == "EXP2026-08-1000"
|
||||
|
||||
|
||||
def test_storage_token_lleva_company_y_no_parece_pedimento_real():
|
||||
import re
|
||||
|
||||
token = storage_token(1, "EXP2026-08-001")
|
||||
assert token == "CRM-1-EXP2026-08-001"
|
||||
# La llave de un pedimento real es todo dígitos con tres guiones. El provisional empieza con
|
||||
# letras justamente para que sea imposible que colisione.
|
||||
assert not re.match(r"^\d{2}-\d{2}-\d{4}-\d{7}$", token)
|
||||
assert len(token) <= 25 # cabe en Pedimento.pedimento_app de EFC
|
||||
|
||||
|
||||
def test_storage_token_de_dos_companies_del_mismo_tenant_no_colisiona():
|
||||
"""H5: tenant → organización es 1:1 pero un tenant tiene N companies.
|
||||
|
||||
Sin el company_id dentro del token, dos companies generando EXP2026-08-001 chocarían en el
|
||||
unique_together de EFC y el get_or_create le devolvería a una el provisional de la otra.
|
||||
"""
|
||||
assert storage_token(1, "EXP2026-08-001") != storage_token(2, "EXP2026-08-001")
|
||||
|
||||
|
||||
def test_tres_folios_seguidos_son_consecutivos(db):
|
||||
on = date(2026, 8, 15)
|
||||
f1, *_ = next_folio(db, TENANT_ID, COMPANY_ID, on=on)
|
||||
f2, *_ = next_folio(db, TENANT_ID, COMPANY_ID, on=on)
|
||||
f3, *_ = next_folio(db, TENANT_ID, COMPANY_ID, on=on)
|
||||
assert [f1, f2, f3] == ["EXP2026-08-001", "EXP2026-08-002", "EXP2026-08-003"]
|
||||
|
||||
|
||||
def test_devuelve_el_folio_descompuesto(db):
|
||||
folio, year, month, sequence = next_folio(db, TENANT_ID, COMPANY_ID, on=date(2026, 8, 15))
|
||||
assert (folio, year, month, sequence) == ("EXP2026-08-001", 2026, 8, 1)
|
||||
|
||||
|
||||
def test_el_consecutivo_reinicia_cada_mes(db):
|
||||
next_folio(db, TENANT_ID, COMPANY_ID, on=date(2026, 8, 15))
|
||||
next_folio(db, TENANT_ID, COMPANY_ID, on=date(2026, 8, 16))
|
||||
septiembre, *_ = next_folio(db, TENANT_ID, COMPANY_ID, on=date(2026, 9, 1))
|
||||
assert septiembre == "EXP2026-09-001"
|
||||
|
||||
# Y volver a agosto sigue donde se quedó: el contador es por (tenant, company, mes).
|
||||
agosto, *_ = next_folio(db, TENANT_ID, COMPANY_ID, on=date(2026, 8, 20))
|
||||
assert agosto == "EXP2026-08-003"
|
||||
|
||||
|
||||
def test_cada_company_lleva_su_propio_consecutivo(db):
|
||||
on = date(2026, 8, 15)
|
||||
a1, *_ = next_folio(db, TENANT_ID, 1, on=on)
|
||||
b1, *_ = next_folio(db, TENANT_ID, 2, on=on)
|
||||
a2, *_ = next_folio(db, TENANT_ID, 1, on=on)
|
||||
assert a1 == "EXP2026-08-001"
|
||||
assert b1 == "EXP2026-08-001" # company 2 arranca de cero
|
||||
assert a2 == "EXP2026-08-002"
|
||||
|
||||
|
||||
def test_next_folio_no_commitea_y_el_rollback_deja_hueco(db):
|
||||
"""Un rollback tiene que poder deshacer el folio, y el siguiente AVANZA igual.
|
||||
|
||||
Es la razón de que ``next_folio`` no commitee: sus llamadores lo invocan dentro de la
|
||||
transacción del alta. Los huecos en la secuencia son aceptables; los duplicados no.
|
||||
"""
|
||||
on = date(2026, 8, 15)
|
||||
primero, *_ = next_folio(db, TENANT_ID, COMPANY_ID, on=on)
|
||||
assert primero == "EXP2026-08-001"
|
||||
|
||||
db.rollback()
|
||||
assert peek_last_sequence(db, TENANT_ID, COMPANY_ID, on=on) == 0
|
||||
|
||||
segundo, *_ = next_folio(db, TENANT_ID, COMPANY_ID, on=on)
|
||||
db.commit()
|
||||
tercero, *_ = next_folio(db, TENANT_ID, COMPANY_ID, on=on)
|
||||
db.rollback()
|
||||
cuarto, *_ = next_folio(db, TENANT_ID, COMPANY_ID, on=on)
|
||||
# El tercero se revirtió; el cuarto vuelve a tomar ese número. Lo que importa es que NUNCA
|
||||
# convivan dos filas con el mismo, y de eso se encarga uq_crm_expedientes_periodo_seq.
|
||||
assert segundo == "EXP2026-08-001"
|
||||
assert cuarto == tercero
|
||||
|
||||
|
||||
def test_peek_no_mueve_el_contador(db):
|
||||
on = date(2026, 8, 15)
|
||||
assert peek_last_sequence(db, TENANT_ID, COMPANY_ID, on=on) == 0
|
||||
next_folio(db, TENANT_ID, COMPANY_ID, on=on)
|
||||
assert peek_last_sequence(db, TENANT_ID, COMPANY_ID, on=on) == 1
|
||||
assert peek_last_sequence(db, TENANT_ID, COMPANY_ID, on=on) == 1
|
||||
|
||||
|
||||
@pytest.mark.skip(
|
||||
reason="Requiere PostgreSQL de verdad: la concurrencia del ON CONFLICT no se puede "
|
||||
"ejercitar en el SQLite en memoria de conftest, que tiene un solo escritor."
|
||||
)
|
||||
def test_n_sesiones_concurrentes_dan_n_folios_distintos():
|
||||
"""N sesiones pidiendo folio a la vez → N folios distintos, sin duplicados.
|
||||
|
||||
Es LA prueba del asignador, y solo dice algo contra PostgreSQL: el ON CONFLICT DO UPDATE
|
||||
serializa sobre la fila del contador y cada sesión recibe su propio valor. En SQLite en memoria
|
||||
con StaticPool hay un único escritor, así que pasaría por construcción y no probaría nada.
|
||||
|
||||
Para correrla: apuntar a la base de e2e (docker-compose.e2e.yml) y abrir N sesiones reales.
|
||||
"""
|
||||
@@ -1,266 +0,0 @@
|
||||
"""Pruebas de la subida de un paso al expediente.
|
||||
|
||||
Lo que se fija: que el tope de tamaño se aplique **antes** de consumir el cuerpo entero, que el
|
||||
catálogo y la allowlist se validen antes de tocar el almacén, y que **un EFC caído siga devolviendo
|
||||
201** con el documento en «Pendiente de enviar».
|
||||
"""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException, UploadFile
|
||||
|
||||
from api.v1.modules.crm.expediente_gateway import service as gateway
|
||||
from api.v1.modules.crm.expediente_gateway.models import 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 api.v1.modules.crm.uploads.routes import MAX_UPLOAD_BYTES
|
||||
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||
|
||||
OTRO_TENANT = 99
|
||||
|
||||
|
||||
class _ArchivoContado(io.BytesIO):
|
||||
"""``BytesIO`` que cuenta cuántos bytes se le han leído.
|
||||
|
||||
Es lo que permite afirmar que el tope se aplicó **sin** haber leído el archivo completo: un
|
||||
``await file.read()`` a secas lo bufferizaría entero antes de poder rechazarlo.
|
||||
"""
|
||||
|
||||
def __init__(self, data: bytes):
|
||||
super().__init__(data)
|
||||
self.leidos = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
chunk = super().read(size)
|
||||
self.leidos += len(chunk)
|
||||
return chunk
|
||||
|
||||
|
||||
def _upload(nombre: str, contenido: bytes, content_type: str = "application/pdf") -> UploadFile:
|
||||
return UploadFile(filename=nombre, file=_ArchivoContado(contenido), headers=None)
|
||||
|
||||
|
||||
@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"))
|
||||
|
||||
subidos = {}
|
||||
import api.v1.modules.crm.expedientes.service as exp_service
|
||||
|
||||
monkeypatch.setattr(
|
||||
exp_service, "put_object_bytes",
|
||||
lambda key, body, content_type="application/octet-stream": subidos.update({key: body}),
|
||||
)
|
||||
|
||||
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, "subidos": subidos}
|
||||
|
||||
|
||||
async def _adjuntar(entorno, **kwargs):
|
||||
return await expedientes_service.attach_document(
|
||||
entorno["db"],
|
||||
kwargs.pop("expediente_id", entorno["expediente"].id),
|
||||
kwargs.pop("file", _upload("guia.pdf", b"%PDF-1.4 contenido")),
|
||||
kwargs.pop("doc_type", "MBL"),
|
||||
kwargs.pop("tenant_id", TENANT_ID),
|
||||
kwargs.pop("company_id", COMPANY_ID),
|
||||
kwargs.pop("name", None),
|
||||
kwargs.pop("user_id", "user-1"),
|
||||
)
|
||||
|
||||
|
||||
# ── Camino feliz ─────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_la_subida_crea_el_documento_pendiente_y_su_fila_de_outbox(entorno):
|
||||
documento = await _adjuntar(entorno)
|
||||
|
||||
assert documento.id is not None
|
||||
assert documento.efc_sync_state == "PENDING"
|
||||
assert documento.expediente_id == entorno["expediente"].id
|
||||
assert documento.efc_document_ref == f"CRMDOC-{COMPANY_ID}-{documento.id}"
|
||||
assert documento.content_sha256 is not None
|
||||
|
||||
filas = entorno["db"].query(EfcFileOutbox).filter(
|
||||
EfcFileOutbox.source_id == documento.id
|
||||
).all()
|
||||
assert len(filas) == 1
|
||||
assert filas[0].delete_local is True
|
||||
assert filas[0].crm_document_ref == documento.efc_document_ref
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_el_objeto_se_guarda_bajo_el_arbol_del_expediente(entorno):
|
||||
documento = await _adjuntar(entorno)
|
||||
keys = list(entorno["subidos"])
|
||||
|
||||
assert len(keys) == 1
|
||||
assert keys[0] == documento.file_key
|
||||
assert f"tenants/{TENANT_ID}/companies/{COMPANY_ID}/expedientes/{entorno['expediente'].id}/" in keys[0]
|
||||
|
||||
|
||||
# ── Validaciones ─────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_un_doc_type_fuera_del_catalogo_da_422_y_no_sube_nada(entorno):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _adjuntar(entorno, doc_type="tipo_inventado")
|
||||
|
||||
assert exc.value.status_code == 422
|
||||
assert exc.value.detail == "Ese tipo de documento no está en el catálogo."
|
||||
assert entorno["subidos"] == {} # nada llegó al almacén
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_una_extension_no_permitida_da_422_y_no_sube_nada(entorno):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _adjuntar(entorno, file=_upload("malicioso.exe", b"MZ"))
|
||||
|
||||
assert exc.value.status_code == 422
|
||||
assert exc.value.detail == "Ese tipo de archivo no está permitido."
|
||||
assert entorno["subidos"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_la_extension_se_valida_ANTES_de_leer_el_archivo(entorno):
|
||||
"""Rechazar por extensión no debe costar leer el archivo: es gratis saberlo por el nombre."""
|
||||
archivo = _upload("malicioso.exe", b"M" * (2 * 1024 * 1024))
|
||||
|
||||
with pytest.raises(HTTPException):
|
||||
await _adjuntar(entorno, file=archivo)
|
||||
|
||||
assert archivo.file.leidos == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_un_archivo_por_encima_del_tope_da_422_SIN_haberlo_leido_completo(entorno):
|
||||
"""**El defecto que esta fase corrige.** Con ``await file.read()`` a secas, un archivo de 2 GB
|
||||
se bufferizaba entero en RAM solo para responder 422 después.
|
||||
|
||||
Aquí la lectura es por partes y aborta al pasar del tope, así que lo leído se queda cerca del
|
||||
tope y no llega al tamaño total.
|
||||
"""
|
||||
tamano = MAX_UPLOAD_BYTES + (3 * 1024 * 1024)
|
||||
archivo = _upload("enorme.pdf", b"x" * tamano)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _adjuntar(entorno, file=archivo)
|
||||
|
||||
assert exc.value.status_code == 422
|
||||
assert exc.value.detail == "El archivo excede el tamaño máximo permitido."
|
||||
assert archivo.file.leidos < tamano
|
||||
assert entorno["subidos"] == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_un_expediente_de_otro_tenant_da_404(entorno):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _adjuntar(entorno, tenant_id=OTRO_TENANT)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_un_expediente_inexistente_da_404(entorno):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await _adjuntar(entorno, expediente_id=999999)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
# ── EFC caído ────────────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_con_efc_apagado_la_subida_SIGUE_funcionando(entorno, monkeypatch):
|
||||
"""El caso que justifica todo el carril: el usuario no pierde su trabajo porque EFC no esté.
|
||||
|
||||
Sin outbox habría que llamar a EFC dentro del request, y un EFC caído devolvería un error al
|
||||
usuario con el archivo ya subido a medias.
|
||||
"""
|
||||
from core.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False)
|
||||
|
||||
documento = await _adjuntar(entorno)
|
||||
|
||||
assert documento.id is not None
|
||||
assert documento.efc_sync_state == "PENDING"
|
||||
assert documento.file_key is not None # la copia local es lo único que hay
|
||||
# Sin integración no se encola: el barrido de huecos lo recogerá cuando se encienda.
|
||||
assert entorno["db"].query(EfcFileOutbox).filter(
|
||||
EfcFileOutbox.source_id == documento.id
|
||||
).count() == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_si_el_despacho_al_broker_falla_la_subida_igual_responde(entorno, monkeypatch):
|
||||
"""«Si el broker no responde, el sweep la recoge»: el despacho es best-effort."""
|
||||
def _revienta(*a, **k):
|
||||
raise RuntimeError("Valkey no responde")
|
||||
|
||||
monkeypatch.setattr(gateway, "_dispatch_file_delivery", _revienta)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
await _adjuntar(entorno)
|
||||
|
||||
# La fila SÍ quedó encolada antes del despacho: el barrido la va a recoger.
|
||||
assert entorno["db"].query(EfcFileOutbox).count() == 1
|
||||
|
||||
|
||||
# ── Lectura y baja ───────────────────────────────────────────────────────────
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_el_listado_solo_devuelve_los_documentos_de_ese_expediente(entorno):
|
||||
documento = await _adjuntar(entorno)
|
||||
|
||||
docs = expedientes_service.list_expediente_documents(
|
||||
entorno["db"], entorno["expediente"].id, TENANT_ID, COMPANY_ID
|
||||
)
|
||||
assert [d.id for d in docs] == [documento.id]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_un_documento_de_otro_tenant_no_se_puede_leer(entorno):
|
||||
documento = await _adjuntar(entorno)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
expedientes_service.get_expediente_document(
|
||||
entorno["db"], entorno["expediente"].id, documento.id, OTRO_TENANT, COMPANY_ID
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_la_baja_desasocia_y_no_borra_nada_en_efc(entorno):
|
||||
documento = await _adjuntar(entorno)
|
||||
|
||||
expedientes_service.detach_document(
|
||||
entorno["db"], entorno["expediente"].id, documento.id, TENANT_ID, COMPANY_ID
|
||||
)
|
||||
|
||||
assert documento.deleted_at is not None
|
||||
assert expedientes_service.list_expediente_documents(
|
||||
entorno["db"], entorno["expediente"].id, TENANT_ID, COMPANY_ID
|
||||
) == []
|
||||
# La fila del outbox NO se toca: si ya se entregó, en EFC sigue estando.
|
||||
assert entorno["db"].query(EfcFileOutbox).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_descargar_un_documento_que_aun_no_llego_a_efc_da_409(entorno):
|
||||
"""No es un 404: el documento existe, solo que todavía no está del otro lado."""
|
||||
documento = await _adjuntar(entorno)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
await expedientes_service.stream_document(
|
||||
entorno["db"], entorno["expediente"].id, documento.id, TENANT_ID, COMPANY_ID
|
||||
)
|
||||
assert exc.value.status_code == 409
|
||||
@@ -1,193 +0,0 @@
|
||||
"""Pruebas de la entidad expediente y de su enganche con la solicitud de servicio."""
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||
from api.v1.modules.crm.expedientes.models import Expediente
|
||||
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
|
||||
|
||||
|
||||
def _crear_solicitud(db, **kwargs) -> object:
|
||||
payload = ServiceRequestCreate(operation_type=kwargs.pop("operation_type", "importacion"), **kwargs)
|
||||
return sr_service.create_service_request(db, payload, TENANT_ID, COMPANY_ID, "user-1")
|
||||
|
||||
|
||||
def test_una_solicitud_nueva_nace_con_su_expediente_y_folio(db):
|
||||
solicitud = _crear_solicitud(db)
|
||||
|
||||
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
assert expediente is not None
|
||||
assert expediente.folio.startswith("EXP")
|
||||
assert expediente.sequence == 1
|
||||
assert expediente.status == "abierto"
|
||||
assert expediente.efc_link_state == "PENDING"
|
||||
|
||||
|
||||
def test_el_expediente_nace_con_su_storage_token_ya_asignado(db):
|
||||
"""El token es la carpeta de MinIO en EFC y es INMUTABLE: se fija al nacer, no al enlazar.
|
||||
|
||||
Si se asignara al momento de crear el provisional en EFC, un documento subido antes de que EFC
|
||||
conteste no sabría bajo qué prefijo va.
|
||||
"""
|
||||
solicitud = _crear_solicitud(db)
|
||||
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
assert expediente.efc_storage_token == f"CRM-{COMPANY_ID}-{expediente.folio}"
|
||||
|
||||
|
||||
def test_ensure_es_idempotente(db):
|
||||
solicitud = _crear_solicitud(db)
|
||||
primero = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
segundo = expedientes_service.ensure_expediente(db, solicitud.id, TENANT_ID, COMPANY_ID, "user-1")
|
||||
tercero = expedientes_service.ensure_expediente(db, solicitud.id, TENANT_ID, COMPANY_ID, "user-1")
|
||||
|
||||
assert primero.id == segundo.id == tercero.id
|
||||
assert primero.folio == segundo.folio == tercero.folio
|
||||
assert len(expedientes_service.list_expedientes(db, TENANT_ID, COMPANY_ID)) == 1
|
||||
|
||||
|
||||
def test_dos_solicitudes_reciben_folios_consecutivos(db):
|
||||
a = _crear_solicitud(db)
|
||||
b = _crear_solicitud(db)
|
||||
exp_a = expedientes_service.find_by_service_request(db, a.id, TENANT_ID, COMPANY_ID)
|
||||
exp_b = expedientes_service.find_by_service_request(db, b.id, TENANT_ID, COMPANY_ID)
|
||||
assert exp_b.sequence == exp_a.sequence + 1
|
||||
assert exp_a.folio != exp_b.folio
|
||||
|
||||
|
||||
def test_dos_expedientes_con_el_mismo_folio_en_el_mismo_tenant_y_company_revientan(db):
|
||||
"""``uq_crm_expedientes_folio`` es la red de seguridad si el contador se corrompe.
|
||||
|
||||
Un folio duplicado tiene que fallar ruidosamente en vez de mezclar dos hilos documentales.
|
||||
"""
|
||||
solicitud = _crear_solicitud(db)
|
||||
original = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
duplicado = Expediente(
|
||||
folio=original.folio,
|
||||
period_year=original.period_year,
|
||||
period_month=original.period_month,
|
||||
sequence=original.sequence + 1, # distinta secuencia: el que debe romper es el folio
|
||||
status="abierto",
|
||||
efc_link_state="PENDING",
|
||||
tenant_id=TENANT_ID,
|
||||
company_id=COMPANY_ID,
|
||||
)
|
||||
db.add(duplicado)
|
||||
with pytest.raises(IntegrityError):
|
||||
db.commit()
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_dos_expedientes_con_la_misma_secuencia_del_mes_revientan(db):
|
||||
"""``uq_crm_expedientes_periodo_seq``: el consecutivo es un constraint real, no un parse."""
|
||||
solicitud = _crear_solicitud(db)
|
||||
original = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
duplicado = Expediente(
|
||||
folio=original.folio + "-BIS", # distinto folio: el que debe romper es (año, mes, seq)
|
||||
period_year=original.period_year,
|
||||
period_month=original.period_month,
|
||||
sequence=original.sequence,
|
||||
status="abierto",
|
||||
efc_link_state="PENDING",
|
||||
tenant_id=TENANT_ID,
|
||||
company_id=COMPANY_ID,
|
||||
)
|
||||
db.add(duplicado)
|
||||
with pytest.raises(IntegrityError):
|
||||
db.commit()
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_el_mismo_folio_en_otra_company_si_puede_existir(db):
|
||||
"""El folio es único por ``(tenant, company)``, no globalmente.
|
||||
|
||||
Por eso el ``storage_token`` que viaja a EFC lleva el company_id: allá sí comparten organización.
|
||||
"""
|
||||
solicitud = _crear_solicitud(db)
|
||||
original = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
gemelo = Expediente(
|
||||
folio=original.folio,
|
||||
period_year=original.period_year,
|
||||
period_month=original.period_month,
|
||||
sequence=original.sequence,
|
||||
status="abierto",
|
||||
efc_link_state="PENDING",
|
||||
tenant_id=TENANT_ID,
|
||||
company_id=OTRA_COMPANY,
|
||||
)
|
||||
db.add(gemelo)
|
||||
db.commit()
|
||||
assert gemelo.id is not None
|
||||
|
||||
|
||||
def test_un_expediente_no_se_ve_desde_otro_tenant_ni_otra_company(db):
|
||||
solicitud = _crear_solicitud(db)
|
||||
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
assert expedientes_service.list_expedientes(db, OTRO_TENANT, COMPANY_ID) == []
|
||||
assert expedientes_service.list_expedientes(db, TENANT_ID, OTRA_COMPANY) == []
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
expedientes_service.get_expediente(db, expediente.id, OTRO_TENANT, COMPANY_ID)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_completar_guarda_la_data_aduanera_y_cambia_el_estado(db):
|
||||
from datetime import date
|
||||
|
||||
from api.v1.modules.crm.expedientes.dto import ExpedienteCompleteInput
|
||||
|
||||
solicitud = _crear_solicitud(db)
|
||||
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
|
||||
# Datos dummy: patente 0000, aduana 000, pedimento 0000-0000000, RFC XAXX010101000.
|
||||
completado = expedientes_service.complete_expediente(
|
||||
db,
|
||||
expediente.id,
|
||||
ExpedienteCompleteInput(
|
||||
patente="0000",
|
||||
aduana="000",
|
||||
numero_pedimento="0000000",
|
||||
anio=2026,
|
||||
clave_pedimento="A1",
|
||||
regimen="IMD",
|
||||
fecha_pago=date(2026, 8, 1),
|
||||
rfc_importador="XAXX010101000",
|
||||
),
|
||||
TENANT_ID,
|
||||
COMPANY_ID,
|
||||
"user-1",
|
||||
)
|
||||
|
||||
assert completado.status == "completado"
|
||||
assert completado.patente == "0000"
|
||||
assert completado.aduana == "000"
|
||||
assert completado.rfc_importador == "XAXX010101000"
|
||||
# El folio y el token NO cambian al completar: es lo que permite que ningún archivo se mueva.
|
||||
assert completado.folio == expediente.folio
|
||||
assert completado.efc_storage_token == expediente.efc_storage_token
|
||||
|
||||
|
||||
def test_completar_dos_veces_da_409(db):
|
||||
from api.v1.modules.crm.expedientes.dto import ExpedienteCompleteInput
|
||||
from fastapi import HTTPException
|
||||
|
||||
solicitud = _crear_solicitud(db)
|
||||
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||
payload = ExpedienteCompleteInput(patente="0000", aduana="000", numero_pedimento="0000000", anio=2026)
|
||||
|
||||
expedientes_service.complete_expediente(db, expediente.id, payload, TENANT_ID, COMPANY_ID)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
expedientes_service.complete_expediente(db, expediente.id, payload, TENANT_ID, COMPANY_ID)
|
||||
assert exc.value.status_code == 409
|
||||
444
backend/tests/test_fin_sat_catalogs.py
Normal file
444
backend/tests/test_fin_sat_catalogs.py
Normal file
@@ -0,0 +1,444 @@
|
||||
"""Pruebas de los catálogos del SAT, el catálogo de conceptos y los datos fiscales
|
||||
del emisor (módulo fin).
|
||||
|
||||
Cubren: lectura de los 8 catálogos y su filtrado, que no acepten escritura, el CRUD de
|
||||
conceptos con la relación 1:1 contra c_ClaveProdServ, el aislamiento multi-tenant, el
|
||||
upsert del emisor y el amarre de las partidas de factura al catálogo de conceptos.
|
||||
|
||||
Los RFC de las pruebas son dummies (XAXX010101000): nunca datos reales.
|
||||
"""
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
import sqlalchemy as sa
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from api.v1.modules.crm.accounts import service as accounts_service
|
||||
from api.v1.modules.crm.accounts.dto import AccountCreate, AccountUpdate
|
||||
from api.v1.modules.fin.catalogs.models import CfdiUse, ProductService, TaxObject, TaxRegime, UnitOfMeasure
|
||||
from api.v1.modules.fin.catalogs.routes import router as catalogs_router
|
||||
from api.v1.modules.fin.catalogs.seed_data import CATALOGS, sync_catalogs
|
||||
from api.v1.modules.fin.concepts import service as concepts_service
|
||||
from api.v1.modules.fin.concepts.dto import ConceptCreate, ConceptUpdate
|
||||
from api.v1.modules.fin.invoices import service as invoices_service
|
||||
from api.v1.modules.fin.invoices.dto import (
|
||||
InvoiceCreate,
|
||||
InvoiceItemCreate,
|
||||
InvoiceItemResponse,
|
||||
InvoiceItemUpdate,
|
||||
)
|
||||
from api.v1.modules.fin.issuer import service as issuer_service
|
||||
from api.v1.modules.fin.issuer.dto import IssuerSettingsInput
|
||||
from api.v1.modules.fin.issuer.models import IssuerSettings
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
T, C = 1, 1
|
||||
OTHER_TENANT, OTHER_COMPANY = 2, 2
|
||||
RFC_DUMMY = "XAXX010101000"
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def client(db):
|
||||
"""App mínima con solo el router de catálogos: evita levantar auth y permisos."""
|
||||
app = FastAPI()
|
||||
app.include_router(catalogs_router, prefix="/fin")
|
||||
app.dependency_overrides[get_core_db] = lambda: db
|
||||
app.dependency_overrides[get_current_user] = lambda: {"sub": "tester", "tenant_id": T}
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _product_service(db, code: str = "78101600") -> ProductService:
|
||||
return db.query(ProductService).filter(ProductService.code == code).one()
|
||||
|
||||
|
||||
def _concept_payload(db, code: str = "FLETE-MAR", ps_code: str = "78101600") -> ConceptCreate:
|
||||
return ConceptCreate(
|
||||
code=code,
|
||||
description="Flete marítimo internacional",
|
||||
product_service_id=_product_service(db, ps_code).id,
|
||||
unit_of_measure_id=db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "E48").one().id,
|
||||
tax_object_id=db.query(TaxObject).filter(TaxObject.code == "02").one().id,
|
||||
unit_price=Decimal("1500.00"),
|
||||
)
|
||||
|
||||
|
||||
# ---------- Catálogos del SAT: lectura ----------
|
||||
|
||||
CATALOG_EXPECTATIONS = [
|
||||
("tax-regimes", 19, "601"),
|
||||
("taxes", 3, "002"),
|
||||
("payment-forms", 22, "03"),
|
||||
("units-of-measure", 21, "H87"),
|
||||
("products-services", 11, "78101500"),
|
||||
("voucher-types", 5, "I"),
|
||||
("payment-methods", 2, "PUE"),
|
||||
("tax-objects", 4, "02"),
|
||||
("cfdi-uses", 24, "G03"),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path,expected_count,sample_code", CATALOG_EXPECTATIONS)
|
||||
def test_catalog_endpoints_return_seeded_rows(client, path, expected_count, sample_code):
|
||||
res = client.get(f"/fin/catalogs/{path}")
|
||||
assert res.status_code == 200
|
||||
rows = res.json()
|
||||
assert len(rows) == expected_count
|
||||
assert sample_code in [r["code"] for r in rows]
|
||||
|
||||
|
||||
def test_catalog_search_filters_by_code_or_description(client):
|
||||
by_code = client.get("/fin/catalogs/payment-forms", params={"search": "03"}).json()
|
||||
assert [r["code"] for r in by_code] == ["03"]
|
||||
|
||||
by_description = client.get("/fin/catalogs/payment-forms", params={"search": "transferencia"}).json()
|
||||
assert [r["code"] for r in by_description] == ["03"]
|
||||
|
||||
prodserv = client.get("/fin/catalogs/products-services", params={"search": "marítimo"}).json()
|
||||
assert [r["code"] for r in prodserv] == ["78101600"]
|
||||
|
||||
|
||||
def test_tax_regimes_person_type_excludes_individual_only(client):
|
||||
moral = client.get("/fin/catalogs/tax-regimes", params={"person_type": "moral"}).json()
|
||||
codes = [r["code"] for r in moral]
|
||||
assert "601" in codes # General de Ley Personas Morales
|
||||
assert "605" not in codes # Sueldos y Salarios: solo persona física
|
||||
assert all(r["applies_to_legal_entity"] for r in moral)
|
||||
|
||||
fisica = client.get("/fin/catalogs/tax-regimes", params={"person_type": "fisica"}).json()
|
||||
fisica_codes = [r["code"] for r in fisica]
|
||||
assert "605" in fisica_codes and "601" not in fisica_codes
|
||||
|
||||
|
||||
def test_products_services_limit_caps_results(client):
|
||||
assert len(client.get("/fin/catalogs/products-services", params={"limit": 3}).json()) == 3
|
||||
assert client.get("/fin/catalogs/products-services", params={"limit": 500}).status_code == 422
|
||||
|
||||
|
||||
def test_catalogs_are_read_only(client):
|
||||
"""Los catálogos del SAT no exponen métodos de escritura."""
|
||||
for method, path in [
|
||||
("post", "/fin/catalogs/payment-forms"),
|
||||
("put", "/fin/catalogs/tax-regimes"),
|
||||
("patch", "/fin/catalogs/units-of-measure"),
|
||||
("delete", "/fin/catalogs/products-services"),
|
||||
]:
|
||||
res = client.request(method.upper(), path, json={"code": "XX", "description": "Inventado"})
|
||||
assert res.status_code == 405, f"{method.upper()} {path} no debería aceptarse"
|
||||
|
||||
|
||||
def _catalog_counts(db) -> dict[str, int]:
|
||||
return {
|
||||
table.name: db.execute(sa.select(sa.func.count()).select_from(table)).scalar()
|
||||
for table, _ in CATALOGS
|
||||
}
|
||||
|
||||
|
||||
def test_sync_catalogs_is_idempotent(db):
|
||||
"""Volver a correrla no duplica ni borra filas."""
|
||||
before = _catalog_counts(db)
|
||||
inserted = sync_catalogs(db.connection()) # el fixture ya sembró los catálogos
|
||||
db.commit()
|
||||
assert sum(inserted.values()) == 0
|
||||
assert _catalog_counts(db) == before
|
||||
|
||||
|
||||
# ---------- Conceptos ----------
|
||||
|
||||
def test_concept_crud(db):
|
||||
created = concepts_service.create_concept(db, _concept_payload(db), T, C, "tester")
|
||||
assert created.code == "FLETE-MAR" and created.currency == "MXN" and created.is_active
|
||||
|
||||
fetched = concepts_service.get_concept(db, created.id, T, C)
|
||||
assert fetched.product_service.code == "78101600" # catálogo resuelto sin N+1
|
||||
|
||||
updated = concepts_service.update_concept(
|
||||
db, created.id, ConceptUpdate(description="Flete marítimo FCL", is_active=False), T, C, "tester"
|
||||
)
|
||||
assert updated.description == "Flete marítimo FCL" and updated.is_active is False
|
||||
|
||||
assert concepts_service.get_concepts(db, T, C, active_only=False) == [updated]
|
||||
assert concepts_service.get_concepts(db, T, C, active_only=True) == []
|
||||
|
||||
concepts_service.delete_concept(db, created.id, T, C)
|
||||
assert concepts_service.get_concepts(db, T, C) == []
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
concepts_service.get_concept(db, created.id, T, C)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_duplicate_product_service_in_same_company_conflicts(db):
|
||||
concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
concepts_service.create_concept(db, _concept_payload(db, code="OTRO-CODIGO"), T, C)
|
||||
assert exc.value.status_code == 409
|
||||
assert "producto/servicio" in exc.value.detail
|
||||
|
||||
|
||||
def test_duplicate_concept_code_in_same_company_conflicts(db):
|
||||
concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
concepts_service.create_concept(db, _concept_payload(db, ps_code="78101500"), T, C)
|
||||
assert exc.value.status_code == 409
|
||||
assert "clave 'FLETE-MAR'" in exc.value.detail
|
||||
|
||||
|
||||
def test_same_product_service_allowed_in_another_company(db):
|
||||
concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
other = concepts_service.create_concept(db, _concept_payload(db), T, OTHER_COMPANY)
|
||||
assert other.company_id == OTHER_COMPANY
|
||||
assert other.product_service_id == _product_service(db).id
|
||||
|
||||
|
||||
def test_soft_deleted_concept_frees_its_product_service(db):
|
||||
first = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
concepts_service.delete_concept(db, first.id, T, C)
|
||||
reused = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
assert reused.id != first.id
|
||||
assert reused.product_service_id == first.product_service_id
|
||||
|
||||
|
||||
def test_concept_is_isolated_by_tenant(db):
|
||||
other_tenant_concept = concepts_service.create_concept(db, _concept_payload(db), OTHER_TENANT, C)
|
||||
assert concepts_service.get_concepts(db, T, C) == []
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
concepts_service.get_concept(db, other_tenant_concept.id, T, C)
|
||||
assert exc.value.status_code == 404
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
concepts_service.update_concept(
|
||||
db, other_tenant_concept.id, ConceptUpdate(description="Ajeno"), T, C
|
||||
)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_concept_rejects_unknown_sat_key(db):
|
||||
payload = _concept_payload(db)
|
||||
payload.product_service_id = 999999
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
concepts_service.create_concept(db, payload, T, C)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
# ---------- Datos fiscales del emisor ----------
|
||||
|
||||
def _issuer_payload(db, legal_name: str = "Empresa Demo SA de CV") -> IssuerSettingsInput:
|
||||
regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one()
|
||||
return IssuerSettingsInput(
|
||||
legal_name=legal_name, rfc=RFC_DUMMY, tax_regime_id=regime.id, zip_code="64000"
|
||||
)
|
||||
|
||||
|
||||
def test_issuer_settings_upsert_keeps_one_row_per_company(db):
|
||||
created = issuer_service.save_issuer_settings(db, _issuer_payload(db), T, C, "tester")
|
||||
assert created.rfc == RFC_DUMMY
|
||||
|
||||
updated = issuer_service.save_issuer_settings(
|
||||
db, _issuer_payload(db, legal_name="Empresa Demo Renombrada SA de CV"), T, C, "tester"
|
||||
)
|
||||
assert updated.id == created.id
|
||||
assert updated.legal_name == "Empresa Demo Renombrada SA de CV"
|
||||
|
||||
rows = db.query(IssuerSettings).filter(
|
||||
IssuerSettings.tenant_id == T, IssuerSettings.company_id == C, IssuerSettings.deleted_at.is_(None)
|
||||
).all()
|
||||
assert len(rows) == 1
|
||||
|
||||
|
||||
def test_issuer_settings_missing_returns_404(db):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
issuer_service.get_issuer_settings(db, T, C)
|
||||
assert exc.value.status_code == 404
|
||||
|
||||
|
||||
def test_issuer_rfc_is_validated_and_normalized(db):
|
||||
regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one()
|
||||
with pytest.raises(ValidationError):
|
||||
IssuerSettingsInput(legal_name="Demo", rfc="RFC-INVALIDO", tax_regime_id=regime.id)
|
||||
with pytest.raises(ValidationError):
|
||||
IssuerSettingsInput(legal_name="Demo", rfc=RFC_DUMMY, tax_regime_id=regime.id, zip_code="123")
|
||||
|
||||
normalized = IssuerSettingsInput(
|
||||
legal_name="Demo", rfc=" xaxx010101000 ", tax_regime_id=regime.id
|
||||
)
|
||||
assert normalized.rfc == RFC_DUMMY
|
||||
|
||||
|
||||
def test_issuer_rejects_unknown_tax_regime(db):
|
||||
payload = _issuer_payload(db)
|
||||
payload.tax_regime_id = 999999
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
issuer_service.save_issuer_settings(db, payload, T, C)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
# ---------- Amarre con las facturas ----------
|
||||
|
||||
def test_invoice_item_inherits_concept_description(db):
|
||||
concept = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-1"), T, C)
|
||||
item = invoices_service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, quantity=1, unit_amount=1500),
|
||||
T,
|
||||
C,
|
||||
)
|
||||
assert item.concept == concept.description # copiada del catálogo para el PDF
|
||||
assert item.concept_id == concept.id
|
||||
|
||||
# La respuesta expone las claves fiscales: el frontend etiqueta la partida con ellas.
|
||||
payload = InvoiceItemResponse.model_validate(item).model_dump()
|
||||
assert payload["concept_id"] == concept.id
|
||||
assert payload["concept"] == concept.description
|
||||
assert {"product_service_id", "unit_of_measure_id", "tax_object_id"} <= payload.keys()
|
||||
|
||||
# Si el cliente sí manda el texto, se respeta tal cual.
|
||||
explicit = invoices_service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(
|
||||
invoice_id=invoice.id, concept_id=concept.id, concept="Flete a la medida", unit_amount=100
|
||||
),
|
||||
T,
|
||||
C,
|
||||
)
|
||||
assert explicit.concept == "Flete a la medida"
|
||||
|
||||
|
||||
def test_invoice_item_without_concept_or_catalog_is_rejected(db):
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-2"), T, C)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
invoices_service.create_item(db, InvoiceItemCreate(invoice_id=invoice.id, unit_amount=10), T, C)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
def test_invoice_item_rejects_concept_from_another_company(db):
|
||||
concept = concepts_service.create_concept(db, _concept_payload(db), T, OTHER_COMPANY)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-3"), T, C)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
invoices_service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, unit_amount=10), T, C
|
||||
)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
def test_invoice_item_inherits_sat_keys_from_concept(db):
|
||||
"""La partida hereda las claves fiscales del concepto para quedar completa (CFDI)."""
|
||||
concept = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-4"), T, C)
|
||||
|
||||
item = invoices_service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=concept.id, unit_amount=1500), T, C
|
||||
)
|
||||
assert item.product_service_id == concept.product_service_id
|
||||
assert item.unit_of_measure_id == concept.unit_of_measure_id
|
||||
assert item.tax_object_id == concept.tax_object_id
|
||||
|
||||
|
||||
def test_invoice_item_sat_keys_sent_by_client_win_over_concept(db):
|
||||
"""Lo que el cliente envía manda: permite facturar con otra unidad de medida."""
|
||||
concept = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-5"), T, C)
|
||||
other_unit = db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "KGM").one()
|
||||
|
||||
item = invoices_service.create_item(
|
||||
db,
|
||||
InvoiceItemCreate(
|
||||
invoice_id=invoice.id, concept_id=concept.id, unit_of_measure_id=other_unit.id, unit_amount=10
|
||||
),
|
||||
T,
|
||||
C,
|
||||
)
|
||||
assert item.unit_of_measure_id == other_unit.id
|
||||
assert item.product_service_id == concept.product_service_id # el resto sí se hereda
|
||||
|
||||
|
||||
def test_changing_item_concept_reinherits_keys(db):
|
||||
"""Cambiar el concepto de una partida revalida y vuelve a heredar del nuevo."""
|
||||
first = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
second = concepts_service.create_concept(
|
||||
db, _concept_payload(db, code="DESPACHO", ps_code="78141600"), T, C
|
||||
)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-6"), T, C)
|
||||
item = invoices_service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=first.id, unit_amount=100), T, C
|
||||
)
|
||||
|
||||
updated = invoices_service.update_item(
|
||||
db, item.id, InvoiceItemUpdate(concept_id=second.id), T, C
|
||||
)
|
||||
assert updated.concept_id == second.id
|
||||
assert updated.product_service_id == second.product_service_id
|
||||
assert updated.concept == second.description
|
||||
|
||||
|
||||
def test_updating_item_rejects_concept_from_another_tenant(db):
|
||||
"""El PATCH valida la referencia igual que el alta: no cruza tenants."""
|
||||
mine = concepts_service.create_concept(db, _concept_payload(db), T, C)
|
||||
alien = concepts_service.create_concept(db, _concept_payload(db), OTHER_TENANT, C)
|
||||
invoice = invoices_service.create_invoice(db, InvoiceCreate(reference="F-SAT-7"), T, C)
|
||||
item = invoices_service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=invoice.id, concept_id=mine.id, unit_amount=100), T, C
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
invoices_service.update_item(db, item.id, InvoiceItemUpdate(concept_id=alien.id), T, C)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
# ---------- Claves fiscales del receptor (crm.accounts) ----------
|
||||
|
||||
def test_account_accepts_sat_fiscal_keys(db):
|
||||
regime = db.query(TaxRegime).filter(TaxRegime.code == "601").one()
|
||||
cfdi_use = db.query(CfdiUse).filter(CfdiUse.code == "G03").one()
|
||||
|
||||
account = accounts_service.create_account(
|
||||
db,
|
||||
AccountCreate(name="Cliente fiscal", tax_regime_id=regime.id, cfdi_use_id=cfdi_use.id),
|
||||
T,
|
||||
C,
|
||||
)
|
||||
assert account.tax_regime_id == regime.id and account.cfdi_use_id == cfdi_use.id
|
||||
|
||||
|
||||
def test_account_rejects_unknown_sat_fiscal_keys(db):
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
accounts_service.create_account(db, AccountCreate(name="Cliente malo", cfdi_use_id=999999), T, C)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
account = accounts_service.create_account(db, AccountCreate(name="Cliente ok"), T, C)
|
||||
with pytest.raises(HTTPException) as exc:
|
||||
accounts_service.update_account(db, account.id, AccountUpdate(tax_regime_id=999999), T, C)
|
||||
assert exc.value.status_code == 422
|
||||
|
||||
|
||||
def test_account_free_text_fiscal_fields_are_preserved(db):
|
||||
"""El texto libre previo se conserva: las FK lo complementan, no lo sustituyen."""
|
||||
account = accounts_service.create_account(
|
||||
db, AccountCreate(name="Cliente heredado", tax_regime="601", cfdi_use="G03"), T, C
|
||||
)
|
||||
assert account.tax_regime == "601" and account.cfdi_use == "G03"
|
||||
assert account.tax_regime_id is None and account.cfdi_use_id is None
|
||||
|
||||
|
||||
def test_legacy_invoices_keep_working_without_sat_fields(db, monkeypatch):
|
||||
"""Las facturas previas, sin claves del SAT, siguen listándose y generando PDF."""
|
||||
stored = {}
|
||||
monkeypatch.setattr(
|
||||
"core.storage_s3.put_object_bytes",
|
||||
lambda key, body, content_type="": stored.update({"key": key, "len": len(body)}),
|
||||
)
|
||||
account = accounts_service.create_account(db, AccountCreate(name="Cliente heredado"), T, C)
|
||||
invoice = invoices_service.create_invoice(
|
||||
db, InvoiceCreate(reference="F-LEGACY", account_id=account.id, tax_rate=Decimal("16")), T, C
|
||||
)
|
||||
invoices_service.create_item(
|
||||
db, InvoiceItemCreate(invoice_id=invoice.id, concept="flete_internacional", unit_amount=1000), T, C
|
||||
)
|
||||
assert invoice.voucher_type_id is None and invoice.payment_form_id is None
|
||||
|
||||
listed = invoices_service.get_invoices(db, T, C)
|
||||
assert invoice.id in [i.id for i in listed]
|
||||
|
||||
sent = invoices_service.send_invoice(db, invoice.id, T, C)
|
||||
assert sent.status == "enviada" and stored["len"] > 0
|
||||
@@ -1,134 +0,0 @@
|
||||
"""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]
|
||||
@@ -1,85 +0,0 @@
|
||||
"""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
|
||||
@@ -770,19 +770,6 @@ async function fetchBlob(
|
||||
export const api = {
|
||||
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||
getBlob: (endpoint: string) => fetchBlob(endpoint, { method: 'GET' }),
|
||||
|
||||
/**
|
||||
* POST multipart con progreso de subida.
|
||||
*
|
||||
* `fetchApiFormDataPost` ya existía con toda la maquinaria de progreso y de refresco de token,
|
||||
* pero no estaba expuesto, así que ningún llamador podía usarlo y las subidas iban por
|
||||
* `api.request` plano —sin barra de progreso—. Esto solo lo publica: la implementación no cambia.
|
||||
*/
|
||||
postFormData: <T = any>(
|
||||
endpoint: string,
|
||||
formData: FormData,
|
||||
opts: CsvFormDataUploadOptions = {}
|
||||
) => fetchApiFormDataPost<T>(endpoint, formData, opts),
|
||||
postBlob: (endpoint: string, body: any) =>
|
||||
fetchBlob(endpoint, {
|
||||
method: 'POST',
|
||||
|
||||
@@ -28,8 +28,11 @@ export interface Account {
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
website: string | null;
|
||||
/** Texto libre histórico; lo que vale al timbrar son las claves del SAT de abajo. */
|
||||
tax_regime: string | null;
|
||||
cfdi_use: string | null;
|
||||
tax_regime_id: number | null;
|
||||
cfdi_use_id: number | null;
|
||||
payment_method: string | null;
|
||||
payment_form: string | null;
|
||||
currency: string | null;
|
||||
@@ -168,13 +171,6 @@ export interface Document {
|
||||
content_type: string | null;
|
||||
size_bytes: number | null;
|
||||
uploaded_by: string | null;
|
||||
// Espejo del documento en el expediente electrónico de EFC (T2026-08-046). Opcionales: las filas
|
||||
// legacy no los traen, y la descarga se ramifica por ellos — `efc_document_id` → proxy de EFC,
|
||||
// `file_key` → URL firmada local, `file_url` → externa.
|
||||
expediente_id?: number | null;
|
||||
efc_document_ref?: string | null;
|
||||
efc_document_id?: string | null;
|
||||
efc_sync_state?: 'PENDING' | 'SYNCED' | 'FAILED' | null;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
created_at: string;
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* Paridad de los catálogos de tipo de documento entre el frontend y el backend.
|
||||
*
|
||||
* `DOC_TYPES` y `SHIPMENT_DOC_TYPES` de `format.ts` son la FUENTE del mapeo, y desde T2026-08-046 el
|
||||
* backend los valida contra un conjunto cerrado que EFC comparte. Si alguien agrega una opción aquí
|
||||
* sin agregarla en los otros dos lados, el usuario la vería en el selector y la subida fallaría con
|
||||
* 422 al guardar — un rojo aquí es mucho más barato que descubrirlo así.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { DOC_TYPES, SHIPMENT_DOC_TYPES } from './../components/crm/format';
|
||||
|
||||
/**
|
||||
* Copia literal de `EFC_DOC_TYPES`
|
||||
* (`backend/api/v1/modules/crm/expedientes/doc_types.py`), que a su vez es copia de
|
||||
* `TIPOS_DOCUMENTO_CRM` de EFC. Al cambiar cualquiera de los tres, se cambia AQUÍ también.
|
||||
*/
|
||||
const ACEPTADOS_POR_EL_BACKEND = new Set([
|
||||
'constancia_fiscal',
|
||||
'acta_constitutiva',
|
||||
'identificacion',
|
||||
'comprobante_domicilio',
|
||||
'contrato',
|
||||
'presentacion',
|
||||
'certificacion',
|
||||
'licencia',
|
||||
'convenio',
|
||||
'tarifario',
|
||||
'MBL',
|
||||
'HBL',
|
||||
'MAWB',
|
||||
'HAWB',
|
||||
'CMR',
|
||||
'factura_comercial',
|
||||
'packing_list',
|
||||
'carta_encomienda',
|
||||
'carta_garantia',
|
||||
'certificado_permiso',
|
||||
'factura_venta',
|
||||
'otro'
|
||||
]);
|
||||
|
||||
describe('paridad de tipos de documento con el backend', () => {
|
||||
it('todo lo que ofrece el selector de documentos de cliente lo acepta el backend', () => {
|
||||
const noAceptados = DOC_TYPES.map((t) => t.value).filter(
|
||||
(v) => !ACEPTADOS_POR_EL_BACKEND.has(v)
|
||||
);
|
||||
expect(noAceptados).toEqual([]);
|
||||
});
|
||||
|
||||
it('todo lo que ofrece el selector de documentos de embarque lo acepta el backend', () => {
|
||||
const noAceptados = SHIPMENT_DOC_TYPES.map((t) => t.value).filter(
|
||||
(v) => !ACEPTADOS_POR_EL_BACKEND.has(v)
|
||||
);
|
||||
expect(noAceptados).toEqual([]);
|
||||
});
|
||||
|
||||
it('el catálogo del backend son exactamente 22 claves', () => {
|
||||
expect(ACEPTADOS_POR_EL_BACKEND.size).toBe(22);
|
||||
});
|
||||
|
||||
it('los dos selectores juntos cubren el catálogo salvo el PDF de factura', () => {
|
||||
// `factura_venta` lo genera el sistema al emitir la factura, no lo elige un usuario: por eso
|
||||
// está en el catálogo del backend y no en ningún selector.
|
||||
const enSelectores = new Set([
|
||||
...DOC_TYPES.map((t) => t.value),
|
||||
...SHIPMENT_DOC_TYPES.map((t) => t.value)
|
||||
]);
|
||||
const soloEnBackend = [...ACEPTADOS_POR_EL_BACKEND].filter((v) => !enSelectores.has(v));
|
||||
expect(soloEnBackend).toEqual(['factura_venta']);
|
||||
});
|
||||
|
||||
it('`otro` está en las dos listas y significa lo mismo', () => {
|
||||
expect(DOC_TYPES.some((t) => t.value === 'otro')).toBe(true);
|
||||
expect(SHIPMENT_DOC_TYPES.some((t) => t.value === 'otro')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,248 +0,0 @@
|
||||
/**
|
||||
* Cliente API — Expedientes del CRM y sus documentos en el expediente electrónico (EFC).
|
||||
*
|
||||
* El archivo de un documento de expediente NO se abre con `window.open`: esa llamada no lleva el
|
||||
* header `Authorization`, así que el proxy de descarga respondería 401. Se pide como blob con
|
||||
* `api.getBlob` y se abre con `URL.createObjectURL`.
|
||||
*/
|
||||
import { api, type CsvFormDataUploadOptions } from '$lib/api';
|
||||
|
||||
/** Estado del espejo del documento en EFC. Es lo que pinta el badge de la ficha. */
|
||||
export type EfcSyncState = 'PENDING' | 'SYNCED' | 'FAILED';
|
||||
|
||||
export interface Expediente {
|
||||
id: number;
|
||||
folio: string;
|
||||
period_year: number;
|
||||
period_month: number;
|
||||
sequence: number;
|
||||
service_request_id: number | null;
|
||||
account_id: number | null;
|
||||
status: string;
|
||||
efc_organizacion_id: string | null;
|
||||
efc_pedimento_id: string | null;
|
||||
efc_storage_token: string | null;
|
||||
efc_link_state: string;
|
||||
efc_error_code: string | null;
|
||||
efc_error_detail: string | null;
|
||||
patente: string | null;
|
||||
aduana: string | null;
|
||||
numero_pedimento: string | null;
|
||||
anio: number | null;
|
||||
clave_pedimento: string | null;
|
||||
regimen: string | null;
|
||||
fecha_pago: string | null;
|
||||
rfc_importador: string | null;
|
||||
rfc_agente_aduanal: string | null;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** Documento de un expediente. Sin `file_key` ni `file_url`: la copia local es de tránsito. */
|
||||
export interface ExpedienteDocument {
|
||||
id: number;
|
||||
expediente_id: number | null;
|
||||
doc_type: string;
|
||||
name: string;
|
||||
content_type: string | null;
|
||||
size_bytes: number | null;
|
||||
efc_sync_state: EfcSyncState | null;
|
||||
efc_document_ref: string | null;
|
||||
efc_document_id: string | null;
|
||||
efc_error_code: string | null;
|
||||
efc_attempts: number | null;
|
||||
uploaded_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface OutboxRow {
|
||||
id: number;
|
||||
tabla: 'sync' | 'file';
|
||||
kind: string;
|
||||
status: 'pending' | 'sent' | 'failed';
|
||||
attempts: number;
|
||||
last_error: string | null;
|
||||
expediente_ref: number | null;
|
||||
file_name?: string;
|
||||
efc_tipo?: string;
|
||||
source_table?: string;
|
||||
source_id?: number | null;
|
||||
crm_document_ref?: string | null;
|
||||
efc_document_id?: string | null;
|
||||
created_at: string | null;
|
||||
sent_at: string | null;
|
||||
}
|
||||
|
||||
export interface OutboxMetrics {
|
||||
pending: number;
|
||||
sent: number;
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export const expedientesAPI = {
|
||||
async list(
|
||||
companyId: number,
|
||||
params?: { service_request_id?: number; account_id?: number; status?: string }
|
||||
): Promise<Expediente[]> {
|
||||
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||
if (params?.service_request_id) qs.set('service_request_id', String(params.service_request_id));
|
||||
if (params?.account_id) qs.set('account_id', String(params.account_id));
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
const res = await api.get<Expediente[]>(`/v1/crm/expedientes?${qs}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async get(id: number, companyId: number): Promise<Expediente> {
|
||||
const res = await api.get<Expediente>(`/v1/crm/expedientes/${id}?company_id=${companyId}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
/** Devuelve el expediente de una solicitud, creándolo si hace falta. Idempotente. */
|
||||
async ensure(serviceRequestId: number, companyId: number): Promise<Expediente> {
|
||||
const res = await api.post<Expediente>(`/v1/crm/expedientes/ensure?company_id=${companyId}`, {
|
||||
service_request_id: serviceRequestId
|
||||
});
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async listDocuments(expedienteId: number, companyId: number): Promise<ExpedienteDocument[]> {
|
||||
const res = await api.get<ExpedienteDocument[]>(
|
||||
`/v1/crm/expedientes/${expedienteId}/documentos?company_id=${companyId}`
|
||||
);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async removeDocument(
|
||||
expedienteId: number,
|
||||
documentId: number,
|
||||
companyId: number
|
||||
): Promise<void> {
|
||||
const res = await api.delete(
|
||||
`/v1/crm/expedientes/${expedienteId}/documentos/${documentId}?company_id=${companyId}`
|
||||
);
|
||||
if (res.error) throw new Error(res.error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Sube un documento al expediente en UN paso (guarda + registra + encola la entrega a EFC).
|
||||
*
|
||||
* Responde 201 aunque EFC esté caído: el documento queda en «Pendiente de enviar» y el sistema lo
|
||||
* entrega solo cuando EFC vuelve.
|
||||
*/
|
||||
export async function uploadExpedienteDocument(
|
||||
expedienteId: number,
|
||||
file: File,
|
||||
docType: string,
|
||||
companyId: number,
|
||||
opts: CsvFormDataUploadOptions & { name?: string } = {}
|
||||
): Promise<ExpedienteDocument> {
|
||||
const { name, ...uploadOpts } = opts;
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('doc_type', docType);
|
||||
if (name) fd.append('name', name);
|
||||
|
||||
const res = await api.postFormData<ExpedienteDocument>(
|
||||
`/v1/crm/expedientes/${expedienteId}/documentos?company_id=${companyId}`,
|
||||
fd,
|
||||
uploadOpts
|
||||
);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Descarga el archivo de un documento del expediente como blob.
|
||||
*
|
||||
* Bufferiza en memoria del NAVEGADOR, no del servidor. Para archivos muy grandes habrá que migrar a
|
||||
* un token firmado en el query string, y eso es otro ticket.
|
||||
*/
|
||||
export async function expedienteDocBlob(
|
||||
expedienteId: number,
|
||||
documentId: number,
|
||||
companyId: number
|
||||
): Promise<Blob> {
|
||||
return api.getBlob(
|
||||
`/v1/crm/expedientes/${expedienteId}/documentos/${documentId}/archivo?company_id=${companyId}`
|
||||
);
|
||||
}
|
||||
|
||||
export const expedienteGatewayAPI = {
|
||||
async outbox(
|
||||
companyId: number,
|
||||
params?: { tipo?: 'sync' | 'file'; status?: string; limit?: number }
|
||||
): Promise<OutboxRow[]> {
|
||||
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||
if (params?.tipo) qs.set('tipo', params.tipo);
|
||||
if (params?.status) qs.set('status', params.status);
|
||||
if (params?.limit) qs.set('limit', String(params.limit));
|
||||
const res = await api.get<OutboxRow[]>(`/v1/crm/expediente-gateway/outbox?${qs}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
/** Reintento manual. Un 404 significa que esa fila no existe para este tenant/company. */
|
||||
async retry(
|
||||
outboxId: number,
|
||||
companyId: number,
|
||||
tipo: 'sync' | 'file' = 'file'
|
||||
): Promise<{ status: string; id: number }> {
|
||||
const res = await api.post<{ status: string; id: number }>(
|
||||
`/v1/crm/expediente-gateway/outbox/${outboxId}/retry?company_id=${companyId}&tipo=${tipo}`,
|
||||
{}
|
||||
);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async metrics(companyId: number): Promise<OutboxMetrics> {
|
||||
const res = await api.get<OutboxMetrics>(
|
||||
`/v1/crm/expediente-gateway/metrics?company_id=${companyId}`
|
||||
);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
};
|
||||
|
||||
/** Reintenta la entrega del documento buscando su fila de outbox por el handle del CRM. */
|
||||
export async function retrySync(
|
||||
documentRef: string,
|
||||
companyId: number
|
||||
): Promise<{ status: string; id: number }> {
|
||||
const filas = await expedienteGatewayAPI.outbox(companyId, { tipo: 'file', limit: 500 });
|
||||
const fila = filas.find((f) => f.crm_document_ref === documentRef);
|
||||
if (!fila) throw new Error('No se encontró el envío de ese documento.');
|
||||
return expedienteGatewayAPI.retry(fila.id, companyId, 'file');
|
||||
}
|
||||
|
||||
// ── Textos es-MX. Fuente única: si el badge y el tooltip se escriben en cada pantalla, acaban
|
||||
// diciendo cosas distintas para el mismo estado.
|
||||
export const EFC_SYNC_LABELS: Record<EfcSyncState, string> = {
|
||||
PENDING: 'Pendiente de enviar',
|
||||
SYNCED: 'En expediente',
|
||||
FAILED: 'No se pudo enviar'
|
||||
};
|
||||
|
||||
export const EFC_SYNC_TOOLTIPS: Partial<Record<EfcSyncState, string>> = {
|
||||
FAILED:
|
||||
'El documento está guardado, pero todavía no llegó al expediente electrónico. Vuelve a intentarlo o avisa a soporte.'
|
||||
};
|
||||
|
||||
export const EFC_TEXTS = {
|
||||
retryButton: 'Reintentar envío',
|
||||
invalidDocType: 'Ese tipo de documento no está en el catálogo.',
|
||||
invalidExtension: 'Ese tipo de archivo no está permitido.',
|
||||
tooLarge: 'El archivo excede el tamaño máximo permitido.',
|
||||
downloadFailed: 'No se pudo obtener el archivo del expediente electrónico.',
|
||||
newDocumentTitle: 'Nuevo documento del expediente'
|
||||
} as const;
|
||||
62
frontend/src/lib/api/fin/catalogs.test.ts
Normal file
62
frontend/src/lib/api/fin/catalogs.test.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const get = vi.fn();
|
||||
|
||||
// El cliente de catálogos solo usa `api.get`; se sustituye para contar peticiones.
|
||||
vi.mock('$lib/api', () => ({ api: { get } }));
|
||||
|
||||
const { satCatalogsAPI, clearCatalogCache } = await import('./catalogs');
|
||||
|
||||
const COMPANY_ID = 1;
|
||||
const PAYMENT_FORMS = [
|
||||
{ id: 1, code: '01', description: 'Efectivo', is_active: true },
|
||||
{ id: 2, code: '03', description: 'Transferencia electrónica de fondos', is_active: true }
|
||||
];
|
||||
|
||||
describe('satCatalogsAPI — cacheo en memoria', () => {
|
||||
beforeEach(() => {
|
||||
clearCatalogCache();
|
||||
get.mockReset();
|
||||
get.mockResolvedValue({ data: PAYMENT_FORMS, status: 200 });
|
||||
});
|
||||
|
||||
it('consulta el backend la primera vez y reusa el cache después', async () => {
|
||||
const first = await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||
const second = await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||
|
||||
expect(first).toEqual(PAYMENT_FORMS);
|
||||
expect(second).toBe(first); // misma referencia: vino del cache
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('cachea por separado cada combinación de parámetros', async () => {
|
||||
await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||
await satCatalogsAPI.paymentForms(COMPANY_ID, { search: 'transferencia' });
|
||||
await satCatalogsAPI.paymentForms(COMPANY_ID, { search: 'transferencia' });
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('no comparte cache entre compañías', async () => {
|
||||
await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||
await satCatalogsAPI.paymentForms(2);
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('clearCatalogCache obliga a volver a consultar', async () => {
|
||||
await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||
clearCatalogCache();
|
||||
await satCatalogsAPI.paymentForms(COMPANY_ID);
|
||||
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('propaga el error del backend y no lo cachea', async () => {
|
||||
get.mockResolvedValueOnce({ error: 'Falla del servidor', status: 500 });
|
||||
await expect(satCatalogsAPI.taxRegimes(COMPANY_ID)).rejects.toThrow('Falla del servidor');
|
||||
|
||||
await satCatalogsAPI.taxRegimes(COMPANY_ID);
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
96
frontend/src/lib/api/fin/catalogs.ts
Normal file
96
frontend/src/lib/api/fin/catalogs.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
/**
|
||||
* Cliente API — Catálogos del SAT (solo lectura).
|
||||
*
|
||||
* Son catálogos fijos que publica el SAT: una vez cargados no cambian durante la
|
||||
* sesión, así que se guardan en un `Map` del módulo para no repetir la petición en
|
||||
* cada selector. No hay POST/PUT/PATCH/DELETE: el backend tampoco los expone.
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export interface SatCatalogItem {
|
||||
id: number;
|
||||
code: string;
|
||||
description: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
export interface SatTaxRegime extends SatCatalogItem {
|
||||
applies_to_individual: boolean; // persona física
|
||||
applies_to_legal_entity: boolean; // persona moral
|
||||
}
|
||||
|
||||
export interface SatTax extends SatCatalogItem {
|
||||
is_withholding: boolean;
|
||||
is_transferred: boolean;
|
||||
is_local: boolean;
|
||||
}
|
||||
|
||||
/** `description` es la nota larga del SAT y puede venir vacía; el nombre corto va en `name`. */
|
||||
export interface SatUnitOfMeasure extends Omit<SatCatalogItem, 'description'> {
|
||||
description: string | null;
|
||||
name: string;
|
||||
symbol: string | null;
|
||||
}
|
||||
|
||||
export type PersonType = 'fisica' | 'moral';
|
||||
|
||||
type CatalogParams = Record<string, string | number | boolean | undefined>;
|
||||
|
||||
/** Cache en memoria del módulo, con la query string completa como llave. */
|
||||
const cache = new Map<string, unknown>();
|
||||
|
||||
function buildQuery(companyId: number, params?: CatalogParams): string {
|
||||
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||
for (const [key, value] of Object.entries(params ?? {})) {
|
||||
if (value !== undefined && value !== '') qs.set(key, String(value));
|
||||
}
|
||||
qs.sort(); // llave de cache estable sin importar el orden de los parámetros
|
||||
return qs.toString();
|
||||
}
|
||||
|
||||
async function fetchCatalog<T>(
|
||||
path: string,
|
||||
companyId: number,
|
||||
params?: CatalogParams
|
||||
): Promise<T[]> {
|
||||
const query = buildQuery(companyId, params);
|
||||
const key = `${path}?${query}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached as T[];
|
||||
|
||||
const res = await api.get<T[]>(`/v1/fin/catalogs/${path}?${query}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
const rows = res.data ?? [];
|
||||
cache.set(key, rows);
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** Vacía el cache; útil tras actualizar los catálogos con `sync_catalogs`. */
|
||||
export function clearCatalogCache(): void {
|
||||
cache.clear();
|
||||
}
|
||||
|
||||
export const satCatalogsAPI = {
|
||||
taxRegimes: (
|
||||
companyId: number,
|
||||
params?: { search?: string; person_type?: PersonType; active_only?: boolean }
|
||||
) => fetchCatalog<SatTaxRegime>('tax-regimes', companyId, params),
|
||||
taxes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatTax>('taxes', companyId, params),
|
||||
paymentForms: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatCatalogItem>('payment-forms', companyId, params),
|
||||
unitsOfMeasure: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatUnitOfMeasure>('units-of-measure', companyId, params),
|
||||
productsServices: (
|
||||
companyId: number,
|
||||
params?: { search?: string; limit?: number; active_only?: boolean }
|
||||
) => fetchCatalog<SatCatalogItem>('products-services', companyId, params),
|
||||
voucherTypes: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatCatalogItem>('voucher-types', companyId, params),
|
||||
paymentMethods: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatCatalogItem>('payment-methods', companyId, params),
|
||||
taxObjects: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatCatalogItem>('tax-objects', companyId, params),
|
||||
cfdiUses: (companyId: number, params?: { search?: string; active_only?: boolean }) =>
|
||||
fetchCatalog<SatCatalogItem>('cfdi-uses', companyId, params)
|
||||
};
|
||||
81
frontend/src/lib/api/fin/concepts.ts
Normal file
81
frontend/src/lib/api/fin/concepts.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Cliente API — Catálogo de conceptos de facturación.
|
||||
*
|
||||
* Cada concepto está ligado 1:1 a una clave de producto/servicio del SAT dentro de la
|
||||
* empresa; el backend responde 409 si la clave ya está tomada.
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
import type { SatCatalogItem, SatUnitOfMeasure } from './catalogs';
|
||||
|
||||
export interface Concept {
|
||||
id: number;
|
||||
code: string;
|
||||
description: string;
|
||||
product_service_id: number;
|
||||
unit_of_measure_id: number | null;
|
||||
tax_object_id: number | null;
|
||||
unit_price: number | null;
|
||||
currency: string;
|
||||
is_active: boolean;
|
||||
notes: string | null;
|
||||
product_service: SatCatalogItem | null;
|
||||
unit_of_measure: SatUnitOfMeasure | null;
|
||||
tax_object: SatCatalogItem | null;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ConceptInput {
|
||||
code: string;
|
||||
description: string;
|
||||
product_service_id: number;
|
||||
unit_of_measure_id?: number | null;
|
||||
tax_object_id?: number | null;
|
||||
unit_price?: number | null;
|
||||
currency?: string;
|
||||
is_active?: boolean;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
export const conceptsAPI = {
|
||||
async list(
|
||||
companyId: number,
|
||||
params?: { search?: string; active_only?: boolean; product_service_id?: number }
|
||||
): Promise<Concept[]> {
|
||||
const qs = new URLSearchParams({ company_id: String(companyId) });
|
||||
if (params?.search) qs.set('search', params.search);
|
||||
if (params?.active_only !== undefined) qs.set('active_only', String(params.active_only));
|
||||
if (params?.product_service_id !== undefined)
|
||||
qs.set('product_service_id', String(params.product_service_id));
|
||||
const res = await api.get<Concept[]>(`/v1/fin/concepts?${qs}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async get(id: number, companyId: number): Promise<Concept> {
|
||||
const res = await api.get<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async create(data: ConceptInput, companyId: number): Promise<Concept> {
|
||||
const res = await api.post<Concept>(`/v1/fin/concepts?company_id=${companyId}`, data);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async update(id: number, data: Partial<ConceptInput>, companyId: number): Promise<Concept> {
|
||||
const res = await api.patch<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`, data);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async remove(id: number, companyId: number): Promise<void> {
|
||||
const res = await api.delete(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
||||
if (res.error) throw new Error(res.error);
|
||||
}
|
||||
};
|
||||
@@ -3,6 +3,10 @@
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
|
||||
export * from './catalogs';
|
||||
export * from './concepts';
|
||||
export * from './issuer';
|
||||
|
||||
export type InvoiceStatus = 'borrador' | 'emitida' | 'enviada' | 'en_revision_cliente' | 'pagada' | 'cancelada';
|
||||
|
||||
export interface Invoice {
|
||||
@@ -33,6 +37,11 @@ export interface Invoice {
|
||||
owner_user_id: string | null;
|
||||
created_by: string | null;
|
||||
updated_by: string | null;
|
||||
// Claves fiscales del CFDI (catálogos SAT); nulas mientras no se capturen.
|
||||
voucher_type_id: number | null;
|
||||
payment_form_id: number | null;
|
||||
payment_method_id: number | null;
|
||||
expedition_zip_code: string | null;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
created_at: string;
|
||||
@@ -43,17 +52,26 @@ export type InvoiceInput = Partial<Omit<Invoice, 'id' | 'status' | 'subtotal' |
|
||||
export interface InvoiceItem {
|
||||
id: number;
|
||||
invoice_id: number;
|
||||
/** Texto libre que consume el PDF; se hereda del catálogo cuando hay `concept_id`. */
|
||||
concept: string;
|
||||
description: string | null;
|
||||
quantity: number;
|
||||
unit_amount: number;
|
||||
line_total: number;
|
||||
// Claves fiscales de la partida (catálogo de conceptos y catálogos SAT).
|
||||
concept_id: number | null;
|
||||
product_service_id: number | null;
|
||||
unit_of_measure_id: number | null;
|
||||
tax_object_id: number | null;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
}
|
||||
/**
|
||||
* `concept` es opcional cuando se envía `concept_id`: el backend copia ahí la
|
||||
* descripción del concepto del catálogo. Sin ninguno de los dos responde 422.
|
||||
*/
|
||||
export type InvoiceItemInput = Partial<Omit<InvoiceItem, 'id' | 'line_total' | 'tenant_id' | 'company_id'>> & {
|
||||
invoice_id: number;
|
||||
concept: string;
|
||||
};
|
||||
|
||||
export interface Payment {
|
||||
|
||||
51
frontend/src/lib/api/fin/issuer.ts
Normal file
51
frontend/src/lib/api/fin/issuer.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Cliente API — Datos fiscales del emisor (una configuración por empresa).
|
||||
*/
|
||||
import { api } from '$lib/api';
|
||||
import type { SatTaxRegime } from './catalogs';
|
||||
|
||||
/** RFC de persona moral (3 letras) o física (4 letras) + fecha + homoclave. */
|
||||
export const RFC_REGEX = /^[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}$/;
|
||||
|
||||
export interface IssuerSettings {
|
||||
id: number;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
legal_name: string;
|
||||
rfc: string;
|
||||
tax_regime_id: number;
|
||||
tax_regime: SatTaxRegime | null;
|
||||
zip_code: string | null;
|
||||
updated_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface IssuerSettingsInput {
|
||||
legal_name: string;
|
||||
rfc: string;
|
||||
tax_regime_id: number;
|
||||
zip_code?: string | null;
|
||||
}
|
||||
|
||||
export const issuerAPI = {
|
||||
/**
|
||||
* Devuelve `null` cuando la empresa todavía no captura sus datos fiscales: el
|
||||
* backend responde 404 y la pantalla debe abrirse en modo alta, no en error.
|
||||
*/
|
||||
async get(companyId: number): Promise<IssuerSettings | null> {
|
||||
const res = await api.get<IssuerSettings>(`/v1/fin/settings/issuer?company_id=${companyId}`);
|
||||
if (res.status === 404) return null;
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
},
|
||||
|
||||
async save(data: IssuerSettingsInput, companyId: number): Promise<IssuerSettings> {
|
||||
const res = await api.put<IssuerSettings>(
|
||||
`/v1/fin/settings/issuer?company_id=${companyId}`,
|
||||
data
|
||||
);
|
||||
if (res.error) throw new Error(res.error);
|
||||
return res.data!;
|
||||
}
|
||||
};
|
||||
@@ -80,13 +80,6 @@ export interface ShipmentDocument {
|
||||
file_url: string | null;
|
||||
file_key: string | null;
|
||||
notes: string | null;
|
||||
// Espejo del documento en el expediente electrónico de EFC (T2026-08-046). Opcionales: las filas
|
||||
// legacy no los traen, y la descarga se ramifica por ellos — `efc_document_id` → proxy de EFC,
|
||||
// `file_key` → URL firmada local, `file_url` → externa.
|
||||
expediente_id?: number | null;
|
||||
efc_document_ref?: string | null;
|
||||
efc_document_id?: string | null;
|
||||
efc_sync_state?: 'PENDING' | 'SYNCED' | 'FAILED' | null;
|
||||
tenant_id: number;
|
||||
company_id: number;
|
||||
created_at: string;
|
||||
|
||||
@@ -1,12 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { AccountInput } from '$lib/api/crm';
|
||||
import { satCatalogsAPI, type SatCatalogItem, type SatTaxRegime } from '$lib/api/fin';
|
||||
import {
|
||||
ACCOUNT_TYPES, ACCOUNT_STATUS, RECORD_TYPES, PERSON_TYPES,
|
||||
COMMERCIAL_CLASSIFICATION, CONTACT_METHODS
|
||||
} from '$lib/components/crm/format';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// `form` es un objeto reactivo del padre; se mutan sus propiedades vía bind:value.
|
||||
let { form = $bindable(), tab }: { form: AccountInput; tab: string } = $props();
|
||||
// `companyId` solo se usa para consultar los catálogos del SAT del receptor.
|
||||
let { form = $bindable(), tab, companyId = null }: { form: AccountInput; tab: string; companyId?: number | null } = $props();
|
||||
|
||||
let taxRegimes = $state<SatTaxRegime[]>([]);
|
||||
let cfdiUses = $state<SatCatalogItem[]>([]);
|
||||
|
||||
// El régimen se acota al tipo de persona de la cuenta: una persona física no puede
|
||||
// declararse en el 601 y viceversa. Sin tipo de persona se ofrecen todos.
|
||||
const regimesForPersonType = $derived(
|
||||
form.person_type === 'fisica'
|
||||
? taxRegimes.filter((r) => r.applies_to_individual)
|
||||
: form.person_type === 'moral'
|
||||
? taxRegimes.filter((r) => r.applies_to_legal_entity)
|
||||
: taxRegimes
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid || tab !== 'fiscal') return;
|
||||
void loadCatalogs(cid);
|
||||
});
|
||||
|
||||
async function loadCatalogs(cid: number) {
|
||||
try {
|
||||
[taxRegimes, cfdiUses] = await Promise.all([
|
||||
satCatalogsAPI.taxRegimes(cid),
|
||||
satCatalogsAPI.cfdiUses(cid)
|
||||
]);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los catálogos del SAT');
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
@@ -35,8 +68,26 @@
|
||||
</div>
|
||||
{:else if tab === 'fiscal'}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Régimen fiscal</span><input class={inputCls} bind:value={form.tax_regime} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Uso de CFDI</span><input class={inputCls} bind:value={form.cfdi_use} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Régimen fiscal</span>
|
||||
<select class={inputCls} bind:value={form.tax_regime_id}>
|
||||
<option value={null}>Sin especificar</option>
|
||||
{#each regimesForPersonType as r (r.id)}<option value={r.id}>{r.code} — {r.description}</option>{/each}
|
||||
</select>
|
||||
{#if !form.tax_regime_id && form.tax_regime}
|
||||
<span class="text-xs text-muted-foreground">Capturado antes como texto: «{form.tax_regime}». Elige la clave del SAT que corresponde.</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Uso de CFDI</span>
|
||||
<select class={inputCls} bind:value={form.cfdi_use_id}>
|
||||
<option value={null}>Sin especificar</option>
|
||||
{#each cfdiUses as u (u.id)}<option value={u.id}>{u.code} — {u.description}</option>{/each}
|
||||
</select>
|
||||
{#if !form.cfdi_use_id && form.cfdi_use}
|
||||
<span class="text-xs text-muted-foreground">Capturado antes como texto: «{form.cfdi_use}». Elige la clave del SAT que corresponde.</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Método de pago</span><input class={inputCls} bind:value={form.payment_method} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Forma de pago</span><input class={inputCls} bind:value={form.payment_form} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Moneda</span><input class={inputCls} maxlength="3" bind:value={form.currency} /></label>
|
||||
|
||||
@@ -10,14 +10,6 @@
|
||||
} from '$lib/api/crm';
|
||||
import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format';
|
||||
import { uploadFile, uploadUrl } from '$lib/api/uploads';
|
||||
import {
|
||||
EFC_SYNC_LABELS,
|
||||
EFC_SYNC_TOOLTIPS,
|
||||
EFC_TEXTS,
|
||||
expedienteDocBlob,
|
||||
retrySync,
|
||||
type EfcSyncState
|
||||
} from '$lib/api/expedientes';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
// Dueño de los registros relacionados y qué sección mostrar
|
||||
@@ -60,61 +52,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre el archivo del documento. Se ramifica en TRES, en este orden:
|
||||
*
|
||||
* 1. `efc_document_id` → está en el expediente electrónico: se pide por el proxy como blob.
|
||||
* No sirve `window.open` con la URL del proxy porque esa llamada no lleva el header
|
||||
* Authorization y el backend respondería 401.
|
||||
* 2. `file_key` → sigue solo en el MinIO local: URL firmada, como siempre.
|
||||
* 3. `file_url` → externa.
|
||||
*
|
||||
* El orden importa: cuando la entrega a EFC se confirma, `delete_local` borra la copia local y
|
||||
* `file_key` queda en NULL, así que preguntar primero por él llevaría a un objeto inexistente.
|
||||
*/
|
||||
async function openDoc(d: Document) {
|
||||
if (!companyId) return;
|
||||
try {
|
||||
if (d.efc_document_id && d.expediente_id) {
|
||||
const blob = await expedienteDocBlob(d.expediente_id, d.id, companyId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener');
|
||||
// Se revoca en diferido: revocarlo de inmediato deja la pestaña nueva sin nada que abrir.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return;
|
||||
}
|
||||
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
|
||||
if (url) window.open(url, '_blank', 'noopener');
|
||||
else toast.error('El documento no tiene archivo');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : EFC_TEXTS.downloadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
/** ¿Este documento vive en el expediente electrónico? Los legacy no traen estado. */
|
||||
function estadoEfc(d: Document): EfcSyncState | null {
|
||||
return d.expediente_id ? ((d.efc_sync_state ?? 'PENDING') as EfcSyncState) : null;
|
||||
}
|
||||
|
||||
function claseBadge(estado: EfcSyncState): string {
|
||||
if (estado === 'SYNCED')
|
||||
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300';
|
||||
if (estado === 'FAILED') return 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300';
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300';
|
||||
}
|
||||
|
||||
let retrying = $state<number | null>(null);
|
||||
|
||||
async function retryDoc(d: Document) {
|
||||
if (!companyId || !d.efc_document_ref) return;
|
||||
retrying = d.id;
|
||||
try {
|
||||
await retrySync(d.efc_document_ref, companyId);
|
||||
await load(companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo reintentar el envío.');
|
||||
} finally {
|
||||
retrying = null;
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,24 +228,13 @@
|
||||
<p class="text-sm text-muted-foreground">Sin documentos.</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header><Table.Row><Table.Head>Tipo</Table.Head><Table.Head>Nombre</Table.Head><Table.Head>Archivo</Table.Head><Table.Head>Expediente</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||
<Table.Header><Table.Row><Table.Head>Tipo</Table.Head><Table.Head>Nombre</Table.Head><Table.Head>Archivo</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||
<Table.Body>
|
||||
{#each documents as d (d.id)}
|
||||
{@const estado = estadoEfc(d)}
|
||||
<Table.Row>
|
||||
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
|
||||
<Table.Cell class="font-medium">{d.name}</Table.Cell>
|
||||
<Table.Cell>{#if d.efc_document_id || d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}—{/if}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if estado}
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {claseBadge(estado)}" title={EFC_SYNC_TOOLTIPS[estado] ?? ''}>{EFC_SYNC_LABELS[estado]}</span>
|
||||
{#if estado === 'FAILED'}
|
||||
<button type="button" class="text-primary ml-2 text-xs hover:underline disabled:opacity-50" disabled={retrying === d.id} onclick={() => retryDoc(d)}>{retrying === d.id ? 'Reintentando…' : EFC_TEXTS.retryButton}</button>
|
||||
{/if}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}—{/if}</Table.Cell>
|
||||
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDocument(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
@@ -360,9 +294,7 @@
|
||||
<span class="font-medium">Archivo {#if uploading}<span class="text-xs text-muted-foreground">(subiendo…)</span>{:else if documentForm.file_key}<span class="text-xs text-emerald-600">(cargado)</span>{/if}</span>
|
||||
<input type="file" class={inputCls} onchange={onFilePicked} />
|
||||
</label>
|
||||
<!-- La URL externa y el expediente son excluyentes: EFC resguarda ARCHIVOS, no enlaces, así
|
||||
que un documento con solo URL nunca podría entregarse y quedaría eternamente pendiente. -->
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">o URL externa</span><input class={inputCls} bind:value={documentForm.file_url} placeholder="https://…" disabled={!!documentForm.file_key} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">o URL externa</span><input class={inputCls} bind:value={documentForm.file_url} placeholder="https://…" /></label>
|
||||
<div class="flex justify-end gap-2"><Button type="button" variant="outline" onclick={() => (activeModal = null)}>Cancelar</Button><Button type="submit" disabled={saving}>Guardar</Button></div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
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>
|
||||
@@ -1,265 +0,0 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Documentos del expediente electrónico de un embarque.
|
||||
*
|
||||
* Subida de UN paso: el usuario elige archivo y tipo, y el sistema guarda, registra y encola la
|
||||
* entrega a EFC. Si EFC está caído la subida IGUAL tiene éxito y el documento se queda en
|
||||
* «Pendiente de enviar» hasta que el carril lo entregue — el badge es lo que se lo cuenta.
|
||||
*/
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import {
|
||||
EFC_SYNC_LABELS,
|
||||
EFC_SYNC_TOOLTIPS,
|
||||
EFC_TEXTS,
|
||||
expedienteDocBlob,
|
||||
expedientesAPI,
|
||||
retrySync,
|
||||
uploadExpedienteDocument,
|
||||
type EfcSyncState,
|
||||
type ExpedienteDocument
|
||||
} from '$lib/api/expedientes';
|
||||
import { SHIPMENT_DOC_TYPES } from '$lib/components/crm/format';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
|
||||
let {
|
||||
expedienteId,
|
||||
companyId,
|
||||
folio = ''
|
||||
}: { expedienteId: number; companyId: number; folio?: string } = $props();
|
||||
|
||||
let documentos = $state<ExpedienteDocument[]>([]);
|
||||
let cargando = $state(false);
|
||||
let modalOpen = $state(false);
|
||||
let subiendo = $state(false);
|
||||
let progreso = $state(0);
|
||||
let reintentando = $state<number | null>(null);
|
||||
|
||||
let archivo = $state<File | null>(null);
|
||||
let docType = $state<string>(SHIPMENT_DOC_TYPES[0]?.value ?? 'otro');
|
||||
let nombre = $state('');
|
||||
|
||||
async function cargar() {
|
||||
cargando = true;
|
||||
try {
|
||||
documentos = await expedientesAPI.listDocuments(expedienteId, companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los documentos.');
|
||||
} finally {
|
||||
cargando = false;
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (expedienteId && companyId) void cargar();
|
||||
});
|
||||
|
||||
function estadoDe(d: ExpedienteDocument): EfcSyncState {
|
||||
return (d.efc_sync_state ?? 'PENDING') as EfcSyncState;
|
||||
}
|
||||
|
||||
function claseBadge(estado: EfcSyncState): string {
|
||||
if (estado === 'SYNCED') return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300';
|
||||
if (estado === 'FAILED') return 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300';
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300';
|
||||
}
|
||||
|
||||
function onFilePicked(e: Event) {
|
||||
const input = e.currentTarget as HTMLInputElement;
|
||||
archivo = input.files?.[0] ?? null;
|
||||
if (archivo && !nombre) nombre = archivo.name;
|
||||
}
|
||||
|
||||
async function guardar(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
if (!archivo) {
|
||||
toast.error('Elige un archivo.');
|
||||
return;
|
||||
}
|
||||
subiendo = true;
|
||||
progreso = 0;
|
||||
try {
|
||||
await uploadExpedienteDocument(expedienteId, archivo, docType, companyId, {
|
||||
name: nombre || undefined,
|
||||
onUploadProgress: (ev) => {
|
||||
progreso = ev.total ? Math.round((ev.loaded / ev.total) * 100) : 0;
|
||||
}
|
||||
});
|
||||
modalOpen = false;
|
||||
archivo = null;
|
||||
nombre = '';
|
||||
await cargar();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : EFC_TEXTS.invalidExtension);
|
||||
} finally {
|
||||
subiendo = false;
|
||||
progreso = 0;
|
||||
}
|
||||
}
|
||||
|
||||
async function abrir(d: ExpedienteDocument) {
|
||||
try {
|
||||
const blob = await expedienteDocBlob(expedienteId, d.id, companyId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener');
|
||||
// Se revoca en diferido: revocarlo de inmediato deja la pestaña nueva sin nada que abrir.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : EFC_TEXTS.downloadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
async function reintentar(d: ExpedienteDocument) {
|
||||
if (!d.efc_document_ref) return;
|
||||
reintentando = d.id;
|
||||
try {
|
||||
await retrySync(d.efc_document_ref, companyId);
|
||||
await cargar();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'No se pudo reintentar el envío.');
|
||||
} finally {
|
||||
reintentando = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function quitar(d: ExpedienteDocument) {
|
||||
try {
|
||||
await expedientesAPI.removeDocument(expedienteId, d.id, companyId);
|
||||
await cargar();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : 'No se pudo quitar el documento.');
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'border-input bg-background focus-visible:ring-ring h-9 w-full rounded-md border px-3 py-1 text-sm outline-none focus-visible:ring-[3px]';
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-sm font-semibold">Documentos del expediente</h3>
|
||||
{#if folio}
|
||||
<span class="text-muted-foreground text-xs">Folio {folio}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Button type="button" size="sm" onclick={() => (modalOpen = true)}>
|
||||
{EFC_TEXTS.newDocumentTitle}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Nombre</Table.Head>
|
||||
<Table.Head>Tipo</Table.Head>
|
||||
<Table.Head>Estado</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#if cargando}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="text-muted-foreground text-center text-sm">
|
||||
Cargando…
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else if documentos.length === 0}
|
||||
<Table.Row>
|
||||
<Table.Cell colspan={4} class="text-muted-foreground text-center text-sm">
|
||||
Todavía no hay documentos en este expediente.
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{:else}
|
||||
{#each documentos as d (d.id)}
|
||||
{@const estado = estadoDe(d)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{d.name}</Table.Cell>
|
||||
<Table.Cell>{d.doc_type}</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {claseBadge(estado)}"
|
||||
title={EFC_SYNC_TOOLTIPS[estado] ?? ''}
|
||||
>
|
||||
{EFC_SYNC_LABELS[estado]}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="flex flex-wrap justify-end gap-2">
|
||||
{#if estado === 'SYNCED'}
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary text-sm hover:underline"
|
||||
onclick={() => abrir(d)}
|
||||
>
|
||||
Ver
|
||||
</button>
|
||||
{/if}
|
||||
{#if estado === 'FAILED'}
|
||||
<button
|
||||
type="button"
|
||||
class="text-primary text-sm hover:underline disabled:opacity-50"
|
||||
disabled={reintentando === d.id}
|
||||
onclick={() => reintentar(d)}
|
||||
>
|
||||
{reintentando === d.id ? 'Reintentando…' : EFC_TEXTS.retryButton}
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="text-destructive text-sm hover:underline"
|
||||
onclick={() => quitar(d)}
|
||||
>
|
||||
Quitar
|
||||
</button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
{/if}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if modalOpen}
|
||||
<div class="bg-background/80 fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div class="bg-card w-full max-w-md rounded-lg border p-4 shadow-lg">
|
||||
<h4 class="mb-3 text-sm font-semibold">{EFC_TEXTS.newDocumentTitle}</h4>
|
||||
<form class="flex flex-col gap-3" onsubmit={guardar}>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Tipo de documento</span>
|
||||
<select class={inputCls} bind:value={docType}>
|
||||
{#each SHIPMENT_DOC_TYPES as t (t.value)}
|
||||
<option value={t.value}>{t.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Nombre</span>
|
||||
<input class={inputCls} bind:value={nombre} placeholder="Nombre del documento" />
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Archivo</span>
|
||||
<input type="file" class={inputCls} onchange={onFilePicked} />
|
||||
</label>
|
||||
|
||||
{#if subiendo}
|
||||
<div class="bg-muted h-2 w-full overflow-hidden rounded-full">
|
||||
<div class="bg-primary h-full transition-all" style="width: {progreso}%"></div>
|
||||
</div>
|
||||
<span class="text-muted-foreground text-xs">Subiendo… {progreso}%</span>
|
||||
{/if}
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onclick={() => (modalOpen = false)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
<Button type="submit" disabled={subiendo}>{subiendo ? 'Subiendo…' : 'Guardar'}</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -67,6 +67,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' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -83,6 +84,10 @@ export function getNavMain(): NavMainItem[] {
|
||||
title: 'Configuración',
|
||||
url: '/dashboard/settings/general',
|
||||
icon: Settings2,
|
||||
items: [
|
||||
{ title: 'General', url: '/dashboard/settings/general' },
|
||||
{ title: 'Facturación', url: '/dashboard/settings/facturacion', permission: 'fin.settings.view' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -104,7 +104,7 @@
|
||||
</div>
|
||||
|
||||
{#if activeTab.kind === 'info'}
|
||||
<AccountFields bind:form tab={tab} />
|
||||
<AccountFields bind:form tab={tab} {companyId} />
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button onclick={save} disabled={saving}>{saving ? 'Guardando…' : 'Guardar cambios'}</Button>
|
||||
</div>
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<AccountFields bind:form {tab} />
|
||||
<AccountFields bind:form {tab} {companyId} />
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||
<Button variant="outline" href="/dashboard/crm/cuentas">Cancelar</Button>
|
||||
|
||||
181
frontend/src/routes/dashboard/fin/conceptos/+page.svelte
Normal file
181
frontend/src/routes/dashboard/fin/conceptos/+page.svelte
Normal file
@@ -0,0 +1,181 @@
|
||||
<script lang="ts">
|
||||
import { Tags, Plus, Trash2, Search, ChevronRight } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import * as Table from '$lib/components/ui/table';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { conceptsAPI, type Concept } from '$lib/api/fin';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let items = $state<Concept[]>([]);
|
||||
let loading = $state(false);
|
||||
let search = $state('');
|
||||
let activeFilter = $state<'todos' | 'activos' | 'inactivos'>('todos');
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
void load(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
items = await conceptsAPI.list(cid, {
|
||||
search: search.trim() || undefined,
|
||||
active_only: activeFilter === 'todos' ? undefined : activeFilter === 'activos'
|
||||
});
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los conceptos');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(concept: Concept) {
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
if (!confirm(`¿Dar de baja el concepto "${concept.code}"?`)) return;
|
||||
try {
|
||||
await conceptsAPI.remove(concept.id, cid);
|
||||
toast.success('Concepto dado de baja');
|
||||
await load(cid);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo dar de baja el concepto');
|
||||
}
|
||||
}
|
||||
|
||||
function money(value: number | null): string {
|
||||
if (value === null || value === undefined) return '—';
|
||||
return new Intl.NumberFormat('es-MX', { minimumFractionDigits: 2 }).format(Number(value));
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Conceptos de facturación</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Tags class="h-6 w-6" />
|
||||
Conceptos de facturación
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Cada concepto se liga a una clave de producto/servicio del SAT, que no puede repetirse en la
|
||||
empresa.
|
||||
</p>
|
||||
</div>
|
||||
<Button href="/dashboard/fin/conceptos/nuevo" disabled={!companyId}>
|
||||
<Plus class="mr-1 h-4 w-4" /> Nuevo concepto
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div class="relative max-w-sm flex-1">
|
||||
<Search class="absolute top-2.5 left-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
class="w-full py-2 pr-3 pl-8 {inputCls}"
|
||||
placeholder="Buscar por clave o descripción…"
|
||||
bind:value={search}
|
||||
onchange={() => companyId && load(companyId)}
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
class="{inputCls} max-w-xs"
|
||||
bind:value={activeFilter}
|
||||
onchange={() => companyId && load(companyId)}
|
||||
>
|
||||
<option value="todos">Todos</option>
|
||||
<option value="activos">Solo activos</option>
|
||||
<option value="inactivos">Solo inactivos</option>
|
||||
</select>
|
||||
</div>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if items.length === 0}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Sin conceptos registrados.</p>
|
||||
{:else}
|
||||
<div class="overflow-x-auto">
|
||||
<Table.Root>
|
||||
<Table.Header>
|
||||
<Table.Row>
|
||||
<Table.Head>Clave</Table.Head>
|
||||
<Table.Head>Descripción</Table.Head>
|
||||
<Table.Head>Clave ProdServ</Table.Head>
|
||||
<Table.Head>Unidad</Table.Head>
|
||||
<Table.Head>Objeto de impuesto</Table.Head>
|
||||
<Table.Head class="text-right">Precio unitario</Table.Head>
|
||||
<Table.Head>Estado</Table.Head>
|
||||
<Table.Head class="text-right">Acciones</Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each items as concept (concept.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-mono text-xs font-medium">
|
||||
<a class="hover:underline" href={`/dashboard/fin/conceptos/${concept.id}`}>
|
||||
{concept.code}
|
||||
</a>
|
||||
</Table.Cell>
|
||||
<Table.Cell>{concept.description}</Table.Cell>
|
||||
<Table.Cell class="text-xs">
|
||||
<span class="font-mono">{concept.product_service?.code ?? '—'}</span>
|
||||
{#if concept.product_service}
|
||||
<span class="block text-muted-foreground">
|
||||
{concept.product_service.description}
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-xs">{concept.unit_of_measure?.name ?? '—'}</Table.Cell>
|
||||
<Table.Cell class="text-xs">{concept.tax_object?.code ?? '—'}</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
{money(concept.unit_price)}
|
||||
{concept.currency}
|
||||
</Table.Cell>
|
||||
<Table.Cell>
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {concept.is_active
|
||||
? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-400'
|
||||
: 'bg-slate-100 text-slate-600 dark:bg-slate-800 dark:text-slate-400'}"
|
||||
>
|
||||
{concept.is_active ? 'Activo' : 'Inactivo'}
|
||||
</span>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
href={`/dashboard/fin/conceptos/${concept.id}`}
|
||||
aria-label="Abrir"
|
||||
>
|
||||
<ChevronRight class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onclick={() => remove(concept)}
|
||||
aria-label="Dar de baja"
|
||||
>
|
||||
<Trash2 class="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
</Table.Root>
|
||||
</div>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
155
frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte
Normal file
155
frontend/src/routes/dashboard/fin/conceptos/[id]/+page.svelte
Normal file
@@ -0,0 +1,155 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Tags, Trash2 } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import ConceptFields from '$lib/components/fin/ConceptFields.svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { conceptsAPI, type Concept, type ConceptInput, type SatCatalogItem } from '$lib/api/fin';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
const conceptId = $derived(Number(page.params.id));
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
let concept = $state<Concept | null>(null);
|
||||
let form = $state<ConceptInput>({ code: '', description: '', product_service_id: 0 });
|
||||
let productService = $state<SatCatalogItem | null>(null);
|
||||
let productServiceError = $state('');
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
const id = conceptId;
|
||||
if (!cid || !id) return;
|
||||
void load(cid, id);
|
||||
});
|
||||
|
||||
function hydrate(c: Concept) {
|
||||
form = {
|
||||
code: c.code,
|
||||
description: c.description,
|
||||
product_service_id: c.product_service_id,
|
||||
unit_of_measure_id: c.unit_of_measure_id,
|
||||
tax_object_id: c.tax_object_id,
|
||||
unit_price: c.unit_price,
|
||||
currency: c.currency,
|
||||
is_active: c.is_active,
|
||||
notes: c.notes ?? ''
|
||||
};
|
||||
productService = c.product_service;
|
||||
productServiceError = '';
|
||||
}
|
||||
|
||||
async function load(cid: number, id: number) {
|
||||
loading = true;
|
||||
try {
|
||||
concept = await conceptsAPI.get(id, cid);
|
||||
hydrate(concept);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo cargar el concepto');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const cid = companyId;
|
||||
if (!cid || !concept) return;
|
||||
if (!form.code.trim() || !form.description.trim()) {
|
||||
toast.error('La clave y la descripción son obligatorias');
|
||||
return;
|
||||
}
|
||||
if (!form.product_service_id) {
|
||||
productServiceError = 'Selecciona la clave de producto/servicio del SAT';
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
productServiceError = '';
|
||||
try {
|
||||
concept = await conceptsAPI.update(
|
||||
concept.id,
|
||||
{
|
||||
...form,
|
||||
unit_price:
|
||||
form.unit_price === null || form.unit_price === undefined
|
||||
? null
|
||||
: Number(form.unit_price),
|
||||
notes: form.notes?.trim() ? form.notes : null
|
||||
},
|
||||
cid
|
||||
);
|
||||
hydrate(concept);
|
||||
toast.success('Cambios guardados');
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'No se pudieron guardar los cambios';
|
||||
// El 409 del backend por clave ProdServ ya asignada se muestra junto al campo.
|
||||
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
|
||||
else toast.error(message);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function remove() {
|
||||
const cid = companyId;
|
||||
if (!cid || !concept) return;
|
||||
if (!confirm(`¿Dar de baja el concepto "${concept.code}"?`)) return;
|
||||
try {
|
||||
await conceptsAPI.remove(concept.id, cid);
|
||||
toast.success('Concepto dado de baja');
|
||||
await goto('/dashboard/fin/conceptos');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo dar de baja el concepto');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{concept ? `Concepto ${concept.code}` : 'Concepto de facturación'}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Button variant="ghost" size="sm" href="/dashboard/fin/conceptos">
|
||||
<ArrowLeft class="mr-1 h-4 w-4" /> Conceptos
|
||||
</Button>
|
||||
|
||||
{#if loading && !concept}
|
||||
<p class="text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else if concept}
|
||||
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Tags class="h-6 w-6" />
|
||||
{concept.code}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
{concept.description}
|
||||
{#if concept.product_service}
|
||||
· <span class="font-mono">{concept.product_service.code}</span>
|
||||
{/if}
|
||||
· {concept.is_active ? 'Activo' : 'Inactivo'}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="outline" onclick={remove}>
|
||||
<Trash2 class="mr-1 h-4 w-4 text-destructive" /> Dar de baja
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Content class="pt-6">
|
||||
<form onsubmit={save}>
|
||||
<ConceptFields bind:form bind:productService bind:productServiceError {companyId} />
|
||||
|
||||
<div class="mt-6 flex justify-end border-t pt-4">
|
||||
<Button type="submit" disabled={saving}
|
||||
>{saving ? 'Guardando…' : 'Guardar cambios'}</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { ArrowLeft, Tags } from '@lucide/svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import ConceptFields from '$lib/components/fin/ConceptFields.svelte';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { conceptsAPI, type ConceptInput, type SatCatalogItem } from '$lib/api/fin';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let form = $state<ConceptInput>({
|
||||
code: '',
|
||||
description: '',
|
||||
product_service_id: 0,
|
||||
unit_of_measure_id: null,
|
||||
tax_object_id: null,
|
||||
unit_price: null,
|
||||
currency: 'MXN',
|
||||
is_active: true,
|
||||
notes: ''
|
||||
});
|
||||
let productService = $state<SatCatalogItem | null>(null);
|
||||
let productServiceError = $state('');
|
||||
let saving = $state(false);
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
if (!form.code.trim() || !form.description.trim()) {
|
||||
toast.error('La clave y la descripción son obligatorias');
|
||||
return;
|
||||
}
|
||||
if (!form.product_service_id) {
|
||||
productServiceError = 'Selecciona la clave de producto/servicio del SAT';
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
productServiceError = '';
|
||||
try {
|
||||
const created = await conceptsAPI.create(
|
||||
{
|
||||
...form,
|
||||
unit_price:
|
||||
form.unit_price === null || form.unit_price === undefined
|
||||
? null
|
||||
: Number(form.unit_price),
|
||||
notes: form.notes?.trim() ? form.notes : null
|
||||
},
|
||||
cid
|
||||
);
|
||||
toast.success('Concepto creado');
|
||||
await goto(`/dashboard/fin/conceptos/${created.id}`);
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : 'No se pudo crear el concepto';
|
||||
// El 409 del backend por clave ProdServ ya asignada se muestra junto al campo.
|
||||
if (message.toLowerCase().includes('producto/servicio')) productServiceError = message;
|
||||
else toast.error(message);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Nuevo concepto de facturación</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<Button variant="ghost" size="sm" href="/dashboard/fin/conceptos">
|
||||
<ArrowLeft class="mr-1 h-4 w-4" /> Conceptos
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Tags class="h-6 w-6" /> Nuevo concepto
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Cada concepto se liga a una clave de producto/servicio del SAT.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card.Root>
|
||||
<Card.Content class="pt-6">
|
||||
<form onsubmit={save}>
|
||||
<ConceptFields bind:form bind:productService bind:productServiceError {companyId} />
|
||||
|
||||
<div class="mt-6 flex justify-end gap-2 border-t pt-4">
|
||||
<Button type="button" variant="outline" href="/dashboard/fin/conceptos">Cancelar</Button>
|
||||
<Button type="submit" disabled={saving || !companyId}
|
||||
>{saving ? 'Guardando…' : 'Crear'}</Button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
</div>
|
||||
@@ -6,8 +6,8 @@
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import {
|
||||
invoicesAPI, invoiceItemsAPI, paymentsAPI,
|
||||
type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
|
||||
invoicesAPI, invoiceItemsAPI, paymentsAPI, conceptsAPI,
|
||||
type Concept, type Invoice, type InvoiceInput, type InvoiceItem, type InvoiceItemInput, type Payment, type PaymentInput
|
||||
} from '$lib/api/fin';
|
||||
import { accountsAPI, type Account } from '$lib/api/crm';
|
||||
import { INVOICE_STATUS, QUOTE_CONCEPTS, PAYMENT_METHODS, labelOf, formatMoney } from '$lib/components/crm/format';
|
||||
@@ -20,6 +20,9 @@
|
||||
let items = $state<InvoiceItem[]>([]);
|
||||
let payments = $state<Payment[]>([]);
|
||||
let accounts = $state<Account[]>([]);
|
||||
/** Catálogo de conceptos de la empresa; se cargan todos para poder etiquetar
|
||||
* partidas que apunten a un concepto ya inactivo. */
|
||||
let concepts = $state<Concept[]>([]);
|
||||
let form = $state<InvoiceInput>({});
|
||||
let tab = $state('conceptos');
|
||||
let loading = $state(false);
|
||||
@@ -29,6 +32,10 @@
|
||||
let addingPay = $state(false);
|
||||
let newItem = $state<InvoiceItemInput>({ invoice_id: 0, concept: 'flete_internacional', quantity: 1, unit_amount: 0 });
|
||||
let newPay = $state<PaymentInput>({ invoice_id: 0, amount: 0, method: 'transferencia' });
|
||||
/** Opción elegida en el selector de concepto: `cat:<id>` del catálogo o `txt:<clave>` genérica. */
|
||||
let conceptChoice = $state('txt:flete_internacional');
|
||||
|
||||
const activeConcepts = $derived(concepts.filter((c) => c.is_active));
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
@@ -40,8 +47,9 @@
|
||||
async function load(cid: number, id: number) {
|
||||
loading = true;
|
||||
try {
|
||||
[invoice, items, payments, accounts] = await Promise.all([
|
||||
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid), accountsAPI.list(cid)
|
||||
[invoice, items, payments, accounts, concepts] = await Promise.all([
|
||||
invoicesAPI.get(id, cid), invoicesAPI.items(id, cid), invoicesAPI.payments(id, cid),
|
||||
accountsAPI.list(cid), conceptsAPI.list(cid)
|
||||
]);
|
||||
form = { ...invoice };
|
||||
} catch (e) {
|
||||
@@ -127,7 +135,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startItem() { newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 }; addingItem = true; }
|
||||
function startItem() {
|
||||
newItem = { invoice_id: invoiceId, concept: 'flete_internacional', quantity: 1, unit_amount: 0 };
|
||||
// Si la empresa ya tiene catálogo, se arranca con su primer concepto.
|
||||
conceptChoice = activeConcepts.length ? `cat:${activeConcepts[0].id}` : 'txt:flete_internacional';
|
||||
applyConceptChoice();
|
||||
addingItem = true;
|
||||
}
|
||||
|
||||
/** Traduce la opción del selector a la partida: referencia al catálogo o texto genérico. */
|
||||
function applyConceptChoice() {
|
||||
if (conceptChoice.startsWith('cat:')) {
|
||||
const c = activeConcepts.find((x) => x.id === Number(conceptChoice.slice(4)));
|
||||
if (!c) return;
|
||||
// Solo se manda concept_id: el backend copia ahí la descripción del concepto.
|
||||
newItem.concept_id = c.id;
|
||||
newItem.concept = undefined;
|
||||
if (c.unit_price !== null && c.unit_price !== undefined) newItem.unit_amount = Number(c.unit_price);
|
||||
} else {
|
||||
newItem.concept_id = null;
|
||||
newItem.concept = conceptChoice.slice(4);
|
||||
}
|
||||
}
|
||||
|
||||
/** Etiqueta de la partida: el concepto del catálogo si lo tiene, si no el texto libre. */
|
||||
function itemConceptLabel(it: InvoiceItem): string {
|
||||
const c = it.concept_id ? concepts.find((x) => x.id === it.concept_id) : undefined;
|
||||
return c ? `${c.code} — ${c.description}` : labelOf(QUOTE_CONCEPTS, it.concept);
|
||||
}
|
||||
|
||||
async function saveItem() {
|
||||
if (!companyId) return;
|
||||
try { await invoiceItemsAPI.create({ ...newItem, invoice_id: invoiceId }, companyId); addingItem = false; await reload(); toast.success('Concepto agregado'); }
|
||||
@@ -207,7 +243,25 @@
|
||||
<div class="mb-3 flex justify-end"><Button size="sm" variant="outline" onclick={startItem}><Plus class="mr-1 h-4 w-4" /> Agregar concepto</Button></div>
|
||||
{#if addingItem}
|
||||
<div class="mb-4 grid gap-3 rounded-md border p-3 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Concepto</span><select class={inputCls} bind:value={newItem.concept}>{#each QUOTE_CONCEPTS as c (c.value)}<option value={c.value}>{c.label}</option>{/each}</select></label>
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Concepto</span>
|
||||
<select class={inputCls} bind:value={conceptChoice} onchange={applyConceptChoice}>
|
||||
{#if activeConcepts.length > 0}
|
||||
<optgroup label="Catálogo de conceptos">
|
||||
{#each activeConcepts as c (c.id)}<option value={`cat:${c.id}`}>{c.code} — {c.description}</option>{/each}
|
||||
</optgroup>
|
||||
{/if}
|
||||
<optgroup label="Conceptos genéricos (sin clave del SAT)">
|
||||
{#each QUOTE_CONCEPTS as c (c.value)}<option value={`txt:${c.value}`}>{c.label}</option>{/each}
|
||||
</optgroup>
|
||||
</select>
|
||||
{#if activeConcepts.length === 0}
|
||||
<span class="text-xs text-muted-foreground">
|
||||
El catálogo de conceptos está vacío.
|
||||
<a class="underline" href="/dashboard/fin/conceptos">Darlos de alta</a> permite facturar con clave del SAT.
|
||||
</span>
|
||||
{/if}
|
||||
</label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Descripción</span><input class={inputCls} bind:value={newItem.description} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Cantidad</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.quantity} /></label>
|
||||
<label class="flex flex-col gap-1 text-sm"><span class="font-medium">Importe unitario</span><input type="number" min="0" step="0.01" class={inputCls} bind:value={newItem.unit_amount} /></label>
|
||||
@@ -222,7 +276,7 @@
|
||||
<Table.Body>
|
||||
{#each items as it (it.id)}
|
||||
<Table.Row>
|
||||
<Table.Cell class="font-medium">{labelOf(QUOTE_CONCEPTS, it.concept)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
|
||||
<Table.Cell class="font-medium">{itemConceptLabel(it)}{#if it.description}<span class="block text-xs text-muted-foreground">{it.description}</span>{/if}</Table.Cell>
|
||||
<Table.Cell class="text-right">{it.quantity}</Table.Cell>
|
||||
<Table.Cell class="text-right">{formatMoney(it.unit_amount, invoice.currency)}</Table.Cell>
|
||||
<Table.Cell class="text-right">{formatMoney(it.line_total, invoice.currency)}</Table.Cell>
|
||||
|
||||
@@ -13,14 +13,6 @@
|
||||
} from '$lib/api/ops';
|
||||
import { invoicesAPI } from '$lib/api/fin';
|
||||
import { uploadFile, uploadUrl } from '$lib/api/uploads';
|
||||
import {
|
||||
EFC_SYNC_LABELS,
|
||||
EFC_SYNC_TOOLTIPS,
|
||||
EFC_TEXTS,
|
||||
expedienteDocBlob,
|
||||
retrySync,
|
||||
type EfcSyncState
|
||||
} from '$lib/api/expedientes';
|
||||
import {
|
||||
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS,
|
||||
DOC_KINDS, SHIPMENT_DOC_TYPES, EVENT_STATUS, labelOf, formatDate
|
||||
@@ -180,53 +172,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abre el archivo. Se ramifica en tres: expediente electrónico (proxy como blob), MinIO local
|
||||
* (URL firmada) o URL externa. El orden importa — al confirmar la entrega a EFC, `delete_local`
|
||||
* borra la copia local y `file_key` queda en NULL.
|
||||
*/
|
||||
async function openDoc(d: ShipmentDocument) {
|
||||
if (!companyId) return;
|
||||
try {
|
||||
if (d.efc_document_id && d.expediente_id) {
|
||||
const blob = await expedienteDocBlob(d.expediente_id, d.id, companyId);
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank', 'noopener');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
return;
|
||||
}
|
||||
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
|
||||
if (url) window.open(url, '_blank', 'noopener');
|
||||
else toast.error('El documento no tiene archivo');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : EFC_TEXTS.downloadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
/** ¿Este documento vive en el expediente electrónico? Los legacy no traen estado. */
|
||||
function estadoEfc(d: ShipmentDocument): EfcSyncState | null {
|
||||
return d.expediente_id ? ((d.efc_sync_state ?? 'PENDING') as EfcSyncState) : null;
|
||||
}
|
||||
|
||||
function claseBadge(estado: EfcSyncState): string {
|
||||
if (estado === 'SYNCED')
|
||||
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-950 dark:text-emerald-300';
|
||||
if (estado === 'FAILED') return 'bg-red-100 text-red-800 dark:bg-red-950 dark:text-red-300';
|
||||
return 'bg-amber-100 text-amber-800 dark:bg-amber-950 dark:text-amber-300';
|
||||
}
|
||||
|
||||
let retryingDoc = $state<number | null>(null);
|
||||
|
||||
async function retryDoc(d: ShipmentDocument) {
|
||||
if (!companyId || !d.efc_document_ref) return;
|
||||
retryingDoc = d.id;
|
||||
try {
|
||||
await retrySync(d.efc_document_ref, companyId);
|
||||
docs = await shipmentsAPI.documents(shipmentId, companyId);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo reintentar el envío.');
|
||||
} finally {
|
||||
retryingDoc = null;
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,26 +330,15 @@
|
||||
<p class="text-sm text-muted-foreground">Sin documentos.</p>
|
||||
{:else}
|
||||
<Table.Root>
|
||||
<Table.Header><Table.Row><Table.Head>Clase</Table.Head><Table.Head>Documento</Table.Head><Table.Head>Número</Table.Head><Table.Head>Emisión</Table.Head><Table.Head>Archivo</Table.Head><Table.Head>Expediente</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||
<Table.Header><Table.Row><Table.Head>Clase</Table.Head><Table.Head>Documento</Table.Head><Table.Head>Número</Table.Head><Table.Head>Emisión</Table.Head><Table.Head>Archivo</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||
<Table.Body>
|
||||
{#each docs as d (d.id)}
|
||||
{@const estado = estadoEfc(d)}
|
||||
<Table.Row>
|
||||
<Table.Cell>{labelOf(DOC_KINDS, d.doc_kind)}</Table.Cell>
|
||||
<Table.Cell class="font-medium">{labelOf(SHIPMENT_DOC_TYPES, d.doc_type)}</Table.Cell>
|
||||
<Table.Cell class="font-mono text-xs">{d.number ?? '—'}</Table.Cell>
|
||||
<Table.Cell>{formatDate(d.issue_date)}</Table.Cell>
|
||||
<Table.Cell>{#if d.efc_document_id || d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}—{/if}</Table.Cell>
|
||||
<Table.Cell>
|
||||
{#if estado}
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium {claseBadge(estado)}" title={EFC_SYNC_TOOLTIPS[estado] ?? ''}>{EFC_SYNC_LABELS[estado]}</span>
|
||||
{#if estado === 'FAILED'}
|
||||
<button type="button" class="text-primary ml-2 text-xs hover:underline disabled:opacity-50" disabled={retryingDoc === d.id} onclick={() => retryDoc(d)}>{retryingDoc === d.id ? 'Reintentando…' : EFC_TEXTS.retryButton}</button>
|
||||
{/if}
|
||||
{:else}
|
||||
—
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell>{#if d.file_key || d.file_url}<button type="button" class="text-primary hover:underline" onclick={() => openDoc(d)}>Ver</button>{:else}—{/if}</Table.Cell>
|
||||
<Table.Cell class="text-right"><Button variant="ghost" size="sm" onclick={() => removeDoc(d)} aria-label="Eliminar"><Trash2 class="h-4 w-4 text-destructive" /></Button></Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
|
||||
200
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
200
frontend/src/routes/dashboard/settings/facturacion/+page.svelte
Normal file
@@ -0,0 +1,200 @@
|
||||
<script lang="ts">
|
||||
import { Receipt } from '@lucide/svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { companyStore } from '$lib/stores/company.svelte';
|
||||
import { authStore, userHasPermission } from '$lib/auth';
|
||||
import {
|
||||
issuerAPI,
|
||||
satCatalogsAPI,
|
||||
RFC_REGEX,
|
||||
type IssuerSettingsInput,
|
||||
type SatTaxRegime
|
||||
} from '$lib/api/fin';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
let form = $state<IssuerSettingsInput>({
|
||||
legal_name: '',
|
||||
rfc: '',
|
||||
tax_regime_id: 0,
|
||||
zip_code: ''
|
||||
});
|
||||
let taxRegimes = $state<SatTaxRegime[]>([]);
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
/** true mientras la empresa no tenga datos capturados (el GET respondió 404). */
|
||||
let isNew = $state(true);
|
||||
let rfcError = $state('');
|
||||
|
||||
const companyId = $derived(companyStore.activeCompany?.id ?? null);
|
||||
const canView = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
|
||||
const canEdit = $derived(userHasPermission($authStore.user, 'fin.settings.edit'));
|
||||
|
||||
$effect(() => {
|
||||
const cid = companyId;
|
||||
if (!cid || !canView) return;
|
||||
void load(cid);
|
||||
});
|
||||
|
||||
async function load(cid: number) {
|
||||
loading = true;
|
||||
try {
|
||||
const [settings, regimes] = await Promise.all([
|
||||
issuerAPI.get(cid),
|
||||
satCatalogsAPI.taxRegimes(cid)
|
||||
]);
|
||||
taxRegimes = regimes;
|
||||
isNew = settings === null;
|
||||
if (settings) {
|
||||
form = {
|
||||
legal_name: settings.legal_name,
|
||||
rfc: settings.rfc,
|
||||
tax_regime_id: settings.tax_regime_id,
|
||||
zip_code: settings.zip_code ?? ''
|
||||
};
|
||||
}
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron cargar los datos fiscales');
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizedRfc(): string {
|
||||
return (form.rfc ?? '').replace(/[\s-]/g, '').toUpperCase();
|
||||
}
|
||||
|
||||
async function save(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
const cid = companyId;
|
||||
if (!cid) return;
|
||||
|
||||
const rfc = normalizedRfc();
|
||||
if (!RFC_REGEX.test(rfc)) {
|
||||
rfcError = 'El RFC no tiene un formato válido (ej. XAXX010101000)';
|
||||
return;
|
||||
}
|
||||
rfcError = '';
|
||||
if (!form.tax_regime_id) {
|
||||
toast.error('Selecciona el régimen fiscal');
|
||||
return;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await issuerAPI.save(
|
||||
{ ...form, rfc, zip_code: form.zip_code?.trim() ? form.zip_code.trim() : null },
|
||||
cid
|
||||
);
|
||||
isNew = false;
|
||||
toast.success('Datos fiscales guardados');
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : 'No se pudieron guardar los datos fiscales');
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
const inputCls =
|
||||
'rounded-md border bg-transparent px-3 py-2 text-sm outline-none focus-visible:ring-2 focus-visible:ring-ring';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Configuración de Facturación</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="flex items-center gap-2 text-2xl font-bold tracking-tight">
|
||||
<Receipt class="h-6 w-6" />
|
||||
Datos fiscales del emisor
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-muted-foreground">
|
||||
Identidad fiscal con la que la empresa emite sus comprobantes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if !canView}
|
||||
<Card.Root>
|
||||
<Card.Content>
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">
|
||||
No tienes permiso para consultar los datos fiscales del emisor.
|
||||
</p>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{:else}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{isNew ? 'Capturar datos fiscales' : 'Datos fiscales registrados'}</Card.Title>
|
||||
<Card.Description>
|
||||
{isNew
|
||||
? 'Esta empresa aún no tiene datos fiscales configurados.'
|
||||
: 'Actualiza la información con la que se emiten los comprobantes.'}
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
{#if loading}
|
||||
<p class="py-6 text-center text-sm text-muted-foreground">Cargando…</p>
|
||||
{:else}
|
||||
<form class="grid max-w-2xl gap-4 sm:grid-cols-2" onsubmit={save}>
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Razón social *</span>
|
||||
<input
|
||||
class={inputCls}
|
||||
bind:value={form.legal_name}
|
||||
maxlength="255"
|
||||
required
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">RFC *</span>
|
||||
<input
|
||||
class="{inputCls} font-mono uppercase"
|
||||
bind:value={form.rfc}
|
||||
maxlength="13"
|
||||
required
|
||||
disabled={!canEdit}
|
||||
oninput={() => (rfcError = '')}
|
||||
/>
|
||||
{#if rfcError}<span class="text-xs text-destructive">{rfcError}</span>{/if}
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm">
|
||||
<span class="font-medium">Código postal del lugar de expedición</span>
|
||||
<input
|
||||
class={inputCls}
|
||||
bind:value={form.zip_code}
|
||||
maxlength="5"
|
||||
inputmode="numeric"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="flex flex-col gap-1 text-sm sm:col-span-2">
|
||||
<span class="font-medium">Régimen fiscal *</span>
|
||||
<select class={inputCls} bind:value={form.tax_regime_id} required disabled={!canEdit}>
|
||||
<option value={0}>Selecciona un régimen…</option>
|
||||
{#each taxRegimes as regime (regime.id)}
|
||||
<option value={regime.id}>{regime.code} — {regime.description}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="flex justify-end sm:col-span-2">
|
||||
<Button type="submit" disabled={saving || !canEdit || !companyId}>
|
||||
{saving ? 'Guardando…' : 'Guardar'}
|
||||
</Button>
|
||||
</div>
|
||||
{#if !canEdit}
|
||||
<p class="text-xs text-muted-foreground sm:col-span-2">
|
||||
Solo puedes consultar: se requiere el permiso de edición de datos fiscales.
|
||||
</p>
|
||||
{/if}
|
||||
</form>
|
||||
{/if}
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
export const ssr = false;
|
||||
@@ -1,6 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { Settings2 } from 'lucide-svelte';
|
||||
import { Settings2, Receipt, ChevronRight } from 'lucide-svelte';
|
||||
import * as Card from '$lib/components/ui/card';
|
||||
import { Button } from '$lib/components/ui/button';
|
||||
import { authStore, userHasPermission } from '$lib/auth';
|
||||
|
||||
const canViewIssuerSettings = $derived(userHasPermission($authStore.user, 'fin.settings.view'));
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
@@ -18,6 +22,25 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{#if canViewIssuerSettings}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title class="flex items-center gap-2">
|
||||
<Receipt class="h-5 w-5" />
|
||||
Facturación
|
||||
</Card.Title>
|
||||
<Card.Description>
|
||||
Datos fiscales del emisor: razón social, RFC, régimen fiscal y lugar de expedición.
|
||||
</Card.Description>
|
||||
</Card.Header>
|
||||
<Card.Content>
|
||||
<Button variant="outline" href="/dashboard/settings/facturacion">
|
||||
Abrir datos fiscales <ChevronRight class="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</Card.Content>
|
||||
</Card.Root>
|
||||
{/if}
|
||||
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>Configuración del sistema</Card.Title>
|
||||
|
||||
Reference in New Issue
Block a user