From cd0a8186415e7d2b80e49434fbe319a72cefb642 Mon Sep 17 00:00:00 2001 From: sjcoetzeesj-coder Date: Tue, 27 Oct 2026 12:19:47 +0200 Subject: [PATCH] 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. --- .gitignore | 1 + Workflow/app/api/cipc/report/action/route.ts | 252 +++++++ Workflow/app/api/cipc/report/route.ts | 619 ++++++++++++++++++ .../app/cipc-report/CipcReportExplorer.tsx | 577 ++++++++++++++++ Workflow/app/cipc-report/page.tsx | 31 + Workflow/scripts/cipc-report/build_report.py | 608 +++++++++++++++++ .../scripts/cipc-report/sample-report.json | 74 +++ 7 files changed, 2162 insertions(+) create mode 100644 Workflow/app/api/cipc/report/action/route.ts create mode 100644 Workflow/app/api/cipc/report/route.ts create mode 100644 Workflow/app/cipc-report/CipcReportExplorer.tsx create mode 100644 Workflow/app/cipc-report/page.tsx create mode 100644 Workflow/scripts/cipc-report/build_report.py create mode 100644 Workflow/scripts/cipc-report/sample-report.json diff --git a/.gitignore b/.gitignore index 03154b7..de140f8 100644 --- a/.gitignore +++ b/.gitignore @@ -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. diff --git a/Workflow/app/api/cipc/report/action/route.ts b/Workflow/app/api/cipc/report/action/route.ts new file mode 100644 index 0000000..8cac719 --- /dev/null +++ b/Workflow/app/api/cipc/report/action/route.ts @@ -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 { + const v = String(idNo ?? "").replace(/\s/g, ""); + if (!v) return null; + const found = await fetchCollection(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 { + const canonical = canonicaliseEnterpriseNo(entNo); + if (!canonical) return null; + const found = await fetchCollection(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(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(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 } + ); + } +} diff --git a/Workflow/app/api/cipc/report/route.ts b/Workflow/app/api/cipc/report/route.ts new file mode 100644 index 0000000..e692954 --- /dev/null +++ b/Workflow/app/api/cipc/report/route.ts @@ -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(collection: string): Promise { + 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; +}> { + const directors = await fetchAll("ysterbaard_directors"); + const regDirectors = await fetchAll("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(); + const ids: string[] = []; + const seen = new Set(); + + 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 { + const out = new Map(); + 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 { + const failed = new Set(); + 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("ysterbaard_directors"); + const companies = await fetchAll("ysterbaard_companies"); + const links = await fetchAll("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>(); + 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(); + 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() : new Set(); + + // 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); +} diff --git a/Workflow/app/cipc-report/CipcReportExplorer.tsx b/Workflow/app/cipc-report/CipcReportExplorer.tsx new file mode 100644 index 0000000..de6d9b9 --- /dev/null +++ b/Workflow/app/cipc-report/CipcReportExplorer.tsx @@ -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; + messages: Record; + /** 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 ( +
+

{emptyText}

+
+ ); + } + return ( +
+
+ + + + + + + + + + + + + {rows.map((r) => { + const key = rowKey(kind, r); + const isDone = resolved.has(key); + const isConfirming = confirming === key; + const isBusy = busy === key; + return ( + + + + + + + + + ); + })} + +
ID NumberDirectorCompanyEnterprise No.CIPC StatusAction
{r.id_no} + {r.director_pb_id ? ( + + {r.director || "—"} + + ) : ( + r.director || "—" + )} + + {r.company_pb_id ? ( + + {r.company} + + ) : ( + + {r.company} + not on file yet + + )} + + {r.enterprise_no} + + + {r.status || "—"} + + + {isDone ? ( + + Done — {messages[key] || "updated"} + + ) : isConfirming ? ( + + + {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}?`} + + + + + + + ) : ( + + )} +
+
+
+ ); +} + +export default function CipcReportExplorer() { + const [data, setData] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [confirming, setConfirming] = useState(null); + const [actionBusy, setActionBusy] = useState(null); + const [actionError, setActionError] = useState(null); + const [resolved, setResolved] = useState>(new Set()); + const [messages, setMessages] = useState>({}); + const pollRef = useRef | 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 ( +
+ {/* ---------------- run bar ---------------- */} +
+
+
+

Director appointment check

+

+ Looks up every director ID number we hold on CIPC and compares the result with + our database. Only the differences are listed. +

+
+
+ + +
+
+ + {running ? ( +
+
+
+
+ + {data?.done || 0} of {data?.total || 0} checked + {data?.current_id ? ` · ${data.current_id}` : ""} + +
+ ) : null} + + {error ? ( +
+ {error} +
+ ) : null} + + {data?.status === "error" ? ( +
+ {data.error} +
+ ) : null} +
+ + {/* ---------------- results ---------------- */} + {done ? ( + <> +
+ + 0} + /> + 0} + /> +
+ + {actionError ? ( +
+ {actionError} +
+ ) : null} + + {data?.failed_ids?.length ? ( +
+

+ {data.failed_ids.length} ID number + {data.failed_ids.length === 1 ? "" : "s"} could not be checked +

+

+ 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. +

+
+ {data.failed_ids.map((id) => ( + + {id} + + ))} +
+
+ ) : null} + + {nothingToReport ? ( +
+

+ No exceptions found +

+

+ Every director ID checked matches the database — no new appointments and nobody + has left a company we still have them listed at. +

+
+ ) : null} + +
+
+

New director appointments

+ + CIPC shows them here, we do not + +
+ +
+ +
+
+

No longer a director

+ + We still list them here, CIPC does not + +
+ +
+ + {data?.is_demo ? ( +

+ This is sample data. Run the check to replace it with live results. +

+ ) : null} + + ) : null} + + {!done && !running ? ( +
+

No run yet

+

+ Press Run CIPC Director Check to look + up every director ID number we hold, or open the sample to see what the report + looks like. +

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

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

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

No exceptions found

" + "

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

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

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

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

{esc(m['title'])}

+

{esc(m['subtitle'])}

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

1.1Directors to action

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

Summary

+
+

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

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

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

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

Exceptions

+
+

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

+ +

2.1Type A — companies we do not have

+

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

+ {c['new_table']} + +

2.2Type B — missing director link

+

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

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

Coverage, method and limitations

+
+ +

4.1Lookups that did not complete

+

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

+ {c['fail_block']} + +

4.2How each appointment was compared

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

Run details

+

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

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