353 lines
17 KiB
Python
353 lines
17 KiB
Python
"""
|
||
Genera cronograma.xlsx — Cronograma de Ejecución del Proyecto
|
||
Laboratorio Ximena / WhatsApp Bot Manager
|
||
Inicio: 2 de marzo de 2026
|
||
"""
|
||
from datetime import date, timedelta
|
||
from openpyxl import Workbook
|
||
from openpyxl.styles import (PatternFill, Font, Alignment, Border, Side,
|
||
GradientFill)
|
||
from openpyxl.utils import get_column_letter
|
||
|
||
# ─── CONFIGURACIÓN DEL PROYECTO ─────────────────────────────────────────────
|
||
|
||
INICIO = date(2026, 3, 2)
|
||
|
||
FASES = [
|
||
# (fase, tarea, inicio_offset_dias, duracion_dias, estado)
|
||
# Estado: 'done' | 'active' | 'pending'
|
||
("Fase 1 – Sistema de Roles", "Diseño BD (roles, modules)", 0, 4, "done"),
|
||
("Fase 1 – Sistema de Roles", "APIs roles y usuarios", 4, 4, "done"),
|
||
("Fase 1 – Sistema de Roles", "UI lab_usuarios.php", 6, 4, "done"),
|
||
("Fase 1 – Sistema de Roles", "Unificación panel admin", 8, 3, "done"),
|
||
|
||
("Fase 2 – Portal Enfermero", "Corrección login enfermero", 11, 2, "done"),
|
||
("Fase 2 – Portal Enfermero", "Fix SyntaxError botón formulario", 11, 2, "done"),
|
||
("Fase 2 – Portal Enfermero", "Submit idempotente (ya completado)", 13, 2, "done"),
|
||
("Fase 2 – Portal Enfermero", "Firma digital — canvas", 15, 3, "done"),
|
||
("Fase 2 – Portal Enfermero", "Firma por foto (opcional)", 18, 2, "active"),
|
||
|
||
("Fase 3 – Formularios Avanz.", "Campos dinámicos tipo fecha/selector", 21, 4, "pending"),
|
||
("Fase 3 – Formularios Avanz.", "Historial de envíos por paciente", 25, 3, "pending"),
|
||
("Fase 3 – Formularios Avanz.", "Descarga PDF de formulario enviado", 25, 4, "pending"),
|
||
|
||
("Fase 4 – Agendamiento", "Calendario de domicilios", 32, 5, "pending"),
|
||
("Fase 4 – Agendamiento", "Mensajes programados WhatsApp", 36, 4, "pending"),
|
||
("Fase 4 – Agendamiento", "Recordatorios automáticos", 38, 3, "pending"),
|
||
|
||
("Fase 5 – Reportes", "Dashboard métricas laboratorio", 44, 4, "pending"),
|
||
("Fase 5 – Reportes", "Exportación Excel / CSV", 46, 3, "pending"),
|
||
("Fase 5 – Reportes", "Reportes por enfermera", 48, 3, "pending"),
|
||
|
||
("Fase 6 – QA y Despliegue", "Pruebas integrales", 53, 4, "pending"),
|
||
("Fase 6 – QA y Despliegue", "Corrección de bugs", 55, 3, "pending"),
|
||
("Fase 6 – QA y Despliegue", "Despliegue en producción", 58, 3, "pending"),
|
||
("Fase 6 – QA y Despliegue", "Capacitación y entrega", 60, 2, "pending"),
|
||
]
|
||
|
||
# ─── COLORES ────────────────────────────────────────────────────────────────
|
||
|
||
COLORES_FASE = {
|
||
"Fase 1 – Sistema de Roles": {"header": "1565C0", "barra_done": "42A5F5", "barra_active": "1E88E5", "barra_pending": "90CAF9"},
|
||
"Fase 2 – Portal Enfermero": {"header": "2E7D32", "barra_done": "66BB6A", "barra_active": "43A047", "barra_pending": "A5D6A7"},
|
||
"Fase 3 – Formularios Avanz.": {"header": "6A1B9A", "barra_done": "AB47BC", "barra_active": "8E24AA", "barra_pending": "CE93D8"},
|
||
"Fase 4 – Agendamiento": {"header": "E65100", "barra_done": "FFA726", "barra_active": "FB8C00", "barra_pending": "FFCC80"},
|
||
"Fase 5 – Reportes": {"header": "00695C", "barra_done": "26A69A", "barra_active": "00897B", "barra_pending": "80CBC4"},
|
||
"Fase 6 – QA y Despliegue": {"header": "B71C1C", "barra_done": "EF5350", "barra_active": "E53935", "barra_pending": "EF9A9A"},
|
||
}
|
||
|
||
ESTADO_LABEL = {"done": "✅ Completado", "active": "🔄 En curso", "pending": "⏳ Pendiente"}
|
||
ESTADO_FILL = {
|
||
"done": "D0F0C0",
|
||
"active": "FFF9C4",
|
||
"pending": "F5F5F5",
|
||
}
|
||
|
||
# ─── HELPERS ────────────────────────────────────────────────────────────────
|
||
|
||
def fecha(offset): return INICIO + timedelta(days=offset)
|
||
def col_for_date(d, week_cols_start, all_weeks):
|
||
"""Devuelve columna Excel (1-based) para una fecha dada."""
|
||
for i, w in enumerate(all_weeks):
|
||
if w <= d < w + timedelta(days=7):
|
||
return week_cols_start + i
|
||
return None
|
||
|
||
def thin():
|
||
s = Side(style='thin', color='CCCCCC')
|
||
return Border(left=s, right=s, top=s, bottom=s)
|
||
|
||
def fill(hex_color):
|
||
return PatternFill("solid", fgColor=hex_color)
|
||
|
||
# ─── GENERAR SEMANAS ────────────────────────────────────────────────────────
|
||
|
||
max_offset = max(t[3] + t[2] for t in FASES)
|
||
num_weeks = (max_offset // 7) + 2
|
||
all_weeks = [INICIO + timedelta(weeks=i) for i in range(num_weeks)]
|
||
|
||
# ─── WORKBOOK ───────────────────────────────────────────────────────────────
|
||
|
||
wb = Workbook()
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# HOJA 1 — GANTT
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
ws = wb.active
|
||
ws.title = "Gantt"
|
||
|
||
TASK_COLS = 4 # Fase | Tarea | Inicio | Estado
|
||
WEEK_COL_START = TASK_COLS + 1 # columna Excel donde empiezan las semanas
|
||
|
||
# ── Dimensiones ─────────────────────────────────────────────────────────────
|
||
ws.column_dimensions['A'].width = 26
|
||
ws.column_dimensions['B'].width = 38
|
||
ws.column_dimensions['C'].width = 13
|
||
ws.column_dimensions['D'].width = 16
|
||
|
||
for i in range(num_weeks):
|
||
cl = get_column_letter(WEEK_COL_START + i)
|
||
ws.column_dimensions[cl].width = 5.5
|
||
|
||
# ── Fila 1 — Título ─────────────────────────────────────────────────────────
|
||
ws.merge_cells(start_row=1, start_column=1, end_row=1, end_column=WEEK_COL_START + num_weeks - 1)
|
||
ws['A1'] = "CRONOGRAMA DE EJECUCIÓN — Laboratorio Ximena"
|
||
ws['A1'].font = Font(bold=True, size=14, color="FFFFFF")
|
||
ws['A1'].fill = fill("0D47A1")
|
||
ws['A1'].alignment = Alignment(horizontal="center", vertical="center")
|
||
ws.row_dimensions[1].height = 28
|
||
|
||
# ── Fila 2 — Sub-título ─────────────────────────────────────────────────────
|
||
ws.merge_cells(start_row=2, start_column=1, end_row=2, end_column=WEEK_COL_START + num_weeks - 1)
|
||
ws['A2'] = f"Inicio: {INICIO.strftime('%d de %B de %Y')} | Total estimado: {num_weeks} semanas"
|
||
ws['A2'].font = Font(italic=True, size=10, color="444444")
|
||
ws['A2'].fill = fill("E3F2FD")
|
||
ws['A2'].alignment = Alignment(horizontal="center", vertical="center")
|
||
ws.row_dimensions[2].height = 18
|
||
|
||
# ── Fila 3 — Encabezados columnas texto ─────────────────────────────────────
|
||
heads = ["Fase", "Tarea", "Inicio", "Estado"]
|
||
for c, h in enumerate(heads, 1):
|
||
cell = ws.cell(row=3, column=c, value=h)
|
||
cell.font = Font(bold=True, size=10, color="FFFFFF")
|
||
cell.fill = fill("37474F")
|
||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||
cell.border = thin()
|
||
|
||
# ── Fila 3 — Encabezados semanas ────────────────────────────────────────────
|
||
for i, w in enumerate(all_weeks):
|
||
col = WEEK_COL_START + i
|
||
cell = ws.cell(row=3, column=col, value=w.strftime("%-d/%m"))
|
||
cell.font = Font(bold=True, size=8, color="FFFFFF")
|
||
cell.fill = fill("546E7A")
|
||
cell.alignment = Alignment(horizontal="center", vertical="center", text_rotation=60)
|
||
cell.border = thin()
|
||
ws.row_dimensions[3].height = 44
|
||
|
||
# ── Filas de datos ───────────────────────────────────────────────────────────
|
||
ROW = 4
|
||
prev_fase = None
|
||
|
||
for (fase, tarea, start_off, dur, estado) in FASES:
|
||
colores = COLORES_FASE[fase]
|
||
|
||
# Separador de fase (cuando cambia)
|
||
if fase != prev_fase:
|
||
ws.merge_cells(start_row=ROW, start_column=1,
|
||
end_row=ROW, end_column=WEEK_COL_START + num_weeks - 1)
|
||
cell = ws.cell(row=ROW, column=1, value=f" {fase}")
|
||
cell.font = Font(bold=True, size=10, color="FFFFFF")
|
||
cell.fill = fill(colores["header"])
|
||
cell.alignment = Alignment(vertical="center")
|
||
cell.border = thin()
|
||
ws.row_dimensions[ROW].height = 20
|
||
ROW += 1
|
||
prev_fase = fase
|
||
|
||
# Calcular fechas
|
||
d_ini = fecha(start_off)
|
||
d_fin = fecha(start_off + dur - 1)
|
||
row_fill = fill(ESTADO_FILL[estado])
|
||
|
||
# Columna A — Fase (vacía, ya fue el encabezado)
|
||
c_fase = ws.cell(row=ROW, column=1, value="")
|
||
c_fase.fill = row_fill
|
||
c_fase.border = thin()
|
||
|
||
# Columna B — Tarea
|
||
c_task = ws.cell(row=ROW, column=2, value=tarea)
|
||
c_task.font = Font(size=9)
|
||
c_task.fill = row_fill
|
||
c_task.alignment = Alignment(vertical="center", wrap_text=True)
|
||
c_task.border = thin()
|
||
|
||
# Columna C — Fecha inicio
|
||
c_date = ws.cell(row=ROW, column=3, value=d_ini.strftime("%-d %b"))
|
||
c_date.font = Font(size=9)
|
||
c_date.fill = row_fill
|
||
c_date.alignment = Alignment(horizontal="center", vertical="center")
|
||
c_date.border = thin()
|
||
|
||
# Columna D — Estado
|
||
c_est = ws.cell(row=ROW, column=4, value=ESTADO_LABEL[estado])
|
||
c_est.font = Font(size=9, bold=(estado == "active"))
|
||
c_est.fill = row_fill
|
||
c_est.alignment = Alignment(horizontal="center", vertical="center")
|
||
c_est.border = thin()
|
||
|
||
# Barras de Gantt
|
||
barra_color = colores[f"barra_{estado}"]
|
||
w_ini = col_for_date(d_ini, WEEK_COL_START, all_weeks)
|
||
w_fin = col_for_date(d_fin, WEEK_COL_START, all_weeks)
|
||
if w_ini is None: w_ini = WEEK_COL_START
|
||
if w_fin is None: w_fin = WEEK_COL_START + num_weeks - 1
|
||
|
||
for col in range(WEEK_COL_START, WEEK_COL_START + num_weeks):
|
||
cell = ws.cell(row=ROW, column=col, value="")
|
||
if w_ini <= col <= w_fin:
|
||
cell.fill = fill(barra_color)
|
||
else:
|
||
cell.fill = fill("F5F5F5")
|
||
cell.border = thin()
|
||
|
||
ws.row_dimensions[ROW].height = 18
|
||
ROW += 1
|
||
|
||
# ── Leyenda ──────────────────────────────────────────────────────────────────
|
||
ROW += 1
|
||
leyenda = [
|
||
("✅ Completado", "D0F0C0"),
|
||
("🔄 En curso", "FFF9C4"),
|
||
("⏳ Pendiente", "F5F5F5"),
|
||
]
|
||
ws.cell(row=ROW, column=1, value="Leyenda:").font = Font(bold=True, size=10)
|
||
for i, (txt, color) in enumerate(leyenda, 2):
|
||
cell = ws.cell(row=ROW, column=i, value=txt)
|
||
cell.fill = fill(color)
|
||
cell.border = thin()
|
||
cell.font = Font(size=9)
|
||
cell.alignment = Alignment(horizontal="center")
|
||
|
||
# Freeze panes
|
||
ws.freeze_panes = "E4"
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# HOJA 2 — RESUMEN POR FASE
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
ws2 = wb.create_sheet("Resumen por Fase")
|
||
ws2.column_dimensions['A'].width = 30
|
||
ws2.column_dimensions['B'].width = 14
|
||
ws2.column_dimensions['C'].width = 14
|
||
ws2.column_dimensions['D'].width = 14
|
||
ws2.column_dimensions['E'].width = 16
|
||
ws2.column_dimensions['F'].width = 16
|
||
|
||
# Título
|
||
ws2.merge_cells("A1:F1")
|
||
ws2['A1'] = "RESUMEN POR FASE"
|
||
ws2['A1'].font = Font(bold=True, size=13, color="FFFFFF")
|
||
ws2['A1'].fill = fill("0D47A1")
|
||
ws2['A1'].alignment = Alignment(horizontal="center", vertical="center")
|
||
ws2.row_dimensions[1].height = 26
|
||
|
||
# Encabezados
|
||
enc2 = ["Fase", "Inicio", "Fin", "Duración (días)", "Tareas", "Estado"]
|
||
for c, h in enumerate(enc2, 1):
|
||
cell = ws2.cell(row=2, column=c, value=h)
|
||
cell.font = Font(bold=True, size=10, color="FFFFFF")
|
||
cell.fill = fill("37474F")
|
||
cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
|
||
cell.border = thin()
|
||
ws2.row_dimensions[2].height = 22
|
||
|
||
# Agrupar por fase
|
||
from collections import defaultdict
|
||
fases_data = defaultdict(list)
|
||
for row in FASES:
|
||
fases_data[row[0]].append(row)
|
||
|
||
ROW2 = 3
|
||
for fase_name, tareas in fases_data.items():
|
||
ini_fase = fecha(min(t[2] for t in tareas))
|
||
fin_fase = fecha(max(t[2] + t[3] - 1 for t in tareas))
|
||
dur_total = (fin_fase - ini_fase).days + 1
|
||
n_tareas = len(tareas)
|
||
estados = [t[4] for t in tareas]
|
||
if all(e == "done" for e in estados):
|
||
estado_fase = "✅ Completada"
|
||
fc = "D0F0C0"
|
||
elif any(e == "active" for e in estados):
|
||
estado_fase = "🔄 En curso"
|
||
fc = "FFF9C4"
|
||
else:
|
||
estado_fase = "⏳ Pendiente"
|
||
fc = "F5F5F5"
|
||
|
||
colores = COLORES_FASE[fase_name]
|
||
fondo = fill(colores["header"] if estado_fase == "✅ Completada" else fc)
|
||
|
||
data = [fase_name, ini_fase.strftime("%-d %b %Y"), fin_fase.strftime("%-d %b %Y"),
|
||
dur_total, n_tareas, estado_fase]
|
||
for c, v in enumerate(data, 1):
|
||
cell = ws2.cell(row=ROW2, column=c, value=v)
|
||
cell.alignment = Alignment(horizontal="center" if c > 1 else "left",
|
||
vertical="center", wrap_text=True)
|
||
cell.border = thin()
|
||
cell.font = Font(size=10, bold=(c == 1),
|
||
color=("FFFFFF" if estado_fase == "✅ Completada" else "222222"))
|
||
cell.fill = PatternFill("solid", fgColor=colores["header"]) if estado_fase == "✅ Completada" \
|
||
else fill(fc)
|
||
ws2.row_dimensions[ROW2].height = 22
|
||
ROW2 += 1
|
||
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
# HOJA 3 — DETALLE DE TAREAS
|
||
# ════════════════════════════════════════════════════════════════════════════
|
||
ws3 = wb.create_sheet("Detalle Tareas")
|
||
ws3.column_dimensions['A'].width = 28
|
||
ws3.column_dimensions['B'].width = 40
|
||
ws3.column_dimensions['C'].width = 14
|
||
ws3.column_dimensions['D'].width = 14
|
||
ws3.column_dimensions['E'].width = 12
|
||
ws3.column_dimensions['F'].width = 16
|
||
|
||
ws3.merge_cells("A1:F1")
|
||
ws3['A1'] = "DETALLE DE TAREAS"
|
||
ws3['A1'].font = Font(bold=True, size=13, color="FFFFFF")
|
||
ws3['A1'].fill = fill("0D47A1")
|
||
ws3['A1'].alignment = Alignment(horizontal="center", vertical="center")
|
||
ws3.row_dimensions[1].height = 26
|
||
|
||
enc3 = ["Fase", "Tarea", "Inicio", "Fin", "Días", "Estado"]
|
||
for c, h in enumerate(enc3, 1):
|
||
cell = ws3.cell(row=2, column=c, value=h)
|
||
cell.font = Font(bold=True, size=10, color="FFFFFF")
|
||
cell.fill = fill("37474F")
|
||
cell.alignment = Alignment(horizontal="center", vertical="center")
|
||
cell.border = thin()
|
||
ws3.row_dimensions[2].height = 22
|
||
|
||
ROW3 = 3
|
||
for (fase, tarea, start_off, dur, estado) in FASES:
|
||
d_ini = fecha(start_off)
|
||
d_fin = fecha(start_off + dur - 1)
|
||
colores = COLORES_FASE[fase]
|
||
fc = ESTADO_FILL[estado]
|
||
|
||
data = [fase, tarea, d_ini.strftime("%-d %b %Y"), d_fin.strftime("%-d %b %Y"),
|
||
dur, ESTADO_LABEL[estado]]
|
||
for c, v in enumerate(data, 1):
|
||
cell = ws3.cell(row=ROW3, column=c, value=v)
|
||
cell.alignment = Alignment(horizontal="center" if c > 2 else "left",
|
||
vertical="center", wrap_text=True)
|
||
cell.border = thin()
|
||
cell.font = Font(size=9)
|
||
cell.fill = fill(fc)
|
||
ws3.row_dimensions[ROW3].height = 18
|
||
ROW3 += 1
|
||
|
||
# ─── GUARDAR ─────────────────────────────────────────────────────────────────
|
||
out = "/Users/lizandro/Documents/GitHub/whatsapp/cronograma.xlsx"
|
||
wb.save(out)
|
||
print(f"✅ Archivo generado: {out}")
|