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>
421 lines
15 KiB
Python
421 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Format invoice check 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_amount(amount: Any, currency: str = "PLN") -> str:
|
|
if amount is None:
|
|
return "—"
|
|
try:
|
|
return f"{float(amount):,.2f} {currency}"
|
|
except (TypeError, ValueError):
|
|
return str(amount)
|
|
|
|
|
|
def contractor_badge(inv: dict) -> str:
|
|
if inv.get("contractor_known"):
|
|
mismatch = inv.get("account_in_contractors") is False
|
|
return "★" + (" ⚠acc" if mismatch else "")
|
|
return "✦ NEW"
|
|
|
|
|
|
def whitelist_badge(inv: dict) -> str:
|
|
status = inv.get("whitelist_status", "")
|
|
if status == "ok":
|
|
return "✓"
|
|
if status == "failed":
|
|
return "✗ FAILED"
|
|
if status == "no_account":
|
|
return "—"
|
|
if status == "error":
|
|
return "? err"
|
|
if status == "no_nip":
|
|
return "? no NIP"
|
|
return status
|
|
|
|
|
|
def rules_badge(inv: dict) -> str:
|
|
violations = inv.get("rule_violations") or []
|
|
return f"⚠ {len(violations)}" if violations else "✓"
|
|
|
|
|
|
def duplicate_badge(inv: dict) -> str:
|
|
if inv.get("potential_duplicate"):
|
|
return "⚠ DUPE?"
|
|
if inv.get("second_in_month"):
|
|
return "2nd"
|
|
return ""
|
|
|
|
|
|
def jira_badge(inv: dict) -> str:
|
|
if not inv.get("jira_checked"):
|
|
return ""
|
|
if inv.get("jira_compliant"):
|
|
return "✓"
|
|
return "✗"
|
|
|
|
|
|
def _invoice_table_row(inv: dict) -> str:
|
|
return (
|
|
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
|
f"| {inv.get('issue_date') or '—'} "
|
|
f"| {inv.get('seller_name') or '—'} "
|
|
f"| {inv.get('seller_nip') or '—'} "
|
|
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} "
|
|
f"| {contractor_badge(inv)} "
|
|
f"| {whitelist_badge(inv)} "
|
|
f"| {rules_badge(inv)} "
|
|
f"| {duplicate_badge(inv)} "
|
|
f"| {jira_badge(inv)} |"
|
|
)
|
|
|
|
|
|
def _invoice_table_header() -> list[str]:
|
|
return [
|
|
"| Invoice | Date | Seller | NIP | Amount | Contractor | White list | Rules | Dup. | Jira |",
|
|
"| ---| ---| ---| ---| ---:| ---| ---| ---| ---| ---|",
|
|
]
|
|
|
|
|
|
def _legend() -> list[str]:
|
|
return [
|
|
"",
|
|
"---",
|
|
"",
|
|
"**Legend:** ★ = known contractor · ✦ NEW = unknown contractor · "
|
|
"✓ = white list OK · ✗ FAILED = not on white list · "
|
|
"— = no bank account · ? = could not verify · ⚠acc = account not in contractors.md · "
|
|
"⚠ N = N rule violation(s) · ✓ = rules OK · "
|
|
"2nd = 2nd invoice from seller this month · ⚠ DUPE? = same amount as earlier invoice · "
|
|
"✓/✗ Jira = Jira compliance check result",
|
|
"",
|
|
"_White list source: [wl-api.mf.gov.pl](https://wl-api.mf.gov.pl)_",
|
|
"_Invoice source: KSeF (Ministerstwo Finansów)_",
|
|
]
|
|
|
|
|
|
def build_report(data: dict[str, Any]) -> str:
|
|
"""Build the per-run report covering only new invoices."""
|
|
period = data["period"]
|
|
summary = data["summary"]
|
|
all_invoices = data.get("invoices", [])
|
|
new_invoices = [i for i in all_invoices if i.get("is_new")]
|
|
generated_at = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
|
|
env = data.get("ksef_env", "prod")
|
|
|
|
lines = [
|
|
"# Invoice Check Report",
|
|
"",
|
|
f"Generated: {generated_at}",
|
|
f"Period: {period['start']} to {period['end']}",
|
|
f"KSeF environment: {env}",
|
|
"",
|
|
"## Summary",
|
|
"",
|
|
f"- Total invoices in period: **{summary['total_invoices']}**",
|
|
f"- New (checked this run): **{summary['new_invoices']}**",
|
|
f"- Known contractors: {summary['known_contractors']}",
|
|
f"- Unknown contractors: **{summary['unknown_contractors']}**",
|
|
f"- White list OK: {summary['whitelist_ok']}",
|
|
f"- White list FAILED: **{summary['whitelist_failed']}**",
|
|
f"- White list errors: {summary['whitelist_error']}",
|
|
f"- Account mismatch (not in contractors.md): {summary['account_mismatch']}",
|
|
f"- Rule violations (limit exceeded): **{summary.get('rule_violations', 0)}**",
|
|
f"- 2nd invoice from same seller this month: **{summary.get('second_invoices', 0)}**",
|
|
f"- Potential duplicates (same seller, same month, same amount): **{summary.get('potential_duplicates', 0)}**",
|
|
f"- Jira non-compliant: **{summary.get('jira_non_compliant', 0)}**",
|
|
]
|
|
|
|
# Jira non-compliance — prominent
|
|
jira_bad = [i for i in new_invoices if i.get("jira_checked") and not i.get("jira_compliant")]
|
|
if jira_bad:
|
|
lines += [
|
|
"",
|
|
"## ✗ Jira Non-Compliant Invoices",
|
|
"",
|
|
"> Quantities on these invoices do not match hours logged in Jira/Tempo.",
|
|
"",
|
|
]
|
|
for inv in jira_bad:
|
|
lines += [
|
|
f"**{inv.get('invoice_number') or inv.get('ksef_reference', '—')}** "
|
|
f"— {inv.get('seller_name') or '—'} ({inv.get('issue_date') or '—'})",
|
|
"",
|
|
]
|
|
violations = inv.get("jira_violations") or []
|
|
no_proj = inv.get("jira_no_project_positions") or []
|
|
if violations:
|
|
lines += [
|
|
"| Project | Invoice qty | Jira hours | Month |",
|
|
"| ---| ---:| ---:| ---|",
|
|
]
|
|
for v in violations:
|
|
lines.append(
|
|
f"| {v['project']} | {v['invoice_qty']} | {v['jira_hours']} | {v['month']} |"
|
|
)
|
|
lines.append("")
|
|
if no_proj:
|
|
lines.append(
|
|
"Positions without a Jira project code: "
|
|
+ ", ".join(
|
|
f"#{p['line_number']} ({p['description'] or 'no description'})"
|
|
for p in no_proj
|
|
)
|
|
)
|
|
lines.append("")
|
|
|
|
# Potential duplicates — prominent
|
|
dupes = [i for i in new_invoices if i.get("potential_duplicate")]
|
|
if dupes:
|
|
lines += [
|
|
"",
|
|
"## ⚠ Potential Duplicates",
|
|
"",
|
|
"> These invoices have the same seller and gross amount as another invoice in the same month.",
|
|
"> Verify they are not duplicate payments before approving.",
|
|
"",
|
|
"| Invoice | Date | Seller | NIP | Amount |",
|
|
"| ---| ---| ---| ---| ---:|",
|
|
]
|
|
for inv in dupes:
|
|
lines.append(
|
|
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
|
f"| {inv.get('issue_date') or '—'} "
|
|
f"| {inv.get('seller_name') or '—'} "
|
|
f"| {inv.get('seller_nip') or '—'} "
|
|
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
|
|
)
|
|
|
|
# Rule violations — prominent
|
|
rule_violated = [i for i in new_invoices if i.get("rule_violations")]
|
|
if rule_violated:
|
|
lines += [
|
|
"",
|
|
"## ⚠ Rule Violations",
|
|
"",
|
|
"> These invoices exceed limits configured in `contractors.md`.",
|
|
"",
|
|
"| Invoice | Seller | Rule | Limit | Actual | Position |",
|
|
"| ---| ---| ---| ---:| ---:| ---|",
|
|
]
|
|
for inv in rule_violated:
|
|
for v in inv.get("rule_violations", []):
|
|
rule_label = {
|
|
"max_invoice_net_value": "Max invoice net",
|
|
"max_position_net_rate": "Max position rate",
|
|
"max_total_quantity": "Max total qty",
|
|
}.get(v["rule"], v["rule"])
|
|
pos_info = f"#{v['position']} {v.get('description', '')}" if v.get("position") else "—"
|
|
lines.append(
|
|
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
|
f"| {inv.get('seller_name') or '—'} "
|
|
f"| {rule_label} "
|
|
f"| {v['limit']:,.2f} "
|
|
f"| {v['actual']:,.2f} "
|
|
f"| {pos_info} |"
|
|
)
|
|
|
|
# White list failures — prominent
|
|
failed = [i for i in new_invoices if i.get("whitelist_status") == "failed"]
|
|
if failed:
|
|
lines += [
|
|
"",
|
|
"## ✗ White List Failures — DO NOT PAY",
|
|
"",
|
|
"> These bank accounts are **not registered** on the MF white list for the given NIP.",
|
|
"> Verify manually before making any payment.",
|
|
"",
|
|
"| Invoice | Seller | NIP | Bank account | Amount |",
|
|
"| ---| ---| ---| ---| ---:|",
|
|
]
|
|
for inv in failed:
|
|
lines.append(
|
|
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
|
|
f"| {inv.get('seller_name') or '—'} "
|
|
f"| {inv.get('seller_nip') or '—'} "
|
|
f"| {inv.get('bank_account') or '—'} "
|
|
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
|
|
)
|
|
|
|
# New invoices table
|
|
if new_invoices:
|
|
lines += ["", "## New Invoices", ""] + _invoice_table_header()
|
|
for inv in new_invoices:
|
|
lines.append(_invoice_table_row(inv))
|
|
else:
|
|
lines += ["", "## New Invoices", "", "_No new invoices found for this period._"]
|
|
|
|
# Unknown contractors
|
|
unknown = [i for i in new_invoices if not i.get("contractor_known")]
|
|
if unknown:
|
|
lines += [
|
|
"",
|
|
"## ✦ Unknown Contractors",
|
|
"",
|
|
"These sellers are not in `contractors.md`. Consider adding them.",
|
|
"",
|
|
"| Seller name | NIP | Bank account |",
|
|
"| ---| ---| ---|",
|
|
]
|
|
seen_nips: set[str] = set()
|
|
for inv in unknown:
|
|
nip = inv.get("seller_nip") or "—"
|
|
if nip in seen_nips:
|
|
continue
|
|
seen_nips.add(nip)
|
|
lines.append(
|
|
f"| {inv.get('seller_name') or '—'} | {nip} | {inv.get('bank_account') or '—'} |"
|
|
)
|
|
|
|
# Account mismatches
|
|
mismatches = [i for i in new_invoices if i.get("account_in_contractors") is False]
|
|
if mismatches:
|
|
lines += [
|
|
"",
|
|
"## ⚠ Account Mismatches",
|
|
"",
|
|
"These invoices carry a bank account **not listed** in `contractors.md` for the seller.",
|
|
"",
|
|
"| Invoice | Seller | NIP | Invoice account |",
|
|
"| ---| ---| ---| ---|",
|
|
]
|
|
for inv in mismatches:
|
|
lines.append(
|
|
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
|
|
f"| {inv.get('seller_name') or '—'} "
|
|
f"| {inv.get('seller_nip') or '—'} "
|
|
f"| {inv.get('bank_account') or '—'} |"
|
|
)
|
|
|
|
# White list errors
|
|
errors = [i for i in new_invoices if i.get("whitelist_status") == "error"]
|
|
if errors:
|
|
lines += [
|
|
"",
|
|
"## ? White List Check Errors",
|
|
"",
|
|
"| Invoice | Seller | NIP | Error |",
|
|
"| ---| ---| ---| ---|",
|
|
]
|
|
for inv in errors:
|
|
lines.append(
|
|
f"| {inv.get('invoice_number') or inv['ksef_reference']} "
|
|
f"| {inv.get('seller_name') or '—'} "
|
|
f"| {inv.get('seller_nip') or '—'} "
|
|
f"| {inv.get('whitelist_error') or '—'} |"
|
|
)
|
|
|
|
lines += _legend()
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def build_all_report(all_data: dict[str, Any]) -> str:
|
|
"""Build the cumulative report covering all invoices ever processed."""
|
|
updated_at = all_data.get("updated_at", "")[:19].replace("T", " ") + " UTC"
|
|
invoices_map: dict[str, dict] = all_data.get("invoices", {})
|
|
|
|
# Sort by issue_date descending, then by ksef_reference for stability
|
|
invoices = sorted(
|
|
invoices_map.values(),
|
|
key=lambda i: (i.get("issue_date") or "0000-00-00", i.get("ksef_reference", "")),
|
|
reverse=True,
|
|
)
|
|
|
|
total = len(invoices)
|
|
wl_ok = sum(1 for i in invoices if i.get("whitelist_status") == "ok")
|
|
wl_failed = sum(1 for i in invoices if i.get("whitelist_status") == "failed")
|
|
unknown_count = sum(1 for i in invoices if not i.get("contractor_known"))
|
|
gross_total = sum(float(i["gross_amount"]) for i in invoices if i.get("gross_amount") is not None)
|
|
|
|
lines = [
|
|
"# All Invoices",
|
|
"",
|
|
f"Last updated: {updated_at}",
|
|
f"Total: **{total}** invoice(s)",
|
|
"",
|
|
"## Summary",
|
|
"",
|
|
f"- Total invoices: **{total}**",
|
|
f"- Total gross value: **{gross_total:,.2f} PLN**",
|
|
f"- White list OK: {wl_ok}",
|
|
f"- White list FAILED: **{wl_failed}**",
|
|
f"- Unknown contractors: **{unknown_count}**",
|
|
]
|
|
|
|
# White list failures — prominent
|
|
failed = [i for i in invoices if i.get("whitelist_status") == "failed"]
|
|
if failed:
|
|
lines += [
|
|
"",
|
|
"## ✗ White List Failures — DO NOT PAY",
|
|
"",
|
|
"> These bank accounts are **not registered** on the MF white list.",
|
|
"",
|
|
"| Invoice | Seller | NIP | Bank account | Amount |",
|
|
"| ---| ---| ---| ---| ---:|",
|
|
]
|
|
for inv in failed:
|
|
lines.append(
|
|
f"| {inv.get('invoice_number') or inv.get('ksef_reference', '—')} "
|
|
f"| {inv.get('seller_name') or '—'} "
|
|
f"| {inv.get('seller_nip') or '—'} "
|
|
f"| {inv.get('bank_account') or '—'} "
|
|
f"| {fmt_amount(inv.get('gross_amount'), inv.get('currency', 'PLN'))} |"
|
|
)
|
|
|
|
# Full invoices table
|
|
lines += ["", "## All Invoices (newest first)", ""] + _invoice_table_header()
|
|
for inv in invoices:
|
|
lines.append(_invoice_table_row(inv))
|
|
|
|
lines += _legend()
|
|
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)
|
|
|
|
# Per-run report (new invoices only)
|
|
report_path = output_dir / "invoices_report.md"
|
|
report_path.write_text(build_report(data), encoding="utf-8")
|
|
print(f"Wrote {report_path}")
|
|
|
|
# Cumulative report (all invoices ever processed)
|
|
all_data_path = output_dir / "invoices_all.json"
|
|
if all_data_path.exists():
|
|
all_data = json.loads(all_data_path.read_text(encoding="utf-8"))
|
|
all_report_path = output_dir / "invoices_all_report.md"
|
|
all_report_path.write_text(build_all_report(all_data), encoding="utf-8")
|
|
print(f"Wrote {all_report_path}")
|
|
|
|
return 0
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(
|
|
description="Format invoice check data into markdown reports."
|
|
)
|
|
parser.add_argument("--data", required=True,
|
|
help="Path to invoices_data.json produced by preprocess.py.")
|
|
parser.add_argument("--output-dir", required=True,
|
|
help="Directory for invoices_report.md and invoices_all_report.md.")
|
|
return parser
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(run(build_parser().parse_args()))
|