Workflow-App/Workflow/scripts/cipc-report/build_report.py
sjcoetzeesj-coder cd0a818641 feat(cipc): director appointment check tab
Reconcile CIPC BizPortal against the Workflow database for every
director ID number we hold, and report only the two kinds of
difference that need a decision:

  1. new appointment  - CIPC lists the person as a director of company
     X, the database does not.
  2. no longer a director - the database lists the person at company X,
     CIPC no longer shows them there.

Everything that is in sync is not reported, so the list stays short
enough to work through by hand.

- New page /cipc-report with an explorer that runs the check, polls
  the run and lists the exceptions.
- New API route /api/cipc/report: POST starts a background batch run
  of the existing "Bizportal Directopr id no search.py" (invoked
  exactly as it already behaves, including its own login rotation),
  GET returns run status and the exception lists. ?demo=1 returns a
  fixed sample so the screen can be shown without a live run.
- New action route /api/cipc/report/action for the per-exception
  follow-up actions.
- scripts/cipc-report/build_report.py renders the run into a PDF with
  the exceptions marked, plus the sample report used by ?demo=1.

The BizPortal tooling only exists on the Windows dev machine, so like
the other CIPC routes this tab cannot run the live search from the
Coolify Linux image.
2026-10-27 12:19:47 +02:00

608 lines
26 KiB
Python

#!/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 <dir> --out <pdf.html>
The script writes:
<work>/content.json content manifest (ensure_ascii=False)
<work>/page.html print-ready A4 HTML
Then render with:
bash "<pdf-skill>/scripts/make.sh" render --in <work>/page.html \
--out <pdf> --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'<div class="running-header"><span class="left">{esc(title)}</span>'
f'<span class="right">{esc(right)}</span></div>')
def footer(left: str, right: str) -> str:
return (f'<div class="running-footer"><span class="left">{esc(left)}</span>'
f'<span class="right">{esc(right)}</span></div>')
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 = ['<table class="data"><colgroup>']
out += [f'<col class="{w}">' for w in widths]
out.append("</colgroup><thead><tr>")
out += [f"<th>{esc(h)}</th>" for h in head]
out.append("</tr></thead><tbody>")
for cells in rows:
out.append("<tr>")
out += cells
out.append("</tr>")
out.append("</tbody></table>")
return "".join(out)
def exception_table(rows: list[dict], kind: str) -> str:
if not rows:
return ('<p class="lead"><span class="pill pill-ok">None found</span>&nbsp; '
"No appointments of this type were found.</p>")
if kind == "new":
pill = '<span class="pill pill-bad">New company</span>'
action = "Add company"
comp_label = "Company (CIPC)"
else:
pill = '<span class="pill pill-warn">Link missing</span>'
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'<td class="bold mono">{esc(r.get("id_no",""))}</td>',
f'<td class="bold">{esc(director)}</td>',
f'<td class="mono">{esc(r.get("enterprise_no",""))}</td>',
f'<td class="bold">{esc(r.get("enterprise_name",""))}</td>',
f'<td class="nowrap">{pill}</td>',
f'<td class="nowrap">{esc(r.get("enterprise_status",""))}<br/>{esc(action)}</td>',
])
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'<td class="bold mono">{esc(id_no)}</td>',
f'<td class="bold">{esc(e["first"])}</td>',
f'<td class="bold">{esc(e["surname"])}</td>',
f'<td class="center">{esc(e["new"])}</td>',
f'<td class="center">{esc(e["link"])}</td>',
f'<td class="center bold">{esc(total)}</td>',
])
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 = '<nav class="toc" aria-label="Table of contents">'
for sid, title, desc, page in toc:
toc_html += (f'<a class="toc-row" href="#{sid}">'
f'<span><span class="t">{esc(title)}</span><span class="d">{esc(desc)}</span></span>'
f'<span class="page-ref">{esc(page)}</span></a>')
toc_html += "</nav>"
if no_exceptions:
summary_block = (
'<div class="allclear avoid-break"><div class="mark">&#10003;</div>'
"<h3>No exceptions found</h3>"
"<p>Every director ID checked returned only appointments that are already "
"recorded in the database. Nothing needs to be added or linked.</p></div>"
)
else:
parts = []
if people_new:
parts.append(f"<strong>{people_new}</strong> director ID"
f"{'s' if people_new != 1 else ''} sit at "
f"<strong>{len(new_rows)} compan"
f"{'ies' if len(new_rows) != 1 else 'y'}</strong> that "
f"{'are' if len(new_rows) != 1 else 'is'} not in the database.")
if people_link:
parts.append(f"<strong>{people_link}</strong> 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 = ('<div class="insight avoid-break"><span class="label">Result</span>'
+ " ".join(parts) + "</div>")
fail_body = [
[f'<td class="bold mono">{esc(f.get("id_no",""))}</td>',
f'<td class="bold">{esc(f.get("first",""))}</td>',
f'<td class="bold">{esc(f.get("surname",""))}</td>',
f'<td>{esc(f.get("reason",""))}</td>']
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
'<p class="lead"><span class="pill pill-ok">Complete</span>&nbsp; Every ID number was '
"looked up successfully. There are no gaps in the coverage.</p>"
)
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 = (
'<div class="sample-banner">SAMPLE REPORT - DUMMY DATA, NOT A REAL RUN</div>'
if m["is_sample"] else ""
)
cover = f"""<section class="cover">
<span class="cover-tag">CIPC / BIZPORTAL - DIRECTOR APPOINTMENT CHECK</span>
<h1>{esc(m['title'])}</h1>
<p class="subtitle">{esc(m['subtitle'])}</p>
{sample_banner}
<div class="cover-meta">
<div class="meta-stats">
<div class="meta-stat"><div class="num">{esc(m['ids_checked'])}</div><div class="lbl">IDs CHECKED</div></div>
<div class="meta-stat"><div class="num">{esc(m['total_exc'])}</div><div class="lbl">DIRECTORS TO ACTION</div></div>
<div class="meta-stat"><div class="num">{esc(m['new_rows'])}</div><div class="lbl">NEW COMPANIES</div></div>
</div>
<div class="cover-info">
<div>{esc(m['doc_type'])}</div>
<div>{esc(m['version'])}</div>
<div>Source: {esc(m['source_file'])}</div>
</div>
</div>
</section>"""
affected_block = ""
if c["affected_table"]:
affected_block = ('<h3><span class="num">1.1</span>Directors to action</h3>\n'
+ c["affected_table"])
summary = f"""<section class="content page-break page" id="sec-summary">
{header(m['title'], 'Summary')}
<span class="section-num">SECTION 01</span>
<h2 class="section">Summary</h2>
<div class="section-line"></div>
<p class="lead">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 <em>not</em> yet have on file.</p>
<div class="kpis">
<div class="kpi blue">
<div class="label">IDs checked</div>
<div class="value">{esc(m['ids_checked'])}</div>
<div class="desc">SA ID numbers looked up on BizPortal</div>
</div>
<div class="kpi green">
<div class="label">Clean</div>
<div class="value">{esc(m['clean_count'])}</div>
<div class="desc">Everything already on record</div>
</div>
<div class="kpi orange">
<div class="label">New companies</div>
<div class="value">{esc(m['new_rows'])}</div>
<div class="desc">Not in the database at all</div>
</div>
<div class="kpi purple">
<div class="label">Links missing</div>
<div class="value">{esc(m['link_rows'])}</div>
<div class="desc">Company known, director not linked</div>
</div>
</div>
{c['summary_block']}
{affected_block}
<h3><span class="num">{'1.2' if c['affected_table'] else '1.1'}</span>Contents</h3>
{c['toc_html']}
{footer('CIPC Director Appointment Check', 'Page 2')}
</section>"""
exceptions = f"""<section class="content page-break page" id="sec-exceptions">
{header(m['title'], 'Exceptions')}
<span class="section-num">SECTION 02</span>
<h2 class="section">Exceptions</h2>
<div class="section-line"></div>
<p class="lead">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.</p>
<h3><span class="num">2.1</span>Type A &mdash; companies we do not have</h3>
<p>Registered at CIPC as a director of a company that does not exist in the Workflow database.</p>
{c['new_table']}
<h3><span class="num">2.2</span>Type B &mdash; missing director link</h3>
<p>Registered at CIPC as a director of a company we already hold, but the director is not
linked to that company in the database.</p>
{c['link_table']}
{footer('CIPC Director Appointment Check', 'Page 3')}
</section>"""
method = f"""<section class="content page-break page" id="sec-method">
{header(m['title'], 'Coverage and method')}
<span class="section-num">SECTION 03</span>
<h2 class="section">Coverage, method and limitations</h2>
<div class="section-line"></div>
<h3><span class="num">4.1</span>Lookups that did not complete</h3>
<p>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.</p>
{c['fail_block']}
<h3><span class="num">4.2</span>How each appointment was compared</h3>
<div class="takeaway"><span class="icon">1</span><span>BizPortal was searched by Director ID
No. using <em>Bizportal Directopr id no search.py</em>, returning the enterprise number,
name and status of every company the person is a director of.</span></div>
<div class="takeaway"><span class="icon">2</span><span>Each enterprise number was matched
against <em>ysterbaard_companies</em> in PocketBase. A company that is absent there is a
<em>new company</em> exception.</span></div>
<div class="takeaway"><span class="icon">3</span><span>Where the company does exist, the
director was matched against <em>ysterbaard_company_directors</em>. A known company with no
link for that director is a <em>missing link</em> exception.</span></div>
<div class="takeaway"><span class="icon">4</span><span>Everything else is already on file and
is not repeated in this report.</span></div>
<div class="conclusion avoid-break">
<h3>Run details</h3>
<p>Source list: {esc(m['source_file'])} &nbsp;&middot;&nbsp; IDs checked:
{esc(m['ids_checked'])} &nbsp;&middot;&nbsp; Lookups completed:
{esc(m['lookups_ok'])} &nbsp;&middot;&nbsp; Lookups failed:
{esc(m['lookups_failed'])}<br/>
BizPortal login used: {esc(m['login_used'])} &nbsp;&middot;&nbsp; Searches consumed:
{esc(m['searches_used'])} &nbsp;&middot;&nbsp; Report generated:
{esc(m['version'])}</p>
</div>
<div class="footer-meta">
<h4>Notes</h4>
<ul>
<li>BizPortal allows a limited number of searches per day per login. The run detail above
records the login used and the number of searches consumed.</li>
<li>Company status and director status are reproduced exactly as CIPC reported them at the
time of the search.</li>
<li>ID numbers are shown as recorded in the database. No masking was applied and no value
was reconstructed.</li>
</ul>
</div>
{footer('CIPC Director Appointment Check', f'Page {m["method_page"]}')}
</section>"""
doc = f"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>{esc(m['title'])}</title>
<style>{CSS}</style>
</head>
<body>
{cover}
{summary}
{exceptions if m['has_exceptions'] else ''}
{method}
</body>
</html>"""
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()