skills-marketplace/plugins/ivona/skills/invoice-checker/scripts/postprocess.py
Michał Kopeć 576a14c82f Add ivona and timesheet plugins, MIT LICENSE, and invoice-prep-test
Adds ivona (invoice-checker, timesheet-checker) and timesheet
(check-my-timesheet, invoice-prep) as new plugins, plus invoice-prep-test — a
one-off experiment that requests the Tempo API token via plugin userConfig at
install time instead of a .env file, to check whether that value actually
reaches the skill's script. Also adds the root LICENSE (MIT) referenced from
the README.
2026-07-15 15:00:21 +02:00

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()))