Rebase del lado emisor de T2026-08-046 sobre esta rama. La entrega anterior partia
de feature/crm-cumplimiento-pdf (16-jul), 40 commits atras, y por eso construyo un
expediente PARALELO -- crm.expedientes con su propio generador de folio y su propia
migracion -- que duplicaba el que ya existe aqui. Dos expedientes y dos secuencias
peleando por el mismo namespace EXP no se fusionan; se tira el nuestro.
La estructura del expediente es de esta rama y no se toca: crm.cases es el
expediente, su folio vive en `reference` y el consecutivo lo reserva
crm/common/folios.py con bloqueo de fila. Nuestro aporte es SOLO la conexion:
- crm.cases gana seis columnas efc_* (espejo de EFC, nunca el handle) y nada mas;
- crm.efc_sync_outbox y crm.efc_file_outbox, el outbox transaccional, con
expediente_ref -> crm.cases.id;
- core/efc_client.py y crm/expediente_gateway/ (outbox, reintentos, barridos),
clonados del gateway Anexo22 -> EFC que ya corre en produccion;
- las ocho variables EFC_* en config. EFC_API_URL vacia = carril apagado.
Verificado contra la base real: next_folio(...,'EXP',None,with_direction=False)
devuelve EXP2026-08-001, identico al formato que el contrato con EFC exige, y
storage_token da CRM-{company}-{folio} de 22 caracteres sobre los 25 de
pedimento_app.
Se corrige un error del docstring de storage_token: decia que cabian companies de
7 digitos y son 6 (4+7+1+14 = 26 > 25). Ahora valida y falla ruidosamente en vez de
entregar un token recortado, que apuntaria a la carpeta de otro expediente y
mezclaria documentos en silencio.
El revision id de la migracion tirada (e6f7a8b9c0d1) chocaba con crm_catalog_items
de esta rama: dos migraciones distintas con el mismo id habrian roto alembic al
fusionar. La nueva es c5d6e7f8a9b0, aditiva sobre d4e5f6a7b8c9.
PENDIENTE: falta el pegamento que invocaba el carril desde los flujos de la app
(alta del provisional al mintear el folio, subida de documento -> outbox, rutas en
el router y UI). Por eso test_efc_outbox, test_gateway_rutas y tres casos de
test_contrato_efc todavia no colectan. El carril no esta cableado al router, asi
que la app funciona igual: backend y frontend responden 200.
Ref: T2026-08-046
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
53 lines
3.2 KiB
Python
53 lines
3.2 KiB
Python
from sqlalchemy import ForeignKey, 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
|
|
|
|
|
|
class Case(Base, TenantScopedMixin, TimestampMixin):
|
|
"""Expediente: hilo maestro de un trámite (Oportunidad → Solicitud → Cotización →
|
|
Operación → Factura). Una sola referencia (``EXP…``) que agrupa toda la historia.
|
|
Nace al crear la Oportunidad y se hereda a las entidades siguientes vía ``case_id``.
|
|
"""
|
|
|
|
__tablename__ = "cases"
|
|
__table_args__ = {"schema": "crm"}
|
|
|
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
|
reference: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True) # folio EXP...
|
|
account_id: Mapped[int | None] = mapped_column(
|
|
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
|
)
|
|
title: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
# Etapa más avanzada alcanzada: oportunidad|solicitud|cotizacion|operacion|facturacion|cerrado
|
|
stage: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'oportunidad'"))
|
|
# abierto | cerrado
|
|
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'abierto'"), index=True)
|
|
created_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
updated_by: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
|
|
|
# ── Espejo del carril hacia EFC (T2026-08-046) ──────────────────────────────────────────
|
|
# EFC es la fuente única de los documentos del expediente: cada expediente se refleja allá
|
|
# como un *pedimento provisional* y los archivos viven en su MinIO, no en el del CRM.
|
|
#
|
|
# Estas columnas son un ESPEJO, nunca el handle. El handle con el que el CRM habla de este
|
|
# expediente es su ``id`` y su ``reference``: ``efc_pedimento_id`` es un caché de la
|
|
# resolución, y del lado de EFC el ``pedimento_app`` es mutable —se reescribe al completar
|
|
# el provisional con la data aduanera real—, así que apoyarse en él rompería justo cuando
|
|
# llegue esa data. La liga vive en EFC, en la tabla desechable ``pedimento_expediente``.
|
|
efc_organizacion_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
|
efc_pedimento_id: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
|
# INMUTABLE una vez asignado: es la carpeta de MinIO donde EFC guarda los objetos de este
|
|
# expediente. Que no cambie nunca es lo que permite completar el pedimento sin mover ni un
|
|
# archivo.
|
|
efc_storage_token: Mapped[str | None] = mapped_column(String(25), nullable=True)
|
|
# PENDING | LINKED | FAILED
|
|
efc_link_state: Mapped[str] = mapped_column(
|
|
String(20), nullable=False, server_default=text("'PENDING'"), index=True
|
|
)
|
|
# El diagnóstico se guarda en la fila para que se vea en la ficha del expediente, sin
|
|
# obligar a nadie a ir a los logs del worker.
|
|
efc_error_code: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
|
efc_error_detail: Mapped[str | None] = mapped_column(Text, nullable=True)
|