#!/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()))