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