Ir al contenido

Metodología de parchado a producción

Estándar para escribir scripts que corrigen datos ya publicados (GSheets / SQLite) de forma consistente, segura y auditable. Destilado del precedente real scripts/patches/patch_missing_zonales.py. Todo parche nuevo debe seguir este patrón.

Cuando un campo llega mal o vacío a producción (síntoma del meta-patrón Deuda-Datos-Correccion-Manual-Implicita), hay dos arreglos:

  1. Corregir el pipeline — la cura: el próximo lote sale bien.
  2. Parchar lo ya publicado — el alivio: arregla el histórico que el pipeline corregido no va a re-procesar.

Casi siempre se necesitan ambos. Este documento estandariza el (2) para que cada parche se vea igual, sea seguro de correr y no reinvente lógica que ya vive en el pipeline.

:::caution Principio rector — síntoma vs causa raíz El parche alivia el síntoma; la cura vive en el pipeline (principio 12 de Deuda-Datos-Correccion-Manual-Implicita). Todo parche debe nacer con un follow-up de origen: la corrección equivalente en el pipeline para que el dato deje de salir mal. Un parche sin follow-up es deuda que vuelve cada mes. :::

SituaciónAcción
El dato sale mal y seguirá saliendo malCorregir el pipeline (causa). Obligatorio.
Hay histórico ya publicado que el pipeline corregido no re-procesaParchar (alivio).
El dato se puede regenerar corriendo el pipeline de nuevoRe-correr el pipeline, no parchar.
Corrección puntual de < ~5 celdas, una sola vezEditar a mano + anotar; no amerita script.

Regla práctica: si vas a tocar las mismas celdas más de una vez, o más de ~10 celdas, es un script de parche, no una edición manual.

