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 + } + ] +}