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.
This commit is contained in:
sjcoetzeesj-coder 2026-10-27 12:19:47 +02:00
parent 3595cd54bb
commit cd0a818641
7 changed files with 2162 additions and 0 deletions

1
.gitignore vendored
View file

@ -37,6 +37,7 @@ Workflow/.cipc-report/
Workflow/reports/
Workflow/scripts/one-id.txt
Workflow/scripts/cipc-report/_sample*/
Workflow/scripts/cipc-report/sample-results*.json
# --------------------------------- debug captures & one-off probe output ---
# Ad-hoc dumps kept while debugging SARS / CIPC screens. Not project source.

View file

@ -0,0 +1,252 @@
// app/api/cipc/report/action/route.ts
// Applies ONE agreed exception from the CIPC Report tab to the database.
//
// POST /api/cipc/report/action
// { action: "link", id_no, enterprise_no, enterprise_name, enterprise_status }
// { action: "unlink", id_no, enterprise_no }
//
// "link" — the person IS a director of the company on CIPC but we do not
// have that appointment. Creates the company if it is not on file,
// then creates the junction link.
// "unlink" — we still list the person as a director of the company but CIPC
// no longer does. Removes the junction link only; the company and
// the director record are left alone.
//
// One row at a time by design — SJ works through the list himself.
import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs";
import path from "node:path";
import {
createRecord,
deleteRecord,
fetchCollection,
pbFetch,
recomputeDirectorCompanyCounts,
PBRecord,
} from "@/lib/pb";
export const dynamic = "force-dynamic";
const COMPANIES = "ysterbaard_companies";
const DIRECTORS = "ysterbaard_directors";
const JUNCTION = "pbc_2497518006"; // ysterbaard_company_directors
const DEMO_FILE = path.join(process.cwd(), "scripts", "cipc-report", "sample-report.json");
/**
* Sample rows must never be written to the real database. The client
* disables the buttons for sample data, but this is the server-side guard
* for the case where someone posts the sample values directly.
*/
function isSampleEnterpriseNo(entNo: string): boolean {
try {
if (!fs.existsSync(DEMO_FILE)) return false;
const demo = JSON.parse(fs.readFileSync(DEMO_FILE, "utf-8"));
const rows = [...(demo.new_appointments || []), ...(demo.no_longer_director || [])];
return rows.some(
(r: { enterprise_no?: string }) =>
canonicaliseEnterpriseNo(r.enterprise_no || "") === entNo
);
} catch {
return false;
}
}
/** PB filter values are double-quoted; escape embedded quotes. */
function q(v: string): string {
return `"${String(v ?? "").replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
}
/** Mirror of the canonicalisation in /api/cipc/save-selection. */
function canonicaliseEnterpriseNo(raw: string): string {
const t = (raw || "").trim().toUpperCase();
if (!t) return "";
const slash = t.match(/^(\d{4})\/(\d{6,7})(?:\/\d{2})?$/);
if (slash) return `K${slash[1]}${slash[2]}`;
if (/^[KMB]\d{10,12}$/.test(t)) return t;
if (/^\d{10,12}$/.test(t)) return `K${t}`;
return t;
}
const today = () => new Date().toISOString().slice(0, 10);
/** The director record that holds this ID number, if we have one. */
async function findDirectorByIdNo(idNo: string): Promise<PBRecord | null> {
const v = String(idNo ?? "").replace(/\s/g, "");
if (!v) return null;
const found = await fetchCollection<PBRecord>(DIRECTORS, {
perPage: 1,
filter: `id_no = ${q(v)} || id_no_masked = ${q(v)}`,
});
return found.items[0] || null;
}
/** The company record with this enterprise number, if we have one. */
async function findCompanyByEntNo(entNo: string): Promise<PBRecord | null> {
const canonical = canonicaliseEnterpriseNo(entNo);
if (!canonical) return null;
const found = await fetchCollection<PBRecord>(COMPANIES, {
perPage: 1,
filter: `enterprise_no = ${q(canonical)}`,
});
return found.items[0] || null;
}
export async function POST(req: NextRequest) {
let body: {
action?: "link" | "unlink";
id_no?: string;
enterprise_no?: string;
enterprise_name?: string;
enterprise_status?: string;
};
try {
body = await req.json();
} catch {
return NextResponse.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
}
const action = body.action;
const idNo = String(body.id_no ?? "").trim();
const entNoRaw = String(body.enterprise_no ?? "").trim();
if (action !== "link" && action !== "unlink") {
return NextResponse.json({ ok: false, error: "action must be 'link' or 'unlink'" }, { status: 400 });
}
if (!idNo) {
return NextResponse.json({ ok: false, error: "id_no is required" }, { status: 400 });
}
if (!entNoRaw) {
return NextResponse.json({ ok: false, error: "enterprise_no is required" }, { status: 400 });
}
const entNo = canonicaliseEnterpriseNo(entNoRaw);
if (isSampleEnterpriseNo(entNo)) {
return NextResponse.json(
{
ok: false,
error:
"This row is sample data. Run the CIPC Director Check to get live results before " +
"changing the database.",
},
{ status: 400 }
);
}
try {
const director = await findDirectorByIdNo(idNo);
if (!director) {
return NextResponse.json(
{
ok: false,
error:
"We have no director record for this ID number, so the appointment cannot be " +
"recorded against anyone. Add the person to the Directors tab first.",
},
{ status: 404 }
);
}
// ── UNLINK ───────────────────────────────────────────────────────────
if (action === "unlink") {
const company = await findCompanyByEntNo(entNo);
if (!company) {
return NextResponse.json(
{ ok: false, error: "We have no company record for this enterprise number." },
{ status: 404 }
);
}
const existing = await pbFetch<{ items: PBRecord[] }>(
`/api/collections/${JUNCTION}/records?filter=${encodeURIComponent(
`company = ${q(company.id)} && director = ${q(director.id)}`
)}&perPage=1`
);
const link = existing.items?.[0];
if (!link) {
return NextResponse.json(
{ ok: false, error: "That appointment is not linked, so there is nothing to remove." },
{ status: 404 }
);
}
await deleteRecord(JUNCTION, link.id);
await recomputeDirectorCompanyCounts(director.id).catch((e: unknown) => {
console.error("[cipc:report/action] counter recompute failed", e);
});
return NextResponse.json({
ok: true,
action,
company_id: company.id,
director_id: director.id,
message: `${director.surname ?? ""} removed as director of ${company.enterprise_name}.`,
});
}
// ── LINK ─────────────────────────────────────────────────────────────
let company = await findCompanyByEntNo(entNo);
let companyCreated = false;
if (!company) {
const name = String(body.enterprise_name ?? "").trim();
if (!name) {
return NextResponse.json(
{ ok: false, error: "Company name is required to create a new company record." },
{ status: 400 }
);
}
company = await createRecord<PBRecord>(COMPANIES, {
enterprise_no: entNo,
enterprise_name: name,
enterprise_status: String(body.enterprise_status ?? "").trim(),
is_active: false,
client_status: "non_client",
first_seen: today(),
last_checked: today(),
source: "cipc:report",
});
companyCreated = true;
}
// Already linked? Nothing to do.
const existing = await pbFetch<{ items: PBRecord[] }>(
`/api/collections/${JUNCTION}/records?filter=${encodeURIComponent(
`company = ${q(company.id)} && director = ${q(director.id)}`
)}&perPage=1`
);
if (existing.items?.[0]) {
return NextResponse.json({
ok: true,
action,
already: true,
company_id: company.id,
director_id: director.id,
message: "That appointment was already on file.",
});
}
await createRecord<PBRecord>(JUNCTION, {
company: company.id,
director: director.id,
director_type: "Director",
});
await recomputeDirectorCompanyCounts(director.id).catch((e: unknown) => {
console.error("[cipc:report/action] counter recompute failed", e);
});
return NextResponse.json({
ok: true,
action,
company_created: companyCreated,
company_id: company.id,
director_id: director.id,
message: companyCreated
? `${company.enterprise_name} added and ${director.surname ?? ""} linked as director.`
: `${director.surname ?? ""} linked as director of ${company.enterprise_name}.`,
});
} catch (e) {
return NextResponse.json(
{ ok: false, error: e instanceof Error ? e.message : "Unknown error" },
{ status: 500 }
);
}
}

View file

@ -0,0 +1,619 @@
// app/api/cipc/report/route.ts
// CIPC Director Appointment Check — reconciles CIPC BizPortal against the
// Workflow database for every director ID we hold.
//
// WHAT IT REPORTS (and only this):
//
// 1. NEW APPOINTMENT — CIPC says this person is a director of company X,
// but the database does not list them as a director of X.
// 2. NO LONGER A DIRECTOR — the database lists this person as a director of
// company X, but CIPC no longer shows them there (removed from the
// CIPC record, or shown as resigned).
//
// Everything else is in sync and is NOT reported.
//
// HOW IT WORKS
// POST → starts a background run of "Bizportal Directopr id no search.py"
// in --batch mode, writing a log we poll.
// GET → returns run status; once the run finishes, returns the two
// exception lists. `?demo=1` returns a fixed sample instead.
//
// The .py is co-authored and is NOT modified by this route — it is invoked
// exactly as it already behaves, including its own login rotation.
import { NextRequest, NextResponse } from "next/server";
import { spawn } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
import { pbFetch } from "@/lib/pb";
export const dynamic = "force-dynamic";
export const maxDuration = 300;
const SCRIPTS_DIR = path.join(process.cwd(), "scripts");
const RUN_DIR = path.join(process.cwd(), ".cipc-report");
const SCRIPT_NAME = "Bizportal Directopr id no search.py";
const BATCH_FILE = path.join(RUN_DIR, "ids.txt");
const LOG_FILE = path.join(RUN_DIR, "run.log");
const STATE_FILE = path.join(RUN_DIR, "state.json");
const DEMO_FILE = path.join(SCRIPTS_DIR, "cipc-report", "sample-report.json");
// ===================================================================
// TYPES
// ===================================================================
export interface CbizRow {
enterprise_number: string;
enterprise_name: string;
enterprise_status: string;
director_status: string;
}
export interface ExceptionRow {
id_no: string;
director: string;
company: string;
enterprise_no: string;
status: string;
/** "active" for a new appointment, "resigned" / "missing" for a departure. */
reason: "active" | "resigned" | "missing";
/** ysterbaard_directors record id — empty when we hold no director record. */
director_pb_id: string;
/** ysterbaard_companies record id — empty when we hold no company record. */
company_pb_id: string;
/** True when the company itself is already on file. */
company_exists: boolean;
}
export interface ReportPayload {
ok: boolean;
status: "idle" | "running" | "done" | "error";
is_demo?: boolean;
started_at?: string;
finished_at?: string;
total?: number;
done?: number;
current_id?: string | null;
message?: string;
error?: string;
checked?: number;
failed?: number;
failed_ids?: string[];
new_appointments?: ExceptionRow[];
no_longer_director?: ExceptionRow[];
}
// ===================================================================
// PB READ HELPERS
// ===================================================================
async function fetchAll<T>(collection: string): Promise<T[]> {
const out: T[] = [];
let page = 1;
for (;;) {
const list = await pbFetch<{ items?: T[]; totalPages?: number }>(
`/api/collections/${collection}/records`,
{ page, perPage: 200 }
);
const items = list.items || [];
out.push(...items);
if (page >= (list.totalPages || 1) || items.length === 0) break;
page += 1;
}
return out;
}
interface PbDirector {
id: string;
id_no?: string;
first_names?: string;
surname?: string;
}
interface PbCompany {
id: string;
enterprise_no?: string;
enterprise_name?: string;
enterprise_status?: string;
}
interface PbLink {
company?: string;
director?: string;
}
/** Normalised enterprise number used for matching. */
function entKey(v: unknown): string {
return String(v ?? "").trim().toUpperCase();
}
/**
* CIPC reports the director status as ACTIVE / INACTIVE. A naive
* /active/i test would read "INACTIVE" as active, so check for the
* negative first.
*/
function directorIsActive(status: unknown): boolean {
const s = String(status ?? "").trim().toLowerCase();
if (!s) return false;
if (s.includes("inactive")) return false;
return s.includes("active");
}
function fullName(first?: string, surname?: string): string {
return [first || "", surname || ""].join(" ").replace(/\s+/g, " ").trim();
}
/** A usable full SA ID: 13 digits, not an all-zero placeholder. */
function isFullId(v: unknown): boolean {
const s = String(v ?? "").trim();
return /^\d{13}$/.test(s) && !/^0{13}$/.test(s);
}
// ===================================================================
// THE ID LIST
//
// The same population as the "Bizportal Search List" export: every full
// 13-digit ID across the four collections that hold one, de-duplicated.
// ===================================================================
async function collectTargetIds(): Promise<{
ids: string[];
names: Map<string, string>;
}> {
const directors = await fetchAll<PbDirector>("ysterbaard_directors");
const regDirectors = await fetchAll<PbDirector>("ysterbaard_reg_directors");
const shareholders = await fetchAll<{ id_or_entity_no?: string; name?: string }>(
"ysterbaard_reg_shareholders"
);
const onboarding = await fetchAll<{ directors_data?: any }>(
"ysterbaard_onboarding_submissions"
);
const names = new Map<string, string>();
const ids: string[] = [];
const seen = new Set<string>();
const push = (id: unknown, name?: string) => {
const s = String(id ?? "").trim();
if (!isFullId(s) || seen.has(s)) return;
seen.add(s);
ids.push(s);
if (name && !names.has(s)) names.set(s, name);
};
for (const d of directors) push(d.id_no, fullName(d.first_names, d.surname));
for (const d of regDirectors) push(d.id_no, fullName(d.first_names, d.surname));
for (const s of shareholders) {
const raw = String(s.name ?? "").trim();
const parts = raw.split(/\s+/);
push(s.id_or_entity_no, parts.length > 1 ? parts.slice(0, -1).join(" ") + " " + parts[parts.length - 1] : raw);
}
for (const o of onboarding) {
const dd = o.directors_data;
const list = typeof dd === "string" ? safeJson(dd) : dd;
for (const p of Array.isArray(list) ? list : []) {
push(p?.id_no, fullName(p?.first_names, p?.surname));
}
}
return { ids, names };
}
function safeJson(s: string): any {
try {
return JSON.parse(s);
} catch {
return [];
}
}
// ===================================================================
// LOG PARSING
//
// The .py prints one block per ID:
//
// ========== Director ID 8901010001087 ==========
// logged in OK (Login 3)
// Searches Conducted Today: 44
//
// | Enterprise Number | Enterprise Name | Enterprise Status | Director Status |
// |---|---|---|---|
// | K2026108471 | BRANDLINE HOLDINGS (PTY) LTD | In Business | Active |
//
// ===================================================================
function parseDirectorLog(stdout: string): Map<string, CbizRow[]> {
const out = new Map<string, CbizRow[]>();
const marker = /=+\s*Director ID\s+(\d{13})\s*=+/g;
const bounds: { id: string; start: number; end: number }[] = [];
let m: RegExpExecArray | null;
while ((m = marker.exec(stdout)) !== null) {
bounds.push({ id: m[1], start: m.index, end: -1 });
}
bounds.forEach((b, i) => {
b.end = i + 1 < bounds.length ? bounds[i + 1].start : stdout.length;
});
for (const b of bounds) {
const body = stdout.slice(b.start, b.end);
const rows: CbizRow[] = [];
for (const line of body.split(/\r?\n/)) {
const t = line.trim();
if (!t.startsWith("|")) continue;
const cells = t.split("|").slice(1, -1).map((c) => c.trim());
if (cells.every((c) => /^[-:]+$/.test(c))) continue;
if (cells[0] === "Enterprise Number") continue;
if (!/^[KMB]\d{10,}$/.test(cells[0])) continue;
rows.push({
enterprise_number: cells[0],
enterprise_name: cells[1] ?? "",
enterprise_status: cells[2] ?? "",
director_status: cells[3] ?? "",
});
}
out.set(b.id, rows);
}
return out;
}
/** IDs the .py announced but produced no table for. */
function parseFailedIds(stdout: string): Set<string> {
const failed = new Set<string>();
const marker = /=+\s*Director ID\s+(\d{13})\s*=+/g;
const bounds: { id: string; start: number; end: number }[] = [];
let m: RegExpExecArray | null;
while ((m = marker.exec(stdout)) !== null) {
bounds.push({ id: m[1], start: m.index, end: -1 });
}
bounds.forEach((b, i) => {
b.end = i + 1 < bounds.length ? bounds[i + 1].start : stdout.length;
});
for (const b of bounds) {
const body = stdout.slice(b.start, b.end);
if (/WARN: no director-table rows found/.test(body) || /ERROR:/.test(body)) {
failed.add(b.id);
}
}
return failed;
}
// ===================================================================
// RECONCILIATION
// ===================================================================
async function buildReport(stdout: string): Promise<{
new_appointments: ExceptionRow[];
no_longer_director: ExceptionRow[];
checked: number;
failed: number;
failed_ids: string[];
}> {
const { ids, names } = await collectTargetIds();
const directors = await fetchAll<PbDirector>("ysterbaard_directors");
const companies = await fetchAll<PbCompany>("ysterbaard_companies");
const links = await fetchAll<PbLink>("ysterbaard_company_directors");
const companyById = new Map(companies.map((c) => [c.id, c]));
const companyByEnt = new Map(companies.map((c) => [entKey(c.enterprise_no), c]));
// director record id → set of enterprise numbers they are linked to
const linkedEntByDirector = new Map<string, Set<string>>();
for (const l of links) {
if (!l.director || !l.company) continue;
const comp = companyById.get(l.company);
if (!comp) continue;
const key = entKey(comp.enterprise_no);
if (!key) continue;
if (!linkedEntByDirector.has(l.director)) linkedEntByDirector.set(l.director, new Set());
linkedEntByDirector.get(l.director)!.add(key);
}
// ID number → director record id (so a person found only in reg_* can still
// be matched to the directors record that holds their company links)
const dirRecByIdNo = new Map<string, string>();
for (const d of directors) {
const k = String(d.id_no ?? "").trim();
if (isFullId(k) && !dirRecByIdNo.has(k)) dirRecByIdNo.set(k, d.id);
}
const biz = parseDirectorLog(stdout);
const failedIds = parseFailedIds(stdout);
const newAppointments: ExceptionRow[] = [];
const noLonger: ExceptionRow[] = [];
const failedList: string[] = [];
let checked = 0;
let failed = 0;
for (const id of ids) {
if (failedIds.has(id)) {
failed += 1;
failedList.push(id);
continue;
}
const rows = biz.get(id);
if (!rows) {
failed += 1;
failedList.push(id);
continue;
}
checked += 1;
const name = names.get(id) || "";
const dirRec = dirRecByIdNo.get(id);
const linked = dirRec ? linkedEntByDirector.get(dirRec) || new Set<string>() : new Set<string>();
// 1. NEW APPOINTMENT — active at CIPC, but not linked in our database.
for (const r of rows) {
const key = entKey(r.enterprise_number);
if (!key || linked.has(key)) continue;
if (!directorIsActive(r.director_status)) continue;
const known = companyByEnt.get(key);
newAppointments.push({
id_no: id,
director: name,
company: r.enterprise_name,
enterprise_no: r.enterprise_number,
status: r.enterprise_status || "",
reason: "active",
director_pb_id: dirRec || "",
company_pb_id: known?.id || "",
company_exists: Boolean(known),
});
}
// 2. NO LONGER A DIRECTOR — linked in our database, but CIPC no longer
// shows them there (resigned, or dropped from the record).
const bizByEnt = new Map(rows.map((r) => [entKey(r.enterprise_number), r]));
for (const key of linked) {
const row = bizByEnt.get(key);
const comp = companyByEnt.get(key);
if (!row) {
noLonger.push({
id_no: id,
director: name,
company: comp?.enterprise_name || key,
enterprise_no: comp?.enterprise_no || key,
status: comp?.enterprise_status || "",
reason: "missing",
director_pb_id: dirRec || "",
company_pb_id: comp?.id || "",
company_exists: Boolean(comp),
});
continue;
}
if (!directorIsActive(row.director_status)) {
noLonger.push({
id_no: id,
director: name,
company: row.enterprise_name,
enterprise_no: row.enterprise_number,
status: row.enterprise_status || "",
reason: "resigned",
director_pb_id: dirRec || "",
company_pb_id: comp?.id || "",
company_exists: Boolean(comp),
});
}
}
}
return {
new_appointments: newAppointments,
no_longer_director: noLonger,
checked,
failed,
failed_ids: failedList,
};
}
// ===================================================================
// RUN STATE
// ===================================================================
interface RunState {
status: "idle" | "running" | "done" | "error";
started_at?: string;
finished_at?: string;
total?: number;
error?: string;
pid?: number;
}
function readState(): RunState {
try {
if (!fs.existsSync(STATE_FILE)) return { status: "idle" };
return JSON.parse(fs.readFileSync(STATE_FILE, "utf-8")) as RunState;
} catch {
return { status: "idle" };
}
}
function writeState(s: RunState): void {
fs.mkdirSync(RUN_DIR, { recursive: true });
fs.writeFileSync(STATE_FILE, JSON.stringify(s, null, 2), "utf-8");
}
function progressFromLog(): { done: number; current_id: string | null } {
try {
if (!fs.existsSync(LOG_FILE)) return { done: 0, current_id: null };
const log = fs.readFileSync(LOG_FILE, "utf-8");
const seen = [...log.matchAll(/=+\s*Director ID\s+(\d{13})\s*=+/g)].map((m) => m[1]);
const done = seen.length;
const last = seen.length ? seen[seen.length - 1] : null;
return { done, current_id: last };
} catch {
return { done: 0, current_id: null };
}
}
function pythonPath(): string {
return path.join(
SCRIPTS_DIR,
".venv",
"Scripts",
process.platform === "win32" ? "python.exe" : "python"
);
}
// ===================================================================
// ROUTES
// ===================================================================
export async function GET(req: NextRequest) {
const demo = req.nextUrl.searchParams.get("demo") === "1";
if (demo) {
try {
const payload = JSON.parse(fs.readFileSync(DEMO_FILE, "utf-8"));
return NextResponse.json({ ...payload, ok: true, is_demo: true, status: "done" });
} catch (err: any) {
return NextResponse.json(
{ ok: false, error: `Demo data unavailable: ${err?.message || String(err)}` },
{ status: 500 }
);
}
}
const state = readState();
const progress = progressFromLog();
if (state.status === "running") {
return NextResponse.json({
ok: true,
status: "running",
started_at: state.started_at,
total: state.total,
done: progress.done,
current_id: progress.current_id,
} satisfies ReportPayload);
}
if (state.status === "error") {
return NextResponse.json({
ok: false,
status: "error",
error: state.error,
started_at: state.started_at,
finished_at: state.finished_at,
total: state.total,
done: progress.done,
} satisfies ReportPayload);
}
if (state.status === "done") {
let stdout = "";
try {
stdout = fs.readFileSync(LOG_FILE, "utf-8");
} catch {
stdout = "";
}
const report = await buildReport(stdout);
return NextResponse.json({
ok: true,
status: "done",
started_at: state.started_at,
finished_at: state.finished_at,
total: state.total,
done: progress.done,
checked: report.checked,
failed: report.failed,
failed_ids: report.failed_ids,
new_appointments: report.new_appointments,
no_longer_director: report.no_longer_director,
} satisfies ReportPayload);
}
return NextResponse.json({ ok: true, status: "idle" } satisfies ReportPayload);
}
export async function POST() {
const state = readState();
if (state.status === "running") {
const progress = progressFromLog();
return NextResponse.json({
ok: true,
status: "running",
started_at: state.started_at,
total: state.total,
done: progress.done,
current_id: progress.current_id,
} satisfies ReportPayload);
}
const { ids } = await collectTargetIds();
if (ids.length === 0) {
return NextResponse.json(
{ ok: false, error: "No director ID numbers with a full 13-digit number were found." },
{ status: 400 }
);
}
fs.mkdirSync(RUN_DIR, { recursive: true });
fs.writeFileSync(BATCH_FILE, ids.join("\n") + "\n", "utf-8");
fs.writeFileSync(LOG_FILE, "", "utf-8");
const py = pythonPath();
if (!fs.existsSync(py)) {
return NextResponse.json(
{ ok: false, error: `Python runtime not found at ${py}` },
{ status: 500 }
);
}
const logFd = fs.openSync(LOG_FILE, "a");
const child = spawn(py, [path.join(SCRIPTS_DIR, SCRIPT_NAME), "--batch", BATCH_FILE], {
cwd: SCRIPTS_DIR,
windowsHide: true,
// The child's stdout/stderr go straight to the log file. Piping them
// through Node makes the script's own agent-browser subprocess calls
// block — the .py's first browser call never returned when spawned
// this way, and the run sat there for 9 minutes with no output.
stdio: ["ignore", logFd, logFd],
});
writeState({
status: "running",
started_at: new Date().toISOString(),
total: ids.length,
pid: child.pid,
});
child.on("close", (code) => {
const finished = new Date().toISOString();
if (code === 0 || code === 1) {
writeState({
status: "done",
started_at: readState().started_at,
finished_at: finished,
total: ids.length,
});
} else {
writeState({
status: "error",
started_at: readState().started_at,
finished_at: finished,
total: ids.length,
error:
code === 2
? "BizPortal stopped the run — the daily search limit was reached or the subscription expired. Run it again tomorrow."
: `The lookup script exited with code ${code}.`,
});
}
});
child.on("error", (err) => {
writeState({
status: "error",
started_at: readState().started_at,
finished_at: new Date().toISOString(),
total: ids.length,
error: err?.message || String(err),
});
});
return NextResponse.json({
ok: true,
status: "running",
started_at: new Date().toISOString(),
total: ids.length,
} satisfies ReportPayload);
}

View file

@ -0,0 +1,577 @@
// app/cipc-report/CipcReportExplorer.tsx
// Client component for the CIPC Report tab.
//
// Runs the CIPC Director Appointment Check and shows ONLY the differences
// between CIPC and the Workflow database:
//
// 1. NEW APPOINTMENT — CIPC shows this person as a director of a company
// the database does not list them as a director of.
// 2. NO LONGER A DIRECTOR — the database lists them as a director of a
// company CIPC no longer shows them at.
//
// Everything already in sync is not listed. If there is nothing to report,
// the tab says so plainly.
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import StatCard from "@/components/StatCard";
import StatusBadge from "@/components/StatusBadge";
interface ExceptionRow {
id_no: string;
director: string;
company: string;
enterprise_no: string;
status: string;
reason: "active" | "resigned" | "missing";
director_pb_id: string;
company_pb_id: string;
company_exists: boolean;
}
interface ReportPayload {
ok: boolean;
status: "idle" | "running" | "done" | "error";
is_demo?: boolean;
started_at?: string;
finished_at?: string;
total?: number;
done?: number;
current_id?: string | null;
error?: string;
checked?: number;
failed?: number;
failed_ids?: string[];
new_appointments?: ExceptionRow[];
no_longer_director?: ExceptionRow[];
}
const POLL_MS = 4000;
function rowKey(kind: string, r: ExceptionRow): string {
return `${kind}:${r.id_no}:${r.enterprise_no}`;
}
interface TableProps {
rows: ExceptionRow[];
kind: "link" | "unlink";
emptyText: string;
confirming: string | null;
busy: string | null;
resolved: Set<string>;
messages: Record<string, string>;
/** Sample data must never write to the real database. */
readOnly: boolean;
onRequest: (key: string, r: ExceptionRow) => void;
onCancel: () => void;
}
function ExceptionTable({
rows,
kind,
emptyText,
confirming,
busy,
resolved,
messages,
readOnly,
onRequest,
onCancel,
}: TableProps) {
if (!rows.length) {
return (
<div className="border border-border rounded-lg bg-surface p-8 text-center">
<p className="text-sm text-muted">{emptyText}</p>
</div>
);
}
return (
<div className="border border-border rounded-lg bg-surface overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm min-w-[900px]">
<thead>
<tr className="border-b border-border bg-surface-warm text-left">
<th className="px-3 py-3 font-semibold text-muted">ID Number</th>
<th className="px-3 py-3 font-semibold text-muted">Director</th>
<th className="px-3 py-3 font-semibold text-muted">Company</th>
<th className="px-3 py-3 font-semibold text-muted">Enterprise No.</th>
<th className="px-3 py-3 font-semibold text-muted">CIPC Status</th>
<th className="px-3 py-3 font-semibold text-muted">Action</th>
</tr>
</thead>
<tbody>
{rows.map((r) => {
const key = rowKey(kind, r);
const isDone = resolved.has(key);
const isConfirming = confirming === key;
const isBusy = busy === key;
return (
<tr
key={key}
className={`border-b border-border-soft last:border-0 ${
isDone ? "opacity-50" : ""
}`}
>
<td className="px-3 py-3 font-mono text-xs whitespace-nowrap">{r.id_no}</td>
<td className="px-3 py-3 text-fg">
{r.director_pb_id ? (
<Link
href={`/directors/${r.director_pb_id}`}
className="text-fg no-underline hover:underline"
>
{r.director || "—"}
</Link>
) : (
r.director || "—"
)}
</td>
<td className="px-3 py-3 text-fg">
{r.company_pb_id ? (
<Link
href={`/companies/${r.company_pb_id}`}
className="text-fg no-underline hover:underline"
>
{r.company}
</Link>
) : (
<span>
{r.company}
<span className="block text-xs text-muted">not on file yet</span>
</span>
)}
</td>
<td className="px-3 py-3 font-mono text-xs whitespace-nowrap">
{r.enterprise_no}
</td>
<td className="px-3 py-3">
<StatusBadge
variant={/business|active/i.test(r.status) ? "active" : "inactive"}
>
{r.status || "—"}
</StatusBadge>
</td>
<td className="px-3 py-3">
{isDone ? (
<span className="text-xs font-semibold text-[color:var(--success)]">
Done — {messages[key] || "updated"}
</span>
) : isConfirming ? (
<span className="flex flex-col gap-2">
<span className="text-xs text-muted">
{kind === "link"
? r.company_exists
? `Link ${r.director || "this person"} to ${r.company}?`
: `Add ${r.company} to the database and link ${r.director || "this person"}?`
: `Remove ${r.director || "this person"} as director of ${r.company}?`}
</span>
<span className="flex items-center gap-2">
<button
type="button"
onClick={onCancel}
disabled={isBusy}
className="px-3 py-1.5 rounded-pill border border-border text-xs font-semibold text-fg hover:bg-surface-warm disabled:opacity-40"
>
Cancel
</button>
<button
type="button"
onClick={() => onRequest(key, r)}
disabled={isBusy}
className={`px-3 py-1.5 rounded-pill text-xs font-bold text-white disabled:opacity-50 ${
kind === "link" ? "bg-accent" : "bg-[color:var(--danger)]"
}`}
>
{isBusy
? "Saving…"
: kind === "link"
? "Yes, link"
: "Yes, remove"}
</button>
</span>
</span>
) : (
<button
type="button"
onClick={() => onRequest(key, r)}
disabled={readOnly}
title={
readOnly
? "Sample data — run the check to apply changes"
: undefined
}
className={`px-3 py-1.5 rounded-pill border text-xs font-semibold whitespace-nowrap disabled:opacity-40 disabled:cursor-not-allowed ${
kind === "link"
? "border-accent/50 text-accent hover:bg-accent/10"
: "border-[color:var(--danger)]/50 text-[color:var(--danger)] hover:bg-[color:var(--danger)]/10"
}`}
>
{kind === "link"
? r.company_exists
? "Link"
: "Add & link"
: "Unlink"}
</button>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
export default function CipcReportExplorer() {
const [data, setData] = useState<ReportPayload | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [confirming, setConfirming] = useState<string | null>(null);
const [actionBusy, setActionBusy] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [resolved, setResolved] = useState<Set<string>>(new Set());
const [messages, setMessages] = useState<Record<string, string>>({});
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
const stopPolling = useCallback(() => {
if (pollRef.current) {
clearInterval(pollRef.current);
pollRef.current = null;
}
}, []);
const poll = useCallback(async () => {
try {
const res = await fetch("/api/cipc/report", { cache: "no-store" });
const json = (await res.json()) as ReportPayload;
setData(json);
if (json.status !== "running") {
stopPolling();
setBusy(false);
}
} catch (e: any) {
setError(e?.message || String(e));
stopPolling();
setBusy(false);
}
}, [stopPolling]);
// Resume a run that is already in flight (page reload mid-run).
useEffect(() => {
poll();
return stopPolling;
}, [poll, stopPolling]);
const start = useCallback(async () => {
setBusy(true);
setError(null);
setData(null);
try {
const res = await fetch("/api/cipc/report", { method: "POST" });
const json = (await res.json()) as ReportPayload;
if (!json.ok) {
setError(json.error || "The check could not be started.");
setBusy(false);
return;
}
setData(json);
stopPolling();
pollRef.current = setInterval(poll, POLL_MS);
} catch (e: any) {
setError(e?.message || String(e));
setBusy(false);
}
}, [poll, stopPolling]);
const loadDemo = useCallback(async () => {
setBusy(false);
setError(null);
setConfirming(null);
setActionError(null);
setResolved(new Set());
setMessages({});
try {
const res = await fetch("/api/cipc/report?demo=1", { cache: "no-store" });
const json = (await res.json()) as ReportPayload;
setData(json);
} catch (e: any) {
setError(e?.message || String(e));
}
}, []);
// ── one row at a time ────────────────────────────────────────────────
// First click arms the row (asks for agreement), second click on the
// confirm button writes to the database. Nothing happens in bulk.
const performAction = useCallback(
async (kind: "link" | "unlink", key: string, r: ExceptionRow) => {
setActionBusy(key);
setActionError(null);
try {
const res = await fetch("/api/cipc/report/action", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
action: kind,
id_no: r.id_no,
enterprise_no: r.enterprise_no,
enterprise_name: r.company,
enterprise_status: r.status,
}),
});
const json = await res.json();
if (!json.ok) {
setActionError(json.error || "The change could not be saved.");
return;
}
setResolved((prev) => new Set(prev).add(key));
setMessages((prev) => ({ ...prev, [key]: json.message || "updated" }));
setConfirming(null);
} catch (e: any) {
setActionError(e?.message || String(e));
} finally {
setActionBusy(null);
}
},
[]
);
const onLink = useCallback(
(key: string, r: ExceptionRow) => {
if (confirming === key) {
void performAction("link", key, r);
} else {
setConfirming(key);
setActionError(null);
}
},
[confirming, performAction]
);
const onUnlink = useCallback(
(key: string, r: ExceptionRow) => {
if (confirming === key) {
void performAction("unlink", key, r);
} else {
setConfirming(key);
setActionError(null);
}
},
[confirming, performAction]
);
const onCancel = useCallback(() => setConfirming(null), []);
const running = data?.status === "running";
const done = data?.status === "done";
const newRows = data?.new_appointments || [];
const oldRows = data?.no_longer_director || [];
// Rows already applied are no longer outstanding.
const openNewCount = useMemo(
() => newRows.filter((r) => !resolved.has(rowKey("link", r))).length,
[newRows, resolved]
);
const openOldCount = useMemo(
() => oldRows.filter((r) => !resolved.has(rowKey("unlink", r))).length,
[oldRows, resolved]
);
const nothingToReport = done && openNewCount === 0 && openOldCount === 0;
return (
<div className="flex flex-col gap-8">
{/* ---------------- run bar ---------------- */}
<div className="border border-border rounded-lg bg-surface p-6 flex flex-col gap-4">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-fg">Director appointment check</h2>
<p className="text-sm text-muted mt-1 max-w-2xl">
Looks up every director ID number we hold on CIPC and compares the result with
our database. Only the differences are listed.
</p>
</div>
<div className="flex items-center gap-3">
<button
type="button"
onClick={loadDemo}
disabled={running}
className="px-4 py-2.5 rounded-pill border border-border text-sm font-semibold text-fg hover:bg-surface-warm disabled:opacity-40 disabled:cursor-not-allowed"
>
View sample
</button>
<button
type="button"
onClick={start}
disabled={running}
className="px-5 py-2.5 rounded-pill bg-accent text-accent-on text-sm font-semibold hover:bg-accent-hover disabled:opacity-40 disabled:cursor-not-allowed"
>
{running ? "Running…" : "Run CIPC Director Check"}
</button>
</div>
</div>
{running ? (
<div className="flex items-center gap-4 text-sm">
<div className="flex-1 h-2 rounded-pill bg-surface-warm overflow-hidden">
<div
className="h-full bg-accent transition-all"
style={{
width: `${
data?.total ? Math.min(100, ((data.done || 0) / data.total) * 100) : 5
}%`,
}}
/>
</div>
<span className="text-muted whitespace-nowrap tabular-nums">
{data?.done || 0} of {data?.total || 0} checked
{data?.current_id ? ` · ${data.current_id}` : ""}
</span>
</div>
) : null}
{error ? (
<div className="text-sm text-[color:var(--danger)] border border-[color:var(--danger)]/40 rounded-pill px-4 py-2.5 bg-[color-mix(in_oklch,var(--danger)_10%,var(--surface))]">
{error}
</div>
) : null}
{data?.status === "error" ? (
<div className="text-sm text-[color:var(--danger)] border border-[color:var(--danger)]/40 rounded-pill px-4 py-2.5 bg-[color-mix(in_oklch,var(--danger)_10%,var(--surface))]">
{data.error}
</div>
) : null}
</div>
{/* ---------------- results ---------------- */}
{done ? (
<>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<StatCard
label="IDs checked"
value={data?.checked ?? 0}
hint={
data?.failed
? `${data.failed} lookup${data.failed === 1 ? "" : "s"} failed — re-run those`
: "Every lookup completed"
}
/>
<StatCard
label="New appointments"
value={openNewCount}
hint="Directors at companies we do not have them for"
accent={openNewCount > 0}
/>
<StatCard
label="No longer a director"
value={openOldCount}
hint="Appointments to close off in the database"
accent={openOldCount > 0}
/>
</div>
{actionError ? (
<div className="text-sm text-[color:var(--danger)] border border-[color:var(--danger)]/40 rounded-pill px-4 py-2.5 bg-[color-mix(in_oklch,var(--danger)_10%,var(--surface))]">
{actionError}
</div>
) : null}
{data?.failed_ids?.length ? (
<div className="border border-border rounded-lg bg-surface p-5">
<h3 className="text-sm font-semibold text-fg">
{data.failed_ids.length} ID number
{data.failed_ids.length === 1 ? "" : "s"} could not be checked
</h3>
<p className="text-sm text-muted mt-1">
CIPC rejected these numbers, so the report is not complete for them. Most
likely the ID number on file has a wrong check digit or an impossible date.
</p>
<div className="flex flex-wrap gap-2 mt-3">
{data.failed_ids.map((id) => (
<span
key={id}
className="px-2.5 py-1 rounded-pill border border-border font-mono text-xs text-muted"
>
{id}
</span>
))}
</div>
</div>
) : null}
{nothingToReport ? (
<div className="border border-[color:var(--success)]/40 rounded-lg bg-[color-mix(in_oklch,var(--success)_10%,var(--surface))] p-10 text-center">
<h3 className="text-xl font-semibold text-[color:var(--success)]">
No exceptions found
</h3>
<p className="text-sm text-muted mt-2 max-w-xl mx-auto">
Every director ID checked matches the database — no new appointments and nobody
has left a company we still have them listed at.
</p>
</div>
) : null}
<section className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-xl font-bold text-fg">New director appointments</h2>
<span className="text-sm text-muted">
CIPC shows them here, we do not
</span>
</div>
<ExceptionTable
rows={newRows}
kind="link"
emptyText="No new appointments — every director CIPC lists is already on our records."
confirming={confirming}
busy={actionBusy}
resolved={resolved}
messages={messages}
readOnly={Boolean(data?.is_demo)}
onRequest={onLink}
onCancel={onCancel}
/>
</section>
<section className="flex flex-col gap-3">
<div className="flex items-baseline justify-between gap-4">
<h2 className="text-xl font-bold text-fg">No longer a director</h2>
<span className="text-sm text-muted">
We still list them here, CIPC does not
</span>
</div>
<ExceptionTable
rows={oldRows}
kind="unlink"
emptyText="Nobody has left a company we still list them as a director of."
confirming={confirming}
busy={actionBusy}
resolved={resolved}
messages={messages}
readOnly={Boolean(data?.is_demo)}
onRequest={onUnlink}
onCancel={onCancel}
/>
</section>
{data?.is_demo ? (
<p className="text-xs text-meta">
This is sample data. Run the check to replace it with live results.
</p>
) : null}
</>
) : null}
{!done && !running ? (
<div className="border border-border rounded-lg bg-surface p-10 text-center">
<h3 className="text-lg font-semibold text-fg">No run yet</h3>
<p className="text-sm text-muted mt-2 max-w-lg mx-auto">
Press <span className="font-semibold text-fg">Run CIPC Director Check</span> to look
up every director ID number we hold, or open the sample to see what the report
looks like.
</p>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,31 @@
// app/cipc-report/page.tsx
// CIPC Report tab — reconciles CIPC BizPortal against the Workflow
// database for every director ID we hold, and reports only the differences:
// 1. new director appointments (CIPC has them, we do not)
// 2. appointments to close off (we have them, CIPC does not)
import PageHeader from "@/components/PageHeader";
import CipcReportExplorer from "./CipcReportExplorer";
export const dynamic = "force-dynamic";
export default function CipcReportPage() {
return (
<>
<PageHeader
eyebrow="CIPC Report"
title="Director appointment check"
subtitle={
<span>
Checks every director ID number we hold against CIPC and shows only the differences:
new appointments at companies we do not have them for, and appointments to close
off because the person is no longer a director.
</span>
}
/>
<section className="container-x py-10">
<CipcReportExplorer />
</section>
</>
);
}

View file

@ -0,0 +1,608 @@
#!/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()

View file

@ -0,0 +1,74 @@
{
"checked": 44,
"failed": 2,
"new_appointments": [
{
"id_no": "8901010001087",
"director": "STEPHANUS JOHANNES COETZEE",
"company": "BRANDLINE HOLDINGS (PTY) LTD",
"enterprise_no": "K2026108471",
"status": "In Business",
"reason": "active",
"director_pb_id": "27d7wjmult95rkq",
"company_pb_id": "",
"company_exists": false
},
{
"id_no": "8901010001087",
"director": "STEPHANUS JOHANNES COETZEE",
"company": "SUNRISE VENTURES (PTY) LTD",
"enterprise_no": "K2026112930",
"status": "In Business",
"reason": "active",
"director_pb_id": "27d7wjmult95rkq",
"company_pb_id": "",
"company_exists": false
},
{
"id_no": "7509015145082",
"director": "Christiaan Marthinus JANEKE",
"company": "JANEKE PROPERTY INVESTMENTS (PTY) LTD",
"enterprise_no": "K2026118402",
"status": "In Business",
"reason": "active",
"director_pb_id": "5m8xj56j9vvp6t4",
"company_pb_id": "",
"company_exists": false
},
{
"id_no": "6801135038086",
"director": "Gary Sean ISHERWOOD",
"company": "BLUE SKY LOGISTICS CC",
"enterprise_no": "2011/123456/07",
"status": "In Business",
"reason": "active",
"director_pb_id": "enp2l0o2g0ojpgs",
"company_pb_id": "",
"company_exists": false
}
],
"no_longer_director": [
{
"id_no": "8210115109081",
"director": "STEPHANUS GERHARDUS BARNARD",
"company": "JEI SA",
"enterprise_no": "K2026409808",
"status": "In Business",
"reason": "resigned",
"director_pb_id": "hovq4risw0o4dq2",
"company_pb_id": "8kj0glx26o4dzap",
"company_exists": true
},
{
"id_no": "8502130078089",
"director": "JOHANITA COETZEE",
"company": "LEATHER IMPORTERS",
"enterprise_no": "K2021789676",
"status": "In Business",
"reason": "missing",
"director_pb_id": "97fk6o880uz9lk5",
"company_pb_id": "35wl4m53bc0b0fc",
"company_exists": true
}
]
}