#!/usr/bin/env python3 """CIPC Director Appointment Check -> PDF report. Reads a results JSON produced by the CIPC report run and renders it to a polished A4 PDF via the pdf skill's HTML->Playwright pipeline. python build_report.py --results results.json --work --out The script writes: /content.json content manifest (ensure_ascii=False) /page.html print-ready A4 HTML Then render with: bash "/scripts/make.sh" render --in /page.html \ --out --format A4 --wait 3000 Results JSON shape ------------------ { "run": { "generated_at": "2026-10-26 22:31 SAST", "source_file": "Bizportal Search List.xlsx", "ids_checked": 44, "lookups_ok": 42, "lookups_failed": 2, "login_used": "Login 3 (st0496)", "searches_used": 44, "is_sample": true }, "exceptions_new_company": [ {"id_no":"...","first":"...","surname":"...", "enterprise_no":"K...","enterprise_name":"...", "enterprise_status":"In Business","director_status":"Active"} ], "exceptions_missing_link": [ ... same fields ... ], "failures": [{"id_no":"...","first":"...","surname":"...","reason":"..."}], "clean_count": 36 } """ import argparse import html import json import os from pathlib import Path def esc(x) -> str: return html.escape(str(x if x is not None else ""), quote=True) # ---------------------------------------------------------------- CSS CSS = """ @page { size: A4 portrait; margin: 13mm 10mm; } * { box-sizing: border-box; -webkit-print-color-adjust: exact; print-color-adjust: exact; } :root { --ink:#1d1d1f; --ink-2:#424245; --ink-3:#6e6e73; --ink-4:#8a8a8f; --hairline:#e5e5ea; --rule:#d2d2d7; --surface:#fff; --mute:#f5f5f7; --accent:#0f6f76; --cover-1:#14202b; --cover-2:#1d3a41; --cover-3:#0f6f76; } html, body { margin:0; padding:0; font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","Segoe UI","Helvetica Neue",Arial,sans-serif; color:var(--ink); background:var(--surface); font-size:10.5pt; line-height:1.55; -webkit-font-smoothing:antialiased; } .content { padding: 0 10mm; } .page-break { page-break-after:always; } .avoid-break { page-break-inside:avoid; } /* running header / footer */ .running-header { position:absolute; top:0; left:0; right:0; height:7mm; border-bottom:0.5pt solid var(--rule); font-size:8.5pt; color:var(--ink-4); line-height:7mm; } .running-header .left { float:left; } .running-header .right { float:right; } .running-footer { position:absolute; bottom:0; left:0; right:0; height:8mm; border-top:0.5pt solid var(--rule); font-size:8pt; color:var(--ink-4); line-height:8mm; } .running-footer .left { float:left; } .running-footer .right { float:right; } .page { position:relative; min-height:268mm; padding:11mm 0 11mm; box-sizing:border-box; } /* cover */ .cover { height:268mm; background:linear-gradient(135deg,var(--cover-1) 0%,var(--cover-2) 55%,var(--cover-3) 100%); color:#fff; padding:30mm 18mm; position:relative; overflow:hidden; page-break-after:always; } .cover::before { content:""; position:absolute; top:-100px; right:-100px; width:500px; height:500px; border-radius:50%; background:radial-gradient(circle,rgba(15,111,118,0.55),transparent 70%); } .cover::after { content:""; position:absolute; bottom:-160px; left:-140px; width:600px; height:600px; border-radius:50%; background:radial-gradient(circle,rgba(255,255,255,0.07),transparent 70%); } .cover-tag { display:inline-block; padding:6px 14px; border:1px solid rgba(255,255,255,0.45); border-radius:20px; font-size:9.5pt; letter-spacing:2px; opacity:0.9; } .cover h1 { font-size:34pt; font-weight:700; margin:26px 0 16px; line-height:1.15; letter-spacing:-0.8px; position:relative; z-index:2; } .cover .subtitle { font-size:12pt; opacity:0.85; max-width:135mm; line-height:1.7; position:relative; z-index:2; } .cover .sample-banner { display:inline-block; margin-top:14px; padding:7px 16px; border-radius:8px; background:rgba(255,214,102,0.92); color:#3d2c05; font-size:10pt; font-weight:700; letter-spacing:0.6px; position:relative; z-index:2; } .cover-meta { position:absolute; bottom:30mm; left:18mm; right:18mm; display:flex; justify-content:space-between; align-items:flex-end; z-index:2; } .meta-stats { display:flex; gap:30px; } .meta-stat .num { font-size:26pt; font-weight:700; line-height:1; } .meta-stat .lbl { font-size:8.5pt; opacity:0.75; margin-top:5px; letter-spacing:1px; } .cover-info { text-align:right; opacity:0.75; font-size:9pt; line-height:1.8; } /* section header */ .section-num { display:inline-block; font-size:9.5pt; font-weight:600; color:var(--accent); letter-spacing:3px; margin-bottom:8px; } h2.section { font-size:19pt; font-weight:700; margin:0 0 4px; letter-spacing:-0.5px; } .section-line { width:50px; height:3px; background:var(--accent); border-radius:2px; margin:10px 0 16px; } h3 { font-size:12pt; font-weight:600; margin:14px 0 6px; page-break-after:avoid; } h3 .num { color:var(--ink-4); font-weight:500; margin-right:8px; } p { margin:6px 0; } .lead { font-size:10.5pt; color:var(--ink-2); line-height:1.65; border-left:3px solid var(--accent); background:linear-gradient(90deg,rgba(15,111,118,0.06),transparent); padding:11px 14px; border-radius:4px; margin:11px 0 14px; } /* KPI grid */ .kpis { display:grid; grid-template-columns:repeat(4,1fr); gap:9px; margin:18px 0; } .kpi { padding:13px; border-radius:10px; background:linear-gradient(135deg,#f5f5f7,#ebebef); border:1px solid var(--rule); } .kpi.blue { background:linear-gradient(135deg,#e6f1f3,#d5e7ea); border-color:#bcd7db; } .kpi.green { background:linear-gradient(135deg,#e8f7ee,#d4f0e0); border-color:#b3e0c2; } .kpi.orange { background:linear-gradient(135deg,#fff0e0,#ffe0c4); border-color:#ffc89b; } .kpi.purple { background:linear-gradient(135deg,#f0e8ff,#e2d4ff); border-color:#c9b3ff; } .kpi .label { font-size:8pt; color:var(--ink-3); letter-spacing:0.5px; text-transform:uppercase; font-weight:600; } .kpi .value { font-size:21pt; font-weight:700; color:var(--ink); line-height:1.1; margin-top:4px; } .kpi .desc { font-size:8pt; color:var(--ink-3); margin-top:5px; line-height:1.45; } /* TOC */ .toc { margin:14px 0 0; } .toc-row { display:flex; justify-content:space-between; align-items:baseline; text-decoration:none; color:var(--ink-2); font-size:10pt; padding:7px 4px; border-bottom:0.5pt solid var(--hairline); } .toc-row .t { font-weight:600; color:var(--ink); } .toc-row .d { color:var(--ink-4); font-size:9pt; margin-left:8px; font-weight:400; } .toc-row .page-ref { color:var(--accent); font-weight:600; font-size:9.5pt; } /* data table */ table.data { width:100%; border-collapse:collapse; font-size:8.8pt; margin:9px 0; background:#fff; border:1px solid var(--hairline); border-radius:8px; overflow:hidden; table-layout:fixed; page-break-inside:avoid; } table.data col.id { width:15%; } table.data col.first { width:19%; } table.data col.sur { width:13%; } table.data col.ent { width:24%; } table.data col.stat { width:15%; } table.data col.act { width:14%; } table.data col.reason{ width:56%; } table.data col.n1 { width:17%; } table.data col.n2 { width:20%; } table.data col.n3 { width:20%; } table.data col.n4 { width:14%; } table.data col.n5 { width:14%; } table.data col.n6 { width:15%; } table.data col.f1 { width:17%; } table.data col.f2 { width:18%; } table.data col.f3 { width:17%; } table.data col.f4 { width:48%; } table.data thead th { background:linear-gradient(180deg,#f5f5f7,#ebebef); padding:8px 9px; text-align:left; font-weight:600; color:var(--ink); border-bottom:1px solid var(--rule); font-size:8.2pt; letter-spacing:0.3px; } table.data tbody td { padding:6px 7px; border-bottom:1px solid var(--mute); color:var(--ink-2); vertical-align:top; overflow-wrap:break-word; } table.data tbody tr:last-child td { border-bottom:0; } table.data tbody tr:nth-child(even) { background:#fbfbfd; } table.data tbody td.bold { font-weight:600; color:var(--ink); } table.data tbody td.mono { font-family:"SF Mono",Consolas,"Courier New",monospace; font-size:8pt; letter-spacing:-0.3px; white-space:nowrap; } table.data tbody td.nowrap { white-space:nowrap; } table.data tbody td.center { text-align:center; } table.data thead { display:table-header-group; } tr { page-break-inside:avoid; } /* pills (fixed semantics) */ .pill { display:inline-block; padding:2px 8px; border-radius:10px; font-size:7.8pt; font-weight:600; white-space:nowrap; } .pill-bad { background:#ffebee; color:#c62828; } .pill-warn { background:#fff3e0; color:#e65100; } .pill-ok { background:#e8f5e9; color:#2e7d32; } .pill-good { background:#e3f2fd; color:#1565c0; } .pill-best { background:#f3e5f5; color:#6a1b9a; } /* panels */ .insight { background:linear-gradient(135deg,#fff8e1,#fff3c4); border:1px solid #ffe082; border-radius:10px; padding:12px 15px; margin:14px 0; font-size:9.5pt; } .insight .label { display:inline-block; font-weight:700; color:#b8860b; font-size:8.5pt; letter-spacing:1px; margin-right:8px; } .allclear { background:linear-gradient(135deg,#e8f5e9,#d4f0e0); border:1px solid #b3e0c2; border-radius:12px; padding:26px 24px; margin:18px 0; text-align:center; } .allclear .mark { font-size:30pt; font-weight:700; color:#2e7d32; line-height:1; } .allclear h3 { margin:10px 0 6px; font-size:15pt; color:#1b5e20; } .allclear p { margin:0; color:#33691e; font-size:10pt; } .conclusion { background:linear-gradient(135deg,#14202b,#1d3a41); color:#fff; padding:20px 22px; border-radius:12px; margin:18px 0; } .conclusion h3 { color:#fff; margin:0 0 8px; font-size:12.5pt; } .conclusion p { opacity:0.92; font-size:9.5pt; margin:0; } .takeaway { display:flex; gap:9px; margin:7px 0; padding:9px 12px; border-radius:8px; background:var(--mute); font-size:9.5pt; } .takeaway .icon { font-weight:700; color:var(--accent); min-width:16px; } .footer-meta { border-top:1px solid var(--hairline); margin-top:22px; padding-top:14px; font-size:8.5pt; color:var(--ink-4); } .footer-meta h4 { color:var(--ink-2); font-size:10pt; margin:0 0 7px; } .footer-meta ul { margin:5px 0; padding-left:17px; line-height:1.7; } /* screen emulation */ @media screen { html { background:#d0d0d0; } body { margin:0; padding:20px 0; } body > section { width:210mm; min-height:297mm; margin:0 auto 20px; background:#fff; box-shadow:0 4px 24px rgba(0,0,0,0.18); box-sizing:border-box; overflow:hidden; } } """ def header(title: str, right: str) -> str: return (f'
{esc(title)}' f'{esc(right)}
') def footer(left: str, right: str) -> str: return (f'') def _table(head: list[str], widths: list[str], rows: list[list[str]]) -> str: """Fixed-layout table with an explicit colgroup so no cell wraps badly.""" out = [''] out += [f'' for w in widths] out.append("") out += [f"" for h in head] out.append("") for cells in rows: out.append("") out += cells out.append("") out.append("
{esc(h)}
") return "".join(out) def exception_table(rows: list[dict], kind: str) -> str: if not rows: return ('

