Registro #92: Azure + CI/CD (14–15-oct) — plataforma en balam-paf-staging.azurewebsites.net, pipeline de GitHub Actions por OIDC sin secretos (build verde; la publicación espera el rol Website Contributor que asigna Balam), PostgreSQL 17 B1ms con respaldos de 35 días, Log Analytics, Application Insights, Key Vault y Storage creados (≈32 USD/mes), traspaso de la BD ensayado y pendiente (red de la BD nueva, ventana tras la prueba de Pedro); pendientes para cerrar la Etapa 1. Incluye lo que estaba sin subir: #91 (primer CFDI timbrado desde la plataforma, 1–2 oct), corte de horas del 2-oct con su Excel y generador, manual de usuario, cartas de datos bancarios, reglas de permisos de Claude Code (.claude/settings.json) y start-n8n.bat

This commit is contained in:
JohannVelazquez
2026-10-15 22:24:18 -06:00
parent 4e90543486
commit 1c4e5349cc
47 changed files with 16654 additions and 14 deletions
+622
View File
@@ -0,0 +1,622 @@
"""Genera Avance-Balam-2026-10-02.xlsx a partir del corte del 16-sep.
Horas — corte del viernes 2-oct: todo lo nuevo en `Seguimiento-horas.csv`
lleva la nota "Corte 2-oct":
- 3.25 h del 9/10-sep que no entraron al corte del 15-sep: el puente a la
web app de BIND para la prefactura vivía sin commit en esta máquina (se
subió el 29-sep en 4d6b3d8); fuente: transcripts de Claude Code.
- 29-sep: sesión con Pedro (login web a BIND), primera prefactura real
(folio 2467) y webhook de Jira FAC (piloto con FAC-169).
- 30-sep: webhook en producción, vista previa automática, modal en vivo,
llamada con Pedro (7:26), puente encendido en producción con cuatro bugs
corregidos y timbrado.
- 1-oct: prueba completa con Balam (llegó a prefactura; timbrado sin probar).
- 2-oct: prefacturas en lote, manual de usuario y este corte. Ese día Pedro
timbró desde la plataforma (cadena completa con CFDI), con lo que se cierran
«Conversión» y «Trazabilidad»; el acompañamiento por WhatsApp no se cobra, y del
corte solo se cobran 0.75 h (no la proyección de costos ni la reclasificación).
- La fila "Demostración semanal + reporte de horas" pasa a llamarse "Seguimiento y
gestión"; lo técnico que traía (20-ago 1.00 h, 29-sep 0.75 h) va a su fila.
- Hoja 2 "Desglose de horas": cada registro del CSV con fecha, etapa, actividad,
horas, monto, estado y evidencia, más un resumen por etapa que cuadra con la hoja 1.
Quedan ajustes de UI/UX de esas pantallas, como fila nueva de la Etapa 2.
Avance y fechas — decisiones de Johann del 2-oct:
- Fin = día en que se terminó el entregable según bitácora y commits (las
tareas en curso: último día trabajado). Los encabezados de etapa toman el
primer Inicio y el último Fin de sus filas.
- Dentro de cada etapa las filas van en orden cronológico (Inicio, luego Fin);
las no iniciadas al final. El bloque del ajuste Jira → BIND se queda al
frente de la Etapa 2 (decisión del 29-jul), con sus filas también en orden.
- El avance de cada encabezado es el promedio de sus filas ponderado por las
horas estimadas (regla 4: el avance mide entregable, no horas). La Etapa 2
excluye las filas del ajuste, igual que sus horas.
- Cierres con alcance ajustado: lista blanca (Balam la eliminó el 6-jul), XML
(se consulta en BIND), XLSX (queda CSV) y tipo de cambio del DOF (se toma
del catálogo de monedas de BIND).
- Estatus simples: una línea de estado y viñetas cortas en cada fila; sin
bloque de notas debajo de la tabla (el detalle vive en Seguimiento-horas.md).
El corte del 16-sep no se toca.
"""
import csv
import os
from collections import defaultdict
from copy import copy
from datetime import datetime
import openpyxl
from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
BASE = os.path.dirname(os.path.abspath(__file__))
SRC = os.path.join(BASE, "Avance-Balam-2026-09-16.xlsx")
DST = os.path.join(BASE, "Avance-Balam-2026-10-02.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_NOM, C_INI, C_FIN, C_PCT, C_EST, C_TRAB, C_STAT = 1, 2, 3, 4, 5, 6, 7
CORTE_ANTERIOR = 94.58 # corte del 16-sep
MARCA = "Corte 2-oct" # nota de las filas nuevas del CSV (incluye fechas del 9/10-sep)
SIN = "—"
# --- CSV: totales por etapa y por actividad -----------------------------------
por_act, por_etapa, nuevas_act = defaultdict(float), 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 MARCA in r["notas"]:
nuevas += h
nuevas_act[r["actividad_id"]] += 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 16-sep"
# Reparto CSV → filas del Excel (mismo criterio que el corte del 15-sep):
FILA_POR_ACT = {
"E1-05": "Demostración semanal", # gestión, bitácora, cortes
"E2-03": "Conversión cotización→factura", # prefactura, timbrado, lote
"E2-04": "Pruebas integrales", # prueba completa del 1-oct + manual de usuario
"AJ-01": "Disparador por estatus", # webhook, vista previa, modal
}
sin_fila = set(nuevas_act) - set(FILA_POR_ACT)
assert not sin_fila, f"Actividades nuevas sin fila en el Excel: {sin_fila}"
DELTAS_TRAB = defaultdict(float)
for a, h in nuevas_act.items():
DELTAS_TRAB[FILA_POR_ACT[a]] += h
# Reasignación entre filas (suma cero): el tipo de cambio se resolvió el 12-ago dentro del
# bloque de cotización (CSV 2026-08-12, E2-02: "tipo de cambio, prefill y selector de cliente").
DELTAS_TRAB["Creación de cotizaciones desde la plataforma"] -= 0.25
DELTAS_TRAB["Tipo de cambio del DOF"] += 0.25
# Reclasificación del 2-oct (decisión de Johann, suma cero): las 7.75 h de cotización contra el API
# de BIND que el corte del 15-sep cargó al ajuste Jira pasan a la Etapa 2 (en el CSV, AJ-01 → E2-02).
# Disparador −4.50: sondeo de FAC-106 y diagnóstico del 500 (20-ago 1.50), investigación hasta el folio 1026 (21-ago 3.00)
# Trazabilidad −3.25: sesión 12-ago 1.00, Excel de catálogos 0.75, sesiones 21-ago 0.75 y 3-sep 0.75
DELTAS_TRAB["Disparador por estatus"] -= 4.50
DELTAS_TRAB["Trazabilidad ticket"] -= 3.25
DELTAS_TRAB["Creación de cotizaciones desde la plataforma"] += 7.75
# 20-ago: de las 2.00 h de "bitácora", 1.00 h fue el registro #65 de la sesión de cotización y el guion del 21-ago
# (commits a03f81f, cabe0fe, 072a3cd) → se dividió en el CSV (E1-05 1.00 + E2-02 1.00) y aquí sigue a su fila.
DELTAS_TRAB["Demostración semanal"] -= 1.00
DELTAS_TRAB["Creación de cotizaciones desde la plataforma"] += 1.00
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")
HORAS_ENCABEZADO = {
"Integracion de plataformas Balam": TOTAL,
"Etapa 0": E0,
"Etapa 1": E1,
"Etapa 2": E2,
"» Ajuste dentro de la Etapa 2": AJ,
"Etapa 3": E3,
}
ETAPAS = ("Etapa 0", "Etapa 1", "Etapa 2", "Etapa 3")
AJUSTE = "» Ajuste dentro de la Etapa 2"
AJUSTE_HIJOS = ("Cliente de la API de Jira", "Disparador por estatus", "Trazabilidad ticket")
# --- Fechas reales de las filas que cambian (dd/mm/yyyy) -----------------------
FECHAS_REALES = {
"Demostración semanal": ("15/07/2026", "02/10/2026"), # corte del 2-oct
"Capa de escritura controlada": ("19/07/2026", "30/09/2026"), # diseño 19-jul → timbrado 30-sep
"Disparador por estatus": ("29/09/2026", "30/09/2026"), # webhook (29-sep) → vista previa y modal (30-sep)
"Trazabilidad ticket": ("10/08/2026", "02/10/2026"), # definiciones 10-ago → Pedro completa la cadena con CFDI 2-oct
"Creación de cotizaciones desde la plataforma": ("06/08/2026", "30/09/2026"), # total desde BIND corregido 30-sep
"Conversión cotización→factura": ("06/08/2026", "02/10/2026"), # puente 9/10-sep → lote 2-oct
"Tipo de cambio del DOF": ("12/08/2026", "12/08/2026"), # TC del catálogo de monedas de BIND
"Pruebas integrales": ("30/09/2026", "02/10/2026"), # recorrido e2e 30-sep, Balam 1-oct, manual 2-oct
"Módulo de cobranza": ("21/07/2026", "01/08/2026"), # cartera por moneda (B4) → vista de vencidas
}
# --- Avance de las filas que cambian -----------------------------------------
AVANCE = {
"Demostración semanal": 1.0,
"Capa de escritura controlada": 1.0,
"Configuración de infraestructura en Azure": 0.75,
"CI/CD": 0.5,
"Definición de alcance Jira": 1.0, # decía "falta el estatus disparador", viejo desde el 7-ago (REGISTRO #62)
"Disparador por estatus": 1.0,
"Trazabilidad ticket": 1.0, # 2-oct: Pedro timbró desde la plataforma, proceso completo
"Detalle de factura": 1.0,
"Catálogo de clientes": 1.0,
"Tipo de cambio del DOF": 1.0,
"Creación de cotizaciones desde la plataforma": 1.0,
"Conversión cotización→factura": 1.0, # 2-oct: primer CFDI timbrado desde la plataforma
"Exportación de facturas": 1.0,
"Pruebas integrales": 1.0,
"Ajustes de UI/UX de la emisión": 0.0,
"Módulo de cobranza": 0.25,
}
# --- Estatus: una línea de estado y viñetas cortas (todas las filas de detalle) --
OK, CURSO, PROX = "✅ Completada", "🟡 En curso", "⚪ Próxima"
ESTATUS = {
# Etapa 0
"Sesión de arranque (kickoff)": [OK],
"Entrega y validación de accesos": [OK],
"Validación técnica de la API de BIND": [OK],
"Sesión de Discovery": [OK],
"Prototipo visual navegable": [OK],
"Documento de hallazgos": [OK],
"Sesión de validación del prototipo": ["✅ Aprobado por Balam (24-jul)"],
# Etapa 1
"Repositorio privado GitHub": [OK],
"Backend base": [OK],
"Arquitectura multi-tenant": [OK],
"Cliente de la API de BIND": [OK],
"Demostración semanal": ["✅ Al día",
"Cortes de horas y Excel de avance (último: 2-oct)",
"Bitácora y comunicación con Balam",
"Las sesiones con Pedro van en las filas técnicas"],
"Sincronización de clientes": [OK],
"Capa de escritura controlada": [OK,
"Vista previa + confirmación humana + auditoría",
"Cotización (21-ago), prefactura (29-sep) y timbrado (30-sep)",
"Timbrado validado en real por Balam (2-oct)"],
"Sesión de reglas y dudas": [OK],
"Sincronización de facturas": [OK],
"Modelo de facturas": [OK],
"Definición de alcance Jira": [OK,
"v1.3 aprobada por Noé (30-jul)",
"Disparador y campos definidos por Balam (7-ago)"],
"Configuración de infraestructura en Azure": [CURSO,
"Balam ya dio el acceso",
"Falta: volver a pedir la contraseña y desplegar",
"Mientras tanto corre en paputec.mx (staging)"],
"CI/CD": [CURSO,
"Despliegue reproducible con respaldo y rollback",
"Secretos fuera del repositorio",
"Falta: el pipeline en Azure"],
# Etapa 2 · ajuste Jira → BIND
"Cliente de la API de Jira": [OK,
"Bandeja y detalle de tickets FAC en vivo",
"Adjuntos descargables"],
"Trazabilidad ticket": [OK,
"Cadena ticket → cotización → prefactura → CFDI visible",
"Pedro completó el proceso con timbrado (2-oct)",
"El tablero se alimenta en la Etapa 3"],
"Disparador por estatus": [OK,
"Webhook de Jira en producción (30-sep)",
"Ticket detectado en ~1 s, cotización lista para aceptar",
"De Balam: 7 categorías sin ítem en BIND"],
# Etapa 2
"Andamiaje del frontend": [OK],
"Listado de facturas": [OK],
"Detalle de factura": [OK,
"Descarga del PDF",
"El XML se consulta en BIND"],
"Catálogo de clientes": [OK,
"Catálogo y búsqueda",
"Lista blanca eliminada por Balam (6-jul)"],
"Exportación de facturas": [OK,
"Facturas y clientes a CSV (abre en Excel)"],
"Layout base": [OK],
"Creación de cotizaciones desde la plataforma": [OK,
"Individual y en lote",
"Vista previa + confirmación humana"],
"Conversión cotización→factura": [OK,
"Prefactura validada con Balam (1-oct)",
"Prefacturas en lote (2-oct)",
"Pedro timbró desde la plataforma (2-oct)"],
"Tipo de cambio del DOF": [OK,
"Se toma del catálogo de monedas de BIND"],
"Pruebas integrales": [OK,
"Prueba completa con Balam (1-oct)",
"Timbrado real desde la plataforma (2-oct)",
"213 pruebas automáticas",
"Manual de usuario en PDF (2-oct)"],
# Etapa 3
"Módulo de cobranza": [CURSO,
"Cartera por moneda con vencidos",
"Falta: rangos 30/60/90 y vista por vencer"],
"Endurecimiento de seguridad": [CURSO,
"Rate limit y bloqueo de login",
"CSP/HSTS y respaldos diarios"],
"Documentación técnica": [CURSO,
"Runbook de publicación, operación y rollback"],
"Alertas internas": [PROX],
"Tablero directivo": [PROX],
"Reporte operativo": [PROX],
"Sesión de capacitación": [PROX],
"Soporte posterior": ["⚪ Incluido en el cierre, sin costo"],
"Ajustes de UI/UX de la emisión": [PROX,
"Botones «Ver en Jira» / «Ver en BIND»",
"Pantallas de Solicitudes, Cotizaciones y Facturas"],
}
ANCHO_ESTATUS = 46 # ancho de la columna G (caracteres)
def texto_estatus(lineas):
return "\n".join([lineas[0]] + [f"• {l}" for l in lineas[1:]])
def alto_fila(*textos_y_anchos):
"""Alto en puntos para que quepa el texto envuelto (fuente 9-10 pt)."""
renglones = 1
for texto, ancho in textos_y_anchos:
n = sum(max(1, -(-len(l) // max(1, int(ancho * 1.05)))) for l in str(texto or "").split("\n"))
renglones = max(renglones, n)
return max(15, 12 * renglones + 4)
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)
def fecha(v):
return datetime.strptime(v, "%d/%m/%Y") if isinstance(v, str) and v.count("/") == 2 else None
def estimadas(v):
"""Punto medio de 'Horas estimadas' ('2', '4–5', '1.5–2'); 0 si no aplica ('—')."""
if isinstance(v, (int, float)):
return float(v)
partes = [p for p in str(v or "").replace(" ", "").split("–") if p]
try:
nums = [float(p) for p in partes]
except ValueError:
return 0.0
return sum(nums) / len(nums) if nums else 0.0
wb = openpyxl.load_workbook(SRC)
ws = wb.active
ws["A2"] = ("Plan y avance al 2-oct-2026 · webhook de Jira FAC en producción con vista previa y modal 'Aceptar', "
"prefactura por la web app de BIND, timbrado desde la plataforma y prefacturas en lote · horas del "
"10-ago al 2-oct reconstruidas, pendientes de validar")
ws["A2"].font = Font(size=10, italic=True, color=GRIS)
# Notas del corte anterior: se limpian y se reescriben abajo.
primera_nota = None
for row in range(5, ws.max_row + 1):
v = str(ws.cell(row=row, column=C_NOM).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=C_NOM).value = None
tope = primera_nota if primera_nota else ws.max_row + 1
# Fila nueva (Johann, 2-oct): ajustes de UI/UX pendientes de la Etapa 2, al final de la etapa. Toma el
# formato de «Pruebas integrales», que en el corte del 16-sep era una fila ⚪ sin iniciar.
UIUX = "Ajustes de UI/UX de la emisión: «Ver en Jira» / «Ver en BIND» y pantallas de Solicitudes, Cotizaciones y Facturas"
assert not any(str(ws.cell(row=r, column=C_NOM).value or "").startswith("Ajustes de UI/UX") for r in range(5, tope))
modelo = next(r for r in range(5, tope) if str(ws.cell(row=r, column=C_NOM).value or "").startswith("Pruebas integrales"))
nueva = next(r for r in range(modelo + 1, tope) if str(ws.cell(row=r, column=C_NOM).value or "").startswith("Etapa 3"))
ws.insert_rows(nueva)
for c, valor in enumerate((UIUX, SIN, SIN, 0, "1–2", SIN, None), start=1):
cel = ws.cell(row=nueva, column=c, value=valor)
cel._style = copy(ws.cell(row=modelo, column=c)._style)
tope += 1
filas_tabla = [r for r in range(5, tope) if str(ws.cell(row=r, column=C_NOM).value or "").strip()]
assert not any(rg.min_row in filas_tabla for rg in ws.merged_cells.ranges), "Hay celdas combinadas en la tabla"
def nombre_de(row):
return str(ws.cell(row=row, column=C_NOM).value or "").strip()
def encabezado_de(nombre):
return next((p for p in HORAS_ENCABEZADO if nombre.startswith(p)), None)
# --- 1. Valores de las filas de detalle ----------------------------------------
aplicado = defaultdict(set)
for row in filas_tabla:
nombre = nombre_de(row)
if nombre.startswith(AJUSTE) and "(en aprobación, v1.3)" in nombre:
ws.cell(row=row, column=C_NOM, value=nombre.replace("(en aprobación, v1.3)", "(aprobado en la v1.3, 30-jul)"))
if encabezado_de(nombre):
continue
for prefijo, (ini, fin) in FECHAS_REALES.items():
if nombre.startswith(prefijo):
ws.cell(row=row, column=C_INI, value=ini)
ws.cell(row=row, column=C_FIN, value=fin)
aplicado["fechas"].add(prefijo)
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["horas"].add(prefijo)
for prefijo, pct in AVANCE.items():
if nombre.startswith(prefijo):
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")
aplicado["avance"].add(prefijo)
for prefijo, lineas in ESTATUS.items():
if nombre.startswith(prefijo):
cel = ws.cell(row=row, column=C_STAT, value=texto_estatus(lineas))
if lineas[0].startswith("✅"):
pinta(cel, V_FILL, V_FONT)
elif lineas[0].startswith("🟡"):
pinta(cel, A_FILL, A_FONT)
else: # ⚪ conserva su formato; solo envuelve el texto
cel.alignment = Alignment(horizontal="left", vertical="center", wrap_text=True)
ws.row_dimensions[row].height = alto_fila((nombre, 56), (cel.value, ANCHO_ESTATUS))
aplicado["estatus"].add(prefijo)
break
for col in (C_INI, C_FIN):
cel = ws.cell(row=row, column=col)
cel.alignment = Alignment(horizontal="center", vertical="center")
cel.font = Font(size=9, color=GRIS) if cel.value == SIN else Font(size=9)
sin_estatus = [nombre_de(r) for r in filas_tabla if not encabezado_de(nombre_de(r))
and not any(nombre_de(r).startswith(p) for p in ESTATUS)]
assert not sin_estatus, f"Filas sin estatus: {sin_estatus}"
for clave, esperado in (("horas", DELTAS_TRAB), ("fechas", FECHAS_REALES), ("avance", AVANCE), ("estatus", ESTATUS)):
faltan = set(esperado) - aplicado[clave]
assert not faltan, f"Filas no encontradas en el Excel ({clave}): {faltan}"
# La fila "Demostración semanal (viernes) + reporte de horas" no tiene demostraciones: las sesiones con Pedro
# están en las filas técnicas. Se renombra para que diga lo que contiene (Johann, 2-oct).
GESTION = "Seguimiento y gestión: reportes de horas, bitácora y comunicación con Balam"
renombradas = 0
for row in filas_tabla:
if nombre_de(row).startswith("Demostración semanal"):
ws.cell(row=row, column=C_NOM, value=GESTION)
renombradas += 1
assert renombradas == 1
# --- 2. Grupos: etapa → (bloque del ajuste) + filas ----------------------------
grupos = [] # [(fila_encabezado, [filas]), ...]; el ajuste es su propio grupo dentro de la Etapa 2
for row in filas_tabla:
enc = encabezado_de(nombre_de(row))
if enc:
grupos.append((row, []))
else:
grupos[-1][1].append(row)
# Las filas del ajuste aparecen justo después de su encabezado; las que siguen son de la Etapa 2.
for i, (enc_row, hijos) in enumerate(grupos):
if nombre_de(enc_row).startswith(AJUSTE):
propios = [r for r in hijos if nombre_de(r).startswith(AJUSTE_HIJOS)]
resto = [r for r in hijos if r not in propios]
assert len(propios) == len(AJUSTE_HIJOS), "No se encontraron las filas del ajuste"
grupos[i] = (enc_row, propios)
grupos.insert(i + 1, ("etapa2-resto", resto))
break
def clave_cronologica(row):
ini, fin = fecha(ws.cell(row=row, column=C_INI).value), fecha(ws.cell(row=row, column=C_FIN).value)
return (ini is None, ini or datetime.max, fin or datetime.max, row)
def promedio(filas):
pesos = [(estimadas(ws.cell(row=r, column=C_EST).value), ws.cell(row=r, column=C_PCT).value or 0) for r in filas]
total = sum(p for p, _ in pesos)
return round(sum(p * float(v) for p, v in pesos) / total, 2) if total else 0.0
def rango_fechas(filas):
inis = [fecha(ws.cell(row=r, column=C_INI).value) for r in filas]
fins = [fecha(ws.cell(row=r, column=C_FIN).value) for r in filas]
inis, fins = [d for d in inis if d], [d for d in fins if d]
return (min(inis).strftime("%d/%m/%Y") if inis else SIN, max(fins).strftime("%d/%m/%Y") if fins else SIN)
# --- 3. Encabezados: horas del CSV, avance ponderado y fechas de sus filas ------
hojas = {} # encabezado → filas de detalle que lo componen (para avance y fechas)
etapa_actual = None
for enc_row, hijos in grupos:
if enc_row == "etapa2-resto":
hojas[etapa_actual]["avance"] += hijos
hojas[etapa_actual]["fechas"] += hijos
continue
enc = encabezado_de(nombre_de(enc_row))
hojas[enc] = {"row": enc_row, "avance": list(hijos), "fechas": list(hijos)}
if enc in ETAPAS:
etapa_actual = enc
elif enc == AJUSTE:
hojas[etapa_actual]["fechas"] += hijos # el ajuste cuenta para las fechas de la Etapa 2, no para su avance
todas = [r for g in hojas.values() for r in g["avance"]]
hojas["Integracion de plataformas Balam"]["avance"] = todas
hojas["Integracion de plataformas Balam"]["fechas"] = todas
for enc, g in hojas.items():
row, pct = g["row"], promedio(g["avance"])
ini, fin = rango_fechas(g["fechas"])
ws.cell(row=row, column=C_INI, value=ini)
ws.cell(row=row, column=C_FIN, value=fin)
c = ws.cell(row=row, column=C_TRAB, value=round(HORAS_ENCABEZADO[enc], 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")
g["pct"] = pct
# --- 4. Orden cronológico dentro de cada grupo ----------------------------------
orden = []
for enc_row, hijos in grupos:
if enc_row != "etapa2-resto":
orden.append(enc_row)
orden += sorted(hijos, key=clave_cronologica)
assert sorted(orden) == filas_tabla
foto = {r: ([(ws.cell(row=r, column=c).value, copy(ws.cell(row=r, column=c)._style)) for c in range(1, 8)],
ws.row_dimensions[r].height) for r in filas_tabla}
for destino, origen in zip(filas_tabla, orden):
celdas, alto = foto[origen]
for c, (valor, estilo) in enumerate(celdas, start=1):
cel = ws.cell(row=destino, column=c)
cel.value = valor
cel._style = copy(estilo)
ws.row_dimensions[destino].height = alto
# --- 5. Cuadre: las filas de detalle suman el total del CSV ----------------------
detalle = 0.0
for row in filas_tabla:
v = ws.cell(row=row, column=C_TRAB).value
if not encabezado_de(nombre_de(row)) and isinstance(v, (int, float)):
detalle += float(v)
assert abs(detalle - TOTAL) < 0.01, f"Las filas suman {detalle:.2f} h y el CSV {TOTAL:.2f} h"
PCT = {enc: g["pct"] for enc, g in hojas.items()}
# Sin notas debajo de la tabla (Johann, 2-oct): el estatus de cada fila lo dice todo.
for r in range(tope, ws.max_row + 1):
ws.row_dimensions[r].height = None
ws.column_dimensions["G"].width = ANCHO_ESTATUS
r = filas_tabla[-1]
ws.print_area = f"A1:G{r}"
# --- Hoja 2: desglose de horas, registro por registro (Johann, 2-oct: "quiero poder justificar todo") ----
TARIFA = 600
AMARILLO, CREMA = "F7BD0C", "FDF3D8"
ACTIVIDADES = {
"E0-01": "Kickoff y preparación", "E0-02": "Discovery de facturación y cobranza",
"E0-03": "Accesos y salvaguardas", "E0-04": "Validación técnica del API de BIND",
"E0-05": "Prototipo navegable", "E0-06": "Entregable de cierre de la Etapa 0",
"E0-07": "Validación del prototipo con Balam",
"E1-01": "Backend base, autenticación, roles y sincronización", "E1-02": "Repositorio",
"E1-03": "Arquitectura multi-tenant y RLS", "E1-04": "Cliente del API de BIND",
"E1-05": "Seguimiento y gestión", "E1-06": "Entorno local y andamiaje",
"E1-07": "Sesión del API de Jira", "E1-08": "Diseño y planeación",
"E1-09": "Publicación provisional (paputec.mx)",
"E2-01": "Pantallas de consulta", "E2-02": "Creación de cotizaciones",
"E2-03": "Prefactura y timbrado", "E2-04": "Pruebas integrales y manual",
"AJ-01": "Integración Jira → BIND",
"E3-01": "Endurecimiento de seguridad", "E3-02": "Documentación técnica",
}
ESTADOS = {"confirmada": "Documentada", "validada": "Validada", "reconstruida": "Reconstruida"}
ETAPA_NOMBRE = {"0": "Etapa 0", "1": "Etapa 1", "2": "Etapa 2", "3": "Etapa 3"}
with open(CSV_HORAS, encoding="utf-8", newline="") as fh:
registros = [x for x in csv.DictReader(fh) if (x.get("fecha") or "").strip()]
sin_nombre = {x["actividad_id"] for x in registros} - set(ACTIVIDADES)
assert not sin_nombre, f"Actividades sin nombre en el desglose: {sin_nombre}"
def etapa_de(x):
return "Ajuste Jira → BIND" if x["actividad_id"] == "AJ-01" else ETAPA_NOMBRE[x["etapa"]]
if "Desglose de horas" in wb.sheetnames:
del wb["Desglose de horas"]
wd = wb.create_sheet("Desglose de horas")
wd.sheet_view.showGridLines = False
for col, ancho in zip("ABCDEFGH", (11, 18, 30, 72, 8, 12, 14, 46)):
wd.column_dimensions[col].width = ancho
wd["A1"] = "Proyecto Balam · Desglose de horas trabajadas"
wd["A1"].font = Font(size=14, bold=True, color=CAFE)
wd["A2"] = (f"Cada registro con su fecha, actividad y evidencia · tarifa ${TARIFA}/h + IVA · corte al 2-oct-2026 · "
"Documentada = duración comprobada (transcript o sesión) · Validada = confirmada por Johann · "
"Reconstruida = armada con evidencia fechada, pendiente de validar")
wd["A2"].font = Font(size=9, italic=True, color=GRIS)
wd["A2"].alignment = Alignment(wrap_text=True, vertical="top")
wd.merge_cells("A2:H2")
wd.row_dimensions[2].height = 28
borde = Border(bottom=Side(style="thin", color="E4E2DC"))
def encabezado(fila, textos):
for c, t in enumerate(textos, start=1):
cel = wd.cell(row=fila, column=c, value=t)
cel.font = Font(size=10, bold=True, color=CAFE)
cel.fill = PatternFill("solid", fgColor=AMARILLO)
cel.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
wd.row_dimensions[fila].height = 20
# Resumen por etapa (cuadra con la hoja principal)
encabezado(4, ["Etapa", "", "Registros", "", "Horas", "Monto (MXN)", "", ""])
orden_etapas = ["Etapa 0", "Etapa 1", "Etapa 2", "Ajuste Jira → BIND", "Etapa 3"]
esperado = {"Etapa 0": E0, "Etapa 1": E1, "Etapa 2": E2, "Ajuste Jira → BIND": AJ, "Etapa 3": E3}
fila = 5
for et in orden_etapas:
regs = [x for x in registros if etapa_de(x) == et]
horas = sum(float(x["horas"]) for x in regs)
assert abs(horas - esperado[et]) < 0.01, f"{et}: desglose {horas:.2f} h vs hoja principal {esperado[et]:.2f} h"
for c, v in ((1, et), (3, len(regs)), (5, round(horas, 2)), (6, round(horas * TARIFA, 2))):
cel = wd.cell(row=fila, column=c, value=v)
cel.font = Font(size=10, color=CAFE)
cel.border = borde
wd.cell(row=fila, column=5).number_format = "0.00"
wd.cell(row=fila, column=6).number_format = '"$"#,##0.00'
fila += 1
for c, v in ((1, "Total"), (3, len(registros)), (5, round(TOTAL, 2)), (6, round(TOTAL * TARIFA, 2))):
cel = wd.cell(row=fila, column=c, value=v)
cel.font = Font(size=10, bold=True, color=CAFE)
cel.fill = PatternFill("solid", fgColor=CREMA)
wd.cell(row=fila, column=5).number_format = "0.00"
wd.cell(row=fila, column=6).number_format = '"$"#,##0.00'
# Detalle, en orden cronológico
cab = fila + 3
encabezado(cab, ["Fecha", "Etapa", "Actividad", "Qué se hizo", "Horas", "Monto (MXN)", "Estado", "Evidencia"])
fila = cab + 1
for x in registros:
h = float(x["horas"])
valores = (datetime.strptime(x["fecha"], "%Y-%m-%d"), etapa_de(x), ACTIVIDADES[x["actividad_id"]],
x["actividad"], h, round(h * TARIFA, 2), ESTADOS.get(x["estado_hora"], x["estado_hora"]),
x["fuente_evidencia"])
for c, v in enumerate(valores, start=1):
cel = wd.cell(row=fila, column=c, value=v)
cel.font = Font(size=9, color=CAFE)
cel.alignment = Alignment(vertical="top", wrap_text=True,
horizontal="center" if c in (1, 7) else ("right" if c in (5, 6) else "left"))
cel.border = borde
wd.cell(row=fila, column=1).number_format = "dd/mm/yyyy"
wd.cell(row=fila, column=5).number_format = "0.00"
wd.cell(row=fila, column=6).number_format = '"$"#,##0.00'
if x["estado_hora"] == "reconstruida":
wd.cell(row=fila, column=7).font = Font(size=9, color=A_FONT)
renglones = max(-(-len(x["actividad"]) // 80), -(-len(x["fuente_evidencia"]) // 50), -(-len(ACTIVIDADES[x["actividad_id"]]) // 32), 1)
wd.row_dimensions[fila].height = 12 * renglones + 4
fila += 1
assert abs(sum(float(x["horas"]) for x in registros) - TOTAL) < 0.01
wd.freeze_panes = f"A{cab + 1}"
wd.auto_filter.ref = f"A{cab}:H{fila - 1}"
wd.page_setup.orientation = "landscape"
wd.page_setup.fitToWidth = 1
wd.page_setup.fitToHeight = 0
wd.sheet_properties.pageSetUpPr.fitToPage = True
wd.print_title_rows = f"{cab}:{cab}"
wb.active = 0
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)")
print(" avance:", " · ".join(f"{k} {v:.0%}" for k, v in PCT.items()))
print(" reparto:", ", ".join(f"{k} {v:+.2f}" for k, v in DELTAS_TRAB.items()))