623 lines
31 KiB
Python
623 lines
31 KiB
Python
"""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()))
|