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>
This commit is contained in:
@@ -19,6 +19,11 @@ tipo, repos afectados, qué se hizo y por qué. Reglas del flujo en `../CLAUDE.m
|
|||||||
`document_type` + nombre de archivo en toda ruta de creación (incluida la ingesta del
|
`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`.
|
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` 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 +
|
- 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
|
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`,
|
(archivo MinIO si no lo referencia otra fila + fila + ajuste de cuota), `--dry-run`,
|
||||||
|
|||||||
@@ -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'])
|
||||||
|
))
|
||||||
@@ -829,3 +829,18 @@ class DocumentFKResolutionTests(TestCase):
|
|||||||
restantes = list(Document.objects.filter(edocument=self.edoc, document_type_id=5))
|
restantes = list(Document.objects.filter(edocument=self.edoc, document_type_id=5))
|
||||||
self.assertEqual(len(restantes), 1)
|
self.assertEqual(len(restantes), 1)
|
||||||
self.assertEqual(restantes[0].id, d1.id) # conservó el único con archivo válido
|
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)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ Mapa tipo→sección (autoritativo, microservice/api/api_v2/modules/*/services.p
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
import posixpath
|
import posixpath
|
||||||
|
import re
|
||||||
|
|
||||||
from core.partida_docs import es_doc_de_partida
|
from core.partida_docs import es_doc_de_partida
|
||||||
|
|
||||||
@@ -74,6 +75,22 @@ def coincide(nombre_archivo, seccion, pedimento_app, numero):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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):
|
def match_entidad(nombre_archivo, seccion, pedimento_app, entidades):
|
||||||
"""Devuelve la entidad de `entidades` cuyo número coincide con el archivo, o None.
|
"""Devuelve la entidad de `entidades` cuyo número coincide con el archivo, o None.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user