feat: BizPortal 2.0 — Python Playwright sidecar
Replaces the brittle agent-driven /api/bizportal/search queue with a
deterministic Python FastAPI service that drives BizPortal via a real
headless Chromium. The old flow is untouched.
What this adds:
- scripts/bizportal_service.py — FastAPI + Playwright service with
/search, /reserve, /health endpoints. Persistent login session
via storage_state.json. Locator-based selectors (vs eval JS).
- app/bizportal-2/ — new /bizportal-2 page, synchronous fetch
(no polling), shows round-trip duration, mirrors the existing
bulk-add + link UX.
- app/api/bizportal-2/{search,reserve,health}/route.ts — thin
proxies to the Python sidecar.
- scripts/README.md, .env.example, .gitignore, requirements.txt.
- .gitignore: ignore tsconfig.tsbuildinfo (build artefact).
Wiring:
- components/NavBar.tsx: 'BizPortal 2.0' nav entry with TEST badge.
- app/bizportal/page.tsx: 'Try BizPortal 2.0' action link in
header so the new option is discoverable from the existing page.
Run: cd scripts && source .venv/bin/activate && uvicorn
bizportal_service:app --port 7001
This commit is contained in:
parent
2d402c0482
commit
7c3847fbb0
13 changed files with 1671 additions and 3 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -11,6 +11,7 @@ dist/
|
||||||
|
|
||||||
# Next.js type helpers
|
# Next.js type helpers
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
tsconfig.tsbuildinfo
|
||||||
|
|
||||||
# Editor cruft
|
# Editor cruft
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
|
||||||
22
app/api/bizportal-2/health/route.ts
Normal file
22
app/api/bizportal-2/health/route.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
// app/api/bizportal-2/health/route.ts
|
||||||
|
// GET /api/bizportal-2/health — proxy to the Python service.
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const revalidate = 0;
|
||||||
|
|
||||||
|
const SERVICE_URL = process.env.BIZPORTAL_SERVICE_URL ?? "http://127.0.0.1:7001";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SERVICE_URL}/health`, { cache: "no-store" });
|
||||||
|
const data = await res.json().catch(() => ({ ok: false }));
|
||||||
|
return NextResponse.json(data, { status: res.ok ? 200 : 503 });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `Python service not reachable at ${SERVICE_URL}: ${err.message ?? err}` },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
app/api/bizportal-2/reserve/route.ts
Normal file
53
app/api/bizportal-2/reserve/route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
// app/api/bizportal-2/reserve/route.ts
|
||||||
|
// POST /api/bizportal-2/reserve
|
||||||
|
// Reserves a name on BizPortal via the Python sidecar. Replaces
|
||||||
|
// the agent-driven /api/register/[id]/apply-bizportal route for
|
||||||
|
// test purposes. The old route is untouched.
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 180;
|
||||||
|
|
||||||
|
const SERVICE_URL = process.env.BIZPORTAL_SERVICE_URL ?? "http://127.0.0.1:7001";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const names: string[] = Array.isArray(body?.names) ? body.names : [];
|
||||||
|
const dryRun: boolean = !!body?.dry_run;
|
||||||
|
const balanceMin: number | undefined = typeof body?.balance_min === "number" ? body.balance_min : undefined;
|
||||||
|
|
||||||
|
if (names.length === 0) {
|
||||||
|
return NextResponse.json({ ok: false, error: "At least one name is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SERVICE_URL}/reserve`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ names, dry_run: dryRun, balance_min: balanceMin }),
|
||||||
|
signal: req.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `Python service returned non-JSON: ${text.slice(0, 200)}` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(data, { status: res.ok ? 200 : res.status });
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") {
|
||||||
|
return NextResponse.json({ ok: false, error: "Reservation cancelled" }, { status: 499 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `Cannot reach Python service at ${SERVICE_URL}: ${err.message ?? err}` },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
app/api/bizportal-2/search/route.ts
Normal file
64
app/api/bizportal-2/search/route.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
// app/api/bizportal-2/search/route.ts
|
||||||
|
// POST /api/bizportal-2/search
|
||||||
|
// Thin wrapper around the Python Playwright sidecar. Synchronous
|
||||||
|
// — no queue, no polling. The client gets the result in one round
|
||||||
|
// trip (~5s typical, ~30s worst case for cold login).
|
||||||
|
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
export const maxDuration = 120;
|
||||||
|
|
||||||
|
const SERVICE_URL = process.env.BIZPORTAL_SERVICE_URL ?? "http://127.0.0.1:7001";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const body = await req.json().catch(() => ({}));
|
||||||
|
const { type, query } = body ?? {};
|
||||||
|
|
||||||
|
if (!type || !["id", "ck"].includes(type)) {
|
||||||
|
return NextResponse.json({ ok: false, error: "type must be 'id' or 'ck'" }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (!query?.trim()) {
|
||||||
|
return NextResponse.json({ ok: false, error: "query is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${SERVICE_URL}/search`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type, query: query.trim() }),
|
||||||
|
// Signal upstream so the browser-side AbortController can cancel.
|
||||||
|
signal: req.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
let data: any;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `Python service returned non-JSON: ${text.slice(0, 200)}` },
|
||||||
|
{ status: 502 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok && data.ok === false) {
|
||||||
|
return NextResponse.json(data, { status: res.status });
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({
|
||||||
|
...data,
|
||||||
|
duration_ms: Date.now() - start,
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") {
|
||||||
|
return NextResponse.json({ ok: false, error: "Search cancelled" }, { status: 499 });
|
||||||
|
}
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, error: `Cannot reach Python service at ${SERVICE_URL}: ${err.message ?? err}` },
|
||||||
|
{ status: 503 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
700
app/bizportal-2/BizPortal2Explorer.tsx
Normal file
700
app/bizportal-2/BizPortal2Explorer.tsx
Normal file
|
|
@ -0,0 +1,700 @@
|
||||||
|
// app/bizportal-2/BizPortal2Explorer.tsx
|
||||||
|
// Client component — same UX as the existing /bizportal explorer
|
||||||
|
// but fetches synchronously from the Python sidecar. No polling,
|
||||||
|
// no task queue, no LLM agent. Times the round-trip so the user
|
||||||
|
// can see the speed difference.
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
import { useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
type SearchType = "id" | "ck";
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
ok: boolean;
|
||||||
|
results?: any[];
|
||||||
|
error?: string;
|
||||||
|
log?: string[];
|
||||||
|
query?: string;
|
||||||
|
type?: string;
|
||||||
|
duration_ms?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BizPortal2Explorer() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const initialType = (searchParams.get("type") as SearchType) === "ck" ? "ck" : "id";
|
||||||
|
const initialQuery = searchParams.get("q") || "";
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState<SearchType>(initialType);
|
||||||
|
const [query, setQuery] = useState(initialQuery);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [serviceUp, setServiceUp] = useState<boolean | null>(null);
|
||||||
|
const [data, setData] = useState<SearchResponse | null>(null);
|
||||||
|
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
|
// Health check on mount so the user can see immediately if the
|
||||||
|
// Python sidecar is running.
|
||||||
|
useEffect(() => {
|
||||||
|
fetch("/api/bizportal-2/health")
|
||||||
|
.then((r) => r.json())
|
||||||
|
.then((d) => setServiceUp(!!d.ok && !!d.playwright_ready))
|
||||||
|
.catch(() => setServiceUp(false));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-fire search when arriving from a director/company deep-link.
|
||||||
|
const autoFiredRef = useRef(false);
|
||||||
|
useEffect(() => {
|
||||||
|
if (initialQuery && !autoFiredRef.current) {
|
||||||
|
autoFiredRef.current = true;
|
||||||
|
setTimeout(() => {
|
||||||
|
const form = document.getElementById("bizportal-2-search-form") as HTMLFormElement | null;
|
||||||
|
if (form) form.requestSubmit();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function handleSearch(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!query.trim()) return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setData(null);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
abortRef.current = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch("/api/bizportal-2/search", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ type: activeTab, query: query.trim() }),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const json: SearchResponse = await res.json();
|
||||||
|
setData(json);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err.name !== "AbortError") {
|
||||||
|
setData({ ok: false, error: err.message || "Network error" });
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelSearch() {
|
||||||
|
abortRef.current?.abort();
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="max-w-2xl space-y-8">
|
||||||
|
<ServiceStatusBanner up={serviceUp} />
|
||||||
|
|
||||||
|
{/* ── Tab switcher ──────────────────────────────────────────────── */}
|
||||||
|
<div className="flex gap-1 rounded-md bg-surface-warm p-1 w-fit">
|
||||||
|
{([["id", "Director ID No."], ["ck", "CK / Enterprise No."]] as [SearchType, string][]).map(([val, label]) => (
|
||||||
|
<button
|
||||||
|
key={val}
|
||||||
|
onClick={() => { setActiveTab(val); setQuery(""); setData(null); }}
|
||||||
|
className={`px-5 py-2.5 rounded-sm text-sm font-semibold transition-all ${
|
||||||
|
activeTab === val
|
||||||
|
? "bg-surface text-fg shadow-sm"
|
||||||
|
: "text-muted hover:text-fg"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* ── Search form ──────────────────────────────────────────────── */}
|
||||||
|
<form id="bizportal-2-search-form" onSubmit={handleSearch} className="flex flex-col gap-3">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
inputMode={activeTab === "id" ? "numeric" : "text"}
|
||||||
|
pattern={activeTab === "id" ? "\\d{13}" : undefined}
|
||||||
|
placeholder={
|
||||||
|
activeTab === "id"
|
||||||
|
? "13-digit SA ID number (e.g. 8709225206083)"
|
||||||
|
: "CK / Enterprise No. (e.g. 2022/808158/08 or K2022808158)"
|
||||||
|
}
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
className="flex-1 px-4 py-3 rounded-md border border-border bg-surface text-fg text-sm placeholder:text-muted focus:outline-none focus:ring-2 focus:ring-accent"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="px-6 py-3 rounded-md bg-accent text-accent-on font-semibold text-sm hover:bg-accent-hover disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Searching…" : "Search"}
|
||||||
|
</button>
|
||||||
|
{loading && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={cancelSearch}
|
||||||
|
className="px-4 py-3 rounded-md border border-border text-muted text-sm hover:text-fg transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* ── Status / Result ──────────────────────────────────────────── */}
|
||||||
|
{data && (
|
||||||
|
<div className="rounded-md border border-border bg-surface p-5 space-y-4">
|
||||||
|
{!data.ok ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-red-500 text-sm font-semibold">Search failed</p>
|
||||||
|
<p className="text-sm text-muted">{data.error}</p>
|
||||||
|
{data.log && data.log.length > 0 && <LogBlock log={data.log} />}
|
||||||
|
</div>
|
||||||
|
) : data.results && data.results.length > 0 ? (
|
||||||
|
<DoneView
|
||||||
|
results={data.results}
|
||||||
|
activeTab={activeTab}
|
||||||
|
query={query}
|
||||||
|
durationMs={data.duration_ms}
|
||||||
|
log={data.log}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-sm text-muted">No results found.</p>
|
||||||
|
{data.log && data.log.length > 0 && <LogBlock log={data.log} />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ServiceStatusBanner({ up }: { up: boolean | null }) {
|
||||||
|
if (up === null) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border bg-surface-warm p-3 text-xs text-muted">
|
||||||
|
Checking Python service…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (up) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-green-300 bg-green-50 p-3 text-xs text-green-800">
|
||||||
|
✅ Python sidecar is up on <span className="font-mono">127.0.0.1:7001</span> — search will be synchronous.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-red-300 bg-red-50 p-3 text-xs text-red-800 space-y-2">
|
||||||
|
<p className="font-semibold">Python sidecar is not reachable.</p>
|
||||||
|
<p>
|
||||||
|
Start it with:{" "}
|
||||||
|
<code className="font-mono bg-surface px-2 py-0.5 rounded">
|
||||||
|
cd scripts && source .venv/bin/activate && uvicorn bizportal_service:app --port 7001
|
||||||
|
</code>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
See <span className="font-mono">scripts/README.md</span> for the first-time setup.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LogBlock({ log }: { log: string[] }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
return (
|
||||||
|
<div className="text-xs">
|
||||||
|
<button
|
||||||
|
onClick={() => setOpen((o) => !o)}
|
||||||
|
className="text-muted hover:text-fg underline"
|
||||||
|
>
|
||||||
|
{open ? "Hide" : "Show"} service log ({log.length} entries)
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<pre className="mt-2 p-3 rounded bg-surface-warm text-fg font-mono text-[11px] overflow-x-auto whitespace-pre-wrap">
|
||||||
|
{log.join("\n")}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Result display ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface DoneViewProps {
|
||||||
|
results: any[];
|
||||||
|
activeTab: SearchType;
|
||||||
|
query: string;
|
||||||
|
durationMs?: number;
|
||||||
|
log?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function DoneView({ results, activeTab, query, durationMs, log }: DoneViewProps) {
|
||||||
|
const [dbCompanies, setDbCompanies] = useState<Map<string, any>>(new Map());
|
||||||
|
const [dbDirectors, setDbDirectors] = useState<Map<string, any>>(new Map());
|
||||||
|
const [links, setLinks] = useState<Set<string>>(new Set());
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [bulkProcessing, setBulkProcessing] = useState(false);
|
||||||
|
const [bulkMsg, setBulkMsg] = useState<string>("");
|
||||||
|
|
||||||
|
function maskId(id: string | undefined): string {
|
||||||
|
if (!id) return "";
|
||||||
|
const digits = String(id).replace(/\s/g, "");
|
||||||
|
if (/X/i.test(digits)) return digits;
|
||||||
|
if (digits.length === 13) return digits.slice(0, 6) + "XXXX" + digits.slice(10, 12) + "X";
|
||||||
|
return digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [compRes, dirRes] = await Promise.all([
|
||||||
|
fetch("/api/pb/records/list?collection=ysterbaard_companies&perPage=5000"),
|
||||||
|
fetch("/api/pb/records/list?collection=ysterbaard_directors&perPage=5000"),
|
||||||
|
]);
|
||||||
|
const compData = await compRes.json();
|
||||||
|
const dirData = await dirRes.json();
|
||||||
|
const cmap = new Map<string, any>();
|
||||||
|
(compData.items || []).forEach((c: any) => {
|
||||||
|
if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c);
|
||||||
|
});
|
||||||
|
const dmap = new Map<string, any>();
|
||||||
|
(dirData.items || []).forEach((d: any) => {
|
||||||
|
if (d.id_no_masked) dmap.set(d.id_no_masked, d);
|
||||||
|
if (d.id_no) dmap.set(d.id_no, d);
|
||||||
|
});
|
||||||
|
setDbCompanies(cmap);
|
||||||
|
setDbDirectors(dmap);
|
||||||
|
|
||||||
|
const linkSet = new Set<string>();
|
||||||
|
for (const c of results) {
|
||||||
|
const compEntNo = String(c.enterprise_no || "").replace(/\s/g, "");
|
||||||
|
const compRecord = cmap.get(compEntNo);
|
||||||
|
if (!compRecord) continue;
|
||||||
|
for (const d of (c.directors || [])) {
|
||||||
|
const masked = maskId(d.id_number);
|
||||||
|
const dirRecord = dmap.get(masked) || dmap.get(d.id_number);
|
||||||
|
if (dirRecord) {
|
||||||
|
try {
|
||||||
|
const jres = await fetch(`/api/pb/records/list?collection=ysterbaard_company_directors&perPage=500&filter=${encodeURIComponent(`director='${dirRecord.id}'`)}`);
|
||||||
|
const jdata = await jres.json();
|
||||||
|
for (const j of (jdata.items || [])) {
|
||||||
|
if (j.company === compRecord.id) {
|
||||||
|
linkSet.add(`${compRecord.id}-${dirRecord.id}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setLinks(linkSet);
|
||||||
|
} finally {
|
||||||
|
setLoaded(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
load();
|
||||||
|
}, [results]);
|
||||||
|
|
||||||
|
function getCompanyRecord(c: any) {
|
||||||
|
return dbCompanies.get(String(c.enterprise_no || "").replace(/\s/g, ""));
|
||||||
|
}
|
||||||
|
function getDirectorRecord(d: any) {
|
||||||
|
return dbDirectors.get(maskId(d.id_number)) || dbDirectors.get(d.id_number);
|
||||||
|
}
|
||||||
|
function isLinked(compRec: any, dirRec: any) {
|
||||||
|
return compRec && dirRec && links.has(`${compRec.id}-${dirRec.id}`);
|
||||||
|
}
|
||||||
|
function toggle(key: string) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(key)) next.delete(key); else next.add(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function selectAllMissing() {
|
||||||
|
const next = new Set<string>();
|
||||||
|
for (const c of results) {
|
||||||
|
if (!getCompanyRecord(c)) next.add(`company:${c.enterprise_no}`);
|
||||||
|
for (const d of (c.directors || [])) {
|
||||||
|
if (!getDirectorRecord(d)) next.add(`director:${maskId(d.id_number)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setSelected(next);
|
||||||
|
}
|
||||||
|
function clearSelection() { setSelected(new Set()); }
|
||||||
|
|
||||||
|
async function processSelected() {
|
||||||
|
if (selected.size === 0) return;
|
||||||
|
setBulkProcessing(true);
|
||||||
|
setBulkMsg("");
|
||||||
|
try {
|
||||||
|
const items: any[] = [];
|
||||||
|
for (const c of results) {
|
||||||
|
if (selected.has(`company:${c.enterprise_no}`)) {
|
||||||
|
items.push({
|
||||||
|
collection: "ysterbaard_companies",
|
||||||
|
fields: {
|
||||||
|
enterprise_no: c.enterprise_no || "",
|
||||||
|
enterprise_name: c.enterprise_name || "",
|
||||||
|
enterprise_type: c.enterprise_type || "",
|
||||||
|
source: "bizportal-2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const c of results) {
|
||||||
|
for (const d of (c.directors || [])) {
|
||||||
|
const masked = maskId(d.id_number);
|
||||||
|
if (selected.has(`director:${masked}`)) {
|
||||||
|
items.push({
|
||||||
|
collection: "ysterbaard_directors",
|
||||||
|
fields: {
|
||||||
|
id_no: masked.replace(/X/gi, "0"),
|
||||||
|
surname: d.surname || "",
|
||||||
|
first_names: d.names || "",
|
||||||
|
director_type: (d.type || "Director").toLowerCase(),
|
||||||
|
source: "bizportal-2",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const res = await fetch("/api/pb/records/bulk-create", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ items }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
const [compRes2, dirRes2] = await Promise.all([
|
||||||
|
fetch("/api/pb/records/list?collection=ysterbaard_companies&perPage=5000"),
|
||||||
|
fetch("/api/pb/records/list?collection=ysterbaard_directors&perPage=5000"),
|
||||||
|
]);
|
||||||
|
const compData2 = await compRes2.json();
|
||||||
|
const dirData2 = await dirRes2.json();
|
||||||
|
const cmap = new Map<string, any>();
|
||||||
|
(compData2.items || []).forEach((c: any) => {
|
||||||
|
if (c.enterprise_no) cmap.set(String(c.enterprise_no).replace(/\s/g, ""), c);
|
||||||
|
});
|
||||||
|
const dmap = new Map<string, any>();
|
||||||
|
(dirData2.items || []).forEach((d: any) => {
|
||||||
|
if (d.id_no_masked) dmap.set(d.id_no_masked, d);
|
||||||
|
if (d.id_no) dmap.set(d.id_no, d);
|
||||||
|
});
|
||||||
|
setDbCompanies(cmap);
|
||||||
|
setDbDirectors(dmap);
|
||||||
|
|
||||||
|
const linkItems: any[] = [];
|
||||||
|
for (const c of results) {
|
||||||
|
const compRec = cmap.get(String(c.enterprise_no || "").replace(/\s/g, ""));
|
||||||
|
if (!compRec) continue;
|
||||||
|
for (const d of (c.directors || [])) {
|
||||||
|
const masked = maskId(d.id_number);
|
||||||
|
const dirRec = dmap.get(masked);
|
||||||
|
if (!dirRec) continue;
|
||||||
|
if (links.has(`${compRec.id}-${dirRec.id}`)) continue;
|
||||||
|
linkItems.push({ company: compRec.id, director: dirRec.id, director_type: d.type || "Director" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let linkSummary = "";
|
||||||
|
if (linkItems.length > 0) {
|
||||||
|
const lres = await fetch("/api/pb/records/bulk-link", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ items: linkItems }),
|
||||||
|
});
|
||||||
|
const ldata = await lres.json();
|
||||||
|
const nextLinks = new Set(links);
|
||||||
|
for (const li of linkItems) nextLinks.add(`${li.company}-${li.director}`);
|
||||||
|
setLinks(nextLinks);
|
||||||
|
linkSummary = `, linked ${ldata.linked_count} (${ldata.already_count} already)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const failMsg = data.fail_count > 0 ? `, ${data.fail_count} failed` : "";
|
||||||
|
setBulkMsg(`✅ Added ${data.success_count}${failMsg}${linkSummary}`);
|
||||||
|
setSelected(new Set());
|
||||||
|
} catch (err: any) {
|
||||||
|
setBulkMsg(`❌ Error: ${err.message}`);
|
||||||
|
} finally {
|
||||||
|
setBulkProcessing(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function linkOne(compRec: any, dirRec: any, d: any) {
|
||||||
|
const res = await fetch("/api/pb/records/link", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ company: compRec.id, director: dirRec.id, director_type: d.type || "Director" }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
setLinks((prev) => new Set(prev).add(`${compRec.id}-${dirRec.id}`));
|
||||||
|
setBulkMsg("✅ Linked");
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
setBulkMsg(`❌ Link failed: ${data.error || res.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedCount = selected.size;
|
||||||
|
const missingCompanies = results.filter((c) => !getCompanyRecord(c)).length;
|
||||||
|
const missingDirectors = results.flatMap((c) => c.directors || []).filter((d) => !getDirectorRecord(d)).length;
|
||||||
|
const linkableCount = results.reduce((acc, c) => {
|
||||||
|
const compRec = getCompanyRecord(c);
|
||||||
|
if (!compRec) return acc;
|
||||||
|
return acc + (c.directors || []).filter((d: any) => {
|
||||||
|
const dirRec = getDirectorRecord(d);
|
||||||
|
return dirRec && !links.has(`${compRec.id}-${dirRec.id}`);
|
||||||
|
}).length;
|
||||||
|
}, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3 p-3 rounded-md bg-surface-warm border border-border flex-wrap">
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
{loaded ? (
|
||||||
|
<>
|
||||||
|
<span className="font-semibold">{results.length}</span> result{results.length === 1 ? "" : "s"} ·{" "}
|
||||||
|
<span className={missingCompanies > 0 ? "text-yellow-700" : "text-green-700"}>
|
||||||
|
{results.length - missingCompanies}/{results.length} companies in DB
|
||||||
|
</span>
|
||||||
|
{" · "}
|
||||||
|
<span className={missingDirectors > 0 ? "text-yellow-700" : "text-green-700"}>
|
||||||
|
{missingDirectors} missing director{missingDirectors === 1 ? "" : "s"}
|
||||||
|
</span>
|
||||||
|
{linkableCount > 0 && (
|
||||||
|
<>
|
||||||
|
{" · "}
|
||||||
|
<span className="text-blue-700">{linkableCount} linkable pair{linkableCount === 1 ? "" : "s"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"Loading DB state…"
|
||||||
|
)}
|
||||||
|
{durationMs !== undefined && (
|
||||||
|
<span className="ml-2 text-meta">· {durationMs}ms round-trip</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span className="text-xs font-semibold text-fg">{selectedCount} selected</span>
|
||||||
|
<button
|
||||||
|
onClick={clearSelection}
|
||||||
|
className="text-xs px-3 py-1 rounded border border-border text-muted hover:text-fg"
|
||||||
|
>
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={processSelected}
|
||||||
|
disabled={bulkProcessing}
|
||||||
|
className="text-xs px-4 py-1 rounded bg-accent text-accent-on font-semibold hover:bg-accent-hover disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{bulkProcessing ? "Processing…" : `PROCESS (${selectedCount})`}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={selectAllMissing}
|
||||||
|
disabled={!loaded || (missingCompanies === 0 && missingDirectors === 0)}
|
||||||
|
className="text-xs px-3 py-1 rounded border border-border text-fg hover:border-accent disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Select all missing
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{bulkMsg && (
|
||||||
|
<div className="text-xs px-3 py-2 rounded bg-surface border border-border text-fg">{bulkMsg}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{log && log.length > 0 && <LogBlock log={log} />}
|
||||||
|
|
||||||
|
<div className="space-y-8">
|
||||||
|
{results.map((c, idx) => (
|
||||||
|
<CompanyResult
|
||||||
|
key={c.enterprise_no || idx}
|
||||||
|
company={c}
|
||||||
|
maskId={maskId}
|
||||||
|
dbCompany={getCompanyRecord(c)}
|
||||||
|
getDirectorRecord={getDirectorRecord}
|
||||||
|
isLinked={isLinked}
|
||||||
|
links={links}
|
||||||
|
selected={selected}
|
||||||
|
toggle={toggle}
|
||||||
|
onLink={linkOne}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CompanyResult({
|
||||||
|
company,
|
||||||
|
maskId,
|
||||||
|
dbCompany,
|
||||||
|
getDirectorRecord,
|
||||||
|
isLinked,
|
||||||
|
links,
|
||||||
|
selected,
|
||||||
|
toggle,
|
||||||
|
onLink,
|
||||||
|
}: {
|
||||||
|
company: any;
|
||||||
|
maskId: (s: string) => string;
|
||||||
|
dbCompany: any;
|
||||||
|
getDirectorRecord: (d: any) => any;
|
||||||
|
isLinked: (compRec: any, dirRec: any) => boolean;
|
||||||
|
links: Set<string>;
|
||||||
|
selected: Set<string>;
|
||||||
|
toggle: (key: string) => void;
|
||||||
|
onLink: (compRec: any, dirRec: any, d: any) => void;
|
||||||
|
}) {
|
||||||
|
const entNo = (company.enterprise_no || "").replace(/\s/g, "");
|
||||||
|
const entName = company.enterprise_name || "—";
|
||||||
|
const entStatus = company.enterprise_status || "—";
|
||||||
|
const directors: any[] = company.directors || [];
|
||||||
|
const inDb = !!dbCompany;
|
||||||
|
const compSelectKey = `company:${company.enterprise_no}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-border rounded-lg bg-surface p-5 space-y-4">
|
||||||
|
<div className="border-b border-border pb-4">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="text-lg font-bold text-fg">{entName}</h3>
|
||||||
|
<span className={`px-3 py-1 rounded-full text-xs font-semibold ${
|
||||||
|
(entStatus || "").toLowerCase().includes("business")
|
||||||
|
? "bg-green-100 text-green-700"
|
||||||
|
: "bg-yellow-100 text-yellow-700"
|
||||||
|
}`}>
|
||||||
|
{entStatus}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted mt-1 font-mono">{entNo}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{inDb ? (
|
||||||
|
<span className="px-2 py-1 rounded bg-green-100 text-green-700 text-xs font-semibold">
|
||||||
|
✅ In DB
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<label className="flex items-center gap-2 px-3 py-1 rounded bg-yellow-50 border border-yellow-300 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(compSelectKey)}
|
||||||
|
onChange={() => toggle(compSelectKey)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
<span className="text-xs font-semibold text-yellow-800">Not in DB — select to add</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h4 className="text-sm font-semibold text-fg mb-2">
|
||||||
|
Director Details
|
||||||
|
<span className="ml-2 text-xs text-muted font-normal">
|
||||||
|
({directors.length} director{directors.length === 1 ? "" : "s"})
|
||||||
|
</span>
|
||||||
|
</h4>
|
||||||
|
{directors.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted italic">No directors found in registry.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto rounded-md border border-border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-left bg-surface-warm text-muted text-xs uppercase tracking-wide">
|
||||||
|
<th className="px-3 py-2 font-semibold w-10"></th>
|
||||||
|
<th className="px-3 py-2 font-semibold">ID Number</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Name(s)</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Surname</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">Status</th>
|
||||||
|
<th className="px-3 py-2 font-semibold">DB Status</th>
|
||||||
|
<th className="px-3 py-2 font-semibold text-right">Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-border bg-surface">
|
||||||
|
{directors.map((d, i) => {
|
||||||
|
const masked = maskId(d.id_number);
|
||||||
|
const dirRec = getDirectorRecord(d);
|
||||||
|
const inDirDb = !!dirRec;
|
||||||
|
const dirSelectKey = `director:${masked}`;
|
||||||
|
const linked = inDirDb && inDb && isLinked(dbCompany, dirRec);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={i} className={linked ? "bg-green-50/30" : "hover:bg-surface-warm"}>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{!inDirDb && (
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selected.has(dirSelectKey)}
|
||||||
|
onChange={() => toggle(dirSelectKey)}
|
||||||
|
className="w-4 h-4"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs">{masked || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{d.names || "—"}</td>
|
||||||
|
<td className="px-3 py-2">{d.surname || "—"}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded font-semibold ${
|
||||||
|
(d.status || "").toLowerCase() === "active"
|
||||||
|
? "bg-green-50 text-green-700"
|
||||||
|
: "bg-gray-100 text-gray-600"
|
||||||
|
}`}>
|
||||||
|
{d.status || "—"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
{inDirDb ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-700 font-semibold">
|
||||||
|
✅ In DB
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-yellow-700 italic">select to add</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{!inDb ? (
|
||||||
|
<span className="text-xs text-muted italic">add company first</span>
|
||||||
|
) : !inDirDb ? (
|
||||||
|
<span className="text-xs text-muted italic">add director first</span>
|
||||||
|
) : linked ? (
|
||||||
|
<span className="text-xs px-2 py-0.5 rounded bg-green-100 text-green-700 font-semibold">
|
||||||
|
🔗 Linked
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
onClick={() => onLink(dbCompany, dirRec, d)}
|
||||||
|
className="text-xs px-3 py-1 rounded bg-blue-600 text-white font-semibold hover:bg-blue-700"
|
||||||
|
>
|
||||||
|
🔗 Link
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
app/bizportal-2/page.tsx
Normal file
33
app/bizportal-2/page.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
// app/bizportal-2/page.tsx
|
||||||
|
// Server Component. Mirrors the existing /bizportal page but uses
|
||||||
|
// the Python Playwright service (scripts/bizportal_service.py) for
|
||||||
|
// synchronous search. The old queue-based flow is untouched.
|
||||||
|
|
||||||
|
import { Suspense } from "react";
|
||||||
|
import PageHeader from "@/components/PageHeader";
|
||||||
|
import BizPortal2Explorer from "./BizPortal2Explorer";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export default async function BizPortal2Page() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader
|
||||||
|
eyebrow="BizPortal 2.0"
|
||||||
|
title="CIPC Entity Search (Python sidecar)"
|
||||||
|
subtitle={
|
||||||
|
<>
|
||||||
|
<span>Direct Playwright service — no agent, no polling.</span>
|
||||||
|
<span>Calls the Python FastAPI sidecar on 127.0.0.1:7001.</span>
|
||||||
|
<span>Test version · old /bizportal page left untouched.</span>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<section className="container-x py-10">
|
||||||
|
<Suspense fallback={<div className="text-muted">Loading…</div>}>
|
||||||
|
<BizPortal2Explorer />
|
||||||
|
</Suspense>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
// Server Component. Renders the BizPortal search interface.
|
// Server Component. Renders the BizPortal search interface.
|
||||||
|
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
import PageHeader from "@/components/PageHeader";
|
import PageHeader from "@/components/PageHeader";
|
||||||
import BizPortalExplorer from "./BizPortalExplorer";
|
import BizPortalExplorer from "./BizPortalExplorer";
|
||||||
|
|
||||||
|
|
@ -14,6 +15,15 @@ export default async function BizPortalPage() {
|
||||||
eyebrow="BizPortal"
|
eyebrow="BizPortal"
|
||||||
title="CIPC Entity Search"
|
title="CIPC Entity Search"
|
||||||
subtitle="Search entities on BizPortal by Director ID Number or Enterprise (CK) Number. Results are fetched live from the CIPC registry."
|
subtitle="Search entities on BizPortal by Director ID Number or Enterprise (CK) Number. Results are fetched live from the CIPC registry."
|
||||||
|
action={
|
||||||
|
<Link
|
||||||
|
href="/bizportal-2"
|
||||||
|
className="inline-flex items-center gap-2 text-xs font-semibold text-accent border border-amber-300 bg-amber-50 px-3 py-2 rounded-md hover:bg-amber-100 transition-colors no-underline"
|
||||||
|
>
|
||||||
|
<span className="uppercase tracking-wider text-[10px] font-bold text-amber-800">Test</span>
|
||||||
|
<span>Try BizPortal 2.0 (Python sidecar) →</span>
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
<section className="container-x py-10">
|
<section className="container-x py-10">
|
||||||
<Suspense fallback={<div className="text-muted">Loading…</div>}>
|
<Suspense fallback={<div className="text-muted">Loading…</div>}>
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import type { ReactNode } from "react";
|
||||||
const LINKS = [
|
const LINKS = [
|
||||||
{ href: "/", label: "Overview", icon: IconHome },
|
{ href: "/", label: "Overview", icon: IconHome },
|
||||||
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
|
{ href: "/bizportal", label: "BizPortal", icon: IconSearch },
|
||||||
|
{ href: "/bizportal-2", label: "BizPortal 2.0", icon: IconSearch, badge: "test" },
|
||||||
{ href: "/directors", label: "Directors", icon: IconUsers },
|
{ href: "/directors", label: "Directors", icon: IconUsers },
|
||||||
{ href: "/companies", label: "Companies", icon: IconBuilding },
|
{ href: "/companies", label: "Companies", icon: IconBuilding },
|
||||||
{ href: "/groups", label: "Groups", icon: IconTag },
|
{ href: "/groups", label: "Groups", icon: IconTag },
|
||||||
|
|
@ -66,15 +67,20 @@ export default function NavBar() {
|
||||||
<Link
|
<Link
|
||||||
key={link.href}
|
key={link.href}
|
||||||
href={link.href}
|
href={link.href}
|
||||||
data-od-id={`nav-${link.label.toLowerCase()}`}
|
data-od-id={`nav-${link.label.toLowerCase().replace(/\s+/g, "-")}`}
|
||||||
className={
|
className={
|
||||||
"px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors " +
|
"px-4 py-2 rounded-md text-sm font-semibold no-underline transition-colors inline-flex items-center gap-2 " +
|
||||||
(isActive
|
(isActive
|
||||||
? "bg-surface-warm text-fg"
|
? "bg-surface-warm text-fg"
|
||||||
: "text-fg hover:bg-surface-warm")
|
: "text-fg hover:bg-surface-warm")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{link.label}
|
<span>{link.label}</span>
|
||||||
|
{"badge" in link && link.badge ? (
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider px-1.5 py-0.5 rounded bg-amber-100 text-amber-800 border border-amber-300">
|
||||||
|
{link.badge}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
15
scripts/.env.example
Normal file
15
scripts/.env.example
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
# BizPortal gateway credentials. Fill in once, never commit.
|
||||||
|
BIZPORTAL_ID_NO=8709225206083
|
||||||
|
BIZPORTAL_PASSWORD=Mull2ZerX1000
|
||||||
|
|
||||||
|
# Run the browser headless on the server. Set to false for debugging.
|
||||||
|
BIZPORTAL_HEADLESS=true
|
||||||
|
|
||||||
|
# Minimum CIPC prepaid balance required to lodge a name reservation.
|
||||||
|
BIZPORTAL_BALANCE_MIN=50
|
||||||
|
|
||||||
|
# Navigation timeout (ms) for slow BizPortal pages.
|
||||||
|
BIZPORTAL_NAV_TIMEOUT_MS=30000
|
||||||
|
|
||||||
|
# Logging — DEBUG for selector-trails, INFO for normal use.
|
||||||
|
LOG_LEVEL=INFO
|
||||||
5
scripts/.gitignore
vendored
Normal file
5
scripts/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
storage_state.json
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
162
scripts/README.md
Normal file
162
scripts/README.md
Normal file
|
|
@ -0,0 +1,162 @@
|
||||||
|
# BizPortal 2.0 — Python Playwright service
|
||||||
|
|
||||||
|
Sidecar that drives `https://www.bizportal.gov.za` via a real headless
|
||||||
|
Chromium. The Next.js side talks to it over HTTP — no LLM agent, no
|
||||||
|
file queue, no polling. Sits next to the existing `/bizportal` page
|
||||||
|
without touching it.
|
||||||
|
|
||||||
|
## Why this exists
|
||||||
|
|
||||||
|
The existing `/bizportal` page queues searches to `/tmp/bizportal_queue/`
|
||||||
|
and an LLM agent reads the page snapshot, decides what to click, and
|
||||||
|
writes the result back. Fast enough when it works, but the agent is
|
||||||
|
the brittle bit — it misreads the page, times out, or clicks the
|
||||||
|
wrong button. This service replaces that with a deterministic Python
|
||||||
|
script.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser ─► /bizportal-2 ─► /api/bizportal-2/* ─► this service ─► BizPortal
|
||||||
|
│
|
||||||
|
└─► PocketBase (via service)
|
||||||
|
```
|
||||||
|
|
||||||
|
The Python service holds a persistent `storage_state.json` so the
|
||||||
|
user is logged in once and every subsequent request reuses the
|
||||||
|
session.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd scripts
|
||||||
|
python3 -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
playwright install chromium
|
||||||
|
cp .env.example .env # fill in real credentials
|
||||||
|
```
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Dev
|
||||||
|
uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 --reload
|
||||||
|
|
||||||
|
# Production (Coolify / systemd)
|
||||||
|
uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 --workers 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep `--workers 1` — the script uses a single shared browser context
|
||||||
|
to preserve the logged-in session.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### `GET /health`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"service": "bizportal-2",
|
||||||
|
"playwright_ready": true,
|
||||||
|
"logged_in_cached": true,
|
||||||
|
"headless": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /search`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "ck", "query": "2022/808158/08" }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"query": "2022/808158/08",
|
||||||
|
"type": "ck",
|
||||||
|
"results": [
|
||||||
|
{
|
||||||
|
"enterprise_no": "2022/808158/08",
|
||||||
|
"enterprise_name": "AMPLEFORTH TRADING (PTY) LTD",
|
||||||
|
"enterprise_type": "Private Company",
|
||||||
|
"enterprise_status": "Business is in Business",
|
||||||
|
"directors": [
|
||||||
|
{
|
||||||
|
"id_number": "870922XXXX08X",
|
||||||
|
"names": "DAVID",
|
||||||
|
"surname": "ELS",
|
||||||
|
"status": "Active",
|
||||||
|
"type": "Director"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"log": ["Navigating to login", "..."]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `POST /reserve`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "names": ["AMPLEFORTH PTY LTD", "AMPLEFORTH TRADING", "AMPLEFORTH SA", "AMPLEFORTH HOLDINGS"], "dry_run": false }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"ok": true,
|
||||||
|
"balance_before": 412.50,
|
||||||
|
"status": "bizportal_done",
|
||||||
|
"log": ["..."],
|
||||||
|
"message": "Submitted via Bizportal. The R50 fee will be debited later."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tunable selectors
|
||||||
|
|
||||||
|
BizPortal uses ASP.NET WebForms (`cntMain_*` IDs). The known-stable
|
||||||
|
IDs for login and name reservation are baked in. The **search** field
|
||||||
|
IDs are not 100% verified — try a search after starting the service
|
||||||
|
once and tune the `DIRECTOR_SEARCH_FIELD_CANDIDATES` /
|
||||||
|
`ENTERPRISE_SEARCH_FIELD_CANDIDATES` lists at the top of
|
||||||
|
`bizportal_service.py` if you get a "Could not find …" error.
|
||||||
|
|
||||||
|
The page URL is logged in the error so you can inspect the live page
|
||||||
|
in your browser and grab the right field ID.
|
||||||
|
|
||||||
|
## Resetting the session
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rm scripts/storage_state.json
|
||||||
|
# Restart the service — it will log in once and re-cache the session.
|
||||||
|
```
|
||||||
|
|
||||||
|
## Running as a systemd service
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=BizPortal 2.0 Python service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=www-data
|
||||||
|
WorkingDirectory=/var/www/workflow/scripts
|
||||||
|
EnvironmentFile=/var/www/workflow/scripts/.env
|
||||||
|
ExecStart=/var/www/workflow/scripts/.venv/bin/uvicorn bizportal_service:app --host 127.0.0.1 --port 7001 --workers 1
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
## What this does NOT replace
|
||||||
|
|
||||||
|
- The existing `/bizportal` page (kept untouched)
|
||||||
|
- The `/api/bizportal/search/*` queue routes (kept untouched)
|
||||||
|
- The `/api/register/[id]/apply-bizportal` route (kept untouched)
|
||||||
|
- The PocketBase collections and bulk-add / link logic
|
||||||
|
|
||||||
|
If you decide to retire the old flow, see
|
||||||
|
`scripts/README.rollback.md` (TODO if you want one).
|
||||||
592
scripts/bizportal_service.py
Normal file
592
scripts/bizportal_service.py
Normal file
|
|
@ -0,0 +1,592 @@
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# BizPortal 2.0 — Python Playwright sidecar
|
||||||
|
#
|
||||||
|
# Tiny FastAPI service that drives https://www.bizportal.gov.za via
|
||||||
|
# a real headless Chromium. The Next.js side talks to this over HTTP
|
||||||
|
# (no LLM agent, no file queue, no polling). All UI selectors live
|
||||||
|
# here so the existing /bizportal page is untouched.
|
||||||
|
#
|
||||||
|
# Run:
|
||||||
|
# cd scripts
|
||||||
|
# python3 -m venv .venv && source .venv/bin/activate
|
||||||
|
# pip install -r requirements.txt
|
||||||
|
# playwright install chromium
|
||||||
|
# cp .env.example .env # fill in credentials
|
||||||
|
# uvicorn bizportal_service:app --host 127.0.0.1 --port 7001
|
||||||
|
#
|
||||||
|
# Smoke test:
|
||||||
|
# curl http://127.0.0.1:7001/health
|
||||||
|
# curl -X POST http://127.0.0.1:7001/search \
|
||||||
|
# -H 'Content-Type: application/json' \
|
||||||
|
# -d '{"type":"ck","query":"2022/808158/08"}'
|
||||||
|
#
|
||||||
|
# Endpoints:
|
||||||
|
# GET /health
|
||||||
|
# POST /search { type: "id"|"ck", query: str }
|
||||||
|
# POST /reserve { names: [str,str,str,str], dry_run: bool, balance_min: float=50 }
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import json
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import List, Optional, Dict, Any
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
from fastapi import FastAPI, HTTPException
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from playwright.async_api import async_playwright, Browser, BrowserContext, Page, TimeoutError as PWTimeout
|
||||||
|
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Config — credentials, endpoints, selectors. Tweak here, not in code.
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
BIZPORTAL_BASE = "https://www.bizportal.gov.za"
|
||||||
|
LOGIN_URL = f"{BIZPORTAL_BASE}/login.aspx"
|
||||||
|
PROFILE_URL = f"{BIZPORTAL_BASE}/user_profile.aspx"
|
||||||
|
NAME_RESERVE_URL = f"{BIZPORTAL_BASE}/name_reserve.aspx"
|
||||||
|
BIZPROFILE_URL = f"{BIZPORTAL_BASE}/bizprofile.aspx"
|
||||||
|
|
||||||
|
# Login fields — verified.
|
||||||
|
LOGIN_ID = "cntMain_txtIDNo"
|
||||||
|
LOGIN_PASSWORD = "cntMain_txtPassword"
|
||||||
|
LOGIN_BUTTON = "cntMain_btnLogin"
|
||||||
|
|
||||||
|
# Name reservation fields — verified.
|
||||||
|
NAME_INPUTS = ["cntMain_txtPropName1", "cntMain_txtPropName2", "cntMain_txtPropName3", "cntMain_txtPropName4"]
|
||||||
|
NAME_CONTINUE_1 = "cntMain_btnContinuePropName"
|
||||||
|
NAME_CONTINUE_2 = "cntMain_btnContinueLodge"
|
||||||
|
|
||||||
|
# BizProfile search — verified. ONE page: dropdown picks the search
|
||||||
|
# type, single input + button. Verified 2026-08-17 against the live
|
||||||
|
# site by probing with Playwright.
|
||||||
|
BIZPROFILE_DROPDOWN = "cntMain_drpSearchOptions"
|
||||||
|
BIZPROFILE_INPUT = "cntMain_txtSearchCIPC"
|
||||||
|
BIZPROFILE_BUTTON = "cntMain_btnSearch"
|
||||||
|
BIZPROFILE_DROPDOWN_OPTIONS = {
|
||||||
|
"id": "IDNo", # Director ID No.
|
||||||
|
"ck": "EntNo", # Enterprise No.
|
||||||
|
"name": "EntName", # Enterprise Name (unused for now)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Company details labels on bizprofile.aspx (CK search result page).
|
||||||
|
BIZPROFILE_LBL_ENT_NO = "cntMain_lblEntNo"
|
||||||
|
BIZPROFILE_LBL_ENT_NAME = "cntMain_lblEntName"
|
||||||
|
BIZPROFILE_LBL_ENT_TYPE = "cntMain_lblEntType"
|
||||||
|
BIZPROFILE_LBL_ENT_STATUS = "cntMain_lblEntStatus"
|
||||||
|
|
||||||
|
# ID search returns a GridView with this id.
|
||||||
|
ID_SEARCH_GRID = "cntMain_gdvEnterprises"
|
||||||
|
|
||||||
|
# Maximum number of companies to click-through for director lookup
|
||||||
|
# during an ID search. The existing UI can handle more, but each
|
||||||
|
# click is a postback round-trip — cap it to keep searches fast.
|
||||||
|
MAX_ID_SEARCH_DIRECTOR_FETCH = 10
|
||||||
|
|
||||||
|
# Session storage — single file, persistent across restarts. Logged-in
|
||||||
|
# cookies live here. Gitignored.
|
||||||
|
STORAGE_STATE_PATH = Path(__file__).parent / "storage_state.json"
|
||||||
|
|
||||||
|
# Required CIPC prepaid balance for name reservation.
|
||||||
|
BALANCE_MIN = float(os.getenv("BIZPORTAL_BALANCE_MIN", "50"))
|
||||||
|
|
||||||
|
# Browser behaviour.
|
||||||
|
HEADLESS = os.getenv("BIZPORTAL_HEADLESS", "true").lower() == "true"
|
||||||
|
NAV_TIMEOUT_MS = int(os.getenv("BIZPORTAL_NAV_TIMEOUT_MS", "30000"))
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Logging
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=os.getenv("LOG_LEVEL", "INFO"),
|
||||||
|
format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
|
||||||
|
)
|
||||||
|
log = logging.getLogger("bizportal-2")
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Pydantic models
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
class SearchRequest(BaseModel):
|
||||||
|
type: str = Field(pattern="^(id|ck)$")
|
||||||
|
query: str = Field(min_length=1, max_length=64)
|
||||||
|
|
||||||
|
class ReserveRequest(BaseModel):
|
||||||
|
names: List[str] = Field(min_length=1, max_length=4)
|
||||||
|
dry_run: bool = False
|
||||||
|
balance_min: Optional[float] = None
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# App + browser lifecycle
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
app = FastAPI(title="BizPortal 2.0", version="2.0.0")
|
||||||
|
_playwright = None
|
||||||
|
_browser: Optional[Browser] = None
|
||||||
|
_context: Optional[BrowserContext] = None
|
||||||
|
_browser_lock = asyncio.Lock()
|
||||||
|
|
||||||
|
@app.on_event("startup")
|
||||||
|
async def on_startup():
|
||||||
|
global _playwright, _browser, _context
|
||||||
|
_playwright = await async_playwright().start()
|
||||||
|
# Use the full Chromium (not the headless-shell variant) — the headless
|
||||||
|
# shell can hit EPERM on macOS when Playwright tries to clean up after
|
||||||
|
# the first request. The full Chromium is more stable across platforms.
|
||||||
|
launch_kwargs = {
|
||||||
|
"headless": HEADLESS,
|
||||||
|
"args": [
|
||||||
|
"--no-sandbox",
|
||||||
|
"--disable-dev-shm-usage",
|
||||||
|
"--disable-gpu",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
if HEADLESS:
|
||||||
|
# Force the full Chromium binary, not the headless-shell.
|
||||||
|
launch_kwargs["channel"] = "chromium"
|
||||||
|
_browser = await _playwright.chromium.launch(**launch_kwargs)
|
||||||
|
_context = await _open_context()
|
||||||
|
log.info("Service ready. headless=%s storage_state=%s", HEADLESS, STORAGE_STATE_PATH.exists())
|
||||||
|
|
||||||
|
@app.on_event("shutdown")
|
||||||
|
async def on_shutdown():
|
||||||
|
if _context:
|
||||||
|
await _context.close()
|
||||||
|
if _browser:
|
||||||
|
await _browser.close()
|
||||||
|
if _playwright:
|
||||||
|
await _playwright.stop()
|
||||||
|
|
||||||
|
async def _open_context() -> BrowserContext:
|
||||||
|
if STORAGE_STATE_PATH.exists():
|
||||||
|
try:
|
||||||
|
ctx = await _browser.new_context(storage_state=str(STORAGE_STATE_PATH))
|
||||||
|
log.info("Loaded storage state from %s", STORAGE_STATE_PATH)
|
||||||
|
return ctx
|
||||||
|
except Exception as e:
|
||||||
|
log.warning("Could not load storage state: %s — starting fresh", e)
|
||||||
|
return await _browser.new_context()
|
||||||
|
|
||||||
|
def _current_context() -> BrowserContext:
|
||||||
|
if not _context:
|
||||||
|
raise RuntimeError("Browser context not initialised")
|
||||||
|
return _context
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Health
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"service": "bizportal-2",
|
||||||
|
"playwright_ready": _browser is not None and _context is not None,
|
||||||
|
"logged_in_cached": STORAGE_STATE_PATH.exists(),
|
||||||
|
"headless": HEADLESS,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Helpers — login, wait, parsing
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _save_storage_state() -> None:
|
||||||
|
state = await _context.storage_state()
|
||||||
|
STORAGE_STATE_PATH.write_text(json.dumps(state, indent=2))
|
||||||
|
log.info("Saved storage state to %s", STORAGE_STATE_PATH)
|
||||||
|
|
||||||
|
async def _is_logged_in(page: Page) -> bool:
|
||||||
|
try:
|
||||||
|
return await page.locator(LOGIN_ID).count() == 0
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _ensure_logged_in(page: Page, log_list: List[str]) -> None:
|
||||||
|
"""Reuse stored session if valid; otherwise log in once."""
|
||||||
|
if await _is_logged_in(page):
|
||||||
|
log_list.append("Already logged in (cached session)")
|
||||||
|
return
|
||||||
|
|
||||||
|
log.info("Logging in…")
|
||||||
|
log_list.append("Navigating to login")
|
||||||
|
await page.goto(LOGIN_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||||
|
|
||||||
|
if await _is_logged_in(page):
|
||||||
|
log_list.append("Already logged in (after navigation)")
|
||||||
|
return
|
||||||
|
|
||||||
|
id_no = os.getenv("BIZPORTAL_ID_NO")
|
||||||
|
password = os.getenv("BIZPORTAL_PASSWORD")
|
||||||
|
if not id_no or not password:
|
||||||
|
raise RuntimeError("BIZPORTAL_ID_NO / BIZPORTAL_PASSWORD not set in environment")
|
||||||
|
|
||||||
|
await page.locator(f"#{LOGIN_ID}").fill(id_no)
|
||||||
|
await page.locator(f"#{LOGIN_PASSWORD}").fill(password)
|
||||||
|
await page.locator(f"#{LOGIN_BUTTON}").click()
|
||||||
|
|
||||||
|
# Wait for the login form to disappear.
|
||||||
|
try:
|
||||||
|
await page.locator(f"#{LOGIN_ID}").wait_for(state="detached", timeout=30000)
|
||||||
|
except PWTimeout:
|
||||||
|
raise RuntimeError("Login failed — login form still present after 30s")
|
||||||
|
|
||||||
|
await _save_storage_state()
|
||||||
|
log_list.append("Logged in and cached session")
|
||||||
|
log.info("Logged in")
|
||||||
|
|
||||||
|
async def _parse_balance(page: Page) -> Optional[float]:
|
||||||
|
await page.goto(PROFILE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||||
|
await page.wait_for_timeout(1500)
|
||||||
|
text = await page.locator("body").inner_text()
|
||||||
|
matches = re.findall(r"CIPC Prepaid Account Balance[:\s]*R\s*([0-9][0-9.,]*)", text, re.IGNORECASE)
|
||||||
|
if not matches:
|
||||||
|
return None
|
||||||
|
raw = matches[0].replace(".", "").replace(",", ".")
|
||||||
|
try:
|
||||||
|
return float(raw)
|
||||||
|
except ValueError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _select_first(page: Page, candidates: List[str], kind: str) -> str:
|
||||||
|
"""Return the first selector that resolves. kind is for error msgs."""
|
||||||
|
for sel in candidates:
|
||||||
|
try:
|
||||||
|
count = await page.locator(sel).count()
|
||||||
|
if count > 0:
|
||||||
|
return sel
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Could not find {kind} on {page.url}. "
|
||||||
|
f"Tried: {candidates}. "
|
||||||
|
f"Update the *_CANDIDATES constants in bizportal_service.py."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_results_table(page: Page) -> List[Dict[str, Any]]:
|
||||||
|
"""Probe-based parse that knows the actual BizPortal layouts.
|
||||||
|
|
||||||
|
Two distinct result shapes:
|
||||||
|
|
||||||
|
CK search (dropdown=EntNo): ONE company shown on the page itself.
|
||||||
|
Labels: cntMain_lblEntNo/EntName/EntType/EntStatus hold the
|
||||||
|
company info. Directors live in a sub-table with columns
|
||||||
|
`ID Number | Name(s) | Surname | Type | Status`.
|
||||||
|
|
||||||
|
ID search (dropdown=IDNo): a GridView of companies.
|
||||||
|
`cntMain_gdvEnterprises` has columns Enterprise Number, Name,
|
||||||
|
Status, Director Status, [hidden Status], View. Each row has
|
||||||
|
a View button that triggers a postback to load that company's
|
||||||
|
directors. We click through each one (up to a cap) and parse
|
||||||
|
the directors from the re-rendered page.
|
||||||
|
"""
|
||||||
|
# Detect which layout we're in.
|
||||||
|
has_company_labels = await page.locator(f"#{BIZPROFILE_LBL_ENT_NO}").count() > 0
|
||||||
|
has_grid = await page.locator(f"#{ID_SEARCH_GRID}").count() > 0
|
||||||
|
|
||||||
|
if has_company_labels:
|
||||||
|
return [await _parse_company_layout(page)]
|
||||||
|
if has_grid:
|
||||||
|
return await _parse_grid_layout(page)
|
||||||
|
|
||||||
|
log_list: List[str] = []
|
||||||
|
raise RuntimeError(
|
||||||
|
"Unrecognised result layout. Neither company labels nor "
|
||||||
|
f"the GridView were found on {page.url}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_company_layout(page: Page) -> Dict[str, Any]:
|
||||||
|
"""CK search result — single company, directors in a sub-table."""
|
||||||
|
async def lbl(suffix: str) -> str:
|
||||||
|
loc = page.locator(f"#cntMain_lbl{suffix}")
|
||||||
|
if await loc.count() == 0:
|
||||||
|
return ""
|
||||||
|
return (await loc.first.inner_text()).strip()
|
||||||
|
|
||||||
|
company = {
|
||||||
|
"enterprise_no": await lbl("EntNo"),
|
||||||
|
"enterprise_name": await lbl("EntName"),
|
||||||
|
"enterprise_type": await lbl("EntType"),
|
||||||
|
"enterprise_status": await lbl("EntStatus"),
|
||||||
|
"directors": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Directors table — find the table whose header row contains
|
||||||
|
# "ID Number". Verified ordering: ID Number | Name(s) | Surname
|
||||||
|
# | Type | Status.
|
||||||
|
tables = await page.locator("table").all()
|
||||||
|
for t in tables:
|
||||||
|
try:
|
||||||
|
headers = await t.locator("th").all()
|
||||||
|
header_text = [(await h.inner_text()).strip() for h in headers]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if not header_text:
|
||||||
|
continue
|
||||||
|
if any("ID Number" in h for h in header_text):
|
||||||
|
cols = {"id": None, "names": None, "surname": None, "type": None, "status": None}
|
||||||
|
for i, h in enumerate(header_text):
|
||||||
|
hl = h.lower()
|
||||||
|
if "id number" in hl:
|
||||||
|
cols["id"] = i
|
||||||
|
elif "name(s)" in hl or hl.startswith("name"):
|
||||||
|
cols["names"] = i
|
||||||
|
elif "surname" in hl:
|
||||||
|
cols["surname"] = i
|
||||||
|
elif "type" in hl:
|
||||||
|
cols["type"] = i
|
||||||
|
elif "status" in hl:
|
||||||
|
cols["status"] = i
|
||||||
|
for row in await t.locator("tbody tr").all():
|
||||||
|
cells = await row.locator("td").all()
|
||||||
|
if not cells:
|
||||||
|
continue
|
||||||
|
cell_text = []
|
||||||
|
for c in cells:
|
||||||
|
try:
|
||||||
|
cell_text.append((await c.inner_text()).strip())
|
||||||
|
except Exception:
|
||||||
|
cell_text.append("")
|
||||||
|
if all(not x for x in cell_text):
|
||||||
|
continue
|
||||||
|
def at(k: str) -> str:
|
||||||
|
i = cols.get(k)
|
||||||
|
if i is None or i >= len(cell_text):
|
||||||
|
return ""
|
||||||
|
return cell_text[i]
|
||||||
|
company["directors"].append({
|
||||||
|
"id_number": at("id"),
|
||||||
|
"names": at("names"),
|
||||||
|
"surname": at("surname"),
|
||||||
|
"status": at("status"),
|
||||||
|
"type": at("type") or "Director",
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
|
return company
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_grid_layout(page: Page) -> List[Dict[str, Any]]:
|
||||||
|
"""ID search result — list of companies in a GridView.
|
||||||
|
|
||||||
|
Returns the company list immediately. Directors are NOT fetched
|
||||||
|
here — the caller can request them per-company via the
|
||||||
|
/directors/<enterprise_no> endpoint. This keeps the search
|
||||||
|
fast even when the ID has many director roles.
|
||||||
|
"""
|
||||||
|
companies: List[Dict[str, Any]] = []
|
||||||
|
rows = await page.locator(f"#{ID_SEARCH_GRID} tbody tr").all()
|
||||||
|
for row in rows:
|
||||||
|
cells = await row.locator("td").all()
|
||||||
|
if len(cells) < 4:
|
||||||
|
continue
|
||||||
|
cell_text = []
|
||||||
|
for c in cells:
|
||||||
|
try:
|
||||||
|
cell_text.append((await c.inner_text()).strip())
|
||||||
|
except Exception:
|
||||||
|
cell_text.append("")
|
||||||
|
if not cell_text[0]:
|
||||||
|
continue
|
||||||
|
companies.append({
|
||||||
|
"enterprise_no": cell_text[0],
|
||||||
|
"enterprise_name": cell_text[1] if len(cell_text) > 1 else "",
|
||||||
|
"enterprise_status": cell_text[2] if len(cell_text) > 2 else "",
|
||||||
|
"director_status": cell_text[3] if len(cell_text) > 3 else "",
|
||||||
|
"directors": [],
|
||||||
|
})
|
||||||
|
return companies
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_directors_for_company(page: Page, enterprise_no: str) -> List[Dict[str, Any]]:
|
||||||
|
"""ID search follow-up — do a separate CK search for the given
|
||||||
|
enterprise number and return its directors.
|
||||||
|
|
||||||
|
Clicking the View button on the GridView navigates to the
|
||||||
|
company details page (the GridView is gone afterwards), so we
|
||||||
|
can't click multiple in a row. Doing a fresh CK search is the
|
||||||
|
simplest reliable path.
|
||||||
|
"""
|
||||||
|
await page.goto(BIZPROFILE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||||
|
await page.wait_for_timeout(1500)
|
||||||
|
await page.locator(f"#{BIZPROFILE_DROPDOWN}").select_option("EntNo")
|
||||||
|
try:
|
||||||
|
await page.wait_for_load_state("domcontentloaded", timeout=15000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await page.wait_for_timeout(1500)
|
||||||
|
await page.locator(f"#{BIZPROFILE_INPUT}").fill(enterprise_no)
|
||||||
|
await page.locator(f"#{BIZPROFILE_BUTTON}").click()
|
||||||
|
await page.wait_for_load_state("domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||||
|
await page.wait_for_timeout(2500)
|
||||||
|
company = await _parse_company_layout(page)
|
||||||
|
return company["directors"]
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# POST /search
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.post("/search")
|
||||||
|
async def search(req: SearchRequest):
|
||||||
|
log_list: List[str] = []
|
||||||
|
async with _browser_lock:
|
||||||
|
page = await _context.new_page() if _context else None
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(503, "Browser not ready")
|
||||||
|
try:
|
||||||
|
await _ensure_logged_in(page, log_list)
|
||||||
|
|
||||||
|
option = BIZPROFILE_DROPDOWN_OPTIONS.get(req.type)
|
||||||
|
if not option:
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": f"Unknown search type: {req.type}", "log": log_list},
|
||||||
|
status_code=400,
|
||||||
|
)
|
||||||
|
|
||||||
|
log_list.append(f"GET {BIZPROFILE_URL}")
|
||||||
|
await page.goto(BIZPROFILE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||||
|
await page.wait_for_timeout(1500)
|
||||||
|
|
||||||
|
log_list.append(f"Select dropdown: {option}")
|
||||||
|
await page.locator(f"#{BIZPROFILE_DROPDOWN}").select_option(option)
|
||||||
|
# Dropdown change triggers a postback — wait for the page to settle.
|
||||||
|
try:
|
||||||
|
await page.wait_for_load_state("domcontentloaded", timeout=15000)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
await page.wait_for_timeout(1500)
|
||||||
|
|
||||||
|
log_list.append(f"Fill query: {req.query}")
|
||||||
|
await page.locator(f"#{BIZPROFILE_INPUT}").fill(req.query)
|
||||||
|
await page.locator(f"#{BIZPROFILE_BUTTON}").click()
|
||||||
|
await page.wait_for_load_state("domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||||
|
await page.wait_for_timeout(3000)
|
||||||
|
|
||||||
|
companies = await _parse_results_table(page)
|
||||||
|
log_list.append(f"Parsed {len(companies)} companies")
|
||||||
|
|
||||||
|
# For ID search, optionally click-through to fetch directors.
|
||||||
|
if req.type == "id" and companies:
|
||||||
|
capped = companies[:MAX_ID_SEARCH_DIRECTOR_FETCH]
|
||||||
|
skipped = len(companies) - len(capped)
|
||||||
|
log_list.append(f"Fetching directors for {len(capped)} companies (cap={MAX_ID_SEARCH_DIRECTOR_FETCH})")
|
||||||
|
for c in capped:
|
||||||
|
directors = await _fetch_directors_for_company(page, c["enterprise_no"])
|
||||||
|
c["directors"] = directors
|
||||||
|
log_list.append(f" {c['enterprise_no']}: {len(directors)} directors")
|
||||||
|
if skipped:
|
||||||
|
log_list.append(f"Skipped {skipped} additional companies to keep search fast")
|
||||||
|
|
||||||
|
return {"ok": True, "query": req.query, "type": req.type, "results": companies, "log": log_list}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log.exception("Search failed")
|
||||||
|
log_list.append(f"ERROR: {e}")
|
||||||
|
return JSONResponse({"ok": False, "error": str(e), "log": log_list}, status_code=500)
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# POST /reserve
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@app.post("/reserve")
|
||||||
|
async def reserve(req: ReserveRequest):
|
||||||
|
log_list: List[str] = []
|
||||||
|
balance_before: Optional[float] = None
|
||||||
|
threshold = req.balance_min if req.balance_min is not None else BALANCE_MIN
|
||||||
|
balance_before = None
|
||||||
|
step = "init"
|
||||||
|
|
||||||
|
async with _browser_lock:
|
||||||
|
page = await _context.new_page() if _context else None
|
||||||
|
if not page:
|
||||||
|
raise HTTPException(503, "Browser not ready")
|
||||||
|
try:
|
||||||
|
names = [n for n in (req.names or []) if n and n.strip()]
|
||||||
|
if not names:
|
||||||
|
raise RuntimeError("No proposed names to apply")
|
||||||
|
log_list.append(f"Names: {names}")
|
||||||
|
|
||||||
|
step = "login"
|
||||||
|
await _ensure_logged_in(page, log_list)
|
||||||
|
|
||||||
|
step = "balance_before"
|
||||||
|
balance_before = await _parse_balance(page)
|
||||||
|
log_list.append(f"Balance BEFORE: {balance_before}")
|
||||||
|
if balance_before is None:
|
||||||
|
raise RuntimeError("Could not read CIPC Prepaid Account Balance")
|
||||||
|
if balance_before <= threshold:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Insufficient balance: R {balance_before:.2f} is not > R {threshold:.2f}. "
|
||||||
|
f"Top up the CIPC Prepaid Account before submitting."
|
||||||
|
)
|
||||||
|
|
||||||
|
step = "open_name_reservation"
|
||||||
|
log_list.append(f"GET {NAME_RESERVE_URL}")
|
||||||
|
await page.goto(NAME_RESERVE_URL, wait_until="domcontentloaded", timeout=NAV_TIMEOUT_MS)
|
||||||
|
await page.wait_for_timeout(1500)
|
||||||
|
|
||||||
|
step = "fill_names"
|
||||||
|
for i, name in enumerate(names[:4]):
|
||||||
|
sel = f"#{NAME_INPUTS[i]}"
|
||||||
|
count = await page.locator(sel).count()
|
||||||
|
if count == 0:
|
||||||
|
raise RuntimeError(f"Name field {sel} not found on name_reserve page")
|
||||||
|
await page.locator(sel).fill(name)
|
||||||
|
log_list.append(f"Filled {len(names[:4])} name boxes")
|
||||||
|
|
||||||
|
step = "continue_1"
|
||||||
|
await page.locator(f"#{NAME_CONTINUE_1}").wait_for(state="visible", timeout=10000)
|
||||||
|
await page.locator(f"#{NAME_CONTINUE_1}").click()
|
||||||
|
log_list.append("Clicked first Continue")
|
||||||
|
await page.wait_for_timeout(2500)
|
||||||
|
|
||||||
|
if req.dry_run:
|
||||||
|
log_list.append("DRY RUN — stopping before second Continue")
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"dry_run": True,
|
||||||
|
"balance_before": balance_before,
|
||||||
|
"status": "dry_run",
|
||||||
|
"log": log_list,
|
||||||
|
"message": "Dry run complete. Cost review screen reached. Re-run with dry_run=false to lodge.",
|
||||||
|
}
|
||||||
|
|
||||||
|
step = "continue_2"
|
||||||
|
await page.locator(f"#{NAME_CONTINUE_2}").wait_for(state="visible", timeout=10000)
|
||||||
|
await page.locator(f"#{NAME_CONTINUE_2}").click()
|
||||||
|
log_list.append("Clicked second Continue")
|
||||||
|
await page.wait_for_timeout(4000)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"balance_before": balance_before,
|
||||||
|
"status": "bizportal_done",
|
||||||
|
"log": log_list,
|
||||||
|
"message": "Submitted via Bizportal. The R50 fee will be debited from your CIPC Prepaid Account at a later stage. You will receive an SMS confirmation.",
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
log_list.append(f"ERROR at step={step}: {e}")
|
||||||
|
return JSONResponse(
|
||||||
|
{"ok": False, "error": str(e), "step": step, "balance_before": balance_before, "log": log_list},
|
||||||
|
status_code=500,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await page.close()
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
# Entrypoint
|
||||||
|
# ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
import uvicorn
|
||||||
|
uvicorn.run("bizportal_service:app", host="127.0.0.1", port=7001, log_level="info")
|
||||||
5
scripts/requirements.txt
Normal file
5
scripts/requirements.txt
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
fastapi==0.115.5
|
||||||
|
uvicorn[standard]==0.32.1
|
||||||
|
playwright==1.49.0
|
||||||
|
pydantic==2.9.2
|
||||||
|
python-dotenv==1.0.1
|
||||||
Loading…
Reference in a new issue