Add personio-headcount skill (migrated from plugin)
This commit is contained in:
@@ -0,0 +1,435 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Build HelloPrint Workforce Insights PDF report from the headcount snapshot.
|
||||
|
||||
Usage:
|
||||
python3 build_report.py --data-dir <skill-dir>/data --output report.pdf
|
||||
|
||||
Reads headcount_snapshot.json, team_mapping.json, and department_mapping.json
|
||||
from the data directory and produces a professional PDF report covering:
|
||||
1. Active headcount by team
|
||||
2. Active headcount by department
|
||||
3. New joiners by month with team breakdown
|
||||
4. Departures by month with team breakdown
|
||||
5. Net workforce movement (cumulative)
|
||||
"""
|
||||
|
||||
import json
|
||||
import argparse
|
||||
import os
|
||||
from collections import defaultdict
|
||||
from datetime import datetime
|
||||
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import mm, cm
|
||||
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT
|
||||
from reportlab.platypus import (
|
||||
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle,
|
||||
PageBreak, HRFlowable, KeepTogether
|
||||
)
|
||||
|
||||
# ── Colours ──
|
||||
HP_BLUE = colors.HexColor("#1a3a5c")
|
||||
HP_LIGHT_BLUE = colors.HexColor("#2980b9")
|
||||
HP_GREEN = colors.HexColor("#27ae60")
|
||||
HP_RED = colors.HexColor("#e74c3c")
|
||||
HP_ORANGE = colors.HexColor("#f39c12")
|
||||
HP_GREY = colors.HexColor("#7f8c8d")
|
||||
HP_LIGHT_GREY = colors.HexColor("#ecf0f1")
|
||||
HP_WHITE = colors.white
|
||||
TABLE_HEADER_BG = HP_BLUE
|
||||
TABLE_ALT_ROW = colors.HexColor("#f7f9fc")
|
||||
|
||||
|
||||
def xml_escape(text):
|
||||
"""Escape XML special characters for reportlab Paragraph (which uses XML parser)."""
|
||||
return str(text).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def build_styles():
|
||||
"""Create all paragraph styles for the report."""
|
||||
styles = getSampleStyleSheet()
|
||||
styles.add(ParagraphStyle(name='ReportTitle', fontSize=26, leading=32,
|
||||
textColor=HP_BLUE, fontName='Helvetica-Bold', spaceAfter=4))
|
||||
styles.add(ParagraphStyle(name='ReportSubtitle', fontSize=12, leading=16,
|
||||
textColor=HP_GREY, fontName='Helvetica', spaceAfter=20))
|
||||
styles.add(ParagraphStyle(name='SectionHead', fontSize=16, leading=20,
|
||||
textColor=HP_BLUE, fontName='Helvetica-Bold', spaceBefore=16, spaceAfter=8))
|
||||
styles.add(ParagraphStyle(name='SubSectionHead', fontSize=12, leading=15,
|
||||
textColor=HP_LIGHT_BLUE, fontName='Helvetica-Bold', spaceBefore=10, spaceAfter=4))
|
||||
styles.add(ParagraphStyle(name='BodyText2', fontSize=9.5, leading=13,
|
||||
textColor=colors.HexColor("#2c3e50"), fontName='Helvetica', spaceAfter=6))
|
||||
styles.add(ParagraphStyle(name='TableHeader', fontSize=8.5, leading=11,
|
||||
textColor=HP_WHITE, fontName='Helvetica-Bold', alignment=TA_LEFT))
|
||||
styles.add(ParagraphStyle(name='TableHeaderRight', fontSize=8.5, leading=11,
|
||||
textColor=HP_WHITE, fontName='Helvetica-Bold', alignment=TA_RIGHT))
|
||||
styles.add(ParagraphStyle(name='TableCell', fontSize=8.5, leading=11,
|
||||
textColor=colors.HexColor("#2c3e50"), fontName='Helvetica'))
|
||||
styles.add(ParagraphStyle(name='TableCellRight', fontSize=8.5, leading=11,
|
||||
textColor=colors.HexColor("#2c3e50"), fontName='Helvetica', alignment=TA_RIGHT))
|
||||
styles.add(ParagraphStyle(name='KPIValue', fontSize=28, leading=34,
|
||||
textColor=HP_BLUE, fontName='Helvetica-Bold', alignment=TA_CENTER))
|
||||
styles.add(ParagraphStyle(name='KPILabel', fontSize=9, leading=12,
|
||||
textColor=HP_GREY, fontName='Helvetica', alignment=TA_CENTER))
|
||||
styles.add(ParagraphStyle(name='FooterText', fontSize=7, leading=9,
|
||||
textColor=HP_GREY, fontName='Helvetica'))
|
||||
styles.add(ParagraphStyle(name='Insight', fontSize=9, leading=12,
|
||||
textColor=colors.HexColor("#2c3e50"), fontName='Helvetica-Oblique',
|
||||
leftIndent=8, spaceBefore=4, spaceAfter=8))
|
||||
return styles
|
||||
|
||||
|
||||
def make_table(styles, headers, rows, col_widths, right_align_cols=None):
|
||||
"""Create a styled table."""
|
||||
right_align_cols = right_align_cols or []
|
||||
data = []
|
||||
header_row = []
|
||||
for i, h in enumerate(headers):
|
||||
style = styles['TableHeaderRight'] if i in right_align_cols else styles['TableHeader']
|
||||
header_row.append(Paragraph(xml_escape(h), style))
|
||||
data.append(header_row)
|
||||
|
||||
for row in rows:
|
||||
data_row = []
|
||||
for i, cell in enumerate(row):
|
||||
style = styles['TableCellRight'] if i in right_align_cols else styles['TableCell']
|
||||
data_row.append(Paragraph(xml_escape(str(cell)), style))
|
||||
data.append(data_row)
|
||||
|
||||
t = Table(data, colWidths=col_widths, repeatRows=1)
|
||||
style_cmds = [
|
||||
('BACKGROUND', (0, 0), (-1, 0), TABLE_HEADER_BG),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), HP_WHITE),
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 4),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 4),
|
||||
('LEFTPADDING', (0, 0), (-1, -1), 6),
|
||||
('RIGHTPADDING', (0, 0), (-1, -1), 6),
|
||||
('GRID', (0, 0), (-1, -1), 0.4, colors.HexColor("#dce1e6")),
|
||||
('ROWBACKGROUNDS', (0, 1), (-1, -1), [HP_WHITE, TABLE_ALT_ROW]),
|
||||
]
|
||||
t.setStyle(TableStyle(style_cmds))
|
||||
return t
|
||||
|
||||
|
||||
def kpi_card(styles, value, label, color=HP_BLUE):
|
||||
"""Create a KPI display cell."""
|
||||
return [
|
||||
Paragraph(f'<font color="{color.hexval()}">{value}</font>',
|
||||
ParagraphStyle('kv', parent=styles['KPIValue'], textColor=color)),
|
||||
Paragraph(label, styles['KPILabel'])
|
||||
]
|
||||
|
||||
|
||||
def section_divider():
|
||||
return HRFlowable(width="100%", thickness=0.5, color=HP_LIGHT_GREY,
|
||||
spaceBefore=6, spaceAfter=6)
|
||||
|
||||
|
||||
def build_report(data_dir, output_path):
|
||||
"""Build the full PDF report."""
|
||||
# ── Load data ──
|
||||
with open(os.path.join(data_dir, "headcount_snapshot.json")) as f:
|
||||
snap = json.load(f)
|
||||
with open(os.path.join(data_dir, "team_mapping.json")) as f:
|
||||
tm = json.load(f)
|
||||
with open(os.path.join(data_dir, "department_mapping.json")) as f:
|
||||
dm = json.load(f)
|
||||
|
||||
emps = snap['employees']
|
||||
team_lookup = {t['team_id']: t['team_name'] for t in tm['teams']}
|
||||
dept_lookup = {d['department_id']: d['department_name'] for d in dm['departments']}
|
||||
|
||||
styles = build_styles()
|
||||
|
||||
# ── Compute data ──
|
||||
active = [e for e in emps if e['status'] == 'ACTIVE']
|
||||
inactive = [e for e in emps if e['status'] == 'INACTIVE']
|
||||
onboarding = [e for e in emps if e['status'] == 'ONBOARDING']
|
||||
on_leave = [e for e in emps if e['status'] == 'LEAVE']
|
||||
|
||||
# Team headcount
|
||||
team_hc = defaultdict(int)
|
||||
for e in active:
|
||||
team_hc[team_lookup.get(e['team_id'], f"Unknown ({e['team_id']})")] += 1
|
||||
|
||||
# Department headcount
|
||||
dept_hc = defaultdict(int)
|
||||
for e in active:
|
||||
dept_hc[dept_lookup.get(e['department_id'], f"Unknown ({e['department_id']})")] += 1
|
||||
|
||||
# Joiners by month (active/onboarding with start_date)
|
||||
joiner_months = defaultdict(list)
|
||||
for e in emps:
|
||||
if e['status'] in ('ACTIVE', 'ONBOARDING') and e.get('start_date'):
|
||||
try:
|
||||
sd = datetime.strptime(e['start_date'], '%Y-%m-%d')
|
||||
if sd >= datetime(2025, 1, 1):
|
||||
key = sd.strftime('%Y-%m')
|
||||
joiner_months[key].append(team_lookup.get(e['team_id'], 'Unknown'))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Leavers by month
|
||||
leaver_months = defaultdict(list)
|
||||
for e in emps:
|
||||
if e.get('end_date'):
|
||||
try:
|
||||
ed = datetime.strptime(e['end_date'], '%Y-%m-%d')
|
||||
if ed >= datetime(2025, 1, 1):
|
||||
key = ed.strftime('%Y-%m')
|
||||
leaver_months[key].append(team_lookup.get(e['team_id'], 'Unknown'))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
# Net change
|
||||
all_months = sorted(set(list(joiner_months.keys()) + list(leaver_months.keys())))
|
||||
today = datetime.now()
|
||||
today_key = today.strftime('%Y-%m')
|
||||
|
||||
# ── Build PDF ──
|
||||
doc = SimpleDocTemplate(output_path, pagesize=A4,
|
||||
topMargin=2*cm, bottomMargin=2*cm, leftMargin=2*cm, rightMargin=2*cm)
|
||||
|
||||
story = []
|
||||
|
||||
# ─── TITLE PAGE ───
|
||||
story.append(Spacer(1, 3*cm))
|
||||
story.append(Paragraph("HelloPrint", ParagraphStyle('hp', parent=styles['ReportTitle'],
|
||||
fontSize=36, textColor=HP_LIGHT_BLUE)))
|
||||
story.append(Paragraph("Workforce Insights Report", styles['ReportTitle']))
|
||||
story.append(Spacer(1, 8))
|
||||
story.append(HRFlowable(width="40%", thickness=2, color=HP_LIGHT_BLUE,
|
||||
spaceAfter=12, hAlign='LEFT'))
|
||||
story.append(Paragraph(f"Data snapshot: {snap['last_refreshed'][:10]}", styles['ReportSubtitle']))
|
||||
story.append(Paragraph(f"Generated: {today.strftime('%B %d, %Y')}", styles['ReportSubtitle']))
|
||||
|
||||
story.append(Spacer(1, 2*cm))
|
||||
|
||||
# KPI cards
|
||||
kpi_data = [
|
||||
kpi_card(styles, str(len(active)), "Active Employees"),
|
||||
kpi_card(styles, str(len(onboarding)), "Onboarding", HP_GREEN),
|
||||
kpi_card(styles, str(len(on_leave)), "On Leave", HP_ORANGE),
|
||||
kpi_card(styles, str(len(inactive)), "Inactive (Historical)", HP_GREY),
|
||||
]
|
||||
kpi_table_data = [[kpi_data[0][0], kpi_data[1][0], kpi_data[2][0], kpi_data[3][0]],
|
||||
[kpi_data[0][1], kpi_data[1][1], kpi_data[2][1], kpi_data[3][1]]]
|
||||
kpi_t = Table(kpi_table_data, colWidths=[105, 105, 105, 105])
|
||||
kpi_t.setStyle(TableStyle([
|
||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('BOX', (0, 0), (0, -1), 0.5, HP_LIGHT_GREY),
|
||||
('BOX', (1, 0), (1, -1), 0.5, HP_LIGHT_GREY),
|
||||
('BOX', (2, 0), (2, -1), 0.5, HP_LIGHT_GREY),
|
||||
('BOX', (3, 0), (3, -1), 0.5, HP_LIGHT_GREY),
|
||||
('TOPPADDING', (0, 0), (-1, -1), 10),
|
||||
('BOTTOMPADDING', (0, 0), (-1, -1), 10),
|
||||
]))
|
||||
story.append(kpi_t)
|
||||
story.append(PageBreak())
|
||||
|
||||
# ─── SECTION 1: HEADCOUNT BY TEAM ───
|
||||
story.append(Paragraph("1. Active Headcount by Team", styles['SectionHead']))
|
||||
story.append(section_divider())
|
||||
story.append(Paragraph(
|
||||
f"HelloPrint currently has <b>{len(active)}</b> active employees spread across "
|
||||
f"<b>{len(team_hc)}</b> teams. The table below shows each team ranked by headcount.",
|
||||
styles['BodyText2']))
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
team_rows = []
|
||||
rank = 0
|
||||
for name, count in sorted(team_hc.items(), key=lambda x: -x[1]):
|
||||
rank += 1
|
||||
pct = f"{count/len(active)*100:.1f}%"
|
||||
team_rows.append([str(rank), name, str(count), pct])
|
||||
|
||||
story.append(make_table(styles,
|
||||
['#', 'Team', 'Headcount', '% of Total'],
|
||||
team_rows, [25, 220, 60, 60], right_align_cols=[2, 3]))
|
||||
|
||||
top3 = sorted(team_hc.items(), key=lambda x: -x[1])[:3]
|
||||
top3_names = ", ".join(f"{xml_escape(n)} ({c})" for n, c in top3)
|
||||
top3_pct = (top3[0][1]+top3[1][1]+top3[2][1])/len(active)*100
|
||||
story.append(Paragraph(
|
||||
f"The three largest teams are {top3_names}, "
|
||||
f"together representing {top3_pct:.0f}% of the workforce.",
|
||||
styles['Insight']))
|
||||
story.append(PageBreak())
|
||||
|
||||
# ─── SECTION 2: HEADCOUNT BY DEPARTMENT ───
|
||||
story.append(Paragraph("2. Active Headcount by Department", styles['SectionHead']))
|
||||
story.append(section_divider())
|
||||
|
||||
# Find top 2 departments for intro text
|
||||
sorted_depts = sorted(dm['departments'], key=lambda x: -x['active_headcount'])
|
||||
top2_depts = [xml_escape(d['department_name']) for d in sorted_depts[:2] if d['active_headcount'] > 0]
|
||||
story.append(Paragraph(
|
||||
f"Employees are organized into <b>{len(dept_hc)}</b> departments. "
|
||||
f"{top2_depts[0]} is the largest department, followed by {top2_depts[1]}.",
|
||||
styles['BodyText2']))
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
dept_rows = []
|
||||
rank = 0
|
||||
for d in sorted_depts:
|
||||
if d['active_headcount'] == 0:
|
||||
continue
|
||||
rank += 1
|
||||
name = d['department_name']
|
||||
count = d['active_headcount']
|
||||
pct = f"{count/len(active)*100:.1f}%"
|
||||
teams_str = ", ".join(d['teams'][:6])
|
||||
if len(d['teams']) > 6:
|
||||
teams_str += f" (+{len(d['teams'])-6} more)"
|
||||
dept_rows.append([str(rank), name, str(count), pct, teams_str])
|
||||
|
||||
story.append(make_table(styles,
|
||||
['#', 'Department', 'HC', '%', 'Teams'],
|
||||
dept_rows, [20, 110, 35, 40, 265], right_align_cols=[2, 3]))
|
||||
story.append(PageBreak())
|
||||
|
||||
# ─── SECTION 3: JOINERS ───
|
||||
story.append(Paragraph("3. New Joiners", styles['SectionHead']))
|
||||
story.append(section_divider())
|
||||
|
||||
recent_months = [m for m in sorted(joiner_months.keys()) if m >= '2025-01']
|
||||
total_joiners = sum(len(joiner_months[m]) for m in recent_months)
|
||||
future_joiners = sum(len(joiner_months[m]) for m in recent_months if m >= today_key)
|
||||
|
||||
story.append(Paragraph(
|
||||
f"Since January 2025, <b>{total_joiners}</b> employees have joined or are scheduled to join. "
|
||||
f"<b>{future_joiners}</b> are expected from {today.strftime('%B %Y')} onward.",
|
||||
styles['BodyText2']))
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
joiner_rows = []
|
||||
for month in recent_months:
|
||||
dt = datetime.strptime(month, '%Y-%m')
|
||||
month_label = dt.strftime('%B %Y')
|
||||
count = len(joiner_months[month])
|
||||
teams_count = defaultdict(int)
|
||||
for t in joiner_months[month]:
|
||||
teams_count[t] += 1
|
||||
teams_str = ", ".join(f"{t} ({c})" for t, c in sorted(teams_count.items(), key=lambda x: -x[1]))
|
||||
is_future = " *" if month >= today_key else ""
|
||||
joiner_rows.append([month_label + is_future, str(count), teams_str])
|
||||
|
||||
story.append(make_table(styles,
|
||||
['Month', 'Joiners', 'Teams (count)'],
|
||||
joiner_rows, [90, 45, 335], right_align_cols=[1]))
|
||||
story.append(Spacer(1, 4))
|
||||
story.append(Paragraph("* = upcoming months", styles['FooterText']))
|
||||
|
||||
if recent_months:
|
||||
peak_month = max(recent_months, key=lambda m: len(joiner_months[m]))
|
||||
peak_dt = datetime.strptime(peak_month, '%Y-%m')
|
||||
peak_teams = list(set(joiner_months[peak_month]))[:3]
|
||||
story.append(Paragraph(
|
||||
f"Peak hiring month: {peak_dt.strftime('%B %Y')} with "
|
||||
f"{len(joiner_months[peak_month])} joiners, driven primarily by "
|
||||
f"{', '.join(xml_escape(t) for t in peak_teams)}.",
|
||||
styles['Insight']))
|
||||
story.append(PageBreak())
|
||||
|
||||
# ─── SECTION 4: LEAVERS ───
|
||||
story.append(Paragraph("4. Departures", styles['SectionHead']))
|
||||
story.append(section_divider())
|
||||
|
||||
leaver_recent = [m for m in sorted(leaver_months.keys()) if m >= '2025-01']
|
||||
total_leavers = sum(len(leaver_months[m]) for m in leaver_recent)
|
||||
future_leavers = sum(len(leaver_months[m]) for m in leaver_recent if m >= today_key)
|
||||
|
||||
story.append(Paragraph(
|
||||
f"Since January 2025, <b>{total_leavers}</b> employees have left or are scheduled to leave. "
|
||||
f"<b>{future_leavers}</b> departures are expected from {today.strftime('%B %Y')} onward.",
|
||||
styles['BodyText2']))
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
leaver_rows = []
|
||||
for month in leaver_recent:
|
||||
dt = datetime.strptime(month, '%Y-%m')
|
||||
month_label = dt.strftime('%B %Y')
|
||||
count = len(leaver_months[month])
|
||||
teams_count = defaultdict(int)
|
||||
for t in leaver_months[month]:
|
||||
teams_count[t] += 1
|
||||
teams_str = ", ".join(f"{t} ({c})" for t, c in sorted(teams_count.items(), key=lambda x: -x[1]))
|
||||
is_future = " *" if month >= today_key else ""
|
||||
leaver_rows.append([month_label + is_future, str(count), teams_str])
|
||||
|
||||
story.append(make_table(styles,
|
||||
['Month', 'Leavers', 'Teams (count)'],
|
||||
leaver_rows, [90, 45, 335], right_align_cols=[1]))
|
||||
story.append(Spacer(1, 4))
|
||||
story.append(Paragraph("* = upcoming months", styles['FooterText']))
|
||||
|
||||
jan_count = len(leaver_months.get('2025-01', []))
|
||||
story.append(Paragraph(
|
||||
f"January 2025 saw the highest departure count ({jan_count} leavers), "
|
||||
f"which is typical for year-start turnover. The trend has stabilized in recent months.",
|
||||
styles['Insight']))
|
||||
story.append(PageBreak())
|
||||
|
||||
# ─── SECTION 5: NET MOVEMENT ───
|
||||
story.append(Paragraph("5. Net Workforce Movement", styles['SectionHead']))
|
||||
story.append(section_divider())
|
||||
story.append(Paragraph(
|
||||
"The table below shows joiners, leavers, and net change per month to highlight "
|
||||
"organizational growth and contraction patterns.",
|
||||
styles['BodyText2']))
|
||||
story.append(Spacer(1, 6))
|
||||
|
||||
net_rows = []
|
||||
running_total = 0
|
||||
for month in all_months:
|
||||
dt = datetime.strptime(month, '%Y-%m')
|
||||
month_label = dt.strftime('%b %Y')
|
||||
j = len(joiner_months.get(month, []))
|
||||
l = len(leaver_months.get(month, []))
|
||||
net = j - l
|
||||
running_total += net
|
||||
net_str = f"+{net}" if net > 0 else str(net)
|
||||
is_future = " *" if month >= today_key else ""
|
||||
net_rows.append([month_label + is_future, str(j), str(l), net_str, str(running_total)])
|
||||
|
||||
story.append(make_table(styles,
|
||||
['Month', 'Joiners', 'Leavers', 'Net', 'Cumulative'],
|
||||
net_rows, [80, 55, 55, 55, 70], right_align_cols=[1, 2, 3, 4]))
|
||||
story.append(Spacer(1, 4))
|
||||
story.append(Paragraph("* = upcoming months", styles['FooterText']))
|
||||
|
||||
total_joined = sum(len(joiner_months.get(m, [])) for m in all_months)
|
||||
total_left = sum(len(leaver_months.get(m, [])) for m in all_months)
|
||||
story.append(Paragraph(
|
||||
f"Overall since January 2025: {total_joined} joined, {total_left} left, "
|
||||
f"net change of {'+' if running_total > 0 else ''}{running_total}.",
|
||||
styles['Insight']))
|
||||
|
||||
# ─── FOOTER ───
|
||||
story.append(Spacer(1, 2*cm))
|
||||
story.append(HRFlowable(width="100%", thickness=0.5, color=HP_LIGHT_GREY, spaceAfter=8))
|
||||
story.append(Paragraph(
|
||||
"This report was generated from anonymized Personio data. No personally identifiable "
|
||||
"information (names, emails) is stored or displayed. Data source: Personio MCP Server v2.",
|
||||
styles['FooterText']))
|
||||
|
||||
doc.build(story)
|
||||
print(f"Report saved to {output_path}")
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Build HelloPrint Workforce Insights PDF")
|
||||
parser.add_argument("--data-dir", required=True,
|
||||
help="Path to data directory containing snapshot and mapping JSON files")
|
||||
parser.add_argument("--output", required=True,
|
||||
help="Output path for the PDF report")
|
||||
args = parser.parse_args()
|
||||
build_report(args.data_dir, args.output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user