El CRM genera documentos que hoy viven sueltos en su MinIO: sin nada que los
agrupe y sin catalogo validado en backend. EFC es el sistema de expedientes de
la casa y debe ser la fuente unica, pero el CRM no tiene ni un dato de pedimento
-verificado: ningun campo de aduana, patente, numero ni anio en crm/ ni en ops/-
y en EFC el expediente ES el pedimento. Esta fase pone del lado del CRM lo que
falta para poder hablar de un expediente antes de que exista data aduanera.
crm.expedientes cuelga de la solicitud de servicio, que es el hilo comercial que
el repo ya tiene de la RFQ a la factura. Nace con folio EXP{YYYY}-{MM}-{NNN} en
la misma transaccion del alta: un flush y no un commit, porque si el alta fallara
despues quedaria un folio quemado colgando de una solicitud inexistente.
El folio se reserva con un unico INSERT ... ON CONFLICT DO UPDATE ... RETURNING
sobre crm.expediente_folio_counters. Un SELECT max(sequence)+1 es justo la
carrera que hay que evitar: dos altas simultaneas leerian el mismo maximo y
entregarian el mismo folio a dos expedientes distintos. El asignador no
commitea, igual que el de folios de Anexo22, para que un rollback posterior
pueda soltar el folio; deja hueco, que es preferible a un duplicado. Los dos
UNIQUE de la tabla son la red por si el contador se corrompe: revienta ruidoso
en vez de mezclar dos hilos documentales.
efc_storage_token se fija al nacer y es inmutable: es la carpeta de MinIO en
EFC. Lleva el company_id dentro porque el puente con EFC es tenant -> organizacion
1:1 pero un tenant tiene N companies, asi que sin el, dos companies generando
EXP2026-08-001 chocarian en el unique_together de EFC y a una le devolverian el
provisional de la otra.
EfcDocumentRefMixin va en las dos tablas de documentos -crm.documents y
ops.shipment_documents- porque las dos alimentan el mismo expediente; el handle
autoritativo es efc_document_ref, que el CRM construye, y efc_document_id es
solo cache de la resolucion. El ref es texto y no entero porque las dos tablas
tienen secuencias independientes: crm.documents.id = 5 y
ops.shipment_documents.id = 5 coexisten.
La migracion se GENERA y se deja SIN aplicar.
Verificacion: pytest tests/ EXIT=0, 90 passed 1 skipped (baseline 70 passed).
Los tests nuevos se vieron en rojo a proposito antes de darlos por buenos:
quitando el enganche del expediente reventaron nombrando el expediente ausente,
quitando el UNIQUE del folio el test dijo DID NOT RAISE IntegrityError, y
dejando el contador sin incrementar los tres consecutivos salieron 001/001/001.
Ticket: T2026-08-046 (fase 5 de 7)
84 lines
3.0 KiB
Python
84 lines
3.0 KiB
Python
"""Fixtures de pruebas del módulo CRM.
|
|
|
|
Las pruebas de servicios corren contra SQLite en memoria usando
|
|
``schema_translate_map`` para mapear los schemas ``crm``/``core`` al schema
|
|
principal de SQLite. Esto permite ejercitar la lógica de negocio sin depender
|
|
de PostgreSQL/Docker en el entorno de desarrollo local.
|
|
|
|
Nota: la validación estructural del esquema (FKs cross-schema, DDL) se hace
|
|
con la migración Alembic contra PostgreSQL en CI (``TEST_DATABASE_URL``).
|
|
"""
|
|
|
|
import os
|
|
import sys
|
|
from datetime import datetime
|
|
|
|
import pytest
|
|
from sqlalchemy import Column, Integer, String, Table, create_engine, event
|
|
from sqlalchemy.orm import sessionmaker
|
|
from sqlalchemy.pool import StaticPool
|
|
|
|
BACKEND_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|
if BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, BACKEND_DIR)
|
|
|
|
from core.database import Base # noqa: E402
|
|
|
|
# Importar los modelos registra sus tablas en Base.metadata
|
|
import api.v1.modules.crm.accounts.models # noqa: E402,F401
|
|
import api.v1.modules.crm.activities.models # noqa: E402,F401
|
|
import api.v1.modules.crm.addresses.models # noqa: E402,F401
|
|
import api.v1.modules.crm.contacts.models # noqa: E402,F401
|
|
import api.v1.modules.crm.documents.models # noqa: E402,F401
|
|
import api.v1.modules.crm.expedientes.models # noqa: E402,F401
|
|
import api.v1.modules.crm.leads.models # noqa: E402,F401
|
|
import api.v1.modules.crm.opportunities.models # noqa: E402,F401
|
|
import api.v1.modules.crm.pipelines.models # noqa: E402,F401
|
|
import api.v1.modules.crm.quotes.models # noqa: E402,F401
|
|
import api.v1.modules.crm.service_requests.models # noqa: E402,F401
|
|
import api.v1.modules.crm.suppliers.models # noqa: E402,F401
|
|
import api.v1.modules.ops.shipments.models # noqa: E402,F401
|
|
import api.v1.modules.fin.invoices.models # noqa: E402,F401
|
|
|
|
_SCHEMA_MAP = {"crm": None, "core": None, "ops": None, "fin": None}
|
|
|
|
# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm.
|
|
# En CI (PostgreSQL) la tabla real la crea la migración inicial del core.
|
|
if "core.tenants" not in Base.metadata.tables:
|
|
Table(
|
|
"tenants",
|
|
Base.metadata,
|
|
Column("id", Integer, primary_key=True),
|
|
Column("name", String(255)),
|
|
schema="core",
|
|
)
|
|
|
|
TENANT_ID = 1
|
|
COMPANY_ID = 1
|
|
|
|
|
|
@pytest.fixture()
|
|
def db():
|
|
engine = create_engine(
|
|
"sqlite://",
|
|
connect_args={"check_same_thread": False},
|
|
poolclass=StaticPool,
|
|
future=True,
|
|
).execution_options(schema_translate_map=_SCHEMA_MAP)
|
|
|
|
@event.listens_for(engine, "connect")
|
|
def _register_now(dbapi_conn, _record):
|
|
# Soporta server_default text("now()") de los mixins de timestamp
|
|
dbapi_conn.create_function(
|
|
"now", 0, lambda: datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S.%f")
|
|
)
|
|
|
|
Base.metadata.create_all(engine)
|
|
session_factory = sessionmaker(bind=engine, future=True)
|
|
session = session_factory()
|
|
try:
|
|
yield session
|
|
finally:
|
|
session.close()
|
|
engine.dispose()
|