Compare commits

...

6 Commits

Author SHA1 Message Date
a13c1460c0 docs(changelog): rastreo del stack legacy de descarga (T2025-09-004)
Documenta que el signal procesamiento.py dispara el stack viejo (api_v1 ->
peticiones.py) en paralelo al auto-proceso v2, causando docs sin ligar y
duplicados. Fix del modulo viejo en microservice. Sin cambio de codigo backend.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 15:27:46 -06:00
dcabfb8762 feat: backfill_document_links_legacy para docs legados de cove/edoc por numero (T2025-09-004)
Algunos documentos viejos quedaron con nomenclatura olvidada (otro pedimento_app
y/o prefijo, p.ej. vu_EDC_0201_800_..._04382515ZIFF5) que el matcher estricto no
liga. Como el numero_cove/numero_edocument es unico y esta en el nombre, este
comando los liga por ese numero (con frontera), sin exigir app ni prefijo.

Solo cove/edoc (llaves unicas); partida queda fuera (enteros cortos -> colisiones)
y nativos no tienen entidad. Correr despues de backfill_document_links.

Agrega core.document_links.numero_en_nombre. Fix: el DISTINCT de pedimento_id
limpia el ordering por defecto del modelo para no duplicar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:26:06 -06:00
b805c791dc feat: comando dedup_documents para duplicados legados (T2025-09-004)
Limpia documentos duplicados (misma sub-entidad + mismo document_type) creados
ANTES del fix de reemplazo del microservicio (jun-2026). Conserva el mas reciente
con archivo valido en storage, borra el resto (archivo MinIO si no lo referencia
otra fila + fila + ajuste de cuota). --dry-run, conteo previo, idempotente; solo
docs ligados a entidad (partida/cove/edocument).

La creacion ya reemplaza desde jun-2026: verificado 0 duplicados posteriores al fix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 12:38:30 -06:00
2e7d78fd8b feat: FK polimorfica Document -> {partida, cove, edocument} + backfill (T2025-09-004)
Reemplaza el matching fragil por nombre de archivo con FK reales:
- 3 FK nullables (CASCADE) en Document; resolucion central en save() por
  document_type + nombre (core.document_links), cubre toda ruta de creacion
  incluida la ingesta del microservicio; set explicito en create_vu_record.
- Comando backfill_document_links (idempotente, dry-run) para filas existentes.
- Lectura/descarga/borrado SIEMPRE por la FK (id); el nombre solo ESTABLECE la
  FK en save()/backfill. Prefetch con select_related(pedimento, fuente) sin N+1.
- Migraciones: 0004 (campos), 0005 (indices CONCURRENTLY IF NOT EXISTS, idempotente
  via SeparateDatabaseAndState), 0006 (ANALYZE document para estadisticas del planner).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 11:16:43 -06:00