None found  ' "No appointments of this type were found.

") if kind == "new": pill = 'New company' action = "Add company" comp_label = "Company (CIPC)" else: pill = 'Link missing' action = "Link director" comp_label = "Company (in database)" head = ["ID Number", "Director", "Enterprise Number", comp_label, "Status", "Action"] widths = ["id", "first", "ent", "sur", "stat", "act"] body = [] for r in rows: director = " ".join(x for x in (r.get("first", ""), r.get("surname", "")) if x).strip() body.append([ f'{esc(r.get("id_no",""))}', f'{esc(director)}', f'{esc(r.get("enterprise_no",""))}', f'{esc(r.get("enterprise_name",""))}', f'{pill}', f'{esc(r.get("enterprise_status",""))}
{esc(action)}', ]) return _table(head, widths, body) def affected_table(new_rows: list[dict], link_rows: list[dict]) -> str: """Compact per-director summary used on the summary page.""" agg: dict[str, dict] = {} for r in new_rows: e = agg.setdefault(r["id_no"], {"first": r.get("first", ""), "surname": r.get("surname", ""), "new": 0, "link": 0}) e["new"] += 1 for r in link_rows: e = agg.setdefault(r["id_no"], {"first": r.get("first", ""), "surname": r.get("surname", ""), "new": 0, "link": 0}) e["link"] += 1 if not agg: return "" head = ["ID Number", "Name", "Surname", "New companies", "Missing links", "Total"] widths = ["n1", "n2", "n3", "n4", "n5", "n6"] body = [] for id_no, e in sorted(agg.items(), key=lambda kv: (-(kv[1]["new"] + kv[1]["link"]), kv[0])): total = e["new"] + e["link"] body.append([ f'{esc(id_no)}', f'{esc(e["first"])}', f'{esc(e["surname"])}', f'{esc(e["new"])}', f'{esc(e["link"])}', f'{esc(total)}', ]) return _table(head, widths, body) def build_content(data: dict) -> dict: run = data.get("run", {}) new_rows = data.get("exceptions_new_company", []) link_rows = data.get("exceptions_missing_link", []) failures = data.get("failures", []) clean = data.get("clean_count", 0) people_new = len({r["id_no"] for r in new_rows}) people_link = len({r["id_no"] for r in link_rows}) total_exc = people_new + people_link no_exceptions = (total_exc == 0) has_exc = total_exc > 0 method_page = 4 if has_exc else 3 toc = [ ("sec-summary", "Summary", "What was checked, what was found, who is affected", "2"), ] if has_exc: toc.append(("sec-exceptions", "Exceptions", "Appointments not yet recorded in the database", "3")) toc.append(("sec-method", "Coverage, method and limitations", "Failed lookups, daily quota and how the comparison works", str(method_page))) toc_html = '" if no_exceptions: summary_block = ( '
✓
' "

