fix: filtrado de partidas por nomenclatura de documento (core/partida_docs)

Frontera (_|.|$) tras vu_PT_{app}_{numero} para cubrir los 3 formatos sin
confundir partida 1 con 11/100. Fuente unica en core/partida_docs.py, reusada
por get_documentos, handlers de borrado/descarga y fix_partidas_error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-24 07:31:07 -06:00
parent d732602775
commit 244bbcb21c
6 changed files with 316 additions and 43 deletions

65
core/partida_docs.py Normal file
View File

@@ -0,0 +1,65 @@
"""
Fuente única de la convención de nombres documento → partida.
Las tablas `document` y `partida` no tienen FK entre sí; solo comparten
`pedimento_id`. La pertenencia de un documento a una partida se infiere del
nombre del archivo, que tiene tres formatos para los documentos de partida
(`document_type_id = 1`):
1. vu_PT_{pedimento_app}_{numero} (sin extensión — formato original)
2. vu_PT_{pedimento_app}_{numero}.xml
3. vu_PT_{pedimento_app}_{numero}_{hex}.xml (sufijo de unicidad del storage)
REQUEST / ERROR son vu_PT_{app}_{numero}_REQUEST.xml / _ERROR.xml (tipos 17/18).
Formato legacy: vu_PT_..._{numero}.xml (el número de partida queda al final).
El matching debe usar una FRONTERA real tras el número (`_`, `.` o fin de
cadena) para cubrir los tres formatos sin confundir la partida 1 con la 11/100.
Aquí viven las dos implementaciones equivalentes de esa regla:
- patron_regex_partida : para filtros en BD (Document.archivo__iregex).
- es_doc_de_partida : para asignación en memoria (basename).
"""
import posixpath
import re
def patron_regex_partida(pedimento_app, numero_partida, incluir_legacy=False):
"""Construye el patrón para `Document.objects.filter(archivo__iregex=...)`.
PostgreSQL aplica el patrón con `~*` (no anclado), por lo que el prefijo de
ruta `documents/` es irrelevante. La frontera `(_|\\.|$)` tras el número
cubre los tres formatos y rechaza la colisión de prefijo (la partida 1 no
matchea con 11/100 porque tras el `1` vendría un dígito, no la frontera).
El filtro `document_type_id` se decide en el call site: el patrón también
matchea `_REQUEST`/`_ERROR`, así que quien quiera excluirlos debe filtrar
por tipo. `pedimento_app` se escapa siempre (es un CharField sin validación).
incluir_legacy: agrega el formato viejo con el número al final. Es un match
difuso (`.+`), úsalo solo en lectura, nunca en operaciones destructivas.
"""
app = re.escape(str(pedimento_app).strip())
num = re.escape(str(numero_partida).strip())
patron = rf"vu_PT_{app}_{num}(_|\.|$)"
if incluir_legacy:
patron = rf"({patron}|vu_PT_.+_{num}\.xml$)"
return patron
def es_doc_de_partida(nombre_archivo, pedimento_app, numero_partida, incluir_legacy=True):
"""Indica si `nombre_archivo` pertenece a la partida, evaluado en memoria.
Equivalente a `patron_regex_partida` pero sobre el basename en minúsculas.
Fuente única para la lectura (PartidaSerializer) y para el comando
fix_partidas_error. La frontera exige que tras `vu_pt_{app}_{numero}` venga
`_`, `.` o el fin de la cadena (formato #1 sin extensión).
"""
base = posixpath.basename(nombre_archivo or "").lower()
prefijo = f"vu_pt_{pedimento_app}_{numero_partida}".lower()
if base.startswith(prefijo) and (len(base) == len(prefijo) or base[len(prefijo)] in "_."):
return True
if incluir_legacy:
return re.match(rf"^vu_pt_.+_{re.escape(str(numero_partida))}\.xml$", base) is not None
return False