244bbcb21c 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>
2026-06-24 07:31:40 -06:00
d732602775 Merge pull request 'fix/de los tickets T2026-05-027, T2025-09-004 y T2025-09-056' (#35) from fix/T2026-05-027-and-T2025-09-004-and-T2025-09-056 into main
Reviewed-on: #35
2026-06-19 13:17:24 +00:00
15 changed files with 1146 additions and 144 deletions

57
CHANGELOG.md Normal file
View File

@@ -0,0 +1,57 @@
# Changelog — EFC Backend
Historial de cambios por ticket (más reciente arriba). Cada entrada: fecha, ticket,
tipo, repos afectados, qué se hizo y por qué. Reglas del flujo en `../CLAUDE.md`.
## T2025-09-004 — Pertenencia documento→entidad (matching de partidas + FK polimórfica)
- **Fecha:** 2026-06-24
- **Tipo:** feature
- **Repos:** backend + microservice (el fix del stack legacy de descarga está en
`microservice/CHANGELOG.md`; frontend sin cambios)
- **Branch:** `feature/T2025-09-004` · **PR:** (pendiente de aceptación manual)
- **Qué se hizo:**
- Matching documento→partida con frontera `(_|.|$)` en `core/partida_docs.py` (cubre los
3 formatos de nombre sin confundir la partida 1 con 11/100).
- FK reales `document.partida` / `cove` / `edocument` (nullable, `CASCADE`); los acuses
cuelgan de su cove/edoc padre; los documentos nativos (PC, remesa, subidas generales)
quedan sin FK.
- Resolución central en `Document.save()` vía `core/document_links.py`: liga la FK por
`document_type` + nombre de archivo en toda ruta de creación (incluida la ingesta del
microservicio); set explícito de la FK en `create_vu_record`.
- Comando `backfill_document_links` para poblar la FK en filas existentes (idempotente).
- Comando `backfill_document_links_legacy` para documentos LEGADOS de cove/edoc con
nomenclatura vieja (otro `pedimento_app`/prefijo, p.ej. `vu_EDC_0201_800_..._{numero}`
en un pedimento `25-80-...`): liga por el `numero_cove`/`numero_edocument` único
presente en el nombre, sin exigir app ni prefijo. Solo cove/edoc (llaves únicas);
partida y nativos quedan fuera. Correr DESPUÉS del backfill estricto.
- Comando `dedup_documents` para limpiar documentos duplicados legados (misma entidad +
mismo tipo): conserva el más reciente con archivo válido en storage, borra el resto
(archivo MinIO si no lo referencia otra fila + fila + ajuste de cuota), `--dry-run`,
conteo previo, idempotente. Los duplicados eran **pre-fix**: la descarga ya reemplaza
en vez de re-crear desde jun-2026 (microservicio, `post_or_update_document`), verificado
con 0 duplicados creados después del fix. Solo aplica a docs ligados a entidad.
- Lectura, descarga y borrado SIEMPRE por la FK (id), nunca por nombre. El nombre solo
ESTABLECE la FK (en `Document.save()` para altas y en el backfill para filas viejas).
- **Rastreo del stack legacy (docs sin ligar/duplicados con datos de hoy):** el signal
`api/customs/signals/procesamiento.py` (post_save de Pedimento/Cove/EDocument, p.ej. al
cargar un datastage) dispara el stack VIEJO de descarga (`tasks/microservice.py` → api_v1
`utils/peticiones.py` del microservicio), que armaba el nombre con campos crudos (no
`pedimento_app`, prefijo `vu_EDC`) y posteaba sin reemplazar → docs sin FK + partidas
duplicadas. El auto-proceso (Celery beat `microservice_v2.process_all_organizations`) ya usa
el stack v2 correcto. Fix del módulo viejo en `microservice/CHANGELOG.md`. El signal/api_v1
se dejan activos (decisión: arreglar `peticiones.py`, no retirarlos); unificar a un solo
stack queda como follow-up.
- **Por qué:** retirar el matching frágil por nombre de archivo (`icontains`/prefijo, que
confundía entidades y se rompía con formatos nuevos) y tener la pertenencia
documento→entidad como dato real, consultable e íntegro.
- **Migraciones:** `0004_document_subentidad_fk` (campos, metadata-only),
`0005_document_subentidad_idx` (índices con `CREATE INDEX CONCURRENTLY IF NOT EXISTS`,
`atomic=False`, idempotente vía `SeparateDatabaseAndState`),
`0006_analyze_document` (`ANALYZE document`: refresca estadísticas del planner — sin esto,
el prefetch hacía seq scan sobre ~5M filas y los endpoints tardaban ~9s).
La tabla `document` tiene ~5M filas: cada índice tarda minutos y NO debe interrumpirse.
Recuperación si se corta: índices válidos → `migrate --fake record 0005`; alguno INVALID →
`DROP INDEX IF EXISTS "<nombre>";` y reintentar `migrate record`.
- **Despliegue (orden obligatorio):** aplicar migraciones (0004-0006) → **correr el backfill
completo** → recién entonces la lectura/descarga/borrado por FK es correcta. Como NO hay
fallback por nombre, un documento sin backfillear queda invisible hasta ligar su FK.

View File

@@ -0,0 +1,94 @@
"""
Backfill de la FK de sub-entidad en documentos existentes (T2025-09-004).
Liga cada Document a su Partida / Cove / EDocument por nombre de archivo, usando el
mismo resolver que `Document.save()` (core.document_links). Solo toca documentos aún
no ligados (idempotente: re-ejecutar converge). Los documentos nativos del pedimento
(PC, remesa, subidas generales) y los que no matchean ninguna entidad se dejan sin FK.
Uso:
python manage.py backfill_document_links [--pedimento UUID] [--organizacion UUID]
[--offset N] [--limit N] [--dry-run]
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from api.customs.models import Pedimento
from api.record.models import Document
from core.document_links import SECCION_CAMPO, match_entidad, seccion_de_tipo
# related_name del FK a Pedimento en cada entidad (EDocument usa 'documentos').
_RELACION = {'partida': 'partidas', 'cove': 'coves', 'edocument': 'documentos'}
class Command(BaseCommand):
help = "Liga documentos existentes a su sub-entidad (partida/cove/edocument) por nombre de archivo."
def add_arguments(self, parser):
parser.add_argument('--pedimento', type=str, default=None, help='UUID de un solo pedimento')
parser.add_argument('--organizacion', type=str, default=None, help='UUID de organización')
parser.add_argument('--offset', type=int, default=0)
parser.add_argument('--limit', type=int, default=None)
parser.add_argument('--dry-run', action='store_true', help='Reporta sin escribir')
def handle(self, *args, **opts):
dry_run = opts['dry_run']
peds = Pedimento.objects.all().order_by('created_at', 'id')
if opts['pedimento']:
peds = peds.filter(id=opts['pedimento'])
if opts['organizacion']:
peds = peds.filter(organizacion_id=opts['organizacion'])
offset = opts['offset'] or 0
peds = peds[offset:offset + opts['limit']] if opts['limit'] else peds[offset:]
if dry_run:
self.stdout.write(self.style.WARNING("== DRY-RUN: no se escribe nada =="))
stats = {'pedimentos': 0, 'partida': 0, 'cove': 0, 'edocument': 0, 'sin_match': 0}
for ped in peds.iterator():
self._procesar_pedimento(ped, dry_run, stats)
self.stdout.write("")
self.stdout.write(self.style.SUCCESS(
f"Pedimentos: {stats['pedimentos']} | ligados → partida={stats['partida']} "
f"cove={stats['cove']} edocument={stats['edocument']} | "
f"tipados sin entidad que matchee={stats['sin_match']}"
))
def _procesar_pedimento(self, ped, dry_run, stats):
# Solo documentos aún NO ligados (idempotente).
docs = list(Document.objects.filter(
pedimento=ped, partida__isnull=True, cove__isnull=True, edocument__isnull=True,
))
if not docs:
return
stats['pedimentos'] += 1
# Precargar las entidades del pedimento una sola vez; el match es en memoria.
entidades = {sec: list(getattr(ped, rel).all()) for sec, rel in _RELACION.items()}
app = ped.pedimento_app
lote = []
for doc in docs:
seccion = seccion_de_tipo(doc.document_type_id)
if not seccion or not doc.archivo:
continue # nativo de pedimento o sin archivo
inst = match_entidad(doc.archivo.name, seccion, app, entidades[seccion])
if inst is None:
stats['sin_match'] += 1
continue
setattr(doc, SECCION_CAMPO[seccion], inst)
lote.append(doc)
stats[seccion] += 1
# SELECT + COUNT previo antes de escribir (estándar de la organización).
self.stdout.write(
f" {ped.pedimento_app}: {len(lote)} de {len(docs)} doc(s) sin ligar serán ligados"
)
if lote and not dry_run:
with transaction.atomic():
Document.objects.bulk_update(
lote, ['partida', 'cove', 'edocument'], batch_size=1000
)

View File

@@ -0,0 +1,94 @@
"""
Backfill LEGADO de la FK de cove/edocument por número único (T2025-09-004).
Para documentos viejos cuyo nombre quedó con una nomenclatura olvidada —otro
`pedimento_app` y/u otro prefijo (p.ej. `vu_EDC_0201_800_..._04382515ZIFF5_hex.pdf`
en un pedimento cuyo `pedimento_app` es `25-80-3452-5000586`)— el matcher estricto
de `backfill_document_links` no liga porque arma `vu_ed_{pedimento_app}_{numero}`.
Pero el **número de cove/edoc** (único y largo) sí está en el nombre y la entidad
existe.
Este comando, SOLO para cove y edocument (llaves únicas; partida queda fuera por ser
enteros cortos con colisiones, y los nativos no tienen entidad), liga la FK buscando
el `numero_cove`/`numero_edocument` como token en el nombre, sin exigir app ni prefijo.
Correr DESPUÉS de `backfill_document_links` (solo toca lo que quedó sin ligar).
Uso:
python manage.py backfill_document_links_legacy [--pedimento UUID] [--organizacion UUID] [--dry-run]
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from api.customs.models import Cove, EDocument
from api.record.models import Document
from core.document_links import (
COVE_TYPES, EDOCUMENT_TYPES, SECCION_CAMPO, SECCION_LLAVE,
numero_en_nombre, seccion_de_tipo,
)
class Command(BaseCommand):
help = "Liga documentos legados de cove/edoc (FK nula) por su número único en el nombre, ignorando app/prefijo viejos. Correr después de backfill_document_links."
def add_arguments(self, parser):
parser.add_argument('--pedimento', type=str, default=None)
parser.add_argument('--organizacion', type=str, default=None)
parser.add_argument('--dry-run', action='store_true', help='Reporta sin escribir')
def handle(self, *args, **opts):
dry_run = opts['dry_run']
tipos = sorted(COVE_TYPES | EDOCUMENT_TYPES)
base = Document.objects.filter(
partida__isnull=True, cove__isnull=True, edocument__isnull=True,
document_type_id__in=tipos,
)
if opts['pedimento']:
base = base.filter(pedimento_id=opts['pedimento'])
if opts['organizacion']:
base = base.filter(pedimento__organizacion_id=opts['organizacion'])
if dry_run:
self.stdout.write(self.style.WARNING("== DRY-RUN: no se escribe nada =="))
# order_by() limpia el ordering por defecto del modelo (created_at), que si
# no se quita se cuela en el DISTINCT y duplica los pedimento_id.
ped_ids = list(base.order_by().values_list('pedimento_id', flat=True).distinct())
stats = {'pedimentos': 0, 'cove': 0, 'edocument': 0, 'ambiguos': 0, 'sin_match': 0}
for ped_id in ped_ids:
docs = list(base.filter(pedimento_id=ped_id))
entidades = {
'cove': list(Cove.objects.filter(pedimento_id=ped_id)),
'edocument': list(EDocument.objects.filter(pedimento_id=ped_id)),
}
lote = []
for doc in docs:
seccion = seccion_de_tipo(doc.document_type_id)
llave = SECCION_LLAVE[seccion]
matches = [
e for e in entidades[seccion]
if numero_en_nombre(doc.archivo.name, getattr(e, llave))
]
if len(matches) == 1:
setattr(doc, SECCION_CAMPO[seccion], matches[0])
lote.append(doc)
stats[seccion] += 1
elif len(matches) > 1:
stats['ambiguos'] += 1 # número ambiguo: se deja sin ligar
else:
stats['sin_match'] += 1 # ni por número aparece la entidad
if lote:
stats['pedimentos'] += 1
self.stdout.write(" %s: %d doc(s) ligados por número" % (str(ped_id)[:8], len(lote)))
if not dry_run:
with transaction.atomic():
Document.objects.bulk_update(lote, ['cove', 'edocument'], batch_size=500)
self.stdout.write("")
self.stdout.write(self.style.SUCCESS(
"Pedimentos: %d | ligados → cove=%d edocument=%d | ambiguos: %d | sin match ni por número: %d"
% (stats['pedimentos'], stats['cove'], stats['edocument'], stats['ambiguos'], stats['sin_match'])
))

View File

@@ -0,0 +1,138 @@
"""
Limpieza de documentos duplicados legados (T2025-09-004).
Un mismo documento (misma sub-entidad + mismo document_type) quedó con varias
filas porque, antes del fix de reemplazo (microservicio, jun-2026), cada descarga
re-creaba en vez de reemplazar. La creación ya está corregida; esto solo limpia lo
viejo.
Estrategia: encuentra los grupos duplicados con UNA agregación global por FK
(no itera pedimento por pedimento — hay ~110k pedimentos y casi ninguno tiene
duplicados). Por cada grupo (FK partida/cove/edocument + document_type, con >1
fila): conserva el MÁS RECIENTE cuyo archivo exista en storage (si ninguno existe,
conserva el más reciente y NO borra el grupo entero) y elimina el resto —archivo
en MinIO (si no lo referencia otra fila) + fila + ajuste de cuota vía Document.delete().
Solo toca documentos ligados a una entidad (partida/cove/edoc). NO toca documentos
nativos del pedimento ni subidas sin FK.
Uso:
python manage.py dedup_documents [--pedimento UUID] [--organizacion UUID]
[--offset N] [--limit N] [--dry-run]
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Count
from api.record.models import Document
from api.utils.storage_service import storage_service
# Campos FK de sub-entidad sobre los que se detectan duplicados.
_CAMPOS_FK = ('partida_id', 'cove_id', 'edocument_id')
class Command(BaseCommand):
help = "Elimina documentos duplicados legados (misma entidad + mismo tipo), conservando el más reciente con archivo válido."
def add_arguments(self, parser):
parser.add_argument('--pedimento', type=str, default=None)
parser.add_argument('--organizacion', type=str, default=None)
parser.add_argument('--offset', type=int, default=0, help='Saltar los primeros N grupos')
parser.add_argument('--limit', type=int, default=None, help='Procesar máximo N grupos')
parser.add_argument('--dry-run', action='store_true', help='Reporta sin borrar')
def handle(self, *args, **opts):
dry_run = opts['dry_run']
base = Document.objects.all()
if opts['pedimento']:
base = base.filter(pedimento_id=opts['pedimento'])
if opts['organizacion']:
base = base.filter(pedimento__organizacion_id=opts['organizacion'])
offset = opts['offset'] or 0
limit = opts['limit']
if dry_run:
self.stdout.write(self.style.WARNING("== DRY-RUN: no se borra nada =="))
stats = {'grupos': 0, 'eliminados': 0, 'bytes': 0, 'sin_archivo_valido': 0}
visto = 0 # índice global de grupos (para offset/limit)
for campo in _CAMPOS_FK:
# Una sola agregación: todos los grupos duplicados de esta FK.
grupos = (
base.filter(**{campo + '__isnull': False})
.values('pedimento_id', campo, 'document_type_id')
.annotate(n=Count('id'))
.filter(n__gt=1)
.order_by() # limpia el ordering por defecto del modelo (rompe el GROUP BY)
)
for g in grupos.iterator():
if visto < offset:
visto += 1
continue
if limit is not None and stats['grupos'] >= limit:
break
visto += 1
self._dedup_grupo(campo, g, dry_run, stats)
if limit is not None and stats['grupos'] >= limit:
break
mb = stats['bytes'] / (1024 * 1024)
self.stdout.write("")
self.stdout.write(self.style.SUCCESS(
"Grupos con duplicados: %d | filas eliminadas: %d | espacio liberado: %.1f MB | grupos sin archivo válido (se conservó el más reciente): %d"
% (stats['grupos'], stats['eliminados'], mb, stats['sin_archivo_valido'])
))
def _dedup_grupo(self, campo, g, dry_run, stats):
docs = list(
Document.objects.filter(
pedimento_id=g['pedimento_id'],
document_type_id=g['document_type_id'],
**{campo: g[campo]},
).select_related('pedimento').order_by('-created_at')
)
if len(docs) < 2:
return
conservado = self._elegir_conservado(docs, dry_run, stats)
a_borrar = [d for d in docs if d.id != conservado.id]
if not a_borrar:
return
stats['grupos'] += 1
# SELECT + COUNT previo (estándar de la organización): reportar antes de borrar.
self.stdout.write(
" ped=%s %s=%s type=%s: conservar %s, eliminar %d (%s)"
% (str(g['pedimento_id'])[:8], campo, g[campo], g['document_type_id'],
str(conservado.id)[:8], len(a_borrar), ', '.join(str(d.id)[:8] for d in a_borrar))
)
if not dry_run:
for d in a_borrar:
self._borrar(d, stats)
def _elegir_conservado(self, docs_desc, dry_run, stats):
"""docs_desc viene ordenado por -created_at. En dry-run conserva el más
reciente sin tocar storage; en ejecución real, el más reciente cuyo
archivo exista en MinIO (fallback: el más reciente, para no borrar todo)."""
if dry_run:
return docs_desc[0]
for d in docs_desc:
try:
if d.archivo and storage_service.file_exists(d.archivo.name):
return d
except Exception:
continue
stats['sin_archivo_valido'] += 1
return docs_desc[0]
def _borrar(self, doc, stats):
with transaction.atomic():
# Borrar el archivo en MinIO solo si ninguna OTRA fila lo referencia.
nombre = doc.archivo.name if doc.archivo else None
if nombre and not Document.objects.filter(archivo=nombre).exclude(id=doc.id).exists():
try:
storage_service.delete_file(nombre)
except Exception as e:
self.stdout.write(self.style.WARNING(f" no se pudo borrar de storage {nombre}: {e}"))
stats['bytes'] += doc.size or 0
doc.delete() # ajusta la cuota de almacenamiento (UsoAlmacenamiento)
stats['eliminados'] += 1

View File

@@ -46,7 +46,6 @@ Uso:
""" """
import io import io
import posixpath import posixpath
import re
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.db import transaction from django.db import transaction
@@ -56,6 +55,7 @@ from django.db.models.functions import Length
from api.customs.models import Partida, Pedimento from api.customs.models import Partida, Pedimento
from api.record.models import Document from api.record.models import Document
from api.utils.minio_client import minio_client from api.utils.minio_client import minio_client
from core.partida_docs import es_doc_de_partida
_PT_REQUEST = 17 _PT_REQUEST = 17
_PT_ERROR = 18 _PT_ERROR = 18
@@ -384,24 +384,13 @@ class Command(BaseCommand):
def _docs_de_partida(self, docs, pedimento_app, numero_partida): def _docs_de_partida(self, docs, pedimento_app, numero_partida):
""" """
Naming actual : vu_PT_{pedimento_app}_{numero} seguido de "_" o "." Asigna documentos a una partida por nombre de archivo. La regla (frontera
(cubre éxito canónico, sufijos de unicidad del storage, _/./fin de cadena + formato legacy) vive en core.partida_docs como fuente
REQUEST y ERROR; "_" evita confundir partida 1 con 11) única, compartida con el serializer y los handlers de borrado/descarga.
Naming legacy : vu_PT_..._{numero}.xml (número de partida al final)
""" """
prefijo = f"vu_pt_{pedimento_app}_{numero_partida}".lower()
legacy_re = re.compile(
rf"^vu_pt_.+_{re.escape(str(numero_partida))}\.xml$", re.IGNORECASE
)
asignados = {} asignados = {}
for doc in docs: for doc in docs:
base = posixpath.basename(doc.archivo.name or "").lower() if es_doc_de_partida(doc.archivo.name, pedimento_app, numero_partida):
es_actual = (
base.startswith(prefijo)
and len(base) > len(prefijo)
and base[len(prefijo)] in "_."
)
if es_actual or legacy_re.match(base):
asignados[doc.id] = doc asignados[doc.id] = doc
return list(asignados.values()) return list(asignados.values())

View File

@@ -11,9 +11,11 @@ from api.customs.models import (
) )
from django.db import models from django.db import models
from django.db.models import Q from django.db.models import Q
from api.record.models import Document # Asegúrate de importar el modelo Documento
from api.record.serializers import DocumentSerializer from api.record.serializers import DocumentSerializer
from api.vucem.serializers import VucemSerializer from api.vucem.serializers import VucemSerializer
import logging
logger = logging.getLogger(__name__)
class PedimentoSerializer(serializers.ModelSerializer): class PedimentoSerializer(serializers.ModelSerializer):
documentos_count = serializers.SerializerMethodField() documentos_count = serializers.SerializerMethodField()
@@ -48,31 +50,18 @@ class PartidaSerializer(serializers.ModelSerializer):
documentos = serializers.SerializerMethodField() documentos = serializers.SerializerMethodField()
def get_documentos(self, obj): def get_documentos(self, obj):
if not obj or not getattr(obj, 'pedimento', None): if not obj:
return [] return []
if not obj or not getattr(obj, 'numero_partida', None):
return []
try: try:
pedimento_app = str(obj.pedimento.pedimento_app).strip() # Documentos de respuesta de la partida (tipo 1) vía la FK real
numero = str(obj.numero_partida).strip() # document.partida. 'documentos_vu' lo precarga el ViewSet con prefetch;
# Incluir pedimento_app en el patrón para evitar falsos positivos # si no está, se consulta directo (retrieve u otros callers).
# entre partidas con números cortos (1 matchearía 10, 100, etc.) docs = getattr(obj, 'documentos_vu', None)
patron = f"vu_PT_{pedimento_app}_{numero}_" if docs is None:
docs = list(obj.documents.filter(document_type_id=1).select_related('pedimento', 'fuente'))
# 17 = REQUEST partida, 18 = ERROR partida return DocumentSerializer(docs, many=True, context=self.context).data
qs = Document.objects.filter( except Exception as e:
pedimento=obj.pedimento, logger.warning("get_documentos partida %s: %s", getattr(obj, 'id', '?'), e)
archivo__icontains=patron,
).exclude(document_type_id__in=[17, 18])
if hasattr(obj, 'organizacion') and obj.organizacion:
qs = qs.filter(organizacion=obj.organizacion)
serializer = DocumentSerializer(qs, many=True, context=self.context)
return serializer.data
except Exception:
return [] return []
class Meta: class Meta:
model = Partida model = Partida
@@ -163,43 +152,18 @@ class EDocumentSerializer(serializers.ModelSerializer):
documentos = serializers.SerializerMethodField() documentos = serializers.SerializerMethodField()
def get_documentos(self, obj): def get_documentos(self, obj):
""" """Documentos del e-documento (incluye acuse y errores; excluye solo los
Busca documentos en la tabla `document` que coincidan con el REQUEST 21/25) vía la FK real document.edocument. 'documentos_vu' lo
`numero_edocument` dentro del nombre del archivo (`archivo`). Se precarga el ViewSet con prefetch; si no está, se consulta directo."""
filtra por organización para evitar devolver documentos de otras orgs. if not obj:
Devuelve la serialización completa de los documentos encontrados:
1. Empiecen con 'vu_EDOCUMENT' en el nombre del archivo
2. Terminen con el numero_edocument + .xml
3. Pertenezcan a la misma organización
"""
if not obj or not getattr(obj, 'numero_edocument', None):
return [] return []
if not obj or not getattr(obj, 'pedimento', None):
return []
# if not obj or not getattr(obj, 'pedimento_id', None):
# return []
try: try:
numero = str(obj.numero_edocument).strip() docs = getattr(obj, 'documentos_vu', None)
# id_pedimento = str(obj.pedimento_id).strip() if docs is None:
docs = list(obj.documents.exclude(document_type_id__in=[21, 25]).select_related('pedimento', 'fuente'))
# excluir solo request (21, 25); errores (22, 26) se incluyen para detección en frontend return DocumentSerializer(docs, many=True, context=self.context).data
qs = Document.objects.filter( except Exception as e:
pedimento=obj.pedimento, logger.warning("get_documentos edocument %s: %s", getattr(obj, 'id', '?'), e)
archivo__icontains=numero,
).exclude(document_type_id__in=[21, 25])
# Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion:
qs = qs.filter(organizacion=obj.organizacion)
serializer = DocumentSerializer(qs, many=True, context=self.context)
return serializer.data
except Exception:
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
return [] return []
class Meta: class Meta:
@@ -255,39 +219,18 @@ class CoveSerializer(serializers.ModelSerializer):
return attrs return attrs
def get_documentos(self, obj): def get_documentos(self, obj):
""" """Documentos del cove (incluye acuse cove y errores; excluye solo los
Busca documentos en la tabla `document` que coincidan con el REQUEST 19/23) vía la FK real document.cove. 'documentos_vu' lo precarga
`numero_cove` dentro del nombre del archivo (`archivo`). Se el ViewSet con prefetch; si no está, se consulta directo."""
filtra por organización para evitar devolver documentos de otras orgs. if not obj:
Devuelve la serialización completa de los documentos encontrados:
1. Empiecen con 'vu_COVE' en el nombre del archivo
2. Terminen con el numero_cove + .xml
3. Pertenezcan a la misma organización
"""
if not obj or not getattr(obj, 'numero_cove', None):
return [] return []
if not obj or not getattr(obj, 'pedimento', None):
return []
try: try:
numero = str(obj.numero_cove).strip() docs = getattr(obj, 'documentos_vu', None)
if docs is None:
# Excluir solo request (19, 23); errores (20, 24) se incluyen para detección en frontend docs = list(obj.documents.exclude(document_type_id__in=[19, 23]).select_related('pedimento', 'fuente'))
qs = Document.objects.filter( return DocumentSerializer(docs, many=True, context=self.context).data
pedimento=obj.pedimento, except Exception as e:
archivo__icontains=numero, logger.warning("get_documentos cove %s: %s", getattr(obj, 'id', '?'), e)
).exclude(document_type_id__in=[19, 23])
# Filtro por organización si aplica
if hasattr(obj, 'organizacion') and obj.organizacion:
qs = qs.filter(organizacion=obj.organizacion)
serializer = DocumentSerializer(qs, many=True, context=self.context)
return serializer.data
except Exception:
# En caso de cualquier error (por ejemplo, importaciones circulares), devolver lista vacía
return [] return []
class ImportadorSerializer(serializers.ModelSerializer): class ImportadorSerializer(serializers.ModelSerializer):

View File

@@ -236,7 +236,11 @@ class BulkCreateDocumentReplaceTests(APITestCase):
from io import StringIO from io import StringIO
from types import SimpleNamespace from types import SimpleNamespace
from django.core.management import call_command from django.core.management import call_command
from django.test import TestCase from django.test import TestCase, SimpleTestCase
from core.partida_docs import es_doc_de_partida, patron_regex_partida
from api.customs.serializers import PartidaSerializer, CoveSerializer, EDocumentSerializer
from api.customs.models import Partida
from api.record.models import Document, DocumentType
XML_RESPUESTA_VALIDA = ( XML_RESPUESTA_VALIDA = (
@@ -496,3 +500,347 @@ class FixPartidasErrorCommandTests(TestCase):
self.assertEqual(ids_p1, {1, 2}) self.assertEqual(ids_p1, {1, 2})
self.assertEqual(ids_p11, {3, 4}) self.assertEqual(ids_p11, {3, 4})
class PartidaDocsHelperTests(SimpleTestCase):
"""Matching documento→partida (core.partida_docs), sin BD."""
APP = "24-01-3420-1234567"
def test_es_doc_de_partida_cubre_los_tres_formatos(self):
for nombre in (
f"documents/vu_PT_{self.APP}_1", # #1 sin extensión
f"documents/vu_PT_{self.APP}_1.xml", # #2
f"documents/vu_PT_{self.APP}_1_a1b2c3.xml", # #3 sufijo hex del storage
f"documents/vu_PT_{self.APP}_1_REQUEST.xml", # REQUEST (coincide por nombre)
):
self.assertTrue(es_doc_de_partida(nombre, self.APP, 1), nombre)
def test_es_doc_de_partida_no_confunde_1_con_11_ni_100(self):
for n in (11, 12, 100):
self.assertFalse(es_doc_de_partida(f"vu_PT_{self.APP}_{n}.xml", self.APP, 1))
self.assertFalse(es_doc_de_partida(f"vu_PT_{self.APP}_{n}_x.xml", self.APP, 1))
# a la inversa: la 11 sí coincide con la 11, no con la 1
self.assertTrue(es_doc_de_partida(f"vu_PT_{self.APP}_11.xml", self.APP, 11))
self.assertFalse(es_doc_de_partida(f"vu_PT_{self.APP}_1.xml", self.APP, 11))
def test_es_doc_de_partida_case_insensitive_y_con_ruta(self):
self.assertTrue(es_doc_de_partida(f"ORG/X/VU_PT_{self.APP}_1.XML", self.APP, 1))
def test_es_doc_de_partida_vacio_o_none(self):
self.assertFalse(es_doc_de_partida("", self.APP, 1))
self.assertFalse(es_doc_de_partida(None, self.APP, 1))
def test_es_doc_de_partida_legacy_segun_flag(self):
legacy = "org/x/vu_PT_010Imp_034_3420_1234567_1.xml" # número de partida al final
self.assertTrue(es_doc_de_partida(legacy, self.APP, 1, incluir_legacy=True))
self.assertFalse(es_doc_de_partida(legacy, self.APP, 1, incluir_legacy=False))
# el formato legacy tampoco confunde la 1 con la 11
legacy11 = "org/x/vu_PT_010Imp_034_3420_1234567_11.xml"
self.assertFalse(es_doc_de_partida(legacy11, self.APP, 1, incluir_legacy=True))
def test_patron_regex_partida_semantica(self):
import re
rx = re.compile(patron_regex_partida(self.APP, 1), re.IGNORECASE)
self.assertTrue(rx.search(f"documents/vu_PT_{self.APP}_1"))
self.assertTrue(rx.search(f"documents/vu_PT_{self.APP}_1.xml"))
self.assertTrue(rx.search(f"documents/vu_PT_{self.APP}_1_a1b2.xml"))
self.assertFalse(rx.search(f"documents/vu_PT_{self.APP}_11.xml"))
self.assertFalse(rx.search(f"documents/vu_PT_{self.APP}_100.xml"))
# pedimento_app se trata como literal: otro pedimento no coincide
self.assertFalse(rx.search("documents/vu_PT_99-99-9999-9999999_1.xml"))
# legacy solo cuando se pide
rxl = re.compile(patron_regex_partida(self.APP, 1, incluir_legacy=True), re.IGNORECASE)
self.assertTrue(rxl.search("vu_PT_010Imp_034_3420_1234567_1.xml"))
class PartidaDocumentosSerializerTests(TestCase):
"""get_documentos (PartidaSerializer) y el prefetch de PartidaViewSet asignan
los documentos correctos a cada partida por nombre de archivo."""
APP = "24-01-3420-1234567"
def setUp(self):
from api.organization.models import Organizacion
from api.licence.models import Licencia
from .models import Pedimento
self.licencia = Licencia.objects.create(nombre="LicPartDocs", almacenamiento=100)
self.org = Organizacion.objects.create(
nombre="OrgPartDocs", licencia=self.licencia, is_active=True, is_verified=True
)
self.pedimento = Pedimento.objects.create(
organizacion=self.org, pedimento="1234567", pedimento_app=self.APP,
aduana="034", patente="3420", numero_operacion="12345678",
)
self.p1 = Partida.objects.create(
pedimento=self.pedimento, organizacion=self.org, numero_partida=1, descargado=True
)
self.p11 = Partida.objects.create(
pedimento=self.pedimento, organizacion=self.org, numero_partida=11, descargado=True
)
self.type_resp = DocumentType.objects.get_or_create(id=1, defaults={"nombre": "XML"})[0]
self.type_req = DocumentType.objects.get_or_create(id=17, defaults={"nombre": "PT Request"})[0]
def _doc(self, filename, doc_type=None):
return Document.objects.create(
organizacion=self.org, pedimento=self.pedimento,
document_type=doc_type or self.type_resp,
archivo=f"documents/{filename}", size=100, extension="xml",
)
def _blob(self, data):
return " ".join(d["archivo"] for d in data["documentos"])
def test_get_documentos_tres_formatos_sin_confundir(self):
self._doc(f"vu_PT_{self.APP}_1") # #1 sin extensión
self._doc(f"vu_PT_{self.APP}_1.xml") # #2
self._doc(f"vu_PT_{self.APP}_1_a1b2c3.xml") # #3 hex
self._doc(f"vu_PT_{self.APP}_1_REQUEST.xml", self.type_req) # tipo 17: no debe salir
self._doc(f"vu_PT_{self.APP}_11.xml") # partida 11
self._doc(f"vu_PT_{self.APP}_11_a1b2c3.xml") # partida 11
data = PartidaSerializer(self.p1).data # sin contexto -> camino fallback
blob = self._blob(data)
# los 3 documentos tipo-1 de la partida 1; el REQUEST (17) excluido
self.assertEqual(len(data["documentos"]), 3)
self.assertIn(f"vu_PT_{self.APP}_1.xml", blob)
self.assertIn(f"vu_PT_{self.APP}_1_a1b2c3.xml", blob)
self.assertNotIn("_11", blob) # no arrastra la partida 11
self.assertNotIn("REQUEST", blob) # no incluye el REQUEST tipo 17
def test_get_documentos_incluye_legacy(self):
self._doc("vu_PT_010Imp_034_3420_1234567_1.xml") # legacy tipo 1, número al final
data = PartidaSerializer(self.p1).data
self.assertEqual(len(data["documentos"]), 1)
def test_get_documentos_via_fk(self):
# save() liga la FK al crear; get_documentos lee por la FK real.
d1 = self._doc(f"vu_PT_{self.APP}_1.xml")
self._doc(f"vu_PT_{self.APP}_11.xml")
d1.refresh_from_db()
self.assertEqual(d1.partida_id, self.p1.id) # FK ligada en save()
data = PartidaSerializer(self.p1).data
self.assertEqual(len(data["documentos"]), 1)
self.assertIn(f"vu_PT_{self.APP}_1.xml", self._blob(data))
self.assertNotIn("_11", self._blob(data))
def test_patron_regex_partida_en_bd(self):
d1 = self._doc(f"vu_PT_{self.APP}_1")
d2 = self._doc(f"vu_PT_{self.APP}_1.xml")
d3 = self._doc(f"vu_PT_{self.APP}_1_a1b2c3.xml")
d_otro = self._doc(f"vu_PT_{self.APP}_11.xml")
ids = set(
Document.objects.filter(
pedimento=self.pedimento,
archivo__iregex=patron_regex_partida(self.APP, 1),
).values_list("id", flat=True)
)
self.assertEqual(ids, {d1.id, d2.id, d3.id})
self.assertNotIn(d_otro.id, ids)
def test_prefetch_documentos_vu_evita_n_plus_1(self):
from django.db.models import Prefetch
self._doc(f"vu_PT_{self.APP}_1.xml")
self._doc(f"vu_PT_{self.APP}_11.xml")
prefetch = Prefetch(
'documents',
queryset=Document.objects.filter(document_type_id=1).select_related('pedimento'),
to_attr='documentos_vu',
)
partidas = list(
Partida.objects.filter(pedimento=self.pedimento)
.order_by('numero_partida').prefetch_related(prefetch)
)
# Serializar la lista no dispara consultas extra: todo viene del prefetch.
with self.assertNumQueries(0):
data = PartidaSerializer(partidas, many=True).data
por_num = {d['numero_partida']: d['documentos'] for d in data}
self.assertEqual(len(por_num[1]), 1)
self.assertEqual(len(por_num[11]), 1)
class DocumentLinksHelperTests(SimpleTestCase):
"""Resolver tipo→sección y matcher por frontera (core.document_links), sin BD."""
# pedimento_app con guiones bajos (caso real): no se puede extraer la llave
# partiendo por '_'; por eso se itera la entidad con su número exacto.
APP = "0101_230_1703_3004804"
def test_seccion_de_tipo(self):
from core.document_links import seccion_de_tipo
self.assertEqual(seccion_de_tipo(1), 'partida')
self.assertEqual(seccion_de_tipo(8), 'cove')
self.assertEqual(seccion_de_tipo(7), 'cove') # acuse cove
self.assertEqual(seccion_de_tipo(5), 'edocument')
self.assertEqual(seccion_de_tipo(4), 'edocument') # acuse edoc
self.assertIsNone(seccion_de_tipo(2)) # PC nativo
self.assertIsNone(seccion_de_tipo(3)) # remesa nativo
self.assertIsNone(seccion_de_tipo(None))
def test_coincide_cove(self):
from core.document_links import coincide
self.assertTrue(coincide(f"documents/vu_COVE_{self.APP}_654001.xml", 'cove', self.APP, "654001"))
self.assertTrue(coincide(f"documents/vu_AC_COVE_{self.APP}_654001.pdf", 'cove', self.APP, "654001"))
self.assertTrue(coincide(f"documents/vu_COVE_{self.APP}_654001_REQUEST.xml", 'cove', self.APP, "654001"))
# colisión de prefijo: 654001 no debe matchear 6540012
self.assertFalse(coincide(f"documents/vu_COVE_{self.APP}_6540012.xml", 'cove', self.APP, "654001"))
def test_coincide_edocument(self):
from core.document_links import coincide
self.assertTrue(coincide(f"documents/vu_ED_{self.APP}_EDOC001.pdf", 'edocument', self.APP, "EDOC001"))
self.assertTrue(coincide(f"documents/vu_AC_{self.APP}_EDOC001.pdf", 'edocument', self.APP, "EDOC001"))
self.assertFalse(coincide(f"documents/vu_ED_{self.APP}_EDOC0011.pdf", 'edocument', self.APP, "EDOC001"))
class DocumentFKResolutionTests(TestCase):
"""save()-resolución de FK por sección, lectura cove/edoc por FK y backfill."""
APP = "0101_230_1703_3004804" # con guiones bajos
def setUp(self):
from api.organization.models import Organizacion
from api.licence.models import Licencia
from .models import Pedimento, Cove, EDocument
self.licencia = Licencia.objects.create(nombre="LicFK", almacenamiento=100)
self.org = Organizacion.objects.create(
nombre="OrgFK", licencia=self.licencia, is_active=True, is_verified=True
)
self.pedimento = Pedimento.objects.create(
organizacion=self.org, pedimento="1234567", pedimento_app=self.APP,
aduana="034", patente="3420", numero_operacion="12345678",
)
self.partida = Partida.objects.create(
pedimento=self.pedimento, organizacion=self.org, numero_partida=3, descargado=True
)
self.cove = Cove.objects.create(
pedimento=self.pedimento, organizacion=self.org, numero_cove="654001"
)
self.edoc = EDocument.objects.create(
pedimento=self.pedimento, organizacion=self.org, numero_edocument="EDOC001"
)
for tid, nombre in [(1, "PT"), (8, "COVE"), (7, "AC_COVE"), (5, "ED"),
(4, "AC_ED"), (2, "PC"), (19, "COVE_REQ")]:
DocumentType.objects.get_or_create(id=tid, defaults={"nombre": nombre})
def _doc(self, filename, type_id):
return Document.objects.create(
organizacion=self.org, pedimento=self.pedimento, document_type_id=type_id,
archivo=f"documents/{filename}", size=100, extension="xml",
)
def test_save_liga_fk_por_seccion(self):
d_pt = self._doc(f"vu_PT_{self.APP}_3.xml", 1)
d_cv = self._doc(f"vu_COVE_{self.APP}_654001.xml", 8)
d_accv = self._doc(f"vu_AC_COVE_{self.APP}_654001.pdf", 7)
d_ed = self._doc(f"vu_ED_{self.APP}_EDOC001.pdf", 5)
d_aced = self._doc(f"vu_AC_{self.APP}_EDOC001.pdf", 4)
d_pc = self._doc(f"vu_PC_{self.APP}.xml", 2) # nativo: sin FK
for d in (d_pt, d_cv, d_accv, d_ed, d_aced, d_pc):
d.refresh_from_db()
self.assertEqual(d_pt.partida_id, self.partida.id)
self.assertEqual(d_cv.cove_id, self.cove.id)
self.assertEqual(d_accv.cove_id, self.cove.id) # acuse cove → cove padre
self.assertEqual(d_ed.edocument_id, self.edoc.id)
self.assertEqual(d_aced.edocument_id, self.edoc.id) # acuse edoc → edoc padre
self.assertIsNone(d_pc.partida_id)
self.assertIsNone(d_pc.cove_id)
self.assertIsNone(d_pc.edocument_id)
def test_lectura_cove_edoc_por_fk(self):
self._doc(f"vu_COVE_{self.APP}_654001.xml", 8)
self._doc(f"vu_AC_COVE_{self.APP}_654001.pdf", 7)
self._doc(f"vu_COVE_{self.APP}_654001_REQUEST.xml", 19) # request: excluido en lectura
self._doc(f"vu_ED_{self.APP}_EDOC001.pdf", 5)
cove_data = CoveSerializer(self.cove).data
edoc_data = EDocumentSerializer(self.edoc).data
self.assertEqual(len(cove_data["documentos"]), 2) # cove + acuse, sin request
self.assertEqual(len(edoc_data["documentos"]), 1)
def test_backfill_liga_filas_existentes(self):
d_pt = self._doc(f"vu_PT_{self.APP}_3.xml", 1)
d_cv = self._doc(f"vu_COVE_{self.APP}_654001.xml", 8)
# Simular filas viejas sin ligar (save() las ligó; las desligamos en BD).
Document.objects.filter(id__in=[d_pt.id, d_cv.id]).update(
partida=None, cove=None, edocument=None
)
# dry-run no escribe
call_command("backfill_document_links", pedimento=str(self.pedimento.id),
dry_run=True, stdout=StringIO())
d_pt.refresh_from_db()
self.assertIsNone(d_pt.partida_id)
# ejecución real liga
call_command("backfill_document_links", pedimento=str(self.pedimento.id), stdout=StringIO())
d_pt.refresh_from_db()
d_cv.refresh_from_db()
self.assertEqual(d_pt.partida_id, self.partida.id)
self.assertEqual(d_cv.cove_id, self.cove.id)
# idempotente: re-ejecutar no rompe ni cambia
call_command("backfill_document_links", pedimento=str(self.pedimento.id), stdout=StringIO())
d_pt.refresh_from_db()
self.assertEqual(d_pt.partida_id, self.partida.id)
def _tres_copias_edoc(self):
"""3 copias del mismo edoc (type 5) con created_at d1<d2<d3 y archivos distintos."""
from django.utils import timezone
import datetime
d1 = self._doc(f"vu_ED_{self.APP}_EDOC001_aaa.pdf", 5)
d2 = self._doc(f"vu_ED_{self.APP}_EDOC001_bbb.pdf", 5)
d3 = self._doc(f"vu_ED_{self.APP}_EDOC001_ccc.pdf", 5)
base = timezone.now()
Document.objects.filter(id=d1.id).update(created_at=base - datetime.timedelta(days=2))
Document.objects.filter(id=d2.id).update(created_at=base - datetime.timedelta(days=1))
Document.objects.filter(id=d3.id).update(created_at=base)
return d1, d2, d3
def test_dedup_conserva_mas_reciente_y_es_idempotente(self):
from unittest.mock import patch
d1, d2, d3 = self._tres_copias_edoc()
self.assertEqual(Document.objects.filter(edocument=self.edoc, document_type_id=5).count(), 3)
with patch('api.customs.management.commands.dedup_documents.storage_service') as st:
st.file_exists.return_value = True
call_command('dedup_documents', pedimento=str(self.pedimento.id), stdout=StringIO())
self.assertEqual(st.delete_file.call_count, 2) # borró los 2 viejos de MinIO
restantes = list(Document.objects.filter(edocument=self.edoc, document_type_id=5))
self.assertEqual(len(restantes), 1)
self.assertEqual(restantes[0].id, d3.id) # conservó el más reciente
# idempotente: re-correr no borra nada
with patch('api.customs.management.commands.dedup_documents.storage_service') as st2:
st2.file_exists.return_value = True
call_command('dedup_documents', pedimento=str(self.pedimento.id), stdout=StringIO())
self.assertEqual(st2.delete_file.call_count, 0)
def test_dedup_dry_run_no_borra(self):
from unittest.mock import patch
self._tres_copias_edoc()
with patch('api.customs.management.commands.dedup_documents.storage_service') as st:
call_command('dedup_documents', pedimento=str(self.pedimento.id), dry_run=True, stdout=StringIO())
self.assertEqual(st.delete_file.call_count, 0)
self.assertEqual(Document.objects.filter(edocument=self.edoc, document_type_id=5).count(), 3)
def test_dedup_conserva_el_que_tiene_archivo_en_storage(self):
from unittest.mock import patch
d1, d2, d3 = self._tres_copias_edoc() # d3 el más reciente
# En storage solo existe el de d1 (el más viejo); los más nuevos no.
with patch('api.customs.management.commands.dedup_documents.storage_service') as st:
st.file_exists.side_effect = lambda nombre: nombre.endswith('_aaa.pdf')
call_command('dedup_documents', pedimento=str(self.pedimento.id), stdout=StringIO())
restantes = list(Document.objects.filter(edocument=self.edoc, document_type_id=5))
self.assertEqual(len(restantes), 1)
self.assertEqual(restantes[0].id, d1.id) # conservó el único con archivo válido
def test_backfill_legacy_liga_por_numero(self):
# Doc legado: app y prefijo viejos (vu_EDC, otro pedimento_app), pero el
# numero_edocument (EDOC001) SÍ está en el nombre.
legado = self._doc("vu_EDC_0201_800_3452_5000586_EDOC001_abc123.pdf", 5)
legado.refresh_from_db()
self.assertIsNone(legado.edocument_id) # save() (match estricto) no lo ligó
# el backfill estricto tampoco (app/prefijo no coinciden)
call_command("backfill_document_links", pedimento=str(self.pedimento.id), stdout=StringIO())
legado.refresh_from_db()
self.assertIsNone(legado.edocument_id)
# el backfill LEGADO sí lo liga por número único
call_command("backfill_document_links_legacy", pedimento=str(self.pedimento.id), stdout=StringIO())
legado.refresh_from_db()
self.assertEqual(legado.edocument_id, self.edoc.id)

View File

@@ -60,6 +60,7 @@ from django.core.files.base import ContentFile
from django.db import transaction from django.db import transaction
from rest_framework.parsers import MultiPartParser, FormParser from rest_framework.parsers import MultiPartParser, FormParser
from api.record.models import Document, DocumentType, Fuente from api.record.models import Document, DocumentType, Fuente
from django.db.models import Prefetch
from unicodedata import normalize from unicodedata import normalize
from datetime import datetime from datetime import datetime
from django.utils import timezone from django.utils import timezone
@@ -2332,14 +2333,21 @@ class PartidaViewSet(viewsets.ModelViewSet):
def get_queryset(self): def get_queryset(self):
user = self.request.user user = self.request.user
# Precarga los documentos de respuesta (tipo 1) de cada partida vía la FK
# real document.partida, para que get_documentos no genere N+1.
prefetch_docs = Prefetch(
'documents',
queryset=Document.objects.filter(document_type_id=1).select_related('pedimento', 'fuente'),
to_attr='documentos_vu',
)
if is_internal_service_request(self.request): if is_internal_service_request(self.request):
return Partida.objects.all() return Partida.objects.all().prefetch_related(prefetch_docs)
if not user_has_permission(user, 'partidas.view'): if not user_has_permission(user, 'partidas.view'):
return Partida.objects.none() return Partida.objects.none()
org = get_org_context(user) org = get_org_context(user)
if not org: if not org:
return Partida.objects.none() return Partida.objects.none()
qs = Partida.objects.filter(pedimento__organizacion=org) qs = Partida.objects.filter(pedimento__organizacion=org).prefetch_related(prefetch_docs)
# Misma precedencia que los mixins de filtrado: superuser y roles # Misma precedencia que los mixins de filtrado: superuser y roles
# operativos ven todo lo de su org; is_importador no los degrada. # operativos ven todo lo de su org; is_importador no los degrada.
if ( if (
@@ -2596,7 +2604,14 @@ class ViewSetEDocument(LoggingMixin, viewsets.ModelViewSet, OrganizacionFiltrada
def get_queryset(self): def get_queryset(self):
if not user_has_permission(self.request.user, 'edocuments.view'): if not user_has_permission(self.request.user, 'edocuments.view'):
return EDocument.objects.none() return EDocument.objects.none()
return self.get_queryset_filtrado_por_organizacion() # Precarga documentos del e-doc (incluye acuse; excluye REQUEST 21/25)
# vía la FK document.edocument, para que get_documentos no genere N+1.
prefetch_docs = Prefetch(
'documents',
queryset=Document.objects.exclude(document_type_id__in=[21, 25]).select_related('pedimento', 'fuente'),
to_attr='documentos_vu',
)
return self.get_queryset_filtrado_por_organizacion().prefetch_related(prefetch_docs)
def perform_create(self, serializer): def perform_create(self, serializer):
if is_internal_service_request(self.request): if is_internal_service_request(self.request):
@@ -2764,7 +2779,14 @@ class ViewSetCove(viewsets.ModelViewSet, OrganizacionFiltradaMixin):
def get_queryset(self): def get_queryset(self):
if not user_has_permission(self.request.user, 'coves.view'): if not user_has_permission(self.request.user, 'coves.view'):
return Cove.objects.none() return Cove.objects.none()
return self.get_queryset_filtrado_por_organizacion() # Precarga documentos del cove (incluye acuse cove; excluye REQUEST 19/23)
# vía la FK document.cove, para que get_documentos no genere N+1.
prefetch_docs = Prefetch(
'documents',
queryset=Document.objects.exclude(document_type_id__in=[19, 23]).select_related('pedimento', 'fuente'),
to_attr='documentos_vu',
)
return self.get_queryset_filtrado_por_organizacion().prefetch_related(prefetch_docs)
def perform_create(self, serializer): def perform_create(self, serializer):
if is_internal_service_request(self.request): if is_internal_service_request(self.request):

View File

@@ -0,0 +1,30 @@
# Generated by Django 5.2.3 on 2026-06-24 13:37
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('customs', '0020_estados_descarga_t2026_05_027'),
('record', '0003_document_vu'),
]
operations = [
migrations.AddField(
model_name='document',
name='cove',
field=models.ForeignKey(blank=True, db_index=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='customs.cove'),
),
migrations.AddField(
model_name='document',
name='edocument',
field=models.ForeignKey(blank=True, db_index=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='customs.edocument'),
),
migrations.AddField(
model_name='document',
name='partida',
field=models.ForeignKey(blank=True, db_index=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='documents', to='customs.partida'),
),
]

View File

@@ -0,0 +1,55 @@
# Índices de las FK de sub-entidad en la tabla `document` (grande: ~5M filas en
# prod) con CREATE INDEX CONCURRENTLY para no bloquear escrituras.
#
# CONCURRENTLY no corre dentro de transacción (atomic=False) y NO es transaccional:
# si el proceso muere a mitad puede dejar un índice a medias. Por eso:
# - IF NOT EXISTS → el reintento es idempotente (no choca con "already exists").
# - SeparateDatabaseAndState → el índice se refleja en el estado del modelo
# (AddIndex) sin que Django intente recrearlo, manteniendo el estado consistente.
#
# Recuperación si un build quedó INVALID (kill DURANTE la construcción, no después):
# DROP INDEX IF EXISTS "<nombre>"; y reintentar python manage.py migrate record
from django.db import migrations, models
class Migration(migrations.Migration):
atomic = False
dependencies = [
('record', '0004_document_subentidad_fk'),
]
operations = [
migrations.SeparateDatabaseAndState(
database_operations=[migrations.RunSQL(
sql='CREATE INDEX CONCURRENTLY IF NOT EXISTS "document_partida_idx" ON "document" ("partida_id");',
reverse_sql='DROP INDEX IF EXISTS "document_partida_idx";',
)],
state_operations=[migrations.AddIndex(
model_name='document',
index=models.Index(fields=['partida'], name='document_partida_idx'),
)],
),
migrations.SeparateDatabaseAndState(
database_operations=[migrations.RunSQL(
sql='CREATE INDEX CONCURRENTLY IF NOT EXISTS "document_cove_idx" ON "document" ("cove_id");',
reverse_sql='DROP INDEX IF EXISTS "document_cove_idx";',
)],
state_operations=[migrations.AddIndex(
model_name='document',
index=models.Index(fields=['cove'], name='document_cove_idx'),
)],
),
migrations.SeparateDatabaseAndState(
database_operations=[migrations.RunSQL(
sql='CREATE INDEX CONCURRENTLY IF NOT EXISTS "document_edocument_idx" ON "document" ("edocument_id");',
reverse_sql='DROP INDEX IF EXISTS "document_edocument_idx";',
)],
state_operations=[migrations.AddIndex(
model_name='document',
index=models.Index(fields=['edocument'], name='document_edocument_idx'),
)],
),
]

View File

@@ -0,0 +1,22 @@
# Recalcula estadísticas del planner tras crear los índices de las FK (0005).
# CREATE INDEX CONCURRENTLY NO corre ANALYZE: sin estadísticas frescas de las
# columnas nuevas (partida_id/cove_id/edocument_id, casi todas NULL antes del
# backfill), el planner puede elegir un seq scan sobre la tabla `document` (~5M
# filas) para las consultas del prefetch en vez de usar el índice → endpoints
# muy lentos. Este ANALYZE lo previene en cada entorno.
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('record', '0005_document_subentidad_idx'),
]
operations = [
migrations.RunSQL(
sql='ANALYZE "document";',
reverse_sql=migrations.RunSQL.noop,
),
]

View File

@@ -17,6 +17,14 @@ class Document(models.Model):
fuente = models.ForeignKey('Fuente', on_delete=models.CASCADE, related_name='documents', blank=True, null=True) fuente = models.ForeignKey('Fuente', on_delete=models.CASCADE, related_name='documents', blank=True, null=True)
vu = models.BooleanField(default=False) vu = models.BooleanField(default=False)
# Sub-entidad a la que pertenece el documento (None para docs nativos del
# pedimento: PC, remesa, subidas generales). Se puebla por nombre de archivo
# en save() vía core.document_links. db_index=False: el índice lo crea una
# migración aparte con CREATE INDEX CONCURRENTLY (tabla grande en prod).
partida = models.ForeignKey('customs.Partida', on_delete=models.CASCADE, related_name='documents', blank=True, null=True, db_index=False)
cove = models.ForeignKey('customs.Cove', on_delete=models.CASCADE, related_name='documents', blank=True, null=True, db_index=False)
edocument = models.ForeignKey('customs.EDocument', on_delete=models.CASCADE, related_name='documents', blank=True, null=True, db_index=False)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@@ -30,6 +38,17 @@ class Document(models.Model):
else: else:
self.vu = False self.vu = False
# Ligar la sub-entidad (partida/cove/edocument) por nombre de archivo si
# aún no está ligada. Cubre todas las rutas de creación —incluida la
# ingesta del microservicio— sin tocar cada call site. Se ejecuta también
# en update porque el patrón común es create() sin archivo y luego
# asignar archivo + save(). Fuente única: core.document_links.
if self.archivo and not (self.partida_id or self.cove_id or self.edocument_id):
from core.document_links import resolver_fk
campo, inst = resolver_fk(self)
if inst is not None:
setattr(self, campo, inst)
# Usar get_or_create en lugar de get para manejar el caso cuando no existe # Usar get_or_create en lugar de get para manejar el caso cuando no existe
uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create( uso_almacenamiento, created = UsoAlmacenamiento.objects.get_or_create(
organizacion=self.organizacion, organizacion=self.organizacion,
@@ -77,6 +96,14 @@ class Document(models.Model):
verbose_name_plural = "Documents" verbose_name_plural = "Documents"
db_table = 'document' db_table = 'document'
ordering = ['created_at'] ordering = ['created_at']
# Índices de las FK de sub-entidad. Se crean con CREATE INDEX CONCURRENTLY
# en una migración aparte (atomic=False); por eso los campos usan
# db_index=False y el índice se declara aquí.
indexes = [
models.Index(fields=['partida'], name='document_partida_idx'),
models.Index(fields=['cove'], name='document_cove_idx'),
models.Index(fields=['edocument'], name='document_edocument_idx'),
]
class DocumentType(models.Model): class DocumentType(models.Model):
nombre = models.CharField(max_length=100, unique=True) nombre = models.CharField(max_length=100, unique=True)

View File

@@ -33,6 +33,7 @@ from core.permissions import (
user_has_permission, user_has_permission,
IsInternalService, IsInternalService,
) )
from core.document_links import ids_documentos_entidad
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -718,14 +719,13 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
) )
# Buscar documentos vu_PT_ asociados a cada partida por pedimento + numero_partida # Buscar documentos asociados a cada partida por nombre de archivo con
# frontera real (core.partida_docs). Sin filtro de tipo: barre tanto la
# respuesta (1) como REQUEST/ERROR (17/18) de la partida.
# incluir_legacy=False: el borrado es destructivo, no se elimina por match difuso.
doc_ids = [] doc_ids = []
for partida in partidas: for partida in partidas:
docs = Document.objects.filter( doc_ids.extend(ids_documentos_entidad(partida, 'partida'))
pedimento_id=partida.pedimento.id,
archivo__icontains=f'vu_pt_{partida.pedimento.pedimento_app}_{partida.numero_partida}_'
).values_list('id', flat=True)
doc_ids.extend(docs)
queryset = self.get_queryset() queryset = self.get_queryset()
existing_documents = queryset.filter(id__in=doc_ids) existing_documents = queryset.filter(id__in=doc_ids)
@@ -856,11 +856,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# Buscar documentos que contengan el numero_cove en el nombre de archivo # Buscar documentos que contengan el numero_cove en el nombre de archivo
doc_ids = [] doc_ids = []
for cove in coves: for cove in coves:
docs = Document.objects.filter( doc_ids.extend(ids_documentos_entidad(cove, 'cove'))
pedimento_id=cove.pedimento.id,
archivo__icontains=cove.numero_cove
).values_list('id', flat=True)
doc_ids.extend(docs)
queryset = self.get_queryset() queryset = self.get_queryset()
existing_documents = queryset.filter(id__in=doc_ids) existing_documents = queryset.filter(id__in=doc_ids)
@@ -989,11 +985,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
# Buscar documentos que contengan el numero_edocument en el nombre de archivo # Buscar documentos que contengan el numero_edocument en el nombre de archivo
doc_ids = [] doc_ids = []
for edoc in edocs: for edoc in edocs:
docs = Document.objects.filter( doc_ids.extend(ids_documentos_entidad(edoc, 'edocument'))
pedimento_id=edoc.pedimento.id,
archivo__icontains=edoc.numero_edocument
).values_list('id', flat=True)
doc_ids.extend(docs)
queryset = self.get_queryset() queryset = self.get_queryset()
existing_documents = queryset.filter(id__in=doc_ids) existing_documents = queryset.filter(id__in=doc_ids)
@@ -1900,6 +1892,10 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
document.delete() document.delete()
raise Exception(f"El archivo no se encuentra en storage tras guardarlo: {file.name}") raise Exception(f"El archivo no se encuentra en storage tras guardarlo: {file.name}")
document.archivo = ruta document.archivo = ruta
# Ligar explícitamente la sub-entidad recién creada
# (exacto, sin depender del matching por nombre).
_campo_fk = {'partida': 'partida', 'cove': 'cove', 'edoc': 'edocument'}[tab_seccion]
setattr(document, _campo_fk, expediente_obj)
document.save() document.save()
else: else:
document.delete() document.delete()
@@ -1997,13 +1993,11 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
if not partidas.exists(): if not partidas.exists():
return Response({"error": "No se encontraron partidas"}, status=status.HTTP_404_NOT_FOUND) return Response({"error": "No se encontraron partidas"}, status=status.HTTP_404_NOT_FOUND)
# Mismo matching por frontera que el borrado, pero incluir_legacy=True:
# la descarga no es destructiva, así que sí incluye archivos legacy.
doc_ids = [] doc_ids = []
for partida in partidas: for partida in partidas:
docs = Document.objects.filter( doc_ids.extend(ids_documentos_entidad(partida, 'partida'))
pedimento_id=partida.pedimento.id,
archivo__icontains=f'vu_pt_{partida.pedimento.pedimento_app}_{partida.numero_partida}_'
).values_list('id', flat=True)
doc_ids.extend(docs)
queryset = self.get_queryset() queryset = self.get_queryset()
docs_qs = queryset.filter(id__in=doc_ids) docs_qs = queryset.filter(id__in=doc_ids)
@@ -2059,11 +2053,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
doc_ids = [] doc_ids = []
for cove in coves: for cove in coves:
docs = Document.objects.filter( doc_ids.extend(ids_documentos_entidad(cove, 'cove'))
pedimento_id=cove.pedimento.id,
archivo__icontains=cove.numero_cove
).values_list('id', flat=True)
doc_ids.extend(docs)
queryset = self.get_queryset() queryset = self.get_queryset()
docs_qs = queryset.filter(id__in=doc_ids) docs_qs = queryset.filter(id__in=doc_ids)
@@ -2119,11 +2109,7 @@ class DocumentViewSet(viewsets.ModelViewSet, DocumentosFiltradosMixin):
doc_ids = [] doc_ids = []
for edoc in edocs: for edoc in edocs:
docs = Document.objects.filter( doc_ids.extend(ids_documentos_entidad(edoc, 'edocument'))
pedimento_id=edoc.pedimento.id,
archivo__icontains=edoc.numero_edocument
).values_list('id', flat=True)
doc_ids.extend(docs)
queryset = self.get_queryset() queryset = self.get_queryset()
docs_qs = queryset.filter(id__in=doc_ids) docs_qs = queryset.filter(id__in=doc_ids)

132
core/document_links.py Normal file
View File

@@ -0,0 +1,132 @@
"""
Resolución de la sub-entidad (partida / cove / edocument) a la que pertenece un
Document, para poblar sus FK reales (`document.partida`/`cove`/`edocument`).
Fuente única, reusada por `Document.save()` (alta de filas nuevas) y por el comando
`backfill_document_links` (filas existentes). Generaliza la frontera de
[core.partida_docs] al resto de secciones.
Discriminador de sección: el `document_type_id` (lo asigna el microservicio al
generar el archivo). El nombre de archivo solo decide **cuál** registro dentro de
la sección, vía frontera tras `vu_<sec>_{pedimento_app}_{numero}` — NO se extrae la
llave partiendo por `_`, porque `pedimento_app` puede contener `_`
(p.ej. `vu_AC_0101_230_1703_3004804_4.pdf`): se itera la entidad con su número exacto.
Mapa tipo→sección (autoritativo, microservice/api/api_v2/modules/*/services.py):
Partida : 1 (respuesta), 17 (request), 18 (error)
Cove : 8 (respuesta), 7 (acuse), 19/20 (request/error), 23/24 (acuse request/error)
EDocument : 5 (respuesta), 4 (acuse), 21/22 (request/error), 25/26 (acuse request/error)
Nativos (sin FK): 2 (PC), 3 (remesa), 6, 13-16, y subidas sin tipo.
"""
import posixpath
import re
from core.partida_docs import es_doc_de_partida
PARTIDA_TYPES = {1, 17, 18}
COVE_TYPES = {7, 8, 19, 20, 23, 24}
EDOCUMENT_TYPES = {4, 5, 21, 22, 25, 26}
TYPE_TO_SECTION = {}
for _t in PARTIDA_TYPES:
TYPE_TO_SECTION[_t] = 'partida'
for _t in COVE_TYPES:
TYPE_TO_SECTION[_t] = 'cove'
for _t in EDOCUMENT_TYPES:
TYPE_TO_SECTION[_t] = 'edocument'
# Campo FK en Document y campo de llave de negocio en la entidad, por sección.
SECCION_CAMPO = {'partida': 'partida', 'cove': 'cove', 'edocument': 'edocument'}
SECCION_LLAVE = {'partida': 'numero_partida', 'cove': 'numero_cove', 'edocument': 'numero_edocument'}
# related_name del FK a Pedimento en cada entidad (EDocument usa 'documentos').
SECCION_RELACION = {'partida': 'partidas', 'cove': 'coves', 'edocument': 'documentos'}
# Prefijos de nombre por sección (cove y edoc tienen variante de acuse).
SECCION_PREFIJOS = {
'partida': ('vu_pt',),
'cove': ('vu_cove', 'vu_ac_cove'),
'edocument': ('vu_ed', 'vu_ac'),
}
def seccion_de_tipo(document_type_id):
"""Sección a la que pertenece un document_type_id, o None si es nativo."""
if document_type_id is None:
return None
return TYPE_TO_SECTION.get(int(document_type_id))
def _coincide_prefijo(base, prefijo):
"""True si `base` empieza con `prefijo` seguido de frontera (`_`, `.` o fin)."""
return base.startswith(prefijo) and (len(base) == len(prefijo) or base[len(prefijo)] in "_.")
def coincide(nombre_archivo, seccion, pedimento_app, numero):
"""Indica si `nombre_archivo` corresponde a (seccion, pedimento_app, numero)."""
if seccion == 'partida':
# Reusa el matcher de partidas (incluye formato legacy).
return es_doc_de_partida(nombre_archivo, pedimento_app, numero)
base = posixpath.basename(nombre_archivo or "").lower()
app = str(pedimento_app).strip().lower()
num = str(numero).strip().lower()
return any(
_coincide_prefijo(base, f"{pref}_{app}_{num}")
for pref in SECCION_PREFIJOS[seccion]
)
def numero_en_nombre(nombre_archivo, numero):
"""True si `numero` aparece como token completo en el basename (con frontera
`_`/`.`/inicio/fin), SIN exigir prefijo ni pedimento_app.
Para documentos LEGADOS cuyo nombre trae otro app/prefijo (p.ej.
`vu_EDC_0201_800_..._04382515ZIFF5_hex.pdf`) pero cuyo número de cove/edoc
(único y largo) sí está en el nombre. NO usar con partida (enteros cortos →
colisiones).
"""
base = posixpath.basename(nombre_archivo or "").lower()
num = re.escape(str(numero).strip().lower())
if not num:
return False
return re.search(rf"(?:^|_){num}(?:_|\.|$)", base) is not None
def match_entidad(nombre_archivo, seccion, pedimento_app, entidades):
"""Devuelve la entidad de `entidades` cuyo número coincide con el archivo, o None.
`entidades` es un iterable de instancias del modelo de la sección (Partida /
Cove / EDocument). Pensado para uso en memoria (backfill con listas precargadas).
"""
llave = SECCION_LLAVE[seccion]
for ent in entidades:
if coincide(nombre_archivo, seccion, pedimento_app, getattr(ent, llave)):
return ent
return None
def resolver_fk(document):
"""Resuelve la FK de sub-entidad de un Document → (campo, instancia | None).
`campo` es 'partida' | 'cove' | 'edocument' (el atributo a setear), o (None, None)
si el documento es nativo de pedimento o no tiene archivo. Hace las consultas
necesarias; para lotes usar `match_entidad` con entidades precargadas.
"""
seccion = seccion_de_tipo(getattr(document, 'document_type_id', None))
if not seccion or not document.archivo:
return (None, None)
pedimento = document.pedimento
entidades = getattr(pedimento, SECCION_RELACION[seccion]).all()
inst = match_entidad(document.archivo.name, seccion, pedimento.pedimento_app, entidades)
return (SECCION_CAMPO[seccion], inst)
# --------------------------------------------------------------------------- #
# Lectura / borrado / descarga: SIEMPRE por la FK real (no por nombre). El nombre
# solo se usa para ESTABLECER la FK (Document.save() en altas, backfill en filas
# viejas). Requiere que el backfill esté completo para datos previos.
# --------------------------------------------------------------------------- #
def ids_documentos_entidad(entidad, seccion):
"""IDs de los documentos de la entidad (borrado/descarga) por la FK real."""
from api.record.models import Document # import diferido: evita ciclo con record.models
return list(Document.objects.filter(**{SECCION_CAMPO[seccion]: entidad}).values_list('id', flat=True))

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