No exceptions found

" "

Every director ID checked returned only appointments that are already " "recorded in the database. Nothing needs to be added or linked.

" ) else: parts = [] if people_new: parts.append(f"{people_new} director ID" f"{'s' if people_new != 1 else ''} sit at " f"{len(new_rows)} compan" f"{'ies' if len(new_rows) != 1 else 'y'} that " f"{'are' if len(new_rows) != 1 else 'is'} not in the database.") if people_link: parts.append(f"{people_link} director ID" f"{'s' if people_link != 1 else ''} {'are' if people_link != 1 else 'is'} " f"appointed at a company we hold, but the director link is missing.") summary_block = ('
Result' + " ".join(parts) + "
") fail_body = [ [f'{esc(f.get("id_no",""))}', f'{esc(f.get("first",""))}', f'{esc(f.get("surname",""))}', f'{esc(f.get("reason",""))}'] for f in failures ] fail_block = ( _table(["ID Number", "Name", "Surname", "Reason the lookup did not complete"], ["f1", "f2", "f3", "f4"], fail_body) if failures else '

Complete  Every ID number was ' "looked up successfully. There are no gaps in the coverage.

" ) return { "meta": { "title": "CIPC Director Appointment Check", "subtitle": "Director appointments recorded at CIPC that are not yet reflected " "in the Workflow client database.", "doc_type": "EXCEPTION REPORT", "version": run.get("generated_at", ""), "source_file": run.get("source_file", ""), "login_used": run.get("login_used", ""), "searches_used": run.get("searches_used", ""), "is_sample": bool(run.get("is_sample")), "ids_checked": run.get("ids_checked", 0), "lookups_ok": run.get("lookups_ok", 0), "lookups_failed": run.get("lookups_failed", 0), "clean_count": clean, "people_new": people_new, "people_link": people_link, "new_rows": len(new_rows), "link_rows": len(link_rows), "total_exc": total_exc, "no_exceptions": no_exceptions, "has_exceptions": has_exc, "method_page": method_page, "failures": failures, }, "toc_html": toc_html, "summary_block": summary_block, "affected_table": affected_table(new_rows, link_rows), "new_table": exception_table(new_rows, "new"), "link_table": exception_table(link_rows, "link"), "fail_block": fail_block, "toc": toc, } def render_html(c: dict) -> str: m = c["meta"] sample_banner = ( '
SAMPLE REPORT - DUMMY DATA, NOT A REAL RUN
' if m["is_sample"] else "" ) cover = f"""
CIPC / BIZPORTAL - DIRECTOR APPOINTMENT CHECK

