"""Genera Avance-Balam-2026-09-16.xlsx a partir del corte del 15-sep. Corte del miércoles 16-sep: sin sesiones ni desarrollo nuevos desde el 15-sep; solo se publicó el Excel de avance en Google Sheets (Drive/Balam) y se regeneró este corte. El corte del 15-sep no se toca. Las horas se leen de `Seguimiento-horas.csv`; el script aborta si el reparto no cuadra con las horas nuevas del CSV. """ import csv import os from collections import defaultdict import openpyxl from openpyxl.styles import Alignment, Font, PatternFill BASE = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(BASE, "Avance-Balam-2026-09-15.xlsx") DST = os.path.join(BASE, "Avance-Balam-2026-09-16.xlsx") CSV_HORAS = os.path.join(BASE, "Seguimiento-horas.csv") CAFE, GRIS = "331F0E", "595959" V_FILL, V_FONT = "C6EFCE", "006100" A_FILL, A_FONT = "FFEB9C", "9C6500" C_PCT, C_TRAB, C_STAT = 4, 6, 7 CORTE_ANTERIOR = 94.08 # corte del 15-sep # --- CSV: totales por etapa y por actividad ----------------------------------- por_act, por_etapa = defaultdict(float), defaultdict(float) nuevas, desde_6ago = 0.0, 0.0 with open(CSV_HORAS, encoding="utf-8", newline="") as fh: for r in csv.DictReader(fh): if not (r.get("fecha") or "").strip(): continue h = float(r["horas"]) por_act[r["actividad_id"]] += h por_etapa[r["etapa"]] += h if r["fecha"] > "2026-09-15": nuevas += h if r["fecha"] > "2026-08-06": desde_6ago += h E0, E1, E3 = por_etapa["0"], por_etapa["1"], por_etapa["3"] AJ = por_act["AJ-01"] E2 = por_etapa["2"] - AJ TOTAL = E0 + E1 + E2 + AJ + E3 assert abs(TOTAL - CORTE_ANTERIOR - nuevas) < 0.01, "El CSV no arranca del corte del 15-sep" PCT_GLOBAL = round(TOTAL / ((119 + 146) / 2), 2) # E1-05 +0.50 (Excel en Google Sheets + corte del 16-sep) → gestión y cortes. DELTAS_TRAB = {"Demostración semanal": 0.50} assert abs(sum(DELTAS_TRAB.values()) - nuevas) < 0.01, ( f"El reparto ({sum(DELTAS_TRAB.values()):.2f}) no suma las {nuevas:.2f} h nuevas del CSV") ENCABEZADOS = { "Integracion de plataformas Balam": (TOTAL, PCT_GLOBAL), "Etapa 0": (E0, 1.0), "Etapa 1": (E1, 0.95), "Etapa 2": (E2, 0.70), "» Ajuste dentro de la Etapa 2": (AJ, 0.85), "Etapa 3": (E3, 0.05), } FILAS = { "Demostración semanal": (0.6, "🟡 En curso · el seguimiento pasó a Pedro (sesiones 'Seguimiento PAF' " "21, 24 y 26-ago; 3, 9 y 10-sep) · corte de horas al 16-sep, publicado en " "Google Sheets", A_FILL, A_FONT, False), } def pinta(celda, fill, font_color, bold=False, size=9, align="left"): celda.fill = PatternFill("solid", fgColor=fill) celda.font = Font(color=font_color, bold=bold, size=size) celda.alignment = Alignment(horizontal=align, vertical="center", wrap_text=True) wb = openpyxl.load_workbook(SRC) ws = wb.active ws["A2"] = ("Plan y avance al 16-sep-2026 · plataforma publicada en paputec.mx (provisional), cotización real " "desde el ticket (individual y en lote); prefactura por API bloqueada por BIND · horas del " "10-ago al 16-sep reconstruidas, pendientes de validar") ws["A2"].font = Font(size=10, italic=True, color=GRIS) # Notas del corte anterior: se limpian antes del bucle y se reescriben abajo. primera_nota = None for row in range(5, ws.max_row + 1): v = str(ws.cell(row=row, column=1).value or "") if v.startswith("✅ Completada ·"): primera_nota = row if primera_nota and row >= primera_nota: for rango in list(ws.merged_cells.ranges): if rango.min_row == row: ws.unmerge_cells(str(rango)) ws.cell(row=row, column=1).value = None tope = primera_nota if primera_nota else ws.max_row + 1 aplicado = set() for row in range(5, tope): nombre = str(ws.cell(row=row, column=1).value or "").strip() if not nombre: continue for prefijo, (trab, pct) in ENCABEZADOS.items(): if nombre.startswith(prefijo): c = ws.cell(row=row, column=C_TRAB, value=round(trab, 2)) c.number_format = "0.00" p = ws.cell(row=row, column=C_PCT, value=pct) p.number_format = "0%" pinta(p, V_FILL if pct >= 1 else A_FILL, V_FONT if pct >= 1 else A_FONT, bold=True, size=10, align="center") break for prefijo, delta in DELTAS_TRAB.items(): if nombre.startswith(prefijo): actual = ws.cell(row=row, column=C_TRAB).value actual = float(actual) if isinstance(actual, (int, float)) else 0.0 c = ws.cell(row=row, column=C_TRAB, value=round(actual + delta, 2)) c.number_format = "0.00" aplicado.add(prefijo) break for prefijo, (pct, txt, fill, fcolor, bold) in FILAS.items(): if nombre.startswith(prefijo): p = ws.cell(row=row, column=C_PCT, value=pct) p.number_format = "0%" pinta(p, A_FILL, A_FONT, bold=True, size=10, align="center") s = ws.cell(row=row, column=C_STAT, value=txt) pinta(s, fill, fcolor, bold) break faltan = set(DELTAS_TRAB) - aplicado assert not faltan, f"Filas no encontradas en el Excel: {faltan}" notas = [ ("✅ Completada · 🟡 En curso · 🔴 Pendiente/bloqueada · ⚪ Próxima · " "(adelantada) = ejecutada antes de su fecha plan", True), (f"Horas trabajadas al 16-sep: Etapa 0 {E0:.2f} h (cerrada y validada) · Etapa 1 {E1:.2f} h " f"(rango 32–39) · Etapa 2 {E2:.2f} h · ajuste Jira {AJ:.2f} h (de 7–10 aprobadas) · " f"Etapa 3 {E3:.2f} h · total {TOTAL:.2f} h. Las {desde_6ago:.2f} h posteriores al corte del 6-ago " "están reconstruidas con evidencia (commits, correos, informes de reunión) y pendientes de " "validar. Base de facturación a $600/h + IVA.", True), ("Etapa 1: la capa de escritura quedó implementada (cotización y prefactura con dry-run, " "confirmación humana y auditoría). Solo Azure y CI/CD siguen dependiendo de Balam; mientras " "tanto la plataforma está publicada en https://paputec.mx/balam/ desde el 3-sep (BIND y Jira " "en solo lectura fuera de sesión).", False), ("Integración Jira → BIND: primera cotización real desde un ticket el 21-ago (folio 1026, tras " "5 intentos por dos semánticas indocumentadas del API: Type=2 y VAT como tasa), tres tickets " "cotizados en la sesión del 3-sep y cotización en lote desde el 7-sep. Rebasa las 7–10 h " "aprobadas por las semánticas indocumentadas y las sesiones acompañadas (12, 21-ago, 3-sep).", False), ("Etapa 2: la prefactura por API NO es posible con lo que BIND expone (26-ago: IsFiscalInvoice=true " "timbra al crear —CFDI 2386, a cancelar— y false produce remisión; el tipo Prefactura solo " "existe en su UI). Sesiones del 9 y 10-sep con Araceli y Pedro para capturar el endpoint " "interno y decidir el camino (réplica, detección al timbrado o soporte de BIND).", False), ("En espera de Balam: acceso a Azure (requisitos enviados el 25-ago), cancelación del CFDI 2386 " "y de la cotización 1026, y la definición del camino de prefactura.", False), ] r = primera_nota if primera_nota else ws.max_row + 2 for txt, bold in notas: c = ws.cell(row=r, column=1, value=txt) c.font = Font(italic=True, size=9, color=CAFE, bold=bold) c.alignment = Alignment(wrap_text=True, vertical="top") try: ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=7) except ValueError: pass ws.row_dimensions[r].height = 40 if len(txt) > 220 else (28 if len(txt) > 120 else 14) r += 1 ws.print_area = f"A1:G{r}" wb.save(DST) print("OK ->", DST) print(f" E0 {E0:.2f} · E1 {E1:.2f} · E2 {E2:.2f} · Jira {AJ:.2f} · E3 {E3:.2f} · TOTAL {TOTAL:.2f} h " f"({nuevas:.2f} nuevas) · avance global {PCT_GLOBAL:.0%}")