Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/expedientes/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

96 lines
4.3 KiB
Python

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