{esc(m['title'])}

{esc(m['subtitle'])}

{sample_banner}
{esc(m['ids_checked'])}
IDs CHECKED
{esc(m['total_exc'])}
DIRECTORS TO ACTION
{esc(m['new_rows'])}
NEW COMPANIES
{esc(m['doc_type'])}
{esc(m['version'])}
Source: {esc(m['source_file'])}
""" affected_block = "" if c["affected_table"]: affected_block = ('

1.1Directors to action

\n' + c["affected_table"]) summary = f"""
{header(m['title'], 'Summary')} SECTION 01

Summary

Every SA ID number on the source list was looked up on CIPC BizPortal to find the companies that person is currently registered as a director of. Each returned company was then compared against the Workflow database. This report lists only the appointments we do not yet have on file.

IDs checked
{esc(m['ids_checked'])}
SA ID numbers looked up on BizPortal
Clean
{esc(m['clean_count'])}
Everything already on record
New companies
{esc(m['new_rows'])}
Not in the database at all
Links missing
{esc(m['link_rows'])}
Company known, director not linked
{c['summary_block']} {affected_block}

{'1.2' if c['affected_table'] else '1.1'}Contents

{c['toc_html']} {footer('CIPC Director Appointment Check', 'Page 2')}
""" exceptions = f"""
{header(m['title'], 'Exceptions')} SECTION 02

