From dcabfb8762edf8984e12d7b9af302906aeb81bef Mon Sep 17 00:00:00 2001 From: marcos Date: Wed, 24 Jun 2026 13:26:06 -0600 Subject: [PATCH] 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) --- CHANGELOG.md | 5 + .../backfill_document_links_legacy.py | 94 +++++++++++++++++++ api/customs/tests.py | 15 +++ core/document_links.py | 17 ++++ 4 files changed, 131 insertions(+) create mode 100644 api/customs/management/commands/backfill_document_links_legacy.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d97447..eef3513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 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`, diff --git a/api/customs/management/commands/backfill_document_links_legacy.py b/api/customs/management/commands/backfill_document_links_legacy.py new file mode 100644 index 0000000..a54a0c5 --- /dev/null +++ b/api/customs/management/commands/backfill_document_links_legacy.py @@ -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']) + )) diff --git a/api/customs/tests.py b/api/customs/tests.py index 5c9ebb9..40f19ef 100644 --- a/api/customs/tests.py +++ b/api/customs/tests.py @@ -829,3 +829,18 @@ class DocumentFKResolutionTests(TestCase): 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) diff --git a/core/document_links.py b/core/document_links.py index 7759690..c719da8 100644 --- a/core/document_links.py +++ b/core/document_links.py @@ -20,6 +20,7 @@ Mapa tipo→sección (autoritativo, microservice/api/api_v2/modules/*/services.p """ import posixpath +import re 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): """Devuelve la entidad de `entidades` cuyo número coincide con el archivo, o None.