Compare commits
3 Commits
c6f18013b3
...
d97cdd2f73
| Author | SHA1 | Date | |
|---|---|---|---|
| d97cdd2f73 | |||
| 02feb973c9 | |||
| 39347f9c97 |
20
.env.example
20
.env.example
@@ -112,3 +112,23 @@ SYNC_SECRET_TOKEN=change-this-sync-token-in-production
|
|||||||
|
|
||||||
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
|
# Lista de spokes (Solo si es HUB y desea retransmitir a otros - Opcional)
|
||||||
SPOKE_URLS=""
|
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=
|
||||||
|
|||||||
163
backend/alembic/versions/e6f7a8b9c0d1_crm_expedientes.py
Normal file
163
backend/alembic/versions/e6f7a8b9c0d1_crm_expedientes.py
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
"""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")
|
||||||
113
backend/alembic/versions/f7a8b9c0d1e2_crm_efc_outbox.py
Normal file
113
backend/alembic/versions/f7a8b9c0d1e2_crm_efc_outbox.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
"""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")
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import DateTime, ForeignKey, Integer
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
@@ -31,3 +31,39 @@ class TenantScopedMixin:
|
|||||||
|
|
||||||
tenant_id: Mapped[int] = mapped_column(Integer, ForeignKey("core.tenants.id"), nullable=False, index=True)
|
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)
|
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)
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
from sqlalchemy import ForeignKey, Integer, String
|
from sqlalchemy import ForeignKey, Integer, String
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
from api.v1.common.base_models import EfcDocumentRefMixin, TenantScopedMixin, TimestampMixin
|
||||||
from core.database import Base
|
from core.database import Base
|
||||||
|
|
||||||
|
|
||||||
class Document(Base, TenantScopedMixin, TimestampMixin):
|
class Document(Base, TenantScopedMixin, TimestampMixin, EfcDocumentRefMixin):
|
||||||
"""Documento de un cliente (``account_id``) o proveedor (``supplier_id``).
|
"""Documento de un cliente (``account_id``) o proveedor (``supplier_id``).
|
||||||
|
|
||||||
Guarda los metadatos y una referencia al archivo (``file_key`` en MinIO/S3 o
|
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.
|
``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"
|
__tablename__ = "documents"
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import logging
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
@@ -8,6 +9,8 @@ from ..suppliers.models import Supplier
|
|||||||
from .dto import DocumentCreate, DocumentUpdate
|
from .dto import DocumentCreate, DocumentUpdate
|
||||||
from .models import Document
|
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:
|
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."""
|
"""Un documento debe pertenecer a exactamente un cliente o proveedor existente."""
|
||||||
@@ -95,6 +98,30 @@ def update_document(
|
|||||||
|
|
||||||
|
|
||||||
def delete_document(db: Session, document_id: int, tenant_id: int, company_id: int) -> None:
|
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 = get_document(db, document_id, tenant_id, company_id)
|
||||||
document.deleted_at = datetime.now(timezone.utc)
|
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()
|
db.commit()
|
||||||
|
|||||||
131
backend/api/v1/modules/crm/expediente_gateway/models.py
Normal file
131
backend/api/v1/modules/crm/expediente_gateway/models.py
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
"""Outbox transaccional del carril CRM Agentes de Carga -> EFC.
|
||||||
|
|
||||||
|
DOS tablas separadas POR PROPÓSITO, igual que en el carril de referencia de Anexo22: una para los
|
||||||
|
expedientes (metadatos, JSON) y otra para los archivos (binarios que viven en MinIO y se referencian
|
||||||
|
por su ``s3_key``). Un worker de Celery las drena hacia EFC con reintentos.
|
||||||
|
|
||||||
|
**Diferencia con el original, y es necesaria:** aquí las filas se insertan en la MISMA transacción
|
||||||
|
que el expediente o el documento, porque el CRM es mono-base. En Anexo22 el outbox vivía en otra
|
||||||
|
base que el pedimento, y ese doble-commit es justamente lo que obligó a inventar el barrido de
|
||||||
|
huecos. Aquí el barrido se conserva —cubre lo creado antes de activar la integración y cualquier
|
||||||
|
crash— pero deja de ser el parche de una ventana estructural.
|
||||||
|
"""
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, Index, Integer, String, Text, text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||||
|
from core.database import Base
|
||||||
|
|
||||||
|
# Tipo de trabajo (columna kind) del outbox de EXPEDIENTES.
|
||||||
|
KIND_EXPEDIENTE = "expediente"
|
||||||
|
KIND_COMPLETAR = "completar"
|
||||||
|
|
||||||
|
# Tipos del outbox de ARCHIVOS (efc_file_outbox).
|
||||||
|
FILE_KIND_DOCUMENTO = "documento"
|
||||||
|
|
||||||
|
# Tablas de origen posibles de un archivo. El CRM tiene DOS tablas de documentos con secuencias
|
||||||
|
# independientes, así que `source_id` por sí solo es ambiguo: crm.documents.id = 5 y
|
||||||
|
# ops.shipment_documents.id = 5 coexisten.
|
||||||
|
SOURCE_CRM_DOCUMENTS = "crm.documents"
|
||||||
|
SOURCE_OPS_SHIPMENT_DOCUMENTS = "ops.shipment_documents"
|
||||||
|
SOURCE_FIN_INVOICES = "fin.invoices"
|
||||||
|
|
||||||
|
# Estados (columna status).
|
||||||
|
STATUS_PENDING = "pending"
|
||||||
|
STATUS_SENT = "sent"
|
||||||
|
STATUS_FAILED = "failed"
|
||||||
|
|
||||||
|
# Tope de reintentos antes de marcar 'failed' (reconciliación / reintento manual).
|
||||||
|
# Heredado del carril de Anexo22. Con barridos de 120 s son ~17 minutos de insistencia antes de
|
||||||
|
# rendirse y dejar la fila visible para que una persona la reintente a mano.
|
||||||
|
MAX_ATTEMPTS = 8
|
||||||
|
|
||||||
|
|
||||||
|
class EfcSyncOutbox(Base, TenantScopedMixin, TimestampMixin):
|
||||||
|
"""Cola de metadatos hacia EFC: crear el expediente provisional y completarlo."""
|
||||||
|
|
||||||
|
__tablename__ = "efc_sync_outbox"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_crm_efc_sync_outbox_status", "status"),
|
||||||
|
Index("ix_crm_efc_sync_outbox_kind_status", "kind", "status"),
|
||||||
|
{"schema": "crm"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
|
||||||
|
kind: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||||
|
|
||||||
|
# Datos para construir el request a EFC (folio, storage_token, tenant slug, company, y la data
|
||||||
|
# aduanera si el kind es 'completar').
|
||||||
|
payload: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||||
|
|
||||||
|
# id local del expediente (crm.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)
|
||||||
58
backend/api/v1/modules/crm/expediente_gateway/routes.py
Normal file
58
backend/api/v1/modules/crm/expediente_gateway/routes.py
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
"""Endpoints de operación y observabilidad del carril CRM -> EFC.
|
||||||
|
|
||||||
|
Tablero mínimo para ver y reintentar la entrega de expedientes y documentos a EFC. Autenticado con
|
||||||
|
el auth normal del CRM y acotado por tenant/company, como el resto del módulo.
|
||||||
|
Montado bajo ``/v1/crm`` → ``/v1/crm/expediente-gateway/...``
|
||||||
|
"""
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from core.database import get_core_db
|
||||||
|
from core.security import get_current_user
|
||||||
|
|
||||||
|
from . import service
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/expediente-gateway", tags=["EFC Gateway (ops)"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/outbox")
|
||||||
|
def list_outbox(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
tipo: str | None = Query(None, description="Filtrar por tabla: sync|file"),
|
||||||
|
status: str | None = Query(None, description="Filtrar por status: pending|sent|failed"),
|
||||||
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""Filas de los dos outbox, para ver los fallos y su ``last_error``."""
|
||||||
|
return service.list_outbox(db, current_user["tenant_id"], company_id, tipo, status, limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/outbox/{outbox_id}/retry")
|
||||||
|
def retry_outbox(
|
||||||
|
outbox_id: int,
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
tipo: str = Query("file", description="Tabla de la fila: sync|file"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""Reintento manual de una fila: la resetea a ``pending`` y la re-despacha.
|
||||||
|
|
||||||
|
Una fila inexistente devuelve **404 con mensaje específico**, no un 200 silencioso: el frontend
|
||||||
|
pinta el botón de reintento según lo que reciba, y un 200 le haría creer que la entrega volvió a
|
||||||
|
la cola cuando no hay nada que entregar.
|
||||||
|
"""
|
||||||
|
ok = service.retry_outbox_row(db, outbox_id, current_user["tenant_id"], company_id, tipo)
|
||||||
|
if not ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Fila de outbox no encontrada")
|
||||||
|
return {"status": "requeued", "id": outbox_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/metrics")
|
||||||
|
def metrics(
|
||||||
|
company_id: int = Query(..., description="Company ID"),
|
||||||
|
current_user: dict = Depends(get_current_user),
|
||||||
|
db: Session = Depends(get_core_db),
|
||||||
|
):
|
||||||
|
"""Conteo de los dos outbox por status (pending/sent/failed) para monitoreo."""
|
||||||
|
return service.outbox_metrics(db, current_user["tenant_id"], company_id)
|
||||||
731
backend/api/v1/modules/crm/expediente_gateway/service.py
Normal file
731
backend/api/v1/modules/crm/expediente_gateway/service.py
Normal file
@@ -0,0 +1,731 @@
|
|||||||
|
"""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()
|
||||||
|
)
|
||||||
122
backend/api/v1/modules/crm/expediente_gateway/tasks.py
Normal file
122
backend/api/v1/modules/crm/expediente_gateway/tasks.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""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
|
||||||
0
backend/api/v1/modules/crm/expedientes/__init__.py
Normal file
0
backend/api/v1/modules/crm/expedientes/__init__.py
Normal file
64
backend/api/v1/modules/crm/expedientes/doc_types.py
Normal file
64
backend/api/v1/modules/crm/expedientes/doc_types.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""Catálogo CERRADO de tipos de documento que EFC acepta del CRM.
|
||||||
|
|
||||||
|
Estas 22 claves son **exactamente** las de ``TIPOS_DOCUMENTO_CRM`` en
|
||||||
|
``api/record/views_integrations_crm.py`` de EFC. La lista está duplicada a mano en dos repos con
|
||||||
|
despliegue independiente, así que ``tests/test_doc_types_paridad.py`` la fija: si alguien agrega un
|
||||||
|
tipo de un solo lado, ese test se pone rojo antes de que un documento se rechace en producción.
|
||||||
|
|
||||||
|
Por qué es un conjunto cerrado y no texto libre, a diferencia del carril de Anexo22 —que manda el
|
||||||
|
tipo suelto y deja que EFC lo resuelva por nombre—: en el CRM ``doc_type`` es ``String(60)`` /
|
||||||
|
``String(30)`` **sin validación de backend**, los catálogos viven solo en TypeScript
|
||||||
|
(``frontend/src/lib/api/crm/format.ts``). Un typo crearía un ``DocumentType`` basura en el catálogo
|
||||||
|
**global** de EFC, que es compartido por todas las organizaciones y no se limpia solo.
|
||||||
|
|
||||||
|
Las tres fuentes del CRM y su origen:
|
||||||
|
|
||||||
|
- ``crm.documents`` → ``DOC_TYPES`` de ``format.ts``
|
||||||
|
- ``ops.shipment_documents`` → ``SHIPMENT_DOC_TYPES`` del mismo archivo
|
||||||
|
- ``fin.invoices`` → el PDF de factura (``factura_venta``)
|
||||||
|
|
||||||
|
``otro`` existe en las dos listas del CRM y significa lo mismo en ambas: es una sola entrada.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# --- crm.documents ---------------------------------------------------------------------------
|
||||||
|
_TIPOS_DOCUMENTOS_CLIENTE = (
|
||||||
|
"constancia_fiscal",
|
||||||
|
"acta_constitutiva",
|
||||||
|
"identificacion",
|
||||||
|
"comprobante_domicilio",
|
||||||
|
"contrato",
|
||||||
|
"presentacion",
|
||||||
|
"certificacion",
|
||||||
|
"licencia",
|
||||||
|
"convenio",
|
||||||
|
"tarifario",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- ops.shipment_documents ------------------------------------------------------------------
|
||||||
|
_TIPOS_DOCUMENTOS_EMBARQUE = (
|
||||||
|
"MBL",
|
||||||
|
"HBL",
|
||||||
|
"MAWB",
|
||||||
|
"HAWB",
|
||||||
|
"CMR",
|
||||||
|
"factura_comercial",
|
||||||
|
"packing_list",
|
||||||
|
"carta_encomienda",
|
||||||
|
"carta_garantia",
|
||||||
|
"certificado_permiso",
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- fin.invoices ----------------------------------------------------------------------------
|
||||||
|
_TIPOS_FACTURACION = ("factura_venta",)
|
||||||
|
|
||||||
|
# --- común a varias fuentes -------------------------------------------------------------------
|
||||||
|
_TIPOS_COMUNES = ("otro",)
|
||||||
|
|
||||||
|
EFC_DOC_TYPES: frozenset[str] = frozenset(
|
||||||
|
_TIPOS_DOCUMENTOS_CLIENTE + _TIPOS_DOCUMENTOS_EMBARQUE + _TIPOS_FACTURACION + _TIPOS_COMUNES
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_valid_doc_type(doc_type: str | None) -> bool:
|
||||||
|
"""``True`` si EFC va a aceptar ese tipo. Se valida en el CRM para no gastar un viaje de red."""
|
||||||
|
return bool(doc_type) and doc_type in EFC_DOC_TYPES
|
||||||
106
backend/api/v1/modules/crm/expedientes/dto.py
Normal file
106
backend/api/v1/modules/crm/expedientes/dto.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
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
|
||||||
95
backend/api/v1/modules/crm/expedientes/folio.py
Normal file
95
backend/api/v1/modules/crm/expedientes/folio.py
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
"""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)
|
||||||
117
backend/api/v1/modules/crm/expedientes/models.py
Normal file
117
backend/api/v1/modules/crm/expedientes/models.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
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"))
|
||||||
178
backend/api/v1/modules/crm/expedientes/routes.py
Normal file
178
backend/api/v1/modules/crm/expedientes/routes.py
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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")
|
||||||
|
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á.
|
||||||
|
"""
|
||||||
|
tenant_id = current_user["tenant_id"]
|
||||||
|
try:
|
||||||
|
iterador, content_type, filename = 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)
|
||||||
422
backend/api/v1/modules/crm/expedientes/service.py
Normal file
422
backend/api/v1/modules/crm/expedientes/service.py
Normal file
@@ -0,0 +1,422 @@
|
|||||||
|
"""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()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _iter_upstream(url: str, headers: dict, params: dict, verify: bool, timeout_s: float):
|
||||||
|
"""Generador async que hace de proxy del archivo de EFC hacia el navegador.
|
||||||
|
|
||||||
|
**El ``AsyncClient`` se crea DENTRO del generador y se cierra en ``finally``.** Si se creara en
|
||||||
|
un ``async with`` de fuera, ese bloque cerraría el cliente antes de que empiece el streaming
|
||||||
|
—FastAPI consume el generador después de devolver la respuesta— y la descarga moriría a medias.
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async def _generador():
|
||||||
|
client = httpx.AsyncClient(verify=verify, timeout=timeout_s)
|
||||||
|
try:
|
||||||
|
async with client.stream("GET", url, headers=headers, params=params) as upstream:
|
||||||
|
if upstream.status_code >= 400:
|
||||||
|
await upstream.aread()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_404_NOT_FOUND
|
||||||
|
if upstream.status_code == 404
|
||||||
|
else status.HTTP_502_BAD_GATEWAY,
|
||||||
|
detail="No se pudo obtener el archivo del expediente electrónico.",
|
||||||
|
)
|
||||||
|
async for chunk in upstream.aiter_bytes():
|
||||||
|
yield chunk
|
||||||
|
finally:
|
||||||
|
await client.aclose()
|
||||||
|
|
||||||
|
return _generador()
|
||||||
|
|
||||||
|
|
||||||
|
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 (
|
||||||
|
_iter_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,6 +16,7 @@ _ENTITIES = [
|
|||||||
("contact", "contactos"),
|
("contact", "contactos"),
|
||||||
("address", "direcciones"),
|
("address", "direcciones"),
|
||||||
("document", "documentos"),
|
("document", "documentos"),
|
||||||
|
("expediente", "expedientes"),
|
||||||
("service_request", "solicitudes de servicio"),
|
("service_request", "solicitudes de servicio"),
|
||||||
("rate_request", "solicitudes de tarifa"),
|
("rate_request", "solicitudes de tarifa"),
|
||||||
("quote", "cotizaciones"),
|
("quote", "cotizaciones"),
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from .addresses.routes import router as addresses_router
|
|||||||
from .catalogs.routes import router as catalogs_router
|
from .catalogs.routes import router as catalogs_router
|
||||||
from .contacts.routes import router as contacts_router
|
from .contacts.routes import router as contacts_router
|
||||||
from .documents.routes import router as documents_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 .leads.routes import router as leads_router
|
||||||
from .metrics.routes import router as metrics_router
|
from .metrics.routes import router as metrics_router
|
||||||
from .opportunities.routes import router as opportunities_router
|
from .opportunities.routes import router as opportunities_router
|
||||||
@@ -35,6 +37,8 @@ router.include_router(suppliers_router)
|
|||||||
router.include_router(contacts_router)
|
router.include_router(contacts_router)
|
||||||
router.include_router(addresses_router)
|
router.include_router(addresses_router)
|
||||||
router.include_router(documents_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(service_requests_router)
|
||||||
router.include_router(quotes_router)
|
router.include_router(quotes_router)
|
||||||
router.include_router(leads_router)
|
router.include_router(leads_router)
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from ..accounts.models import Account
|
from ..accounts.models import Account
|
||||||
from ..catalogs.data import INCOTERM_CODES
|
from ..catalogs.data import INCOTERM_CODES
|
||||||
|
from ..expedientes import service as expedientes_service
|
||||||
from ..opportunities.models import Opportunity
|
from ..opportunities.models import Opportunity
|
||||||
from ..suppliers.models import Supplier
|
from ..suppliers.models import Supplier
|
||||||
from .dto import (
|
from .dto import (
|
||||||
@@ -49,6 +50,24 @@ 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) -----
|
# ----- Service requests (RFQ) -----
|
||||||
|
|
||||||
def get_service_requests(
|
def get_service_requests(
|
||||||
@@ -104,6 +123,11 @@ def create_service_request(
|
|||||||
_validate_request_refs(db, data, tenant_id, company_id)
|
_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)
|
obj = ServiceRequest(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
||||||
db.add(obj)
|
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.commit()
|
||||||
db.refresh(obj)
|
db.refresh(obj)
|
||||||
return obj
|
return obj
|
||||||
@@ -184,6 +208,8 @@ def create_from_opportunity(
|
|||||||
updated_by=user_id,
|
updated_by=user_id,
|
||||||
)
|
)
|
||||||
db.add(obj)
|
db.add(obj)
|
||||||
|
db.flush()
|
||||||
|
_ensure_expediente(db, obj, tenant_id, company_id, user_id)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(obj)
|
db.refresh(obj)
|
||||||
return obj
|
return obj
|
||||||
|
|||||||
@@ -17,6 +17,21 @@ router = APIRouter()
|
|||||||
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
|
MAX_UPLOAD_BYTES = 25 * 1024 * 1024 # 25 MB
|
||||||
_SAFE_NAME = re.compile(r"[^A-Za-z0-9._-]+")
|
_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:
|
def _safe_filename(name: str | None) -> str:
|
||||||
base = (name or "archivo").strip().replace(" ", "_")
|
base = (name or "archivo").strip().replace(" ", "_")
|
||||||
@@ -24,6 +39,44 @@ def _safe_filename(name: str | None) -> str:
|
|||||||
return base[:120]
|
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")
|
@router.post("/uploads")
|
||||||
async def upload_file(
|
async def upload_file(
|
||||||
file: UploadFile = File(...),
|
file: UploadFile = File(...),
|
||||||
@@ -31,12 +84,8 @@ async def upload_file(
|
|||||||
current_user: dict = Depends(get_current_user),
|
current_user: dict = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
tenant_id = current_user["tenant_id"]
|
tenant_id = current_user["tenant_id"]
|
||||||
content = await file.read()
|
validar_extension(file.filename)
|
||||||
if len(content) > MAX_UPLOAD_BYTES:
|
content = await leer_acotado(file)
|
||||||
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)
|
filename = _safe_filename(file.filename)
|
||||||
key = f"tenants/{tenant_id}/companies/{company_id}/crm-docs/{uuid.uuid4().hex}/{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")
|
put_object_bytes(key, content, content_type=file.content_type or "application/octet-stream")
|
||||||
@@ -60,4 +109,13 @@ def get_upload_url(
|
|||||||
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
|
prefix = f"tenants/{tenant_id}/companies/{company_id}/"
|
||||||
if not key.startswith(prefix):
|
if not key.startswith(prefix):
|
||||||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Archivo fuera de tu alcance")
|
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)}
|
return {"url": presigned_get_url(key)}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ from datetime import date, datetime
|
|||||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
from api.v1.common.base_models import EfcDocumentRefMixin, TenantScopedMixin, TimestampMixin
|
||||||
from core.database import Base
|
from core.database import Base
|
||||||
|
|
||||||
|
|
||||||
@@ -92,8 +92,12 @@ class ShipmentEvent(Base, TenantScopedMixin, TimestampMixin):
|
|||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
|
||||||
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin):
|
class ShipmentDocument(Base, TenantScopedMixin, TimestampMixin, EfcDocumentRefMixin):
|
||||||
"""Documento de transporte del embarque (Master/House: MBL, HBL, MAWB, HAWB, CMR, etc.)."""
|
"""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.
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "shipment_documents"
|
__tablename__ = "shipment_documents"
|
||||||
__table_args__ = {"schema": "ops"}
|
__table_args__ = {"schema": "ops"}
|
||||||
|
|||||||
@@ -96,6 +96,7 @@ def _reset_rls_context_from_task(task_id=None, task=None, **_):
|
|||||||
celery_app.conf.update(
|
celery_app.conf.update(
|
||||||
include=[
|
include=[
|
||||||
"api.v1.modules.core.help_center.tasks",
|
"api.v1.modules.core.help_center.tasks",
|
||||||
|
"api.v1.modules.crm.expediente_gateway.tasks",
|
||||||
# Agrega aquí las tareas de tu proyecto:
|
# Agrega aquí las tareas de tu proyecto:
|
||||||
# "api.v1.modules.example.tasks",
|
# "api.v1.modules.example.tasks",
|
||||||
]
|
]
|
||||||
@@ -120,6 +121,21 @@ celery_app.conf.beat_schedule = {
|
|||||||
"task": "cleanup_orphan_layout_imports",
|
"task": "cleanup_orphan_layout_imports",
|
||||||
"schedule": 3600.0,
|
"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__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class Settings(BaseSettings):
|
|||||||
# URL pública del frontend — usada en links de email (invitaciones, etc.)
|
# URL pública del frontend — usada en links de email (invitaciones, etc.)
|
||||||
APP_PUBLIC_URL: str = "http://localhost:3000"
|
APP_PUBLIC_URL: str = "http://localhost:3000"
|
||||||
|
|
||||||
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", mode="before")
|
@field_validator("CENTRAL_SERVER_URL", "SPOKE_URLS", "HUB_URL", "HUB_API_BASE_URL", "EFC_API_URL", mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def strip_quotes(cls, v: str) -> str:
|
def strip_quotes(cls, v: str) -> str:
|
||||||
if v and isinstance(v, str):
|
if v and isinstance(v, str):
|
||||||
@@ -103,6 +103,25 @@ class Settings(BaseSettings):
|
|||||||
S3_FILE_STORAGE: bool = True
|
S3_FILE_STORAGE: bool = True
|
||||||
S3_PRESIGNED_EXPIRES_SECONDS: int = 3600
|
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(
|
model_config = SettingsConfigDict(
|
||||||
env_file=[".env", "../.env"],
|
env_file=[".env", "../.env"],
|
||||||
case_sensitive=True,
|
case_sensitive=True,
|
||||||
|
|||||||
301
backend/core/efc_client.py
Normal file
301
backend/core/efc_client.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""Cliente HTTP hacia EFC (carril de integración CRM Agentes de Carga -> EFC).
|
||||||
|
|
||||||
|
Clon del cliente del gateway de Anexo22, que es el carril de referencia ya en producción
|
||||||
|
(``anexo22/backend/api/v1/modules/pedimentos/pedimento_gateway/client.py``), con las rutas
|
||||||
|
cambiadas a ``.../integrations/crm/...``. No es una reinterpretación: el molde de reintentos, el
|
||||||
|
corte en 4xx y la forma del error se conservan tal cual.
|
||||||
|
|
||||||
|
**Síncrono a propósito** (``httpx.Client``): el consumidor es el worker de Celery que drena el
|
||||||
|
outbox, que corre en contexto sync. El único punto async del carril es el proxy de descarga de cara
|
||||||
|
al usuario, y ése no usa este cliente.
|
||||||
|
|
||||||
|
Todos los endpoints se autentican con el header ``X-Api-Key``
|
||||||
|
(``settings.EFC_API_KEY`` == ``CRM_INTEGRATION_API_KEY`` del lado de EFC).
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Rutas de EFC (prefijo /api/v1/, ver config/urls.py del backend de EFC).
|
||||||
|
_PATH_ORG_BUSCAR = "/api/v1/organization/integrations/crm/organizaciones/"
|
||||||
|
_PATH_ORG_RESOLVER = "/api/v1/organization/integrations/crm/organizaciones/resolver/"
|
||||||
|
_PATH_EXPEDIENTE = "/api/v1/customs/integrations/crm/expedientes/"
|
||||||
|
_PATH_EXPEDIENTE_COMPLETAR = "/api/v1/customs/integrations/crm/expedientes/{folio}/completar/"
|
||||||
|
_PATH_EXPEDIENTE_DETALLE = "/api/v1/customs/integrations/crm/expedientes/{folio}/"
|
||||||
|
_PATH_DOCS = "/api/v1/record/integrations/crm/documentos/"
|
||||||
|
_PATH_DOCS_LIST = "/api/v1/record/integrations/crm/documentos/list/"
|
||||||
|
_PATH_DOC_DESCARGAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/descargar/"
|
||||||
|
_PATH_DOC_ELIMINAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/eliminar/"
|
||||||
|
_PATH_DOC_REEMPLAZAR = "/api/v1/record/integrations/crm/documentos/{doc_id}/reemplazar/"
|
||||||
|
|
||||||
|
|
||||||
|
class EfcClientError(Exception):
|
||||||
|
"""Error de comunicación con EFC.
|
||||||
|
|
||||||
|
``retryable=True`` marca fallos transitorios (5xx/timeout/red) que el worker debe reintentar.
|
||||||
|
``status_code``/``code`` exponen la respuesta de EFC para que el worker pueda ramificar
|
||||||
|
(p. ej. 404 ``expediente_no_encontrado`` → ensure-then-upload).
|
||||||
|
|
||||||
|
El worker decide **por el campo ``retryable``**, nunca parseando el mensaje: un texto de error
|
||||||
|
cambia con cualquier refactor del otro repo y con él se caería la política de reintentos sin que
|
||||||
|
nada se vea roto.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: Optional[int] = None,
|
||||||
|
code: Optional[str] = None, retryable: bool = False):
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.code = code
|
||||||
|
self.retryable = retryable
|
||||||
|
|
||||||
|
|
||||||
|
class EfcClient:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
timeout_ms: Optional[int] = None,
|
||||||
|
upload_timeout_ms: Optional[int] = None,
|
||||||
|
verify_ssl: Optional[bool] = None,
|
||||||
|
retries: int = 2,
|
||||||
|
transport: Optional[httpx.BaseTransport] = None,
|
||||||
|
):
|
||||||
|
self.base_url = (base_url if base_url is not None else settings.EFC_API_URL).rstrip("/")
|
||||||
|
self.api_key = api_key if api_key is not None else settings.EFC_API_KEY
|
||||||
|
self.timeout_s = max(0.1, float(timeout_ms or settings.EFC_API_TIMEOUT_MS) / 1000.0)
|
||||||
|
# Timeout aparte para las subidas: los 8 s de los metadatos no alcanzan para un archivo de
|
||||||
|
# 25 MB. Tiene que quedar POR DEBAJO del proxy_read_timeout del nginx de EFC — si el CRM
|
||||||
|
# esperara más, vería un 504 opaco sin saber si el documento entró. Fallando primero de este
|
||||||
|
# lado, el reintento con el mismo efc_document_ref es limpio.
|
||||||
|
self.upload_timeout_s = max(
|
||||||
|
0.1, float(upload_timeout_ms or settings.EFC_UPLOAD_TIMEOUT_MS) / 1000.0
|
||||||
|
)
|
||||||
|
self.verify_ssl = settings.EFC_API_VERIFY_SSL if verify_ssl is None else verify_ssl
|
||||||
|
self.retries = max(0, int(retries))
|
||||||
|
self.transport = transport
|
||||||
|
# mTLS (scaffolding pre-prod): si hay CA se usa para verificar; si hay par cert/key se
|
||||||
|
# presenta como certificado de cliente. Vacío = TLS normal.
|
||||||
|
self._ca_path = settings.EFC_MTLS_CA_PATH or ""
|
||||||
|
self._cert_path = settings.EFC_MTLS_CERT_PATH or ""
|
||||||
|
self._key_path = settings.EFC_MTLS_KEY_PATH or ""
|
||||||
|
|
||||||
|
def _client_kwargs(self, timeout_s: Optional[float] = None) -> dict:
|
||||||
|
"""``verify``/``cert`` para httpx según config mTLS (o TLS normal si no hay mTLS)."""
|
||||||
|
verify = self._ca_path if self._ca_path else self.verify_ssl
|
||||||
|
kwargs = {
|
||||||
|
"timeout": timeout_s or self.timeout_s,
|
||||||
|
"verify": verify,
|
||||||
|
"transport": self.transport,
|
||||||
|
}
|
||||||
|
if self._cert_path and self._key_path:
|
||||||
|
kwargs["cert"] = (self._cert_path, self._key_path)
|
||||||
|
return kwargs
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_configured(self) -> bool:
|
||||||
|
"""``False`` = integración deshabilitada (best-effort): sin URL o sin key."""
|
||||||
|
return bool(self.base_url and self.api_key)
|
||||||
|
|
||||||
|
# ── HTTP interno ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _request(self, method: str, path: str, *, json: Any = None,
|
||||||
|
params: dict = None, files: dict = None, data: dict = None,
|
||||||
|
stream: bool = False, timeout_s: Optional[float] = None):
|
||||||
|
if not self.is_configured:
|
||||||
|
raise EfcClientError("EFC no configurado (EFC_API_URL/EFC_API_KEY vacíos).", retryable=False)
|
||||||
|
|
||||||
|
url = f"{self.base_url}{path}"
|
||||||
|
headers = {"X-Api-Key": self.api_key}
|
||||||
|
last_error: Optional[Exception] = None
|
||||||
|
|
||||||
|
for attempt in range(self.retries + 1):
|
||||||
|
try:
|
||||||
|
client = httpx.Client(**self._client_kwargs(timeout_s))
|
||||||
|
try:
|
||||||
|
response = client.request(method, url, headers=headers, json=json,
|
||||||
|
params=params, files=files, data=data)
|
||||||
|
except Exception:
|
||||||
|
client.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
if 200 <= response.status_code < 300:
|
||||||
|
if stream:
|
||||||
|
# El caller lee response.content y cierra el cliente.
|
||||||
|
return response, client
|
||||||
|
client.close()
|
||||||
|
return response
|
||||||
|
|
||||||
|
# 5xx: transitorio, reintentar.
|
||||||
|
if response.status_code >= 500 and attempt < self.retries:
|
||||||
|
client.close()
|
||||||
|
time.sleep(0.15 * (attempt + 1))
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 4xx u otro: no reintentar. Extraer code/mensaje de EFC.
|
||||||
|
code, message = _parse_error_body(response)
|
||||||
|
client.close()
|
||||||
|
raise EfcClientError(
|
||||||
|
message or f"EFC respondió {response.status_code}",
|
||||||
|
status_code=response.status_code,
|
||||||
|
code=code,
|
||||||
|
retryable=response.status_code >= 500,
|
||||||
|
)
|
||||||
|
|
||||||
|
except (httpx.TimeoutException, httpx.NetworkError) as exc:
|
||||||
|
last_error = exc
|
||||||
|
if attempt >= self.retries:
|
||||||
|
break
|
||||||
|
time.sleep(0.15 * (attempt + 1))
|
||||||
|
except EfcClientError:
|
||||||
|
raise
|
||||||
|
except Exception as exc: # noqa: BLE001 — cualquier fallo inesperado es no-retryable
|
||||||
|
raise EfcClientError(str(exc), retryable=False) from exc
|
||||||
|
|
||||||
|
raise EfcClientError(
|
||||||
|
f"EFC inaccesible tras {self.retries + 1} intentos: {last_error}",
|
||||||
|
retryable=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Organización ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def buscar_organizaciones(self, q: str) -> list:
|
||||||
|
"""Búsqueda por texto, para el alta manual desde una pantalla de administración."""
|
||||||
|
return self._request("GET", _PATH_ORG_BUSCAR, params={"q": q}).json()
|
||||||
|
|
||||||
|
def resolve_organizacion(self, tenant_slug: str, tenant_name: Optional[str] = None) -> dict:
|
||||||
|
payload = {"tenant_slug": tenant_slug}
|
||||||
|
if tenant_name:
|
||||||
|
payload["tenant_name"] = tenant_name
|
||||||
|
return self._request("POST", _PATH_ORG_RESOLVER, json=payload).json()
|
||||||
|
|
||||||
|
# ── Expediente (pedimento provisional en EFC) ──────────────────────────────
|
||||||
|
|
||||||
|
def ingest_expediente(self, payload: dict) -> dict:
|
||||||
|
"""Crea el pedimento provisional del expediente. 201 si es nuevo, 200 si ya existía."""
|
||||||
|
return self._request("POST", _PATH_EXPEDIENTE, json=payload).json()
|
||||||
|
|
||||||
|
def completar_expediente(self, folio: str, payload: dict) -> dict:
|
||||||
|
"""Completa un provisional con la data aduanera real. Ningún archivo se mueve."""
|
||||||
|
path = _PATH_EXPEDIENTE_COMPLETAR.format(folio=folio)
|
||||||
|
return self._request("POST", path, json=payload).json()
|
||||||
|
|
||||||
|
def get_expediente(self, folio: str, organizacion_id: str) -> dict:
|
||||||
|
path = _PATH_EXPEDIENTE_DETALLE.format(folio=folio)
|
||||||
|
return self._request("GET", path, params={"organizacion_id": str(organizacion_id)}).json()
|
||||||
|
|
||||||
|
# ── Documentos ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def upload_documento(self, organizacion_id: str, crm_company_id: int, crm_expediente_id: int,
|
||||||
|
tipo: str, filename: str, content: bytes,
|
||||||
|
content_type: str = "application/octet-stream",
|
||||||
|
crm_document_ref: Optional[str] = None) -> dict:
|
||||||
|
"""Sube un documento al expediente. Multipart, nunca base64.
|
||||||
|
|
||||||
|
``crm_document_ref`` es **el handle de NUESTRO registro de origen**, y es lo que después
|
||||||
|
permite recuperar el archivo sin guardar de este lado ningún identificador de EFC. EFC lo
|
||||||
|
guarda junto al documento con un UNIQUE parcial por ``(organizacion, ref)``, así que una
|
||||||
|
entrega repetida devuelve el documento que ya existía (200) en vez de crear otro (201).
|
||||||
|
|
||||||
|
Es la cuarta capa de idempotencia del carril y la única que garantiza la base: la entrega la
|
||||||
|
hace un worker con reintentos, así que un timeout ambiguo —EFC commiteó y contestó tarde—
|
||||||
|
duplicaría el documento sin esto.
|
||||||
|
"""
|
||||||
|
files = {"file": (filename, content, content_type)}
|
||||||
|
data = {
|
||||||
|
"organizacion_id": str(organizacion_id),
|
||||||
|
"crm_company_id": str(int(crm_company_id)),
|
||||||
|
"crm_expediente_id": str(int(crm_expediente_id)),
|
||||||
|
"tipo": tipo,
|
||||||
|
}
|
||||||
|
if crm_document_ref:
|
||||||
|
data["crm_document_ref"] = crm_document_ref
|
||||||
|
return self._request(
|
||||||
|
"POST", _PATH_DOCS, files=files, data=data, timeout_s=self.upload_timeout_s
|
||||||
|
).json()
|
||||||
|
|
||||||
|
def list_documentos(self, organizacion_id: str, crm_expediente_id: int, *,
|
||||||
|
tipo: Optional[str] = None,
|
||||||
|
crm_document_ref: Optional[str] = None) -> list:
|
||||||
|
"""Documentos del expediente. ``tipo`` y ``crm_document_ref`` son filtros OPCIONALES.
|
||||||
|
|
||||||
|
Preguntar por ``crm_document_ref`` es lo que permite recuperar ``efc_document_id`` si el
|
||||||
|
cache local se perdió, sin guardar identificadores ajenos como handle.
|
||||||
|
"""
|
||||||
|
params = {
|
||||||
|
"organizacion_id": str(organizacion_id),
|
||||||
|
"crm_expediente_id": str(int(crm_expediente_id)),
|
||||||
|
}
|
||||||
|
if tipo:
|
||||||
|
params["tipo"] = tipo
|
||||||
|
if crm_document_ref:
|
||||||
|
params["crm_document_ref"] = crm_document_ref
|
||||||
|
return self._request("GET", _PATH_DOCS_LIST, params=params).json()
|
||||||
|
|
||||||
|
def replace_documento(self, organizacion_id: str, doc_id: str, filename: str, content: bytes,
|
||||||
|
content_type: str = "application/octet-stream") -> dict:
|
||||||
|
"""Sustituye el CONTENIDO de un documento conservando su fila (mismo id, mismo tipo).
|
||||||
|
|
||||||
|
EFC sube primero y borra el viejo al final, así que una subida fallida deja el anterior
|
||||||
|
intacto y descargable.
|
||||||
|
"""
|
||||||
|
files = {"file": (filename, content, content_type)}
|
||||||
|
data = {"organizacion_id": str(organizacion_id)}
|
||||||
|
path = _PATH_DOC_REEMPLAZAR.format(doc_id=doc_id)
|
||||||
|
return self._request(
|
||||||
|
"PUT", path, files=files, data=data, timeout_s=self.upload_timeout_s
|
||||||
|
).json()
|
||||||
|
|
||||||
|
def download_documento(self, organizacion_id: str, doc_id: str) -> tuple[bytes, str]:
|
||||||
|
path = _PATH_DOC_DESCARGAR.format(doc_id=doc_id)
|
||||||
|
params = {"organizacion_id": str(organizacion_id)}
|
||||||
|
response, client = self._request("GET", path, params=params, stream=True)
|
||||||
|
try:
|
||||||
|
content = response.content
|
||||||
|
filename = _filename_from_response(response, default=str(doc_id))
|
||||||
|
finally:
|
||||||
|
client.close()
|
||||||
|
return content, filename
|
||||||
|
|
||||||
|
def download_url(self, doc_id: str) -> str:
|
||||||
|
"""URL absoluta del endpoint de descarga de EFC, para el proxy async de la fase 7.
|
||||||
|
|
||||||
|
El proxy no puede usar ``download_documento``: éste es síncrono y bufferiza el archivo
|
||||||
|
entero. Lo que necesita es la URL y el header, y hace su propio streaming.
|
||||||
|
"""
|
||||||
|
return f"{self.base_url}{_PATH_DOC_DESCARGAR.format(doc_id=doc_id)}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def auth_headers(self) -> dict:
|
||||||
|
"""El header de autenticación, para el proxy async que no pasa por ``_request``."""
|
||||||
|
return {"X-Api-Key": self.api_key}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_error_body(response) -> tuple[Optional[str], Optional[str]]:
|
||||||
|
"""Extrae ``(code, message)`` del cuerpo de error estructurado de EFC
|
||||||
|
(``{"error": {"code", "message"}}``) sin reventar si no es JSON."""
|
||||||
|
try:
|
||||||
|
body = response.json()
|
||||||
|
except Exception:
|
||||||
|
return None, None
|
||||||
|
err = body.get("error") if isinstance(body, dict) else None
|
||||||
|
if isinstance(err, dict):
|
||||||
|
return err.get("code"), err.get("message")
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_from_response(response, default: str) -> str:
|
||||||
|
disp = response.headers.get("Content-Disposition", "")
|
||||||
|
if "filename=" in disp:
|
||||||
|
return disp.split("filename=")[-1].strip().strip('"') or default
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
# Instancia por defecto (lee settings). Los tests inyectan su propio transport construyendo
|
||||||
|
# EfcClient(transport=httpx.MockTransport(...)).
|
||||||
|
efc_client = EfcClient()
|
||||||
@@ -250,6 +250,30 @@ 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:
|
def legacy_csv_import_key(job_type: str, job_id: str) -> str:
|
||||||
"""Clave antigua sin tenant/company (solo migración / cleanup)."""
|
"""Clave antigua sin tenant/company (solo migración / cleanup)."""
|
||||||
_segment(job_id, "job_id")
|
_segment(job_id, "job_id")
|
||||||
|
|||||||
@@ -82,6 +82,31 @@ def put_csv_object(key: str, body: bytes, content_type: str = "text/csv") -> Non
|
|||||||
put_object_bytes(key, body, content_type=content_type)
|
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:
|
def get_object_bytes(key: str) -> bytes:
|
||||||
resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key)
|
resp = _client().get_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||||
return resp["Body"].read()
|
return resp["Body"].read()
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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.addresses.models # noqa: E402,F401
|
||||||
import api.v1.modules.crm.contacts.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.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.leads.models # noqa: E402,F401
|
||||||
import api.v1.modules.crm.opportunities.models # noqa: E402,F401
|
import api.v1.modules.crm.opportunities.models # noqa: E402,F401
|
||||||
import api.v1.modules.crm.pipelines.models # noqa: E402,F401
|
import api.v1.modules.crm.pipelines.models # noqa: E402,F401
|
||||||
|
|||||||
85
backend/tests/test_doc_types_paridad.py
Normal file
85
backend/tests/test_doc_types_paridad.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Paridad del catálogo de tipos de documento entre el CRM y EFC.
|
||||||
|
|
||||||
|
``EFC_DOC_TYPES`` del CRM tiene que ser **exactamente** el juego de claves de
|
||||||
|
``TIPOS_DOCUMENTO_CRM`` de ``api/record/views_integrations_crm.py`` en EFC. La lista está duplicada
|
||||||
|
a mano en dos repos con despliegue independiente, y este archivo es lo único que la mantiene
|
||||||
|
honesta: si alguien agrega un tipo de un solo lado, esto se pone rojo **antes** de que un documento
|
||||||
|
se rechace en producción con ``tipo_invalido``.
|
||||||
|
|
||||||
|
El juego esperado va escrito **literal** aquí y no derivado de ``doc_types.py``, porque una prueba
|
||||||
|
que se lo pregunte al mismo módulo que valida no prueba nada: pasaría con cualquier cambio.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from api.v1.modules.crm.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
|
||||||
248
backend/tests/test_efc_client.py
Normal file
248
backend/tests/test_efc_client.py
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
"""Pruebas del cliente HTTP hacia EFC.
|
||||||
|
|
||||||
|
**Existen porque el carril de referencia no las tiene.** Verificado: en el gateway de Anexo22 no hay
|
||||||
|
ni una prueba de ``EfcClient._request``, así que su bucle de reintentos, su backoff, su corte en 4xx
|
||||||
|
y su header nunca se ejercitan. Ese hueco no se clona.
|
||||||
|
|
||||||
|
Todo va contra ``httpx.MockTransport`` por el parámetro ``transport``, que existe justamente para
|
||||||
|
esto: **ninguna de estas pruebas toca la red**.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from core.efc_client import EfcClient, EfcClientError
|
||||||
|
|
||||||
|
BASE = "https://efc.example.test"
|
||||||
|
KEY = "llave-de-prueba"
|
||||||
|
|
||||||
|
|
||||||
|
def _client(handler, **kwargs) -> EfcClient:
|
||||||
|
return EfcClient(
|
||||||
|
base_url=kwargs.pop("base_url", BASE),
|
||||||
|
api_key=kwargs.pop("api_key", KEY),
|
||||||
|
timeout_ms=kwargs.pop("timeout_ms", 500),
|
||||||
|
upload_timeout_ms=kwargs.pop("upload_timeout_ms", 500),
|
||||||
|
verify_ssl=False,
|
||||||
|
transport=httpx.MockTransport(handler),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reintenta_un_500_y_devuelve_el_exito():
|
||||||
|
intentos = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
intentos["n"] += 1
|
||||||
|
if intentos["n"] == 1:
|
||||||
|
return httpx.Response(500, json={"detail": "boom"})
|
||||||
|
return httpx.Response(200, json={"id": "org-1"})
|
||||||
|
|
||||||
|
resp = _client(handler).resolve_organizacion("temex")
|
||||||
|
assert resp == {"id": "org-1"}
|
||||||
|
assert intentos["n"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_500_permanente_hace_exactamente_tres_intentos_y_es_retryable():
|
||||||
|
"""``retries = 2`` significa 3 intentos: el original + 2. Ni 2 ni 4."""
|
||||||
|
intentos = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
intentos["n"] += 1
|
||||||
|
return httpx.Response(500, json={"detail": "boom"})
|
||||||
|
|
||||||
|
with pytest.raises(EfcClientError) as exc:
|
||||||
|
_client(handler).resolve_organizacion("temex")
|
||||||
|
|
||||||
|
assert intentos["n"] == 3
|
||||||
|
assert exc.value.retryable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_timeout_permanente_hace_tres_intentos_y_es_retryable():
|
||||||
|
intentos = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
intentos["n"] += 1
|
||||||
|
raise httpx.ConnectTimeout("se acabó el tiempo", request=request)
|
||||||
|
|
||||||
|
with pytest.raises(EfcClientError) as exc:
|
||||||
|
_client(handler).resolve_organizacion("temex")
|
||||||
|
|
||||||
|
assert intentos["n"] == 3
|
||||||
|
assert exc.value.retryable is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_400_no_se_reintenta_y_extrae_el_code_del_cuerpo():
|
||||||
|
"""El corte en 4xx es lo que evita machacar a EFC con una petición que nunca va a pasar.
|
||||||
|
|
||||||
|
Y el ``code`` extraído es lo que permite al worker ramificar **por campo**, nunca parseando el
|
||||||
|
texto del mensaje: un texto cambia con cualquier refactor del otro repo.
|
||||||
|
"""
|
||||||
|
intentos = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
intentos["n"] += 1
|
||||||
|
return httpx.Response(
|
||||||
|
400,
|
||||||
|
json={"error": {"code": "espacio_insuficiente", "message": "La licencia no tiene espacio"}},
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(EfcClientError) as exc:
|
||||||
|
_client(handler).resolve_organizacion("temex")
|
||||||
|
|
||||||
|
assert intentos["n"] == 1
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert exc.value.code == "espacio_insuficiente"
|
||||||
|
assert exc.value.retryable is False
|
||||||
|
assert "La licencia no tiene espacio" in str(exc.value)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("status_code", [401, 403])
|
||||||
|
def test_401_y_403_no_se_reintentan(status_code):
|
||||||
|
"""Una key mal configurada no mejora insistiendo: reintentarla solo gasta cuota y llena logs."""
|
||||||
|
intentos = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
intentos["n"] += 1
|
||||||
|
return httpx.Response(status_code)
|
||||||
|
|
||||||
|
with pytest.raises(EfcClientError) as exc:
|
||||||
|
_client(handler).resolve_organizacion("temex")
|
||||||
|
|
||||||
|
assert intentos["n"] == 1
|
||||||
|
assert exc.value.retryable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_cuerpo_de_error_que_no_es_json_no_revienta():
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(400, text="<html>502 Bad Gateway</html>")
|
||||||
|
|
||||||
|
with pytest.raises(EfcClientError) as exc:
|
||||||
|
_client(handler).resolve_organizacion("temex")
|
||||||
|
|
||||||
|
assert exc.value.code is None
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_toda_llamada_manda_el_header_x_api_key():
|
||||||
|
visto = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
visto["key"] = request.headers.get("X-Api-Key")
|
||||||
|
return httpx.Response(200, json={"id": "org-1"})
|
||||||
|
|
||||||
|
_client(handler).resolve_organizacion("temex")
|
||||||
|
assert visto["key"] == KEY
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_url_configurada_no_toca_la_red_y_el_error_no_es_retryable():
|
||||||
|
"""``is_configured is False`` es lo que hace que todo el carril sea best-effort.
|
||||||
|
|
||||||
|
Si esto tocara la red, cada operación del CRM con EFC apagado pagaría un timeout.
|
||||||
|
"""
|
||||||
|
llamado = {"n": 0}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
llamado["n"] += 1
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
|
||||||
|
client = _client(handler, base_url="")
|
||||||
|
assert client.is_configured is False
|
||||||
|
|
||||||
|
with pytest.raises(EfcClientError) as exc:
|
||||||
|
client.resolve_organizacion("temex")
|
||||||
|
|
||||||
|
assert llamado["n"] == 0
|
||||||
|
assert exc.value.retryable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_sin_api_key_tampoco_esta_configurado():
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, json={})
|
||||||
|
|
||||||
|
assert _client(handler, api_key="").is_configured is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_base_url_con_y_sin_barra_final_dan_la_misma_url():
|
||||||
|
urls = []
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
urls.append(str(request.url))
|
||||||
|
return httpx.Response(200, json={"id": "org-1"})
|
||||||
|
|
||||||
|
_client(handler, base_url=BASE).resolve_organizacion("temex")
|
||||||
|
_client(handler, base_url=BASE + "/").resolve_organizacion("temex")
|
||||||
|
|
||||||
|
assert urls[0] == urls[1]
|
||||||
|
assert "//organization" not in urls[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_subida_va_multipart_y_lleva_el_crm_document_ref():
|
||||||
|
"""El ref es la tercera capa de idempotencia: EFC devuelve 200 con el que ya existía."""
|
||||||
|
visto = {}
|
||||||
|
|
||||||
|
def handler(request):
|
||||||
|
visto["content_type"] = request.headers.get("Content-Type", "")
|
||||||
|
visto["body"] = request.content
|
||||||
|
return httpx.Response(201, json={"id": "doc-1"})
|
||||||
|
|
||||||
|
resp = _client(handler).upload_documento(
|
||||||
|
"org-1", 1, 42, "MBL", "guia.pdf", b"%PDF-1.4 contenido", "application/pdf",
|
||||||
|
crm_document_ref="SHPDOC-1-4471",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resp == {"id": "doc-1"}
|
||||||
|
assert visto["content_type"].startswith("multipart/form-data")
|
||||||
|
assert b"SHPDOC-1-4471" in visto["body"]
|
||||||
|
assert b"%PDF-1.4 contenido" in visto["body"]
|
||||||
|
# Nada de base64: el archivo viaja crudo dentro del multipart.
|
||||||
|
assert b"base64" not in visto["body"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_subida_usa_el_timeout_largo_y_los_metadatos_el_corto():
|
||||||
|
"""Los 8 s de los metadatos no alcanzan para un archivo de 25 MB, y un timeout de subida
|
||||||
|
demasiado largo haría que el CRM vea un 504 opaco de nginx sin saber si el documento entró."""
|
||||||
|
client = _client(handler=lambda r: httpx.Response(200, json={}), timeout_ms=8000, upload_timeout_ms=55000)
|
||||||
|
assert client.timeout_s == 8.0
|
||||||
|
assert client.upload_timeout_s == 55.0
|
||||||
|
assert client.upload_timeout_s > client.timeout_s
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensure_then_upload_puede_ramificar_por_el_code_del_404():
|
||||||
|
"""El 404 del expediente tiene que llegar al worker con su ``code`` y su ``status_code``.
|
||||||
|
|
||||||
|
Es lo que dispara el ensure-then-upload; sin el code, el worker tendría que adivinar de qué es
|
||||||
|
el 404 y crearía provisionales por cualquier ausencia.
|
||||||
|
"""
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
404, json={"error": {"code": "expediente_no_encontrado", "message": "no está"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(EfcClientError) as exc:
|
||||||
|
_client(handler).upload_documento("org-1", 1, 42, "MBL", "g.pdf", b"x")
|
||||||
|
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
assert exc.value.code == "expediente_no_encontrado"
|
||||||
|
assert exc.value.retryable is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_descarga_devuelve_contenido_y_nombre_del_content_disposition():
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(
|
||||||
|
200,
|
||||||
|
content=b"contenido binario",
|
||||||
|
headers={"Content-Disposition": 'attachment; filename="factura.pdf"'},
|
||||||
|
)
|
||||||
|
|
||||||
|
contenido, nombre = _client(handler).download_documento("org-1", "doc-1")
|
||||||
|
assert contenido == b"contenido binario"
|
||||||
|
assert nombre == "factura.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_descarga_sin_content_disposition_cae_al_id_del_documento():
|
||||||
|
def handler(request):
|
||||||
|
return httpx.Response(200, content=b"x")
|
||||||
|
|
||||||
|
_contenido, nombre = _client(handler).download_documento("org-1", "doc-9")
|
||||||
|
assert nombre == "doc-9"
|
||||||
350
backend/tests/test_efc_entrega_documento.py
Normal file
350
backend/tests/test_efc_entrega_documento.py
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
"""Pruebas de la entrega de un archivo al expediente de EFC.
|
||||||
|
|
||||||
|
Cubren las tres cosas que hacen que este carril no pierda ni duplique archivos: el
|
||||||
|
**ensure-then-upload** cuando el provisional todavía no existe allá, el **corte directo**
|
||||||
|
(``delete_local``) que borra la copia local solo al confirmar, y la idempotencia por
|
||||||
|
``crm_document_ref`` cuando un timeout ambiguo hace reintentar.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.v1.modules.crm.expediente_gateway import service as gateway
|
||||||
|
from api.v1.modules.crm.expediente_gateway.models import (
|
||||||
|
FILE_KIND_DOCUMENTO,
|
||||||
|
MAX_ATTEMPTS,
|
||||||
|
SOURCE_CRM_DOCUMENTS,
|
||||||
|
STATUS_FAILED,
|
||||||
|
STATUS_PENDING,
|
||||||
|
STATUS_SENT,
|
||||||
|
EfcFileOutbox,
|
||||||
|
)
|
||||||
|
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||||
|
from api.v1.modules.crm.documents.models import Document
|
||||||
|
from api.v1.modules.crm.service_requests import service as sr_service
|
||||||
|
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
||||||
|
from core.efc_client import EfcClientError
|
||||||
|
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||||
|
|
||||||
|
CONTENIDO = b"%PDF-1.4 guia madre"
|
||||||
|
S3_KEY = "tenants/1/companies/1/expedientes/1/guia.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
class _ClienteFalso:
|
||||||
|
"""Doble del cliente de EFC. Registra qué se le pidió, para poder afirmarlo."""
|
||||||
|
|
||||||
|
is_configured = True
|
||||||
|
|
||||||
|
def __init__(self, *, upload_falla_con=None, falla_solo_la_primera=True):
|
||||||
|
self.uploads = []
|
||||||
|
self.ingests = []
|
||||||
|
self._upload_falla_con = upload_falla_con
|
||||||
|
self._falla_solo_la_primera = falla_solo_la_primera
|
||||||
|
|
||||||
|
def ingest_expediente(self, payload):
|
||||||
|
self.ingests.append(payload)
|
||||||
|
return {"status": "created", "efc": {"pedimento_id": "ped-1"}}
|
||||||
|
|
||||||
|
def upload_documento(self, org_id, company_id, expediente_id, tipo, filename, content,
|
||||||
|
content_type="application/octet-stream", crm_document_ref=None):
|
||||||
|
primera = not self.uploads
|
||||||
|
self.uploads.append({
|
||||||
|
"org_id": org_id, "company_id": company_id, "expediente_id": expediente_id,
|
||||||
|
"tipo": tipo, "filename": filename, "content": content,
|
||||||
|
"content_type": content_type, "crm_document_ref": crm_document_ref,
|
||||||
|
})
|
||||||
|
if self._upload_falla_con is not None and (primera or not self._falla_solo_la_primera):
|
||||||
|
raise self._upload_falla_con
|
||||||
|
return {"id": f"doc-{len(self.uploads)}"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def entorno(db, monkeypatch):
|
||||||
|
"""EFC encendido, storage simulado y organización ya resuelta."""
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
||||||
|
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
|
||||||
|
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
|
||||||
|
|
||||||
|
borrados = []
|
||||||
|
import core.storage_s3 as storage
|
||||||
|
|
||||||
|
monkeypatch.setattr(storage, "get_object_bytes", lambda key: CONTENIDO)
|
||||||
|
monkeypatch.setattr(storage, "delete_object_if_exists", lambda key: borrados.append(key))
|
||||||
|
|
||||||
|
solicitud = sr_service.create_service_request(
|
||||||
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||||
|
)
|
||||||
|
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||||
|
return {"db": db, "expediente": expediente, "borrados": borrados}
|
||||||
|
|
||||||
|
|
||||||
|
def _documento_local(db, expediente) -> Document:
|
||||||
|
doc = Document(
|
||||||
|
doc_type="MBL",
|
||||||
|
name="guia.pdf",
|
||||||
|
file_key=S3_KEY,
|
||||||
|
content_type="application/pdf",
|
||||||
|
size_bytes=len(CONTENIDO),
|
||||||
|
expediente_id=expediente.id,
|
||||||
|
efc_sync_state="PENDING",
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
company_id=COMPANY_ID,
|
||||||
|
)
|
||||||
|
db.add(doc)
|
||||||
|
db.flush()
|
||||||
|
doc.efc_document_ref = f"CRMDOC-{COMPANY_ID}-{doc.id}"
|
||||||
|
db.commit()
|
||||||
|
return doc
|
||||||
|
|
||||||
|
|
||||||
|
def _fila(db, expediente, documento, **kwargs) -> EfcFileOutbox:
|
||||||
|
row = EfcFileOutbox(
|
||||||
|
kind=FILE_KIND_DOCUMENTO,
|
||||||
|
s3_key=S3_KEY,
|
||||||
|
file_name="guia.pdf",
|
||||||
|
content_type="application/pdf",
|
||||||
|
efc_tipo="MBL",
|
||||||
|
source_table=SOURCE_CRM_DOCUMENTS,
|
||||||
|
source_id=documento.id,
|
||||||
|
crm_document_ref=documento.efc_document_ref,
|
||||||
|
expediente_ref=expediente.id,
|
||||||
|
delete_local=kwargs.pop("delete_local", True),
|
||||||
|
status=kwargs.pop("status", STATUS_PENDING),
|
||||||
|
tenant_id=TENANT_ID,
|
||||||
|
company_id=COMPANY_ID,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# ── Camino feliz ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_entrega_feliz_marca_la_fila_y_el_documento(entorno):
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento)
|
||||||
|
cliente = _ClienteFalso()
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, cliente)
|
||||||
|
|
||||||
|
assert row.status == STATUS_SENT
|
||||||
|
assert row.efc_document_id == "doc-1"
|
||||||
|
assert row.sent_at is not None
|
||||||
|
assert documento.efc_sync_state == "SYNCED"
|
||||||
|
assert documento.efc_document_id == "doc-1"
|
||||||
|
assert documento.efc_synced_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_subida_lleva_el_crm_document_ref_y_el_contenido_leido_de_minio(entorno):
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento)
|
||||||
|
cliente = _ClienteFalso()
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, cliente)
|
||||||
|
|
||||||
|
assert len(cliente.uploads) == 1
|
||||||
|
subida = cliente.uploads[0]
|
||||||
|
assert subida["crm_document_ref"] == documento.efc_document_ref
|
||||||
|
assert subida["content"] == CONTENIDO
|
||||||
|
assert subida["tipo"] == "MBL"
|
||||||
|
assert subida["expediente_id"] == expediente.id
|
||||||
|
|
||||||
|
|
||||||
|
# ── Ensure-then-upload ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_un_404_de_expediente_crea_el_provisional_y_reintenta_una_vez(entorno):
|
||||||
|
"""La creación del provisional y la subida son colas distintas: la subida puede llegar antes.
|
||||||
|
|
||||||
|
Sin esto, el primer documento de cada expediente fallaría y esperaría al barrido.
|
||||||
|
"""
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento)
|
||||||
|
cliente = _ClienteFalso(
|
||||||
|
upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado")
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, cliente)
|
||||||
|
|
||||||
|
assert len(cliente.ingests) == 1
|
||||||
|
assert cliente.ingests[0]["folio"] == expediente.folio
|
||||||
|
assert cliente.ingests[0]["storage_token"] == expediente.efc_storage_token
|
||||||
|
assert len(cliente.uploads) == 2 # el que falló + UNO de reintento
|
||||||
|
assert row.status == STATUS_SENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_404_con_OTRO_code_no_dispara_el_ensure(entorno):
|
||||||
|
"""El ensure se dispara por el ``code``, no por el 404 a secas.
|
||||||
|
|
||||||
|
Si se disparara por cualquier 404, un documento no encontrado crearía provisionales espurios.
|
||||||
|
"""
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento)
|
||||||
|
cliente = _ClienteFalso(
|
||||||
|
upload_falla_con=EfcClientError("otro", status_code=404, code="documento_no_encontrado"),
|
||||||
|
falla_solo_la_primera=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, cliente)
|
||||||
|
|
||||||
|
assert cliente.ingests == []
|
||||||
|
assert len(cliente.uploads) == 1
|
||||||
|
assert row.attempts == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_ensure_reintenta_UNA_vez_y_no_entra_en_bucle(entorno):
|
||||||
|
"""Si el reintento vuelve a dar 404, se registra el fallo. No se reintenta indefinidamente."""
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento)
|
||||||
|
cliente = _ClienteFalso(
|
||||||
|
upload_falla_con=EfcClientError("no está", status_code=404, code="expediente_no_encontrado"),
|
||||||
|
falla_solo_la_primera=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, cliente)
|
||||||
|
|
||||||
|
assert len(cliente.ingests) == 1
|
||||||
|
assert len(cliente.uploads) == 2
|
||||||
|
assert row.attempts == 1
|
||||||
|
assert row.status == STATUS_FAILED # un 404 no es retryable
|
||||||
|
|
||||||
|
|
||||||
|
# ── Corte directo (delete_local) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_al_confirmar_se_borra_la_copia_local(entorno):
|
||||||
|
"""«EFC es la fuente única» se cumple así: el objeto local se borra AL CONFIRMAR, no antes."""
|
||||||
|
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento, delete_local=True)
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, _ClienteFalso())
|
||||||
|
|
||||||
|
assert borrados == [S3_KEY]
|
||||||
|
# La key local se limpia: dejarla apuntaría a un objeto que ya no existe y la descarga se
|
||||||
|
# ramificaría por el camino equivocado.
|
||||||
|
assert documento.file_key is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_con_delete_local_en_false_no_se_borra_nada(entorno):
|
||||||
|
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento, delete_local=False)
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, _ClienteFalso())
|
||||||
|
|
||||||
|
assert borrados == []
|
||||||
|
assert documento.file_key == S3_KEY
|
||||||
|
assert row.status == STATUS_SENT
|
||||||
|
|
||||||
|
|
||||||
|
def test_si_el_borrado_local_falla_la_entrega_sigue_siendo_valida(entorno, monkeypatch):
|
||||||
|
"""El archivo YA está en EFC. No poder borrar la copia local no invalida la entrega, y volver a
|
||||||
|
intentarla subiría el mismo documento otra vez."""
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
import core.storage_s3 as storage
|
||||||
|
|
||||||
|
def _revienta(key):
|
||||||
|
raise RuntimeError("MinIO no responde")
|
||||||
|
|
||||||
|
monkeypatch.setattr(storage, "delete_object_if_exists", _revienta)
|
||||||
|
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento, delete_local=True)
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, _ClienteFalso())
|
||||||
|
|
||||||
|
assert row.status == STATUS_SENT
|
||||||
|
assert documento.efc_sync_state == "SYNCED"
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_borrado_local_ocurre_ANTES_de_marcar_enviada_pero_no_antes_de_subir(entorno):
|
||||||
|
"""Nunca se borra el original antes de confirmar la subida: si se borrara primero y la subida
|
||||||
|
fallara, el archivo se habría perdido."""
|
||||||
|
db, expediente, borrados = entorno["db"], entorno["expediente"], entorno["borrados"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento, delete_local=True)
|
||||||
|
cliente = _ClienteFalso(
|
||||||
|
upload_falla_con=EfcClientError("500", status_code=500, retryable=True),
|
||||||
|
falla_solo_la_primera=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, cliente)
|
||||||
|
|
||||||
|
assert borrados == [] # la subida falló: el original sigue ahí
|
||||||
|
assert row.status == STATUS_PENDING
|
||||||
|
assert documento.file_key == S3_KEY
|
||||||
|
|
||||||
|
|
||||||
|
# ── Fallos ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_un_fallo_de_efc_no_propaga_y_se_refleja_en_el_documento(entorno):
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento, attempts=MAX_ATTEMPTS - 1)
|
||||||
|
cliente = _ClienteFalso(
|
||||||
|
upload_falla_con=EfcClientError("EFC caído", status_code=503, retryable=True),
|
||||||
|
falla_solo_la_primera=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, cliente) # no lanza
|
||||||
|
|
||||||
|
assert row.status == STATUS_FAILED
|
||||||
|
assert documento.efc_sync_state == "FAILED"
|
||||||
|
assert "EFC caído" in documento.efc_error_detail
|
||||||
|
assert documento.efc_attempts == MAX_ATTEMPTS
|
||||||
|
|
||||||
|
|
||||||
|
def test_una_fila_ya_enviada_no_vuelve_a_subir_el_archivo(entorno):
|
||||||
|
"""Segunda guarda de idempotencia. Un re-despacho tras un timeout ambiguo no duplica."""
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento, status=STATUS_SENT)
|
||||||
|
cliente = _ClienteFalso()
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, cliente)
|
||||||
|
|
||||||
|
assert cliente.uploads == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_expediente_borrado_deja_la_fila_reintentable(entorno):
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento)
|
||||||
|
row.expediente_ref = 999999
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
gateway.deliver_file_row(db, row, _ClienteFalso())
|
||||||
|
|
||||||
|
assert row.status == STATUS_PENDING # retryable: el expediente puede reaparecer
|
||||||
|
assert row.attempts == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_reintento_tras_timeout_manda_el_mismo_ref_y_no_duplica(entorno):
|
||||||
|
"""El ``crm_document_ref`` es estable entre reintentos: EFC devuelve 200 con el que ya existía.
|
||||||
|
|
||||||
|
Es la tercera capa de idempotencia y la que cubre el timeout ambiguo —EFC commiteó y contestó
|
||||||
|
tarde—, donde el CRM no puede saber si el documento entró.
|
||||||
|
"""
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
documento = _documento_local(db, expediente)
|
||||||
|
row = _fila(db, expediente, documento)
|
||||||
|
|
||||||
|
primer_cliente = _ClienteFalso(
|
||||||
|
upload_falla_con=EfcClientError("timeout", retryable=True), falla_solo_la_primera=False
|
||||||
|
)
|
||||||
|
gateway.deliver_file_row(db, row, primer_cliente)
|
||||||
|
assert row.status == STATUS_PENDING
|
||||||
|
|
||||||
|
segundo_cliente = _ClienteFalso()
|
||||||
|
gateway.deliver_file_row(db, row, segundo_cliente)
|
||||||
|
|
||||||
|
assert primer_cliente.uploads[0]["crm_document_ref"] == segundo_cliente.uploads[0]["crm_document_ref"]
|
||||||
|
assert row.status == STATUS_SENT
|
||||||
386
backend/tests/test_efc_outbox.py
Normal file
386
backend/tests/test_efc_outbox.py
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
"""Pruebas de la máquina de reintentos del outbox hacia EFC.
|
||||||
|
|
||||||
|
Lo que se fija aquí es la capa 2 de las tres del carril: el worker **nunca lanza**, registra el
|
||||||
|
fallo en la propia fila, y decide reintentar o rendirse por el campo ``retryable`` —nunca parseando
|
||||||
|
el texto del error—.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from api.v1.modules.crm.expediente_gateway import service as gateway
|
||||||
|
from api.v1.modules.crm.expediente_gateway.models import (
|
||||||
|
FILE_KIND_DOCUMENTO,
|
||||||
|
KIND_EXPEDIENTE,
|
||||||
|
MAX_ATTEMPTS,
|
||||||
|
SOURCE_CRM_DOCUMENTS,
|
||||||
|
SOURCE_OPS_SHIPMENT_DOCUMENTS,
|
||||||
|
STATUS_FAILED,
|
||||||
|
STATUS_PENDING,
|
||||||
|
STATUS_SENT,
|
||||||
|
EfcFileOutbox,
|
||||||
|
EfcSyncOutbox,
|
||||||
|
)
|
||||||
|
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||||
|
from api.v1.modules.crm.service_requests import service as sr_service
|
||||||
|
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
||||||
|
from core.efc_client import EfcClientError
|
||||||
|
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||||
|
|
||||||
|
OTRO_TENANT = 99
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def efc_encendido(monkeypatch):
|
||||||
|
"""Enciende la integración y evita que el encolado toque el broker o el tenant real."""
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
||||||
|
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def efc_apagado(monkeypatch):
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "EFC_API_URL", "", raising=False)
|
||||||
|
return settings
|
||||||
|
|
||||||
|
|
||||||
|
def _expediente(db):
|
||||||
|
solicitud = sr_service.create_service_request(
|
||||||
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||||
|
)
|
||||||
|
return expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _fila_sync(db, expediente, **kwargs):
|
||||||
|
row = EfcSyncOutbox(
|
||||||
|
kind=kwargs.pop("kind", KIND_EXPEDIENTE),
|
||||||
|
payload=kwargs.pop("payload", {"folio": expediente.folio}),
|
||||||
|
expediente_ref=expediente.id,
|
||||||
|
status=kwargs.pop("status", STATUS_PENDING),
|
||||||
|
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
||||||
|
company_id=kwargs.pop("company_id", COMPANY_ID),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _fila_archivo(db, expediente, **kwargs):
|
||||||
|
row = EfcFileOutbox(
|
||||||
|
kind=kwargs.pop("kind", FILE_KIND_DOCUMENTO),
|
||||||
|
s3_key=kwargs.pop("s3_key", "tenants/1/companies/1/expedientes/1/guia.pdf"),
|
||||||
|
file_name=kwargs.pop("file_name", "guia.pdf"),
|
||||||
|
content_type="application/pdf",
|
||||||
|
efc_tipo=kwargs.pop("efc_tipo", "MBL"),
|
||||||
|
source_table=kwargs.pop("source_table", SOURCE_CRM_DOCUMENTS),
|
||||||
|
source_id=kwargs.pop("source_id", 1),
|
||||||
|
crm_document_ref=kwargs.pop("crm_document_ref", "CRMDOC-1-1"),
|
||||||
|
expediente_ref=expediente.id,
|
||||||
|
status=kwargs.pop("status", STATUS_PENDING),
|
||||||
|
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
||||||
|
company_id=kwargs.pop("company_id", COMPANY_ID),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
# ── _register_failure ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_un_fallo_retryable_suma_un_intento_y_deja_la_fila_pendiente(db, efc_encendido):
|
||||||
|
expediente = _expediente(db)
|
||||||
|
row = _fila_sync(db, expediente)
|
||||||
|
|
||||||
|
gateway._register_failure(db, row, EfcClientError("EFC no responde", retryable=True), True)
|
||||||
|
|
||||||
|
assert row.attempts == 1
|
||||||
|
assert row.status == STATUS_PENDING
|
||||||
|
assert "EFC no responde" in row.last_error
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_fallo_no_retryable_marca_failed_de_inmediato(db, efc_encendido):
|
||||||
|
"""Un 400 no mejora insistiendo: reintentarlo ocho veces solo retrasa que alguien lo vea."""
|
||||||
|
expediente = _expediente(db)
|
||||||
|
row = _fila_sync(db, expediente)
|
||||||
|
|
||||||
|
gateway._register_failure(db, row, EfcClientError("tipo inválido", retryable=False), False)
|
||||||
|
|
||||||
|
assert row.attempts == 1
|
||||||
|
assert row.status == STATUS_FAILED
|
||||||
|
|
||||||
|
|
||||||
|
def test_al_llegar_a_max_attempts_la_fila_queda_failed(db, efc_encendido):
|
||||||
|
expediente = _expediente(db)
|
||||||
|
row = _fila_sync(db, expediente, attempts=MAX_ATTEMPTS - 1)
|
||||||
|
|
||||||
|
gateway._register_failure(db, row, EfcClientError("otra vez", retryable=True), True)
|
||||||
|
|
||||||
|
assert row.attempts == MAX_ATTEMPTS
|
||||||
|
assert row.status == STATUS_FAILED
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_ultimo_error_se_trunca_a_2000_caracteres(db, efc_encendido):
|
||||||
|
"""``last_error`` es Text, pero un traceback de 5000 caracteres por fila llena la tabla de ruido."""
|
||||||
|
expediente = _expediente(db)
|
||||||
|
row = _fila_sync(db, expediente)
|
||||||
|
|
||||||
|
gateway._register_failure(db, row, Exception("x" * 5000), True)
|
||||||
|
|
||||||
|
assert len(row.last_error) == 2000
|
||||||
|
|
||||||
|
|
||||||
|
# ── deliver_row: no propaga ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class _ClienteQueRevienta:
|
||||||
|
is_configured = True
|
||||||
|
|
||||||
|
def __init__(self, exc):
|
||||||
|
self._exc = exc
|
||||||
|
self.llamadas = 0
|
||||||
|
|
||||||
|
def ingest_expediente(self, payload):
|
||||||
|
self.llamadas += 1
|
||||||
|
raise self._exc
|
||||||
|
|
||||||
|
def completar_expediente(self, folio, payload):
|
||||||
|
self.llamadas += 1
|
||||||
|
raise self._exc
|
||||||
|
|
||||||
|
|
||||||
|
def test_deliver_row_no_propaga_la_excepcion_de_efc(db, efc_encendido, monkeypatch):
|
||||||
|
"""Si esto propagara, un EFC caído mataría al worker y se perdería la cola entera."""
|
||||||
|
expediente = _expediente(db)
|
||||||
|
row = _fila_sync(db, expediente)
|
||||||
|
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
|
||||||
|
|
||||||
|
gateway.deliver_row(db, row, _ClienteQueRevienta(EfcClientError("caído", retryable=True)))
|
||||||
|
|
||||||
|
assert row.status == STATUS_PENDING
|
||||||
|
assert row.attempts == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_deliver_row_tampoco_propaga_una_excepcion_inesperada(db, efc_encendido, monkeypatch):
|
||||||
|
expediente = _expediente(db)
|
||||||
|
row = _fila_sync(db, expediente)
|
||||||
|
monkeypatch.setattr(gateway, "_resolve_org_id", lambda c, t: "org-1")
|
||||||
|
|
||||||
|
gateway.deliver_row(db, row, _ClienteQueRevienta(RuntimeError("algo raro")))
|
||||||
|
|
||||||
|
assert row.attempts == 1
|
||||||
|
# Una excepción inesperada se trata como transitoria: no se sabe que sea permanente.
|
||||||
|
assert row.status == STATUS_PENDING
|
||||||
|
|
||||||
|
|
||||||
|
def test_una_fila_ya_enviada_no_vuelve_a_llamar_a_efc(db, efc_encendido):
|
||||||
|
"""Segunda guarda de idempotencia. Sin ella, un re-despacho duplicaría el expediente en EFC."""
|
||||||
|
expediente = _expediente(db)
|
||||||
|
row = _fila_sync(db, expediente, status=STATUS_SENT)
|
||||||
|
cliente = _ClienteQueRevienta(EfcClientError("no debería llamarse"))
|
||||||
|
|
||||||
|
gateway.deliver_row(db, row, cliente)
|
||||||
|
|
||||||
|
assert cliente.llamadas == 0
|
||||||
|
assert row.status == STATUS_SENT
|
||||||
|
|
||||||
|
|
||||||
|
# ── _ya_entregado: la ambigüedad de las dos secuencias ───────────────────────
|
||||||
|
|
||||||
|
def test_no_se_encola_dos_veces_el_mismo_archivo(db, efc_encendido):
|
||||||
|
expediente = _expediente(db)
|
||||||
|
_fila_archivo(db, expediente, source_id=7, status=STATUS_SENT)
|
||||||
|
|
||||||
|
assert gateway._ya_entregado(db, SOURCE_CRM_DOCUMENTS, 7, FILE_KIND_DOCUMENTO) is True
|
||||||
|
|
||||||
|
row = gateway.enqueue_file_best_effort(
|
||||||
|
db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None,
|
||||||
|
efc_tipo="MBL", source_table=SOURCE_CRM_DOCUMENTS, source_id=7,
|
||||||
|
crm_document_ref="CRMDOC-1-7", expediente_ref=expediente.id,
|
||||||
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||||
|
)
|
||||||
|
assert row is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_mismo_id_en_otra_tabla_de_origen_SI_se_encola(db, efc_encendido):
|
||||||
|
"""La prueba de la ambigüedad de las dos secuencias.
|
||||||
|
|
||||||
|
``crm.documents.id = 7`` y ``ops.shipment_documents.id = 7`` son documentos DISTINTOS. Sin
|
||||||
|
``source_table`` en la guarda, entregar el primero haría que el segundo se saltara para
|
||||||
|
siempre — y nadie vería un error.
|
||||||
|
"""
|
||||||
|
expediente = _expediente(db)
|
||||||
|
_fila_archivo(db, expediente, source_id=7, source_table=SOURCE_CRM_DOCUMENTS, status=STATUS_SENT)
|
||||||
|
|
||||||
|
assert gateway._ya_entregado(db, SOURCE_OPS_SHIPMENT_DOCUMENTS, 7, FILE_KIND_DOCUMENTO) is False
|
||||||
|
|
||||||
|
row = gateway.enqueue_file_best_effort(
|
||||||
|
db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None,
|
||||||
|
efc_tipo="MBL", source_table=SOURCE_OPS_SHIPMENT_DOCUMENTS, source_id=7,
|
||||||
|
crm_document_ref="SHPDOC-1-7", expediente_ref=expediente.id,
|
||||||
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||||
|
)
|
||||||
|
assert row is not None
|
||||||
|
assert row.source_table == SOURCE_OPS_SHIPMENT_DOCUMENTS
|
||||||
|
|
||||||
|
|
||||||
|
# ── retry ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_retry_resetea_la_fila_y_la_re_despacha(db, efc_encendido, monkeypatch):
|
||||||
|
despachos = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
gateway, "_dispatch_file_delivery", lambda oid, t, c: despachos.append((oid, t, c))
|
||||||
|
)
|
||||||
|
expediente = _expediente(db)
|
||||||
|
row = _fila_archivo(db, expediente, status=STATUS_FAILED, attempts=MAX_ATTEMPTS,
|
||||||
|
last_error="se acabaron los intentos")
|
||||||
|
|
||||||
|
ok = gateway.retry_outbox_row(db, row.id, TENANT_ID, COMPANY_ID, "file")
|
||||||
|
|
||||||
|
assert ok is True
|
||||||
|
assert row.status == STATUS_PENDING
|
||||||
|
assert row.attempts == 0
|
||||||
|
assert row.last_error is None
|
||||||
|
assert despachos == [(row.id, TENANT_ID, COMPANY_ID)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_de_otro_tenant_devuelve_false(db, efc_encendido):
|
||||||
|
"""Devuelve False y el llamador lo traduce a 404: un 200 le haría creer al frontend que se
|
||||||
|
reencoló algo que ni siquiera es suyo."""
|
||||||
|
expediente = _expediente(db)
|
||||||
|
row = _fila_archivo(db, expediente, status=STATUS_FAILED)
|
||||||
|
|
||||||
|
assert gateway.retry_outbox_row(db, row.id, OTRO_TENANT, COMPANY_ID, "file") is False
|
||||||
|
assert row.status == STATUS_FAILED # intacta
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_de_una_fila_inexistente_devuelve_false(db, efc_encendido):
|
||||||
|
assert gateway.retry_outbox_row(db, 999999, TENANT_ID, COMPANY_ID, "file") is False
|
||||||
|
|
||||||
|
|
||||||
|
# ── métricas y listado ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_las_metricas_cuentan_por_status_sumando_las_dos_tablas(db, efc_encendido):
|
||||||
|
expediente = _expediente(db)
|
||||||
|
_fila_sync(db, expediente, status=STATUS_SENT)
|
||||||
|
_fila_archivo(db, expediente, source_id=1, status=STATUS_PENDING)
|
||||||
|
_fila_archivo(db, expediente, source_id=2, status=STATUS_FAILED)
|
||||||
|
_fila_archivo(db, expediente, source_id=3, status=STATUS_FAILED)
|
||||||
|
|
||||||
|
metricas = gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID)
|
||||||
|
|
||||||
|
# El alta del expediente encoló su propia fila pendiente al crearse la solicitud.
|
||||||
|
assert metricas["failed"] == 2
|
||||||
|
assert metricas["sent"] == 1
|
||||||
|
assert metricas["pending"] >= 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_las_metricas_no_ven_las_filas_de_otro_tenant(db, efc_encendido):
|
||||||
|
expediente = _expediente(db)
|
||||||
|
_fila_archivo(db, expediente, source_id=5, status=STATUS_FAILED, tenant_id=OTRO_TENANT)
|
||||||
|
|
||||||
|
assert gateway.outbox_metrics(db, TENANT_ID, COMPANY_ID)["failed"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_listado_marca_de_que_tabla_viene_cada_fila(db, efc_encendido):
|
||||||
|
expediente = _expediente(db)
|
||||||
|
_fila_archivo(db, expediente, source_id=1)
|
||||||
|
|
||||||
|
filas = gateway.list_outbox(db, TENANT_ID, COMPANY_ID)
|
||||||
|
tablas = {f["tabla"] for f in filas}
|
||||||
|
assert tablas == {"sync", "file"}
|
||||||
|
|
||||||
|
solo_archivos = gateway.list_outbox(db, TENANT_ID, COMPANY_ID, tipo="file")
|
||||||
|
assert {f["tabla"] for f in solo_archivos} == {"file"}
|
||||||
|
|
||||||
|
|
||||||
|
# ── huecos ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_find_expediente_gaps_encuentra_los_que_no_tienen_fila(db, efc_apagado):
|
||||||
|
"""Con EFC apagado no se encola nada, así que todos los expedientes son huecos.
|
||||||
|
|
||||||
|
Es exactamente el caso que el barrido cubre: lo creado ANTES de activar la integración.
|
||||||
|
"""
|
||||||
|
_expediente(db)
|
||||||
|
_expediente(db)
|
||||||
|
|
||||||
|
huecos = gateway.find_expediente_gaps(db)
|
||||||
|
assert len(huecos) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_un_expediente_con_fila_failed_NO_es_un_hueco(db, efc_encendido):
|
||||||
|
"""Un ``failed`` existe como fila: es visible en el tablero y reintentable a mano.
|
||||||
|
|
||||||
|
Tratarlo como hueco lo re-encolaría en cada barrido y escondería el fallo.
|
||||||
|
"""
|
||||||
|
expediente = _expediente(db)
|
||||||
|
fila = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.expediente_ref == expediente.id).first()
|
||||||
|
assert fila is not None
|
||||||
|
fila.status = STATUS_FAILED
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
assert gateway.find_expediente_gaps(db) == []
|
||||||
|
|
||||||
|
|
||||||
|
# ── best-effort ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_con_efc_apagado_no_se_encola_nada(db, efc_apagado):
|
||||||
|
"""``EFC_API_URL`` vacía apaga el carril entero. El CRM sigue funcionando igual."""
|
||||||
|
expediente = _expediente(db)
|
||||||
|
|
||||||
|
assert db.query(EfcSyncOutbox).count() == 0
|
||||||
|
|
||||||
|
fila = gateway.enqueue_file_best_effort(
|
||||||
|
db, kind=FILE_KIND_DOCUMENTO, s3_key="k", file_name="f.pdf", content_type=None,
|
||||||
|
efc_tipo="MBL", source_table=SOURCE_CRM_DOCUMENTS, source_id=1,
|
||||||
|
crm_document_ref="CRMDOC-1-1", expediente_ref=expediente.id,
|
||||||
|
tenant_id=TENANT_ID, company_id=COMPANY_ID,
|
||||||
|
)
|
||||||
|
assert fila is None
|
||||||
|
assert db.query(EfcFileOutbox).count() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_con_efc_encendido_crear_una_solicitud_encola_su_expediente(db, efc_encendido):
|
||||||
|
expediente = _expediente(db)
|
||||||
|
|
||||||
|
filas = db.query(EfcSyncOutbox).filter(EfcSyncOutbox.expediente_ref == expediente.id).all()
|
||||||
|
assert len(filas) == 1
|
||||||
|
assert filas[0].kind == KIND_EXPEDIENTE
|
||||||
|
assert filas[0].status == STATUS_PENDING
|
||||||
|
assert filas[0].payload["folio"] == expediente.folio
|
||||||
|
assert filas[0].payload["storage_token"] == expediente.efc_storage_token
|
||||||
|
|
||||||
|
|
||||||
|
def test_si_el_encolado_revienta_la_operacion_local_no_se_rompe(db, efc_encendido, monkeypatch):
|
||||||
|
"""La integración NUNCA puede tumbar el alta de una solicitud del usuario."""
|
||||||
|
def _revienta(*a, **k):
|
||||||
|
raise RuntimeError("la tabla del outbox no existe")
|
||||||
|
|
||||||
|
monkeypatch.setattr(gateway, "_expediente_ya_encolado", _revienta)
|
||||||
|
|
||||||
|
solicitud = sr_service.create_service_request(
|
||||||
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert solicitud.id is not None
|
||||||
|
assert expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID) is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_se_encola_dos_veces_el_mismo_expediente(db, efc_encendido):
|
||||||
|
"""Primera guarda: ``ensure`` es idempotente y no debe generar una segunda réplica."""
|
||||||
|
expediente = _expediente(db)
|
||||||
|
solicitud_id = expediente.service_request_id
|
||||||
|
|
||||||
|
expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1")
|
||||||
|
expedientes_service.ensure_expediente(db, solicitud_id, TENANT_ID, COMPANY_ID, "user-1")
|
||||||
|
|
||||||
|
filas = db.query(EfcSyncOutbox).filter(
|
||||||
|
EfcSyncOutbox.expediente_ref == expediente.id,
|
||||||
|
EfcSyncOutbox.kind == KIND_EXPEDIENTE,
|
||||||
|
).all()
|
||||||
|
assert len(filas) == 1
|
||||||
134
backend/tests/test_expediente_folio.py
Normal file
134
backend/tests/test_expediente_folio.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""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.
|
||||||
|
"""
|
||||||
266
backend/tests/test_expediente_upload.py
Normal file
266
backend/tests/test_expediente_upload.py
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
"""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:
|
||||||
|
expedientes_service.stream_document(
|
||||||
|
entorno["db"], entorno["expediente"].id, documento.id, TENANT_ID, COMPANY_ID
|
||||||
|
)
|
||||||
|
assert exc.value.status_code == 409
|
||||||
193
backend/tests/test_expedientes.py
Normal file
193
backend/tests/test_expedientes.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
"""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
|
||||||
134
backend/tests/test_gateway_rutas.py
Normal file
134
backend/tests/test_gateway_rutas.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""Contrato del tablero de ops del carril CRM -> EFC.
|
||||||
|
|
||||||
|
Lo que se fija aquí es lo que el frontend espera recibir: el 404 del reintento sobre una fila que no
|
||||||
|
existe (y **no** un 200 silencioso), la forma exacta de la respuesta de éxito, y el aislamiento por
|
||||||
|
tenant/company.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from api.v1.modules.crm.expediente_gateway import routes
|
||||||
|
from api.v1.modules.crm.expediente_gateway import service as gateway
|
||||||
|
from api.v1.modules.crm.expediente_gateway.models import (
|
||||||
|
FILE_KIND_DOCUMENTO,
|
||||||
|
SOURCE_CRM_DOCUMENTS,
|
||||||
|
STATUS_FAILED,
|
||||||
|
STATUS_PENDING,
|
||||||
|
STATUS_SENT,
|
||||||
|
EfcFileOutbox,
|
||||||
|
)
|
||||||
|
from api.v1.modules.crm.expedientes import service as expedientes_service
|
||||||
|
from api.v1.modules.crm.service_requests import service as sr_service
|
||||||
|
from api.v1.modules.crm.service_requests.dto import ServiceRequestCreate
|
||||||
|
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||||
|
|
||||||
|
OTRO_TENANT = 99
|
||||||
|
OTRA_COMPANY = 77
|
||||||
|
USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def entorno(db, monkeypatch):
|
||||||
|
from core.config import settings
|
||||||
|
|
||||||
|
monkeypatch.setattr(settings, "EFC_API_URL", "https://efc.example.test/", raising=False)
|
||||||
|
monkeypatch.setattr(gateway, "_dispatch_delivery", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(gateway, "_dispatch_file_delivery", lambda *a, **k: None)
|
||||||
|
monkeypatch.setattr(gateway, "_tenant_slug", lambda tid: ("temex", "TEMEX"))
|
||||||
|
|
||||||
|
solicitud = sr_service.create_service_request(
|
||||||
|
db, ServiceRequestCreate(operation_type="importacion"), TENANT_ID, COMPANY_ID, "user-1"
|
||||||
|
)
|
||||||
|
expediente = expedientes_service.find_by_service_request(db, solicitud.id, TENANT_ID, COMPANY_ID)
|
||||||
|
return {"db": db, "expediente": expediente}
|
||||||
|
|
||||||
|
|
||||||
|
def _fila_archivo(db, expediente, **kwargs) -> EfcFileOutbox:
|
||||||
|
row = EfcFileOutbox(
|
||||||
|
kind=FILE_KIND_DOCUMENTO,
|
||||||
|
s3_key="k",
|
||||||
|
file_name="guia.pdf",
|
||||||
|
content_type="application/pdf",
|
||||||
|
efc_tipo="MBL",
|
||||||
|
source_table=SOURCE_CRM_DOCUMENTS,
|
||||||
|
source_id=kwargs.pop("source_id", 1),
|
||||||
|
crm_document_ref="CRMDOC-1-1",
|
||||||
|
expediente_ref=expediente.id,
|
||||||
|
status=kwargs.pop("status", STATUS_PENDING),
|
||||||
|
tenant_id=kwargs.pop("tenant_id", TENANT_ID),
|
||||||
|
company_id=kwargs.pop("company_id", COMPANY_ID),
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_de_una_fila_inexistente_da_404_con_mensaje_especifico(entorno):
|
||||||
|
"""**Es contrato con el frontend.** Un 200 le haría pintar «reencolado» cuando no hay nada que
|
||||||
|
entregar, y el usuario esperaría un badge que nunca va a cambiar."""
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
routes.retry_outbox(999999, COMPANY_ID, "file", USUARIO, entorno["db"])
|
||||||
|
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
assert exc.value.detail == "Fila de outbox no encontrada"
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_exitoso_devuelve_requeued_con_el_id(entorno):
|
||||||
|
row = _fila_archivo(entorno["db"], entorno["expediente"], status=STATUS_FAILED)
|
||||||
|
|
||||||
|
resp = routes.retry_outbox(row.id, COMPANY_ID, "file", USUARIO, entorno["db"])
|
||||||
|
|
||||||
|
assert resp == {"status": "requeued", "id": row.id}
|
||||||
|
assert row.status == STATUS_PENDING
|
||||||
|
|
||||||
|
|
||||||
|
def test_retry_de_una_fila_de_otra_company_da_404(entorno):
|
||||||
|
"""No se filtra la existencia: para ese usuario la fila simplemente no existe."""
|
||||||
|
row = _fila_archivo(entorno["db"], entorno["expediente"], company_id=OTRA_COMPANY)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
routes.retry_outbox(row.id, COMPANY_ID, "file", USUARIO, entorno["db"])
|
||||||
|
assert exc.value.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_cuenta_por_status(entorno):
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
_fila_archivo(db, expediente, source_id=1, status=STATUS_FAILED)
|
||||||
|
_fila_archivo(db, expediente, source_id=2, status=STATUS_SENT)
|
||||||
|
|
||||||
|
metricas = routes.metrics(COMPANY_ID, USUARIO, db)
|
||||||
|
|
||||||
|
assert set(metricas) == {"pending", "sent", "failed"}
|
||||||
|
assert metricas["failed"] == 1
|
||||||
|
assert metricas["sent"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_metrics_no_ve_otro_tenant(entorno):
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
_fila_archivo(db, expediente, source_id=3, status=STATUS_FAILED, tenant_id=OTRO_TENANT)
|
||||||
|
|
||||||
|
assert routes.metrics(COMPANY_ID, USUARIO, db)["failed"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_listado_solo_devuelve_lo_del_tenant_y_la_company(entorno):
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
_fila_archivo(db, expediente, source_id=1)
|
||||||
|
_fila_archivo(db, expediente, source_id=2, tenant_id=OTRO_TENANT)
|
||||||
|
_fila_archivo(db, expediente, source_id=3, company_id=OTRA_COMPANY)
|
||||||
|
|
||||||
|
filas = routes.list_outbox(COMPANY_ID, "file", None, 100, USUARIO, db)
|
||||||
|
|
||||||
|
assert len(filas) == 1
|
||||||
|
assert filas[0]["source_id"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_el_listado_filtra_por_status(entorno):
|
||||||
|
db, expediente = entorno["db"], entorno["expediente"]
|
||||||
|
_fila_archivo(db, expediente, source_id=1, status=STATUS_FAILED)
|
||||||
|
_fila_archivo(db, expediente, source_id=2, status=STATUS_SENT)
|
||||||
|
|
||||||
|
fallidas = routes.list_outbox(COMPANY_ID, "file", STATUS_FAILED, 100, USUARIO, db)
|
||||||
|
|
||||||
|
assert [f["source_id"] for f in fallidas] == [1]
|
||||||
85
backend/tests/test_uploads_alcance.py
Normal file
85
backend/tests/test_uploads_alcance.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Alcance de ``GET /uploads/url``: qué objetos puede firmar este endpoint y cuáles no.
|
||||||
|
|
||||||
|
**Cierra una fuga real.** Antes bastaba con que la key empezara por
|
||||||
|
``tenants/{tid}/companies/{cid}/`` para firmar una URL de lectura, lo que permitía firmar
|
||||||
|
**cualquier** objeto de esa company —incluidos los certificados de la FIEL— con solo el permiso de
|
||||||
|
módulo ``crm.access``. El alcance de este endpoint es «los archivos que el CRM subió», no «todo el
|
||||||
|
almacén de la company».
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
from api.v1.modules.crm.uploads.routes import get_upload_url, validar_extension
|
||||||
|
from tests.conftest import COMPANY_ID, TENANT_ID
|
||||||
|
|
||||||
|
USUARIO = {"tenant_id": TENANT_ID, "sub": "user-1"}
|
||||||
|
PREFIJO = f"tenants/{TENANT_ID}/companies/{COMPANY_ID}/"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _sin_s3(monkeypatch):
|
||||||
|
import api.v1.modules.crm.uploads.routes as uploads
|
||||||
|
|
||||||
|
monkeypatch.setattr(uploads, "presigned_get_url", lambda key, **k: f"https://firmada/{key}")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sufijo",
|
||||||
|
[
|
||||||
|
"certificates/fiel_20260101.key", # llave privada de la FIEL
|
||||||
|
"certificates/fiel_20260101.cer",
|
||||||
|
"invoices/9/cove/cove.xml",
|
||||||
|
"imports/csv/invoice/job-1.csv",
|
||||||
|
"branding/logo.png",
|
||||||
|
"doda/1/report/doda_report.pdf",
|
||||||
|
"signatures/1/photo_x.png",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_no_se_puede_firmar_nada_fuera_de_los_documentos_del_crm(sufijo):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"sufijo",
|
||||||
|
[
|
||||||
|
"crm-docs/abc123/contrato.pdf",
|
||||||
|
"expedientes/1/documents/abc123_guia.pdf",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_los_documentos_del_crm_si_se_pueden_firmar(sufijo):
|
||||||
|
resp = get_upload_url(PREFIJO + sufijo, COMPANY_ID, USUARIO)
|
||||||
|
assert resp["url"].endswith(sufijo)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_se_puede_firmar_nada_de_otro_tenant_ni_de_otra_company():
|
||||||
|
"""El aislamiento previo sigue en pie: es una guarda adicional, no un reemplazo."""
|
||||||
|
for key in (
|
||||||
|
"tenants/999/companies/1/crm-docs/a/b.pdf",
|
||||||
|
f"tenants/{TENANT_ID}/companies/999/crm-docs/a/b.pdf",
|
||||||
|
):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
get_upload_url(key, COMPANY_ID, USUARIO)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_una_key_que_solo_CONTIENE_el_prefijo_no_pasa():
|
||||||
|
"""La comprobación es de prefijo, no de subcadena: ``startswith`` y no ``in``."""
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
get_upload_url(f"otro/{PREFIJO}crm-docs/a/b.pdf", COMPANY_ID, USUARIO)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_allowlist_de_extensiones_rechaza_lo_ejecutable():
|
||||||
|
for nombre in ("virus.exe", "script.sh", "macro.bat", "lib.dll", "sin_extension"):
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
validar_extension(nombre)
|
||||||
|
assert exc.value.status_code == 422
|
||||||
|
assert exc.value.detail == "Ese tipo de archivo no está permitido."
|
||||||
|
|
||||||
|
|
||||||
|
def test_la_allowlist_acepta_los_formatos_de_documento():
|
||||||
|
for nombre in ("guia.pdf", "factura.XML", "foto.JPG", "hoja.xlsx", "carta.docx", "paquete.zip"):
|
||||||
|
validar_extension(nombre) # no lanza
|
||||||
@@ -770,6 +770,19 @@ async function fetchBlob(
|
|||||||
export const api = {
|
export const api = {
|
||||||
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
get: <T = any>(endpoint: string) => fetchApi<T>(endpoint, { method: 'GET' }),
|
||||||
getBlob: (endpoint: string) => fetchBlob(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) =>
|
postBlob: (endpoint: string, body: any) =>
|
||||||
fetchBlob(endpoint, {
|
fetchBlob(endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -168,6 +168,13 @@ export interface Document {
|
|||||||
content_type: string | null;
|
content_type: string | null;
|
||||||
size_bytes: number | null;
|
size_bytes: number | null;
|
||||||
uploaded_by: string | 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;
|
tenant_id: number;
|
||||||
company_id: number;
|
company_id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
77
frontend/src/lib/api/expedientes.doctypes.test.ts
Normal file
77
frontend/src/lib/api/expedientes.doctypes.test.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
248
frontend/src/lib/api/expedientes.ts
Normal file
248
frontend/src/lib/api/expedientes.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
@@ -80,6 +80,13 @@ export interface ShipmentDocument {
|
|||||||
file_url: string | null;
|
file_url: string | null;
|
||||||
file_key: string | null;
|
file_key: string | null;
|
||||||
notes: 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;
|
tenant_id: number;
|
||||||
company_id: number;
|
company_id: number;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
|
|||||||
@@ -10,6 +10,14 @@
|
|||||||
} from '$lib/api/crm';
|
} from '$lib/api/crm';
|
||||||
import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format';
|
import { ADDRESS_TYPES, DOC_TYPES, CONTACT_AREAS, labelOf } from '$lib/components/crm/format';
|
||||||
import { uploadFile, uploadUrl } from '$lib/api/uploads';
|
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';
|
import { toast } from 'svelte-sonner';
|
||||||
|
|
||||||
// Dueño de los registros relacionados y qué sección mostrar
|
// Dueño de los registros relacionados y qué sección mostrar
|
||||||
@@ -52,14 +60,61 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
async function openDoc(d: Document) {
|
||||||
if (!companyId) return;
|
if (!companyId) return;
|
||||||
try {
|
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;
|
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
|
||||||
if (url) window.open(url, '_blank', 'noopener');
|
if (url) window.open(url, '_blank', 'noopener');
|
||||||
else toast.error('El documento no tiene archivo');
|
else toast.error('El documento no tiene archivo');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,13 +283,24 @@
|
|||||||
<p class="text-sm text-muted-foreground">Sin documentos.</p>
|
<p class="text-sm text-muted-foreground">Sin documentos.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Root>
|
<Table.Root>
|
||||||
<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.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.Body>
|
<Table.Body>
|
||||||
{#each documents as d (d.id)}
|
{#each documents as d (d.id)}
|
||||||
|
{@const estado = estadoEfc(d)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
|
<Table.Cell>{labelOf(DOC_TYPES, d.doc_type)}</Table.Cell>
|
||||||
<Table.Cell class="font-medium">{d.name}</Table.Cell>
|
<Table.Cell class="font-medium">{d.name}</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>{#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 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.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>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
@@ -294,7 +360,9 @@
|
|||||||
<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>
|
<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} />
|
<input type="file" class={inputCls} onchange={onFilePicked} />
|
||||||
</label>
|
</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>
|
<!-- 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>
|
||||||
<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>
|
<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>
|
</form>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
265
frontend/src/lib/components/ops/ExpedienteDocuments.svelte
Normal file
265
frontend/src/lib/components/ops/ExpedienteDocuments.svelte
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
<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}
|
||||||
@@ -13,6 +13,14 @@
|
|||||||
} from '$lib/api/ops';
|
} from '$lib/api/ops';
|
||||||
import { invoicesAPI } from '$lib/api/fin';
|
import { invoicesAPI } from '$lib/api/fin';
|
||||||
import { uploadFile, uploadUrl } from '$lib/api/uploads';
|
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 {
|
import {
|
||||||
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS,
|
OPERATION_TYPES, TRANSPORT_MODES, SERVICE_TYPES, SHIPMENT_STATUS,
|
||||||
DOC_KINDS, SHIPMENT_DOC_TYPES, EVENT_STATUS, labelOf, formatDate
|
DOC_KINDS, SHIPMENT_DOC_TYPES, EVENT_STATUS, labelOf, formatDate
|
||||||
@@ -172,14 +180,53 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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) {
|
async function openDoc(d: ShipmentDocument) {
|
||||||
if (!companyId) return;
|
if (!companyId) return;
|
||||||
try {
|
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;
|
const url = d.file_key ? await uploadUrl(d.file_key, companyId) : d.file_url;
|
||||||
if (url) window.open(url, '_blank', 'noopener');
|
if (url) window.open(url, '_blank', 'noopener');
|
||||||
else toast.error('El documento no tiene archivo');
|
else toast.error('El documento no tiene archivo');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : 'No se pudo abrir el archivo');
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -330,15 +377,26 @@
|
|||||||
<p class="text-sm text-muted-foreground">Sin documentos.</p>
|
<p class="text-sm text-muted-foreground">Sin documentos.</p>
|
||||||
{:else}
|
{:else}
|
||||||
<Table.Root>
|
<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></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>Expediente</Table.Head><Table.Head></Table.Head></Table.Row></Table.Header>
|
||||||
<Table.Body>
|
<Table.Body>
|
||||||
{#each docs as d (d.id)}
|
{#each docs as d (d.id)}
|
||||||
|
{@const estado = estadoEfc(d)}
|
||||||
<Table.Row>
|
<Table.Row>
|
||||||
<Table.Cell>{labelOf(DOC_KINDS, d.doc_kind)}</Table.Cell>
|
<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-medium">{labelOf(SHIPMENT_DOC_TYPES, d.doc_type)}</Table.Cell>
|
||||||
<Table.Cell class="font-mono text-xs">{d.number ?? '—'}</Table.Cell>
|
<Table.Cell class="font-mono text-xs">{d.number ?? '—'}</Table.Cell>
|
||||||
<Table.Cell>{formatDate(d.issue_date)}</Table.Cell>
|
<Table.Cell>{formatDate(d.issue_date)}</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>{#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 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.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>
|
</Table.Row>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
Reference in New Issue
Block a user