Exceptions

Every appointment below is held at CIPC but is not yet reflected in the Workflow database. They are the only entries in this report - appointments we already have on file are not repeated.

2.1Type A — companies we do not have

Registered at CIPC as a director of a company that does not exist in the Workflow database.

{c['new_table']}

2.2Type B — missing director link

Registered at CIPC as a director of a company we already hold, but the director is not linked to that company in the database.

{c['link_table']} {footer('CIPC Director Appointment Check', 'Page 3')}
""" method = f"""
{header(m['title'], 'Coverage and method')} SECTION 03

Coverage, method and limitations

4.1Lookups that did not complete

These ID numbers were not confirmed against CIPC, so this report is not complete for them. Re-run the check for this list before treating the result as final.

{c['fail_block']}

4.2How each appointment was compared

1BizPortal was searched by Director ID No. using Bizportal Directopr id no search.py, returning the enterprise number, name and status of every company the person is a director of.
2Each enterprise number was matched against ysterbaard_companies in PocketBase. A company that is absent there is a new company exception.
3Where the company does exist, the director was matched against ysterbaard_company_directors. A known company with no link for that director is a missing link exception.
4Everything else is already on file and is not repeated in this report.

Run details

Source list: {esc(m['source_file'])}  ·  IDs checked: {esc(m['ids_checked'])}  ·  Lookups completed: {esc(m['lookups_ok'])}  ·  Lookups failed: {esc(m['lookups_failed'])}
BizPortal login used: {esc(m['login_used'])}  ·  Searches consumed: {esc(m['searches_used'])}  ·  Report generated: {esc(m['version'])}

{footer('CIPC Director Appointment Check', f'Page {m["method_page"]}')}
""" doc = f""" {esc(m['title'])} {cover} {summary} {exceptions if m['has_exceptions'] else ''} {method} """ return doc def main(): ap = argparse.ArgumentParser(description="Build the CIPC director appointment exception report.") ap.add_argument("--results", required=True, help="Results JSON from the CIPC report run.") ap.add_argument("--work", required=True, help="Working directory for content.json + page.html.") ap.add_argument("--out-html", help="Optional explicit path for the rendered HTML.") args = ap.parse_args() with open(args.results, encoding="utf-8-sig") as f: data = json.load(f) work = Path(args.work) work.mkdir(parents=True, exist_ok=True) content = build_content(data) (work / "content.json").write_text( json.dumps(content, ensure_ascii=False, indent=2), encoding="utf-8") doc = render_html(content) out_html = Path(args.out_html) if args.out_html else (work / "page.html") out_html.parent.mkdir(parents=True, exist_ok=True) out_html.write_text(doc, encoding="utf-8") m = content["meta"] print(f"content.json -> {work / 'content.json'}") print(f"page.html -> {out_html}") print(f" IDs checked : {m['ids_checked']}") print(f" new companies : {m['new_rows']} ({m['people_new']} directors)") print(f" missing links : {m['link_rows']} ({m['people_link']} directors)") print(f" failed lookups : {m['lookups_failed']}") print(f" no exceptions : {m['no_exceptions']}") if __name__ == "__main__": main()