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:
@@ -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()))
|
||||
Reference in New Issue
Block a user