609 lines
26 KiB
Python
609 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> '
|
||
|
|
"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">✓</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> 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 — 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 — 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'])} · IDs checked:
|
||
|
|
{esc(m['ids_checked'])} · Lookups completed:
|
||
|
|
{esc(m['lookups_ok'])} · Lookups failed:
|
||
|
|
{esc(m['lookups_failed'])}<br/>
|
||
|
|
BizPortal login used: {esc(m['login_used'])} · Searches consumed:
|
||
|
|
{esc(m['searches_used'])} · 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()
|