Files
CRM_AGENTES_CARGA/backend/tests/test_expedientes.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

194 lines
7.4 KiB
Python

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