Según cómo afectan el dato existente, hay dos clases. Comparten todos los rasgos de abajo, pero difieren en el rasgo de sobrescritura (#4):

  • Relleno aditivo (ej. patch_missing_zonales.py): solo rellena celdas vacías. Nunca sobrescribe lo que ya tiene valor → cero regresión por construcción. Es el caso por defecto y el más seguro.
  • Normalización de grafía (ej. patch_proceso_typos.py): sobrescribe valores existentes con su forma canónica ("obras mediana""OBRAS MEDIANAS"). Es seguro solo si la transformación es idempotente, preserva la semántica, y proviene de una fuente única auditada (el mismo normalize_* del pipeline). Exige cero-falsos-positivos estricto: tocar únicamente celdas cuyo valor cambia a un canónico conocido — nunca por reformateo casual (p. ej. saneo de espacios).

Los 11 rasgos que todo script en scripts/patches/ debe cumplir, destilados de patch_missing_zonales.py y patch_proceso_typos.py:

  1. Ubicación y nombre. scripts/patches/patch_<que>.py. Un parche = un campo/problema (SRP).
  2. Docstring con la estrategia explícita. Encabeza el archivo con: qué corrige, la cascada de resolución numerada (“primera regla que matchea gana”) y la sección Uso: con los dos modos.
  3. Dry-run por defecto, --apply para escribir. argparse con --apply (action="store_true"). Sin la bandera, el script solo reporta, no toca producción.
  4. Respeta la clase de parche (ver arriba). Relleno aditivo: salta filas con valor (if val and val != "NAN": continue) — nunca sobrescribe (principio 7 del doc de deuda). Normalización: solo toca celdas donde normalize_*(val) != val y el resultado es un canónico conocido; el passthrough (p. ej. saneo de espacios) no cuenta como cambio. Cualquiera de las dos clases: cero regresión por diseño.
  5. Cascada de resolución con método etiquetado. Cada resolución devuelve (valor, método) — el método es la procedencia ("sf_reprocess", "ot_maps", "prefix_205", "classify_zone_desc"…). Esto hace auditable de dónde salió cada valor (principio 9 del doc de deuda).
  6. Reutiliza las herramientas del pipeline — no dupliques lógica (DRY). El parche importa y usa los mismos normalizadores/clasificadores que la ingesta (classify_zone, normalize_text, get_ot_maps, PAT_OT, std_ids). Si el pipeline clasifica una zona de una forma, el parche debe clasificarla igual. Esta es la regla más importante: una sola fuente de verdad de normalización, usada en ingesta + parche.
  7. Reporte completo antes de aplicar. Imprime: total resolubles vs. sin resolver, conteo por método, conteo por valor, y detalle fila por fila (fila N: VALOR (via método) | claves). El dry-run debe dejar ver exactamente qué se va a escribir.
  8. Lo no resuelto NO se toca, se reporta. Cero falsos positivos: lo ambiguo se lista para revisión manual, nunca se rellena con un valor adivinado (principio 9 del doc de deuda).
  9. Idempotente. Correrlo dos veces no cambia nada la segunda vez (aditivo: las filas ya no están vacías; normalización: los valores ya son canónicos). Verifícalo: un segundo dry-run debe reportar 0 cambios.
  10. Escritura resiliente. Escribe en lote con reintento (call_with_retry(ws.update_cells, cells)), no celda-por-celda en bucle sin retry. Respeta los rate limits de GSheets (ver Debugging-GSheets-Rate-Limits).
  11. Salida UTF-8 (Windows). El reporte usa caracteres no-ASCII (, tildes, ñ). En consolas Windows (cp1252, p. ej. anaconda) eso aborta con UnicodeEncodeError al imprimir. Reconfigura la salida al inicio del script:
    for _stream in (sys.stdout, sys.stderr):
    if hasattr(_stream, "reconfigure"):
    _stream.reconfigure(encoding="utf-8")
# scripts/patches/patch_<que>.py
"""
Parche one-shot: <qué corrige y por qué quedó mal>.
Estrategia (cascada, primera que matchea gana):
0. <fuente más confiable>
1. <regla determinística>
...
N. Fallback: no se toca (se reporta)
Uso:
python scripts/patches/patch_<que>.py # dry-run
python scripts/patches/patch_<que>.py --apply # escribe a producción
Follow-up de origen: <link al fix del pipeline / issue>
"""
import argparse
# Reutilizar SIEMPRE las herramientas del pipeline (DRY):
from utils.utils_text import normalize_text # normalización compartida
from static_data.classifiers import classify_zone # clasificador compartido
def _resolver(row) -> tuple[str | None, str]:
"""Devuelve (valor, metodo) o (None, 'unresolved'). Procedencia auditable."""
...
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--apply", action="store_true", help="Escribir a producción")
args = ap.parse_args()
patches, unresolved = [], []
for row in filas:
if ya_tiene_valor(row): # aditivo: no sobrescribir
continue
val, metodo = _resolver(row)
(patches if val else unresolved).append(...)
reporte(patches, unresolved) # conteos + detalle fila a fila
if not args.apply:
print(f"[DRY-RUN] {len(patches)} cambios. Usa --apply para escribir.")
return
escribir_con_retry(patches)

Antes de correr --apply:

  • Corrí el dry-run y revisé el detalle fila por fila.
  • Las reglas reutilizan los normalizadores/clasificadores del pipeline (no hay lógica duplicada).
  • Identifiqué la clase (relleno aditivo o normalización) y confirmé su invariante: aditivo no sobrescribe; normalización solo toca cambios a un canónico conocido.
  • Lo no resuelto/ambiguo queda listado, no rellenado a la fuerza.
  • La salida no rompe en consola Windows (UTF-8 reconfigurado — rasgo #11).

Después de aplicar:

  • Anoté cuántas celdas se tocaron y por qué método (procedencia).
  • Abrí/anoté el follow-up de origen (fix del pipeline) para que el dato deje de salir mal.
  • Registré el parche (commit + nota en sesión/engram).

Clase relleno aditivoscripts/patches/patch_missing_zonales.py (commit 3635aae, PR #40): rellenó 105 filas de FACTURACIÓN sin Zonal_Ejecutora. Cascada de 8 niveles (SF reprocesado → OT maps → prefijo OT → misma factura → SAP → pagos pendientes → classify_zone sobre la descripción → no-tocar). Reutiliza classify_zone y normalize_text del pipeline — la regla DRY de este estándar.

Clase normalización de grafíascripts/patches/patch_proceso_typos.py (PR #58): normaliza la columna Proceso de FACTURACIÓN reutilizando normalize_proceso (PR #57). Corrige "obras mediana""OBRAS MEDIANAS", "eerr""EE.RR.". Aplicó 7 celdas de 6567, cero falsos positivos, idempotencia verificada (segundo dry-run = 0 cambios). Lógica de decisión pura (_resolver_cambios) separada del I/O para tests sin red. Destapó el rasgo #11 (UTF-8 en Windows).

  • Single-Source-of-Truth — la normalización vive en un solo lugar (los helpers/clasificadores del pipeline) y tanto la ingesta como el parche la consumen; el parche nunca define su propia versión.
  • Criterio Modularizacion - DRY — rasgo #6: prohibido duplicar lógica de resolución entre pipeline y parche.
  • Criterio Modularizacion - Adapter Port — la misma función de normalización se aplica en dos puntos de entrada distintos (ingesta y corrección post-hoc) sin reimplementarse.
  • Separation-of-Concerns — un parche = un campo/problema; el reporte (qué se haría) está separado de la escritura (--apply).
  • Deuda-Datos-Correccion-Manual-Implicita — el meta-patrón que motiva los parches; el parche ataca el síntoma, el pipeline la causa.
  • Como-Crear-Pipeline-Nuevo — dónde vive la corrección de origen (el follow-up).
  • Debugging-GSheets-Rate-Limits — escritura resiliente a GSheets.
  • Pedidos-HES · Facturacion — dominios donde más se parcha.