Workflow-App/Workflow/scripts/cipc-report/build_report.py

609 lines
26 KiB
Python
Raw Normal View History

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