Add four skills for Tempo/KSeF timesheet and invoice workflows: - check-my-timesheet: show/log the current user's Tempo time entries - timesheet-checker: audit timesheet completion across all reporters - invoice-checker: pull KSeF invoices, check contractors + MF white list - invoice-prep: summarize Tempo hours per Jira project for invoicing Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
156 lines
5.4 KiB
Python
156 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
||
"""Format timesheet completion data into a markdown report."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
|
||
def fmt_h(hours: float) -> str:
|
||
"""Format hours with one decimal place, dropping .0 for whole numbers."""
|
||
if hours == int(hours):
|
||
return f"{int(hours)}h"
|
||
return f"{hours:.1f}h"
|
||
|
||
|
||
def completion_badge(pct: float) -> str:
|
||
if pct >= 100:
|
||
return "✓"
|
||
if pct >= 75:
|
||
return ""
|
||
if pct >= 50:
|
||
return "⚠"
|
||
return "⚠⚠"
|
||
|
||
|
||
def build_report(data: dict[str, Any]) -> str:
|
||
period = data["period"]
|
||
summary = data["summary"]
|
||
employees = data["employees"]
|
||
|
||
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
||
n = summary["total_employees"]
|
||
per_person = fmt_h(summary["total_expected_hours"] / n) if n else "0h"
|
||
|
||
wdays_by_country: dict[str, int] = period.get("working_days_by_country", {})
|
||
if len(wdays_by_country) == 1:
|
||
country, wdays = next(iter(wdays_by_country.items()))
|
||
wdays_line = f"Working days: {wdays} ({country} calendar, public holidays excluded)"
|
||
else:
|
||
parts = ", ".join(f"{c}: {d}d" for c, d in sorted(wdays_by_country.items()))
|
||
wdays_line = f"Working days by country: {parts} (public holidays excluded)"
|
||
|
||
lines = [
|
||
"# Timesheet Completion Report",
|
||
"",
|
||
f"Generated: {generated_at}",
|
||
f"Period: {period['label'].capitalize()} ({period['start']} to {period['end']})",
|
||
wdays_line,
|
||
"",
|
||
"## Summary",
|
||
"",
|
||
f"- Expected: **{fmt_h(summary['total_expected_hours'])}** ({n} employees × {per_person} avg)",
|
||
f"- Reported: **{fmt_h(summary['total_reported_hours'])}**",
|
||
f"- Overall completion: **{summary['overall_completion_pct']}%**",
|
||
f"- Fully complete: {summary['employees_fully_complete']} of {n} employees",
|
||
f"- With gaps: {summary['employees_with_gaps']} employees",
|
||
"",
|
||
"## Employees (ranked by missing hours)",
|
||
"",
|
||
"| Employee | Expected | Reported | Completion | Missing |",
|
||
"| --- | ---: | ---: | ---: | ---: |",
|
||
]
|
||
|
||
# Determine the most common working-day count to detect deviations
|
||
max_wdays = max(wdays_by_country.values()) if wdays_by_country else 0
|
||
for emp in employees:
|
||
badge = completion_badge(emp["completion_pct"])
|
||
missing = fmt_h(emp["missing_hours"]) if emp["missing_hours"] > 0 else "—"
|
||
pct_str = f"{emp['completion_pct']}% {badge}".strip()
|
||
name = emp["name"]
|
||
notes = []
|
||
eff = emp.get("effective_working_days", max_wdays)
|
||
if emp.get("start_date") and eff < max_wdays:
|
||
notes.append(f"from {emp['start_date']}")
|
||
if emp.get("termination_date") and eff < max_wdays:
|
||
notes.append(f"until {emp['termination_date']}")
|
||
if len(wdays_by_country) > 1:
|
||
notes.append(emp.get("country", ""))
|
||
if notes:
|
||
name += f" _({', '.join(n for n in notes if n)})_"
|
||
lines.append(
|
||
f"| {name} | {fmt_h(emp['expected_hours'])} "
|
||
f"| {fmt_h(emp['reported_hours'])} | {pct_str} | {missing} |"
|
||
)
|
||
|
||
out_of_range = [
|
||
emp for emp in employees
|
||
if emp.get("hours_before_start", 0) > 0 or emp.get("hours_after_termination", 0) > 0
|
||
]
|
||
if out_of_range:
|
||
lines += [
|
||
"",
|
||
"## Hours logged outside employment period",
|
||
"",
|
||
"These hours are excluded from the completion calculation above.",
|
||
"",
|
||
"| Employee | Before start date | After termination date |",
|
||
"| --- | ---: | ---: |",
|
||
]
|
||
for emp in out_of_range:
|
||
before = fmt_h(emp["hours_before_start"]) if emp.get("hours_before_start", 0) > 0 else "—"
|
||
after = fmt_h(emp["hours_after_termination"]) if emp.get("hours_after_termination", 0) > 0 else "—"
|
||
lines.append(f"| {emp['name']} | {before} | {after} |")
|
||
|
||
countries_str = ", ".join(sorted(wdays_by_country.keys()))
|
||
lines += [
|
||
"",
|
||
"---",
|
||
"",
|
||
f"_Working days source: [date.nager.at](https://date.nager.at) ({countries_str})._",
|
||
"_Hours source: Tempo._",
|
||
]
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def run(args: argparse.Namespace) -> int:
|
||
data_path = Path(args.data)
|
||
if not data_path.exists():
|
||
print(f"Error: {data_path} not found. Run preprocess.py first.", file=sys.stderr)
|
||
return 1
|
||
|
||
data = json.loads(data_path.read_text(encoding="utf-8"))
|
||
output_dir = Path(args.output_dir)
|
||
output_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
report = build_report(data)
|
||
report_path = output_dir / "timesheet_report.md"
|
||
report_path.write_text(report, encoding="utf-8")
|
||
print(f"Wrote {report_path}")
|
||
return 0
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
parser = argparse.ArgumentParser(
|
||
description="Format timesheet completion data into a markdown report."
|
||
)
|
||
parser.add_argument(
|
||
"--data",
|
||
required=True,
|
||
help="Path to timesheet_data.json produced by preprocess.py.",
|
||
)
|
||
parser.add_argument(
|
||
"--output-dir",
|
||
required=True,
|
||
help="Directory for timesheet_report.md.",
|
||
)
|
||
return parser
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(run(build_parser().parse_args()))
|