Files
balam/planeacion/generar_plan_completo_2026_07_28.py
T
Johann 07e461d07f Ordena el ajuste Jira antes de las actividades propias de la Etapa 2
El bloque quedó anidado bajo la Etapa 2 en el commit anterior, pero se
pintaba DESPUÉS de las 10 actividades propias de la etapa (29 jul-12 ago)
aunque sus 3 días (27-29 jul) son cronológicamente anteriores — al leer
de arriba a abajo, la fecha retrocedía 2 semanas de golpe. Se movió el
bloque Jira a inmediatamente debajo del encabezado de Etapa 2 y antes de
sus propias actividades: la lectura ahora es monotónica (27, 28, 29, 29,
30, 31 jul... 12 ago) sin perder el anidado visual del turno anterior.

No se tocó el orden interno de Etapa 0/Etapa 1: su ligero traslape de
fechas (el desarrollo de Etapa 1 arrancó mientras se cerraba la
validación de Etapa 0) es agrupación real por etapa contractual, no un
error de orden — desintercalarlas rompería la estructura sin ganar
claridad.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-01 13:02:07 -06:00

360 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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 = ("» Ajuste dentro de la Etapa 2: Integración Jira → BIND (en aprobación, v1.3)",
"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, fill=None):
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=fill or 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
titulo, ini, fin, lo, hi, acts = ETAPA2
pinta_fila(r, titulo, ini, fin, 0, f"{lo}–{hi}", None, "", etapa=True)
r += 1
# El ajuste Jira se pinta ANIDADO justo debajo del encabezado de Etapa 2 (mismo
# lugar en la propuesta) y ANTES de las actividades propias de la Etapa 2: sus
# 3 días (27-29 jul) son cronológicamente anteriores al arranque de la Etapa 2
# (29 jul), así que leer de arriba a abajo también respeta el calendario.
# Relleno ámbar "pendiente" en vez del crema de una etapa contractual, y
# prefijo "»" para que no se lea como una 5ª etapa.
jtitulo, jini, jfin, jlo, jhi, jacts = JIRA
pinta_fila(r, jtitulo, jini, jfin, 0, f"{jlo}–{jhi}", None, "", etapa=True, fill=A_FILL)
r += 1
for nombre, aini, afin, est, stat in jacts:
pinta_fila(r, nombre, aini, afin, 0, est, None, stat)
r += 1
for nombre, aini, afin, est, stat in acts:
pinta_fila(r, nombre, aini, afin, 0, est, None, stat)
r += 1
titulo, ini, fin, lo, hi, acts = ETAPA3
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), señalada con » dentro de la Etapa 2: "
"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 agrupa junto a la Etapa 2 porque automatiza el mismo entregable "
"(creación de cotizaciones); 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])}")