"""Genera Avance-Balam-2026-07-28.xlsx — plan y avance COMPLETO para Erika. Pedido por Erika el 27-28 jul: el conteo de horas de cada etapa (base de la facturación) en el mismo formato de los exceles anteriores, extendido a las Etapas 2 y 3 desglosadas por actividades diarias. Etapas 0-1 (hechas/en curso) se leen del Excel interno (`Plan-actividades-avance-2026-07-27.xlsx`), que a su vez sale de `Seguimiento-horas.csv` — una sola fuente de datos. Las Etapas 2-3 y el ajuste Jira → BIND se planifican aquí con los rangos contractuales de la propuesta v1.3 (§2 y §4): E2 38–46 h · E3 24–29 h · Jira 7–10 h · total 119–146 h. """ import os import re from datetime import date import openpyxl from openpyxl.styles import Alignment, Border, Font, PatternFill, Side from openpyxl.utils import get_column_letter BASE = os.path.dirname(os.path.abspath(__file__)) SRC = os.path.join(BASE, "Plan-actividades-avance-2026-07-27.xlsx") DST = os.path.join(BASE, "Avance-Balam-2026-07-28.xlsx") # Marca Balam CAFE, AMARILLO, CREMA = "331F0E", "F7BD0C", "FDF3D8" V_FILL, V_FONT = "C6EFCE", "006100" # verde A_FILL, A_FONT = "FFEB9C", "9C6500" # amarillo R_FILL, R_FONT = "FFC7CE", "9C0006" # rojo GRIS = "595959" BORDE = "D9D9D9" thin = Side(style="thin", color=BORDE) box = Border(left=thin, right=thin, top=thin, bottom=thin) # Columnas del origen (interno 27-jul) O_NOMBRE, O_INI, O_FIN, O_PCT, O_EST, O_TRAB, O_STAT = 1, 3, 4, 7, 9, 10, 11 # Estatus del interno que quedaron amarrados a la "sesión del 28-jul" — # la sesión se movió a revisión interna de Balam (REGISTRO #58). REEMPLAZOS_STAT = { "🔴 Diseño listo · impl. en pausa (def. Balam 28-jul)": "🔴 Diseño listo · impl. en pausa (flujo en revisión interna de Balam)", "🟡 Conectividad lista · falta regla 28-jul": "🟡 Conectividad lista · falta regla (revisión interna de Balam)", } # Los rangos por actividad del interno sumaban 34–42 h en Etapa 1, fuera del # rango contractual de 32–39 (tope de facturación $23,400). Se recortan las # tres actividades cuyas horas reales quedaron muy por debajo del estimado, # para que la descomposición sume exactamente 32–39. REEMPLAZOS_EST = { "Sincronización de clientes": "1.5–2", # antes 2.5–3 (real: 0.5) "Sincronización de facturas y cotizaciones": "4–4.5", # antes 4.5–5.5 (real: 0.5) "Modelo de facturas + migraciones + datos de prueba": "3–3.5" # antes 3.5–4.5 (real: 0.5) } def limpia_fecha(txt): """'vie 24/07/26' -> '24/07/2026'.""" m = re.search(r"(\d{2})/(\d{2})/(\d{2})", str(txt or "")) return f"{m.group(1)}/{m.group(2)}/20{m.group(3)}" if m else "" # --- Lee Etapas 0-1 del interno ---------------------------------------------- wsrc = openpyxl.load_workbook(SRC).active hechas = [] # filas E0/E1 (encabezados de etapa + actividades) trab_por_etapa = {} for row in range(2, wsrc.max_row + 1): nombre = str(wsrc.cell(row=row, column=O_NOMBRE).value or "").strip() if not nombre or nombre.startswith(("✅", "▬", "Horas:", "Etapa 1:", "🔴", "Pendientes", "API de Jira", "Núcleo")): continue if nombre.startswith("Integracion"): trab_por_etapa["TOTAL"] = float(wsrc.cell(row=row, column=O_TRAB).value or 0) continue es_etapa = nombre.startswith("Etapa") if es_etapa: trab_por_etapa[nombre] = float(wsrc.cell(row=row, column=O_TRAB).value or 0) stat = str(wsrc.cell(row=row, column=O_STAT).value or "").strip() est = str(wsrc.cell(row=row, column=O_EST).value or "") hechas.append({ "nombre": nombre, "ini": limpia_fecha(wsrc.cell(row=row, column=O_INI).value), "fin": limpia_fecha(wsrc.cell(row=row, column=O_FIN).value), "pct": wsrc.cell(row=row, column=O_PCT).value or 0, "est": REEMPLAZOS_EST.get(nombre, est), "trab": wsrc.cell(row=row, column=O_TRAB).value, "stat": REEMPLAZOS_STAT.get(stat, stat), "etapa": es_etapa, }) E0, E1 = trab_por_etapa["Etapa 0"], trab_por_etapa["Etapa 1"] TOTAL = trab_por_etapa["TOTAL"] assert abs((E0 + E1) - TOTAL) < 0.01, f"Horas no cuadran: {E0}+{E1} != {TOTAL}" def suma_est(filas): lo = hi = 0.0 for f in filas: est = f["est"] if isinstance(f, dict) else f if not est or est == "—": continue p = est.replace("–", "-").split("-") lo += float(p[0]) hi += float(p[-1]) return lo, hi # Las actividades de E0 y E1 deben sumar exactamente su rango contractual. _secciones = {} _actual = None for f in hechas: if f["etapa"]: _actual = f["nombre"] else: _secciones.setdefault(_actual, []).append(f) assert suma_est(_secciones["Etapa 0"]) == (18, 22), \ f"Etapa 0: actividades suman {suma_est(_secciones['Etapa 0'])} != (18, 22)" assert suma_est(_secciones["Etapa 1"]) == (32, 39), \ f"Etapa 1: actividades suman {suma_est(_secciones['Etapa 1'])} != (32, 39)" # --- Plan de Etapas 2-3 y ajuste Jira (actividades diarias) ------------------- PROX = "⚪ Próxima" PROX_FLUJO = "⚪ Próxima · requiere flujo de facturación definido" PROX_JIRA = "⚪ Próxima · sujeta a aprobación (v1.3) y flujo definido" # (nombre, ini, fin, est_min, est_max, estatus) ETAPA2 = ("Etapa 2 — Facturación (consulta y emisión), catálogo y multimoneda", "29/07/2026", "12/08/2026", 38, 46, [ ("Andamiaje del frontend (Angular): proyecto, login y guardias por rol", "29/07/2026", "30/07/2026", "4–5", PROX), ("Layout base con la identidad de marca; gestión de estado y caché", "30/07/2026", "31/07/2026", "3–4", PROX), ("Listado de facturas: filtros, búsqueda, paginación y ordenamiento", "31/07/2026", "03/08/2026", "6–7", PROX), ("Detalle de factura con descarga de PDF/XML desde BIND", "04/08/2026", "04/08/2026", "3–4", PROX), ("Catálogo de clientes con lista blanca configurable (editable por rol Administración)", "05/08/2026", "05/08/2026", "3–4", PROX), ("Tipo de cambio del DOF: proceso programado y captura al facturar", "06/08/2026", "06/08/2026", "2", PROX), ("Creación de cotizaciones desde la plataforma (con simulación dry-run)", "06/08/2026", "07/08/2026", "5–6", PROX_FLUJO), ("Conversión cotización→factura con confirmación humana (MXN con IVA / USD sin IVA)", "10/08/2026", "11/08/2026", "5–6", PROX_FLUJO), ("Exportación de facturas y clientes a CSV/XLSX", "11/08/2026", "11/08/2026", "2", PROX), ("Pruebas integrales del flujo de emisión + manual de usuario", "12/08/2026", "12/08/2026", "5–6", PROX), ]) ETAPA3 = ("Etapa 3 — Cobranza, tablero, reportes y cierre", "13/08/2026", "21/08/2026", 24, 29, [ ("Módulo de cobranza: antigüedad de cartera 30/60/90 y vistas por vencer/vencidas", "13/08/2026", "14/08/2026", "5–6", PROX), ("Alertas internas configurables + aplicación de la lista blanca", "14/08/2026", "17/08/2026", "4–5", PROX), ("Tablero directivo con indicadores de cuentas por cobrar", "17/08/2026", "18/08/2026", "4–5", PROX), ("Reporte operativo configurable (frecuencia, destinatarios, CSV/XLSX/PDF)", "19/08/2026", "19/08/2026", "3–4", PROX), ("Endurecimiento de seguridad, secretos, respaldos y plan de recuperación", "20/08/2026", "20/08/2026", "3–4", PROX), ("Documentación técnica: README, runbook y ADRs", "20/08/2026", "21/08/2026", "2", PROX), ("Sesión de capacitación grabada + transferencia y cierre", "21/08/2026", "21/08/2026", "3", PROX), ("Soporte posterior al lanzamiento (2 semanas, corrección de defectos — sin costo)", "24/08/2026", "04/09/2026", "—", "⚪ Incluida en el cierre · sin costo"), ]) JIRA = ("Integración Jira → BIND (ajuste propuesta v1.3, en aprobación)", "27/07/2026", "29/07/2026", 7, 10, [ ("Cliente de la API de Jira (token PAF): lectura de tickets FAC y control de límites", "27/07/2026", "27/07/2026", "2–3", PROX_JIRA), ("Disparador por estatus → generación automática de la cotización en BIND", "28/07/2026", "28/07/2026", "3–4", PROX_JIRA), ("Trazabilidad ticket↔cotización↔factura, alimentación del tablero y prueba observada", "29/07/2026", "29/07/2026", "2–3", PROX_JIRA), ]) def suma_rangos(acts): lo = hi = 0 for _, _, _, est, _ in acts: if est == "—": # filas sin costo (soporte incluido) no suman continue p = est.split("–") lo += float(p[0]) hi += float(p[-1]) return lo, hi for titulo, _, _, lo, hi, acts in (ETAPA2, ETAPA3, JIRA): slo, shi = suma_rangos(acts) assert (slo, shi) == (lo, hi), f"{titulo}: actividades {slo}–{shi} != rango {lo}–{hi}" TOT_MIN = 18 + 32 + ETAPA2[3] + ETAPA3[3] + JIRA[3] # 119 TOT_MAX = 22 + 39 + ETAPA2[4] + ETAPA3[4] + JIRA[4] # 146 PCT_GLOBAL = round(TOTAL / ((TOT_MIN + TOT_MAX) / 2), 2) # --- Construcción ------------------------------------------------------------- wb = openpyxl.Workbook() ws = wb.active ws.title = "Plan y avance" ws["A1"] = "Proyecto Balam · Plataforma de Automatización Financiera" ws["A1"].font = Font(bold=True, size=14, color=CAFE) ws["A2"] = ("Plan y avance al 28-jul-2026 · horas trabajadas al corte del lunes 27-jul · " "Etapas 2-3 y ajuste Jira desglosados por actividad diaria") ws["A2"].font = Font(size=10, italic=True, color=GRIS) ENC = ["Actividad", "Inicio", "Fin", "Avance", "Horas\nestimadas", "Horas\ntrabajadas", "Estatus"] ANCHOS = [56, 12, 12, 9, 11, 11, 40] FILA_ENC = 4 for i, (txt, ancho) in enumerate(zip(ENC, ANCHOS), start=1): c = ws.cell(row=FILA_ENC, column=i, value=txt) c.font = Font(bold=True, size=10, color=CAFE) c.fill = PatternFill("solid", fgColor=AMARILLO) c.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) c.border = box ws.column_dimensions[get_column_letter(i)].width = ancho ws.row_dimensions[FILA_ENC].height = 26 def pinta_fila(r, nombre, ini, fin, pct, est, trab, stat, etapa=False): c = ws.cell(row=r, column=1, value=nombre) c.font = Font(bold=etapa, size=10, color=CAFE) c.alignment = Alignment(vertical="center", wrap_text=True, indent=0 if etapa else 1) for col, val in ((2, ini), (3, fin)): cc = ws.cell(row=r, column=col, value=val) cc.font = Font(size=9, color=GRIS) cc.alignment = Alignment(horizontal="center", vertical="center") cp = ws.cell(row=r, column=4, value=pct) cp.number_format = "0%" cp.alignment = Alignment(horizontal="center", vertical="center") if pct >= 1: cp.font = Font(bold=True, size=10, color=V_FONT) cp.fill = PatternFill("solid", fgColor=V_FILL) elif pct > 0: cp.font = Font(bold=True, size=10, color=A_FONT) cp.fill = PatternFill("solid", fgColor=A_FILL) else: cp.font = Font(size=10, color=GRIS) ce = ws.cell(row=r, column=5, value=est) ce.font = Font(bold=etapa, size=10, color=CAFE) ce.alignment = Alignment(horizontal="center", vertical="center") ct = ws.cell(row=r, column=6, value=trab if trab not in (None, "") else "—") if isinstance(trab, (int, float)): ct.number_format = "0.00" ct.font = Font(bold=etapa, size=10, color=CAFE) ct.alignment = Alignment(horizontal="center", vertical="center") cs = ws.cell(row=r, column=7, value=stat) cs.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True) cs.font = Font(size=9, color=GRIS) if stat.startswith("✅"): cs.font = Font(size=9, color=V_FONT); cs.fill = PatternFill("solid", fgColor=V_FILL) elif stat.startswith("🟡"): cs.font = Font(size=9, color=A_FONT); cs.fill = PatternFill("solid", fgColor=A_FILL) elif stat.startswith("🔴"): cs.font = Font(size=9, bold=True, color=R_FONT); cs.fill = PatternFill("solid", fgColor=R_FILL) for col in range(1, 8): ws.cell(row=r, column=col).border = box if etapa and col in (1, 2, 3): ws.cell(row=r, column=col).fill = PatternFill("solid", fgColor=CREMA) r = FILA_ENC + 1 pinta_fila(r, "Integracion de plataformas Balam", "01/07/2026", "21/08/2026", PCT_GLOBAL, f"{TOT_MIN:.0f}–{TOT_MAX:.0f}", TOTAL, "", etapa=True) r += 1 for f in hechas: pinta_fila(r, f["nombre"], f["ini"], f["fin"], f["pct"], f["est"], f["trab"], f["stat"], etapa=f["etapa"]) r += 1 for titulo, ini, fin, lo, hi, acts in (ETAPA2, ETAPA3, JIRA): pinta_fila(r, titulo, ini, fin, 0, f"{lo}–{hi}", None, "", etapa=True) r += 1 for nombre, aini, afin, est, stat in acts: pinta_fila(r, nombre, aini, afin, 0, est, None, stat) r += 1 # --- Leyenda y notas ---------------------------------------------------------- r += 1 leyenda = "✅ Completada · 🟡 En curso · 🔴 Pendiente/bloqueada · ⚪ Próxima" notas = [ (leyenda, False), (f"Horas trabajadas al corte del lunes 27-jul: {E0:.2f} h en Etapa 0 (cerrada y " f"validada, rango 18–22 h) y {E1:.2f} h en Etapa 1 ({TOTAL:.2f} h en total). " "Las horas por etapa son la base de la facturación (tarifa $600/h + IVA).", True), ("Etapas 2 y 3 desglosadas por actividad diaria con sus rangos estimados " "(38–46 h y 24–29 h). Las fechas planificadas asumen media jornada en promedio " "y están sujetas a la definición del flujo de facturación (en revisión interna " "de Balam) y al acceso a Azure. El soporte post-lanzamiento (2 semanas, sin " "costo) corre del 24-ago al 4-sep, después del cierre.", False), ("Integración Jira → BIND (7–10 h): ajuste no contemplado en la propuesta " "original, incorporado en la propuesta ajustada v1.3; se ejecuta tras su " "aprobación por escrito y una vez definido el flujo. Se programó a partir " "del lunes 27-jul —día en que Pedro entregó el token PAF— en paralelo con " "el cierre de la Etapa 1 y el arranque de la Etapa 2.", False), (f"Total del plan con el ajuste: {TOT_MIN:.0f}–{TOT_MAX:.0f} h. El avance global " f"({PCT_GLOBAL:.0%}) se calcula sobre el plan completo; el corte anterior (80%) " "se calculaba solo sobre las Etapas 0-1.", False), ("En Etapa 1 siguen abiertos: capa de escritura (en pausa por la definición del " "flujo), datos de prueba, y Azure/CI-CD (accesos del lado de Balam).", False), ] 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") ws.merge_cells(start_row=r, start_column=1, end_row=r, end_column=7) ws.row_dimensions[r].height = 28 if len(txt) > 120 else 14 r += 1 ws.freeze_panes = f"A{FILA_ENC + 1}" ws.sheet_view.showGridLines = False ws.print_area = f"A1:G{r}" ws.page_setup.orientation = "landscape" ws.page_setup.fitToWidth = 1 ws.sheet_properties.pageSetUpPr.fitToPage = True wb.save(DST) print("OK ->", DST) print(f" E0 {E0:.2f} h · E1 {E1:.2f} h · trabajadas {TOTAL:.2f} h · " f"plan {TOT_MIN:.0f}–{TOT_MAX:.0f} h · avance global {PCT_GLOBAL:.0%} · " f"filas E2 {len(ETAPA2[5])} · E3 {len(ETAPA3[5])} · Jira {len(JIRA[5])}")