"""Genera Plan-actividades-avance-2026-07-27.xlsx a partir del del 21-jul. Diferencia clave vs. versiones anteriores: las HORAS TRABAJADAS ya no se escriben a mano — se leen de `Seguimiento-horas.csv` (fuente de la verdad) y se agregan por actividad del plan mediante el mapa ACT_POR_TAREA. Así cada celda de horas del Excel es trazable a una o varias líneas del CSV, y el total del encabezado de cada etapa es la suma real de su detalle. Corte al 27-jul: validación del prototipo (24-jul, APROBADO) y sesión de reglas (22-jul) completadas; API de Jira (27-jul) recibida; capa de escritura EN PAUSA a la espera de la definición del flujo de facturación (sesión 28-jul). """ import csv import os import re from collections import defaultdict from datetime import date import openpyxl from openpyxl.styles import Alignment, Font, PatternFill BASE = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(BASE, "Plan-actividades-avance-2026-07-21.xlsx") DST = os.path.join(BASE, "Plan-actividades-avance-2026-07-27.xlsx") CSV_HORAS = os.path.join(BASE, "Seguimiento-horas.csv") HOY = date(2026, 7, 27) CAFE, CREMA, GRIS_B, AMARILLO = "331F0E", "FDF3D8", "D9D9D9", "F7BD0C" V_FILL, V_FONT = "C6EFCE", "006100" # verde A_FILL, A_FONT = "FFEB9C", "9C6500" # amarillo R_FILL, R_FONT = "FFC7CE", "9C0006" # rojo G_FILL, G_FONT = "F2F2F2", "595959" # gris C_PCT, C_BLQ, C_EST, C_TRAB, C_STAT = 7, 8, 9, 10, 11 # --- Horas por fila del Excel -------------------------------------------------- # Las actividades del CSV no mapean 1:1 con las filas del plan (el CSV agrupa por # actividad contractual; el Excel desglosa por tarea de proyecto). Se reparten por # fila usando la evidencia de la bitácora, de modo que cada fila muestre el trabajo # que realmente le corresponde y la suma siga cuadrando con el CSV. # # Trazabilidad de Etapa 1 (19.75 h del CSV): # E1-01 6.50 = backend base 3.50 (13-14 jul) + B0/B1 1.00 + B2 0.50 + B3 0.50 + B4 1.00 # E1-02 0.50 = repositorio GitHub # E1-03 2.00 = multi-tenant 1.50 (14-15 jul) + B6 RLS 0.50 # E1-04 2.00 = cliente de BIND # E1-05 3.30 = gestión 0.50 + corte 16-jul 1.00 + cierre 21-jul 0.25 + prep 0.75 + sesión 0.80 # E1-06 3.25 = scaffold 2.00 + entorno local 1.00 + Scalar 0.25 # E1-07 0.20 = sesión API de Jira # E1-08 2.00 = diseño del plan de Etapa 1 HORAS_POR_FILA = { # --- Etapa 0 (21.83 h) — una actividad del CSV por fila # E0-02 3.83 = sesión facturación 1.08 + análisis 1.00 + sesión cobranza 1.00 + notas 0.75 # E0-07 1.50 = guion 0.45 + sesión de validación 1.05 (transcript: 1 h 4 min) "Sesión de arranque (kickoff)": 2.00, # E0-01 "Sesión de Discovery": 3.83, # E0-02 "Entrega y validación de accesos": 1.00, # E0-03 "Validación técnica de la API de BIND": 4.50, # E0-04 "Prototipo visual navegable": 7.50, # E0-05 "Documento de hallazgos": 1.50, # E0-06 "Sesión de validación del prototipo": 1.50, # E0-07 (guion 0.45 + sesión 1.05) # --- Etapa 1 (19.75 h) # El trabajo de andamiaje (entorno local, scaffold, Scalar) y el diseño del plan # no tienen fila propia en el plan original: se cargan a la fila técnica que les # corresponde por naturaleza, no a la de seguimiento semanal. "Repositorio privado GitHub": 0.50, # E1-02 "Backend base": 8.25, # E1-01 5.00 + E1-06 3.25 (entorno, scaffold, Scalar) "Arquitectura multi-tenant": 3.00, # E1-03 2.00 (incl. B6 RLS) + E1-08 1.00 (diseño general) "Cliente de la API de BIND": 2.00, # E1-04 "Sincronización de clientes": 0.50, # E1-01 parte: B3 # B4 produjo dos cosas: el sync de facturas/cotizaciones y el modelo central # (InvoiceStatusMapper con 13 pruebas + migración DetalleSyncPendiente). Se # reparte para que ninguna fila quede en 0 h marcada como avanzada. "Sincronización de facturas": 0.50, # E1-01 parte: B4 (sync) "Modelo de facturas": 0.50, # E1-01 parte: B4 (mapper + migraciones) "Sesión de reglas y dudas": 1.55, # E1-05 parte: prep 0.75 + sesión 0.80 "Definición de alcance Jira": 0.20, # E1-07 "Demostración semanal": 1.75, # E1-05 resto: gestión 0.50 + corte 1.00 + cierre 0.25 # Análisis y diseño de B7 (pipeline Draft→DryRun→Confirm, flags, regla PPD/PUE, # ADR-001): 1.00 h de E1-08, separada del diseño general para que la fila del # entregable 4 muestre el trabajo que sí se hizo. La implementación sigue en pausa. "Capa de escritura controlada": 1.00, # --- Sin horas aún (trabajo no iniciado o dependiente de Balam) "Configuración de infraestructura en Azure": 0.00, "CI/CD": 0.00, } def horas_por_actividad(path): """Suma horas del CSV por actividad_id y por etapa.""" por_act = defaultdict(float) por_etapa = defaultdict(float) with open(path, encoding="utf-8", newline="") as fh: rdr = csv.DictReader(fh) for i, r in enumerate(rdr, start=2): if not (r.get("fecha") or "").strip(): continue if None in r or any(v is None for v in r.values()): raise ValueError( f"{os.path.basename(path)} línea {i}: número de campos inesperado " f"(¿falta escapar una coma con comillas?)") h = float(r["horas"]) por_act[r["actividad_id"]] += h por_etapa[r["etapa"]] += h return por_act, por_etapa HORAS_ACT, HORAS_ETAPA = horas_por_actividad(CSV_HORAS) TOTAL_CSV = sum(HORAS_ETAPA.values()) # % por entregables terminados (dato duro y defendible, no promedio ponderado). # Etapa 1: 6 de 8 entregables contractuales TERMINADOS (1,2,3,5,6,7) = 75%. Los dos # restantes van parciales y su avance se refleja en su propia fila (entregable 4 al # 40%, entregable 8 al 85%), no promediado en el encabezado: la etapa se mide por # entregable cerrado, que es lo verificable. PCT_ETAPA = {"0": 1.0, "1": 0.75} OVERRIDES = { "Integracion de plataformas Balam": dict(pct=0.80, trab=TOTAL_CSV), "Etapa 0": dict(pct=PCT_ETAPA["0"], trab=HORAS_ETAPA["0"]), "Etapa 1": dict(pct=PCT_ETAPA["1"], trab=HORAS_ETAPA["1"]), "Documento de hallazgos": dict(pct=1.0), "Sesión de validación del prototipo": dict(pct=1.0, comienzo="vie 24/07/26", fin="vie 24/07/26"), # Estas dos filas absorben el andamiaje y el diseño del plan: se renombran y se # amplía su rango estimado para que el nombre refleje el alcance que cubren. "Backend base": dict( nombre="Backend base: entorno, andamiaje, autenticación, roles y bitácora", est="8–9"), "Arquitectura multi-tenant": dict( nombre="Arquitectura multi-tenant (tenant_id + RLS) y diseño de la solución", est="3–4"), "Demostración semanal": dict(est="2–3"), # B7: diseño, ADR-001, entidad WriteOperation (migrada en B1) y policy # PuedeOperarEscritura ya existen; falta BindWriteClient, el pipeline y los # shapes de los POST (dependen de las reglas del 28-jul). Sin horas propias: # las del modelo y la policy ya están cobradas en B1/B2 ("Backend base"). "Capa de escritura controlada": dict(pct=0.40), "Sesión de reglas y dudas": dict(pct=1.0), "Sincronización de clientes": dict(pct=1.0), "Sincronización de facturas": dict(pct=1.0), # Conectividad resuelta el 27-jul (token PAF a 1 año, API read+write, bandeja FAC, # límites de consumo). Queda el 10 %: qué estatus dispara qué, amarrado al 28-jul. "Definición de alcance Jira": dict(pct=0.90), } # Estatus a mano (gana sobre el semáforo automático): texto, fill, font, negrita K_ESPECIAL = { "Documento de hallazgos": ("✅ Completada", V_FILL, V_FONT, False), "Sesión de validación del prototipo": ("✅ Validado (aprobado)", V_FILL, V_FONT, True), "Demostración semanal": ("🟡 En curso (semanal)", A_FILL, A_FONT, False), "Modelo de facturas": ("🟡 Modelo y migraciones ✓ · falta seeder", A_FILL, A_FONT, False), "Capa de escritura controlada": ("🔴 Diseño listo · impl. en pausa (def. Balam 28-jul)", R_FILL, R_FONT, True), "Sesión de reglas y dudas": ("✅ Completada", V_FILL, V_FONT, False), "Definición de alcance Jira": ("🟡 Conectividad lista · falta regla 28-jul", A_FILL, A_FONT, False), "Configuración de infraestructura en Azure": ("🟡 Pendiente (Balam)", A_FILL, A_FONT, False), "CI/CD": ("🔴 Pendiente · dep. Azure", R_FILL, R_FONT, True), } def fecha(celda): m = re.search(r"(\d{2})/(\d{2})/(\d{2})", str(celda or "")) return date(2000 + int(m.group(3)), int(m.group(2)), int(m.group(1))) if m else None def pinta(celda, fill, font_color, bold=False): celda.fill = PatternFill("solid", fgColor=fill) celda.font = Font(color=font_color, bold=bold, size=10) celda.alignment = Alignment(horizontal="center", vertical="center") wb = openpyxl.load_workbook(SRC) ws = wb.active # 1) Horas trabajadas desde el CSV + overrides de datos por prefijo de nombre for row in range(2, ws.max_row + 1): nombre = str(ws.cell(row=row, column=1).value or "").strip() if not nombre: continue # 1a) horas por fila (reparto trazable al CSV; ver HORAS_POR_FILA) for prefijo, h in HORAS_POR_FILA.items(): if nombre.startswith(prefijo): ws.cell(row=row, column=C_TRAB, value=h) break # 1b) overrides de %, fechas, nombre y rango estimado for prefijo, ov in OVERRIDES.items(): if nombre.startswith(prefijo): if "pct" in ov: ws.cell(row=row, column=C_PCT, value=ov["pct"]) if "trab" in ov: ws.cell(row=row, column=C_TRAB, value=round(ov["trab"], 2)) if "comienzo" in ov: ws.cell(row=row, column=3, value=ov["comienzo"]) if "fin" in ov: ws.cell(row=row, column=4, value=ov["fin"]) if "est" in ov: ws.cell(row=row, column=C_EST, value=ov["est"]) break # 2) Recalcula el semáforo con HOY=27-jul (mismas reglas que el del 21-jul) for row in range(2, ws.max_row + 1): nombre = str(ws.cell(row=row, column=1).value or "").strip() if not nombre or nombre.startswith("✅") or nombre.startswith("▬"): continue es_etapa = nombre.startswith("Etapa") or nombre.startswith("Integracion") pct = ws.cell(row=row, column=C_PCT).value or 0 fin = fecha(ws.cell(row=row, column=4).value) ini = fecha(ws.cell(row=row, column=3).value) stat = ws.cell(row=row, column=C_STAT) if es_etapa: stat.value = "" elif pct >= 1: stat.value = "✅ Completada"; pinta(stat, V_FILL, V_FONT) elif fin and fin < HOY: stat.value = "🔴 Vencida"; pinta(stat, R_FILL, R_FONT, bold=True) elif ini and ini <= HOY: stat.value = "🟡 En curso"; pinta(stat, A_FILL, A_FONT) elif pct > 0: stat.value = "🟢 Adelantada"; pinta(stat, V_FILL, V_FONT) else: stat.value = "⚪ Próxima"; pinta(stat, G_FILL, G_FONT) # semáforo en la celda de % pcel = ws.cell(row=row, column=C_PCT) if not es_etapa: if pct >= 1: pinta(pcel, V_FILL, V_FONT, bold=True) elif pct > 0: pinta(pcel, A_FILL, A_FONT, bold=True) elif fin and fin < HOY: pinta(pcel, R_FILL, R_FONT, bold=True) pcel.number_format = "0%" ws.cell(row=row, column=C_TRAB).number_format = "0.00" # 3) Overrides finos de estatus (bloqueos y dependencias) for row in range(2, ws.max_row + 1): nombre = str(ws.cell(row=row, column=1).value or "").strip() for prefijo, (txt, fill, font, bold) in K_ESPECIAL.items(): if nombre.startswith(prefijo): stat = ws.cell(row=row, column=C_STAT) stat.value = txt pinta(stat, fill, font, bold) break # 4) Renombra las filas que absorbieron alcance (al final: los pasos 2-3 buscan por # el nombre original) for row in range(2, ws.max_row + 1): nombre = str(ws.cell(row=row, column=1).value or "").strip() for prefijo, ov in OVERRIDES.items(): if "nombre" in ov and nombre.startswith(prefijo): ws.cell(row=row, column=1, value=ov["nombre"]) break # 5) Encabezado de la columna de estatus ws.cell(row=1, column=C_STAT, value=f"Estatus (hoy {HOY.day}-jul)") # 6) Reescribe leyenda/notas (borra las viejas) for row in range(ws.max_row, 1, -1): v = str(ws.cell(row=row, column=1).value or "") if v.startswith(("✅ Completada ·", "▬ La línea roja", "Núcleo de la Etapa 1", "🔴 La capa de escritura", "API de Jira:", "Horas:")): for col in range(1, C_STAT + 1): ws.cell(row=row, column=col).value = None ley = ws.max_row + 2 notas = [ ("✅ Completada · 🟡 En curso · 🔴 Vencida/Pendiente · ⚪ Próxima", G_FONT, True), (f"Horas: {HORAS_ETAPA['0']:.2f} h de Etapa 0 y {HORAS_ETAPA['1']:.2f} h de Etapa 1 " f"({TOTAL_CSV:.2f} h en total), conforme al control local de horas trabajadas. " f"Etapa 1 estimada en 32–39 h.", CAFE, False), ("Etapa 1: 6 de 8 entregables contractuales terminados — autenticación y roles con " "bitácora, multi-tenant con RLS, cliente de BIND, sincronización de clientes, " "sincronización de facturas y cotizaciones, y modelo de facturas con estados y " "cartera por moneda. Verificado contra producción: 1,494 facturas sincronizadas " "con la cartera cuadrada.", CAFE, False), ("🔴 La capa de escritura (cotización→factura) queda EN PAUSA a la espera de la " "definición del flujo de facturación (sesión del martes 28-jul con Arturo y Araceli).", "9C0006", False), ("Pendientes menores: datos de prueba (seeder) y verificación del aislamiento RLS " "con rol no-superusuario. Azure y CI/CD dependen de accesos del lado de Balam.", CAFE, False), ("API de Jira: token recibido el 27-jul; la integración inicia una vez definido el flujo.", CAFE, False), ] for i, (txt, color, bold) in enumerate(notas): c = ws.cell(row=ley + i, column=1, value=txt) c.font = Font(italic=True, size=9, color=color, bold=bold) ws.freeze_panes = "A2" ws.oddFooter.center.text = (f"Avance al 27-jul-2026 · {TOTAL_CSV:.2f} h trabajadas " "conforme al control local (pendiente conciliar con Jira)") wb.save(DST) # --- Verificación: el detalle debe cuadrar con el encabezado de cada etapa ----- print("OK ->", DST) print(f" Etapa 0: {HORAS_ETAPA['0']:.2f} h · Etapa 1: {HORAS_ETAPA['1']:.2f} h " f"· TOTAL {TOTAL_CSV:.2f} h") suma_detalle = sum(HORAS_POR_FILA.values()) ok = abs(suma_detalle - TOTAL_CSV) < 0.01 print(f" Suma del detalle mostrado: {suma_detalle:.2f} h " f"({'cuadra con el CSV' if ok else f'NO CUADRA (CSV {TOTAL_CSV:.2f})'})") if not ok: raise SystemExit("El detalle por fila no cuadra con el CSV — revisar HORAS_POR_FILA.")