Files
CRM_AGENTES_CARGA/backend/tests/test_expediente_folio.py
marcos 39347f9c97 feat(crm): expediente con folio propio y espejo del documento en EFC
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)
2026-08-07 17:50:11 -06:00

135 lines
5.4 KiB
Python

"""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.
"""