Akuisisi PT (Pengambilalihan) Flow — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Ship a working AKUISISI_PT OCR flow — upload akta pengambilalihan → route → extract (reusing the perubahan cap-table extractor) → SABH lookup → diff → cross-validate → review → verifikator → finalize — reusing the perubahan pipeline, adding only the acquisition net-new (required koran + advisory 30-day window + attestation-lite legal path).
Architecture: A separate AKUISISI_PT SubmissionType with akuisisi-processor.ts as a thin fork of perubahan-processor.ts (3 hooks: force peralihan_saham, extract+require koran, none for attestation — that is user-entered). Extraction reuses the perubahan akta extractor via AKTA_AKUISISI → akta_subtype 'perubahan'. Routes reuse the perubahan endpoints by relaxing their type-guards to a perubahan-family set; PerubahanSectionApproval + VerifikasiPerubahan tables are shared (keyed by submissionId). Frontend mirrors the perubahan pages/hooks parameterized by type.
Tech Stack: Bun + Hono + TypeScript; Prisma 7 / PostgreSQL; bun:test; React 19 + Vite; on-prem vLLM (3B cleanup CLEANUP_LLM_URL for the focused koran pass).
Global Constraints
- Spec:
docs/superpowers/specs/2026-07-01-akuisisi-pt-flow-design.md. Reuse-heavy: fork thin, do not rewrite the perubahan pipeline. AKUISISI_PTis a real Prisma enum value → thedispatchProcessor+processorKindForTypeconst _exhaustive: neverswitches will not compile until the case is added.reMatchAndValidateMUST branch toAKUISISI_PTor the default path wipes validations ([[reference_rematch_dispatch]]).- Per-jenis review sections need EXPLICIT persisted
PerubahanSectionApproval— the submit gate reads it ([[reference_perubahan_section_approval]]). - SABH parity / single-subject: acquirer = a shareholder row in the target's roster, never a resolved/linked entity. No two-party modeling.
- Koran window is WARNING-only, never FAIL (Pasal 127(8) exempts direct acquisitions). Koran fields are required-for-review (mirror SABH) but their absence is a review gap, not an OCR error.
- Attestation-lite = user-entered legal-path radios
{jenisPerseroan: TERTUTUP|TERBUKA, caraPengambilalihan: LANGSUNG|MELALUI_DIREKSI, deedType: AKTA_NOTARIS|DI_BAWAH_TANGAN}persisted toSubmission.akuisisiAttestation; NO conditional-doc/OJK/Rancangan validation. - Test DB
ahu_ocr_testvia bunfig preload ([[feedback_test_db_isolation]]); classifier already emitsAKTA_AKUISISI(built last session). ValidationStatusvalues:PASS | WARNING | FAIL | SKIPPED(exact).
File Structure
| File | Responsibility | Action |
|---|---|---|
backend/prisma/schema.prisma |
AKUISISI_PT enum + Submission.akuisisiAttestation Json? |
Modify |
backend/src/ocr/classifier.ts |
AKTA_AKUISISI → 'perubahan' subtype map |
Modify |
backend/src/services/akta-txn-classifier.ts |
akuisisi ∈ BUILT_AKTA_TXN_TYPES |
Modify |
backend/src/schema/akta-perubahan.ts |
tanggal_koran_akuisisi/nama_koran_akuisisi fields + schema doc |
Modify |
backend/src/services/akuisisi-processor.ts |
Thin fork of perubahan-processor (forced peralihan + koran require) | Create |
backend/src/services/akta-koran-extract.ts |
Focused grounded koran extraction (extractKoranViaFocusedLlm) |
Create |
backend/src/services/document-processor.ts |
Thread classificationOverride into akta path + koran hook |
Modify |
backend/src/services/cross-validator.ts |
validateAkuisisiKoranWindow (WARNING) + ValidationInput.tanggalKoran |
Modify |
backend/src/services/submission-dispatch.ts |
akuisisi-pt ProcessorKind + 2 switch cases |
Modify |
backend/src/services/submission-processor.ts |
reMatchAndValidate → AKUISISI_PT branch |
Modify |
backend/src/routes/submissions.ts |
inferSubmissionType/mapToDocumentType/getProcessingOrder |
Modify |
backend/src/routes/perubahan.ts |
Relax type-guards to {PERUBAHAN_PT, AKUISISI_PT}; akuisisi attestation shape + submit gate |
Modify |
backend/src/routes/klasifikasi.ts |
AKUISISI_DOC_TYPES + ?type=akuisisi |
Modify |
frontend/src/lib/classifier-labels.ts, components/klasifikasi/detect-banner.tsx, pages/KlasifikasiPage.tsx, lib/flow-steps.ts, lib/destination-for.ts, routes.tsx |
Klasifikasi surfacing + routing | Modify |
frontend/src/pages/AkuisisiExtractionPage.tsx, AkuisisiDeltaReviewPage.tsx |
Thin mirrors of the perubahan pages | Create |
Phasing: A (backend core: route + process AKTA_AKUISISI end-to-end, API-testable) → B (backend routes: shared-guard relax + akuisisi attestation/submit) → C (frontend surfacing + pages). Each phase is independently testable.
Phase A — Backend core
Task 1: Recognize + route AKUISISI_PT (schema, subtype map, built set, inference)
Files:
- Modify: backend/prisma/schema.prisma (enum SubmissionType, model Submission)
- Modify: backend/src/ocr/classifier.ts:52-69 (AKTA_SUBTYPE_MAP, getAktaSubtype)
- Modify: backend/src/services/akta-txn-classifier.ts (BUILT_AKTA_TXN_TYPES)
- Modify: backend/src/routes/submissions.ts (inferSubmissionType L82-96, mapToDocumentType L26, getProcessingOrder L47)
- Test: backend/src/routes/__tests__/akuisisi-routing.test.ts
Interfaces:
- Produces: SubmissionType.AKUISISI_PT; getAktaSubtype({classification:"AKTA_AKUISISI"}) === "perubahan"; inferSubmissionType([{classification:"AKTA_AKUISISI"}]) === "AKUISISI_PT"; BUILT_AKTA_TXN_TYPES.has("akuisisi").
- [ ] Step 1: Write the failing test
// backend/src/routes/__tests__/akuisisi-routing.test.ts
import { describe, it, expect } from "bun:test";
import { inferSubmissionType, mapToDocumentType, getProcessingOrder } from "../submissions";
import { getAktaSubtype } from "../../ocr/classifier";
import { BUILT_AKTA_TXN_TYPES } from "../../services/akta-txn-classifier";
describe("AKUISISI_PT recognition + routing", () => {
it("akuisisi is a built txn type", () => {
expect(BUILT_AKTA_TXN_TYPES.has("akuisisi")).toBe(true);
});
it("AKTA_AKUISISI extracts via the perubahan subtype (hybrid reuse)", () => {
expect(getAktaSubtype({ classification: "AKTA_AKUISISI", confidence: 1 })).toBe("perubahan");
});
it("infers AKUISISI_PT, before PERUBAHAN_PT", () => {
expect(inferSubmissionType([{ classification: "AKTA_AKUISISI" }])).toBe("AKUISISI_PT");
// and does not perturb existing routing
expect(inferSubmissionType([{ classification: "AKTA_PERUBAHAN" }])).toBe("PERUBAHAN_PT");
});
it("AKTA_AKUISISI maps to AKTA doc-type + processing order 0", () => {
expect(mapToDocumentType("AKTA_AKUISISI")).toBe("AKTA");
expect(getProcessingOrder("AKTA_AKUISISI")).toBe(0);
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bun test src/routes/__tests__/akuisisi-routing.test.ts
Expected: FAIL — getAktaSubtype throws on AKTA_AKUISISI; inferSubmissionType returns PENDIRIAN_PT; BUILT_AKTA_TXN_TYPES lacks akuisisi.
- [ ] Step 3: Write minimal implementation
schema.prisma — add to enum SubmissionType after PERUBAHAN_PT:
AKUISISI_PT
schema.prisma — add to model Submission after selectedTahun Int?:
akuisisiAttestation Json?
Then: cd backend && bunx prisma generate.
classifier.ts — AKTA_SUBTYPE_MAP gains a line (hybrid: akuisisi extracts as perubahan):
const AKTA_SUBTYPE_MAP: Record<string, AktaSubtype> = {
AKTA_PENDIRIAN: "pendirian",
AKTA_PERUBAHAN: "perubahan",
AKTA_AKUISISI: "perubahan",
};
akta-txn-classifier.ts — add to BUILT_AKTA_TXN_TYPES:
"akuisisi",
submissions.ts mapToDocumentType already returns "AKTA" for any AKTA_* (Task 6 of the classifier plan added the prefix rule) — confirm AKTA_AKUISISI → AKTA and getProcessingOrder → 0 already hold via the startsWith("AKTA_") guards; if not present, add the explicit lines. inferSubmissionType — add BEFORE the AKTA_PERUBAHAN check:
if (files.some((f) => eff(f) === "AKTA_AKUISISI")) return "AKUISISI_PT";
and extend the return-type union with | "AKUISISI_PT".
- [ ] Step 4: Run test to verify it passes
Run: cd backend && bunx prisma generate && bun test src/routes/__tests__/akuisisi-routing.test.ts
Expected: PASS (4 tests).
- [ ] Step 5: Commit
git add backend/prisma/schema.prisma backend/src/ocr/classifier.ts backend/src/services/akta-txn-classifier.ts backend/src/routes/submissions.ts backend/src/routes/__tests__/akuisisi-routing.test.ts
git commit -m "feat(akuisisi): recognize AKTA_AKUISISI + route AKUISISI_PT (enum, subtype map, built set, inference)"
Task 2: akuisisi-processor.ts — thin fork of the perubahan processor
Files:
- Create: backend/src/services/akuisisi-processor.ts (fork of backend/src/services/perubahan-processor.ts)
- Test: backend/src/services/__tests__/akuisisi-processor.test.ts
Interfaces:
- Consumes: shared services company-lookup (lookupCompany, loadOldData, loadPerseroanBlokirState, lookupNotarisIdByName), change-type-detector (detectChangeTypes), cross-validator (runAllValidations, validateRupsAttendanceQuorum), document-processor (processDocument).
- Produces: processAkuisisiSubmission(submissionId: string): Promise<void>, continueAkuisisiProcessing(submissionId: string): Promise<void>, runAkuisisiCrossValidation(submissionId: string): Promise<void>, reMatchAndValidateAkuisisi(submissionId: string): Promise<void>.
Fork instruction (this IS the implementation — the processor is ~95% identical): copy perubahan-processor.ts → akuisisi-processor.ts verbatim, then apply exactly these diffs. Do NOT reproduce the file from scratch.
1. Rename exports: processPerubahanSubmission→processAkuisisiSubmission, continuePerubahanProcessing→continueAkuisisiProcessing, runPerubahanCrossValidation→runAkuisisiCrossValidation, reMatchAndValidatePerubahan→reMatchAndValidateAkuisisi. Update the internal call from continuePerubahanProcessing→continueAkuisisiProcessing and runPerubahanCrossValidation→runAkuisisiCrossValidation.
2. Phase-1 akta lookup: change d.classifiedType === "AKTA_PERUBAHAN" → "AKTA_AKUISISI" (both in processAkuisisiSubmission and continueAkuisisiProcessing).
3. HOOK — force peralihan_saham. In continueAkuisisiProcessing, immediately after the const changeTypeResult = detectChangeTypes(...) line (before persisting it), insert:
// AKUISISI: peralihan saham is the definitional change — force it on.
if (!changeTypeResult.checkedItems.nonpad.includes("peralihanSaham")) {
changeTypeResult.checkedItems.nonpad.push("peralihanSaham");
changeTypeResult.categories.nonpad = true;
}
reMatchAndValidateAkuisisire-detects change types near its end — apply the identical force-peralihan insert there too, before it persists / beforerunAkuisisiCrossValidation.- Everything else (Phase 2 lookup, Phase 3 remaining docs + contact matching, Phase 5
runAllValidations+validateRupsAttendanceQuorum+upsert, the ValidationInput construction) stays verbatim — it reads the persistedchangeTypeResult, so the forced peralihan flows through automatically. Do NOT add koran/attestation logic here (koran = Task 5, attestation = user-entered in Task 8).
- [ ] Step 1: Write the failing test (unit test the one behavioral delta — forced peralihan — via a DB fixture)
// backend/src/services/__tests__/akuisisi-processor.test.ts
import { describe, it, expect, afterEach } from "bun:test";
import { db } from "../../lib/db";
const subIds: string[] = [];
afterEach(async () => { for (const id of subIds) await db.submission.deleteMany({ where: { id } }).catch(() => {}); subIds.length = 0; });
describe("akuisisi-processor exports + forced peralihan", () => {
it("exports the four fork functions", async () => {
const m = await import("../akuisisi-processor");
expect(typeof m.processAkuisisiSubmission).toBe("function");
expect(typeof m.continueAkuisisiProcessing).toBe("function");
expect(typeof m.runAkuisisiCrossValidation).toBe("function");
expect(typeof m.reMatchAndValidateAkuisisi).toBe("function");
});
it("forces peralihanSaham into changeTypeResult (helper is applied)", async () => {
// The force logic is inlined; assert it via a small exported pure helper.
const { forcePeralihanOn } = await import("../akuisisi-processor");
const ct = { categories: { pad: false, ppad: false, nonpad: false }, checkedItems: { pad: [], ppad: [], nonpad: [] }, requiredDocuments: [], unmatchedItems: [] };
const out = forcePeralihanOn(ct);
expect(out.checkedItems.nonpad).toContain("peralihanSaham");
expect(out.categories.nonpad).toBe(true);
});
});
To make the force logic testable, extract it into a tiny exported pure helper in akuisisi-processor.ts and call it from both hook sites:
import type { ChangeTypeDetectionResult } from "./change-type-detector";
export function forcePeralihanOn(ct: ChangeTypeDetectionResult): ChangeTypeDetectionResult {
if (!ct.checkedItems.nonpad.includes("peralihanSaham")) {
ct.checkedItems.nonpad.push("peralihanSaham");
ct.categories.nonpad = true;
}
return ct;
}
Both hook sites become forcePeralihanOn(changeTypeResult);.
- [ ] Step 2: Run test to verify it fails
Run: cd backend && bun test src/services/__tests__/akuisisi-processor.test.ts
Expected: FAIL — module ../akuisisi-processor not found.
-
[ ] Step 3: Write minimal implementation — perform the fork per the Fork instruction above, including the
forcePeralihanOnhelper. -
[ ] Step 4: Run test + typecheck
Run: cd backend && bunx tsc --noEmit && bun test src/services/__tests__/akuisisi-processor.test.ts
Expected: tsc clean; PASS (2 tests). (ChangeTypeDetectionResult is exported from change-type-detector.ts — confirm/add the export.)
- [ ] Step 5: Commit
git add backend/src/services/akuisisi-processor.ts backend/src/services/__tests__/akuisisi-processor.test.ts backend/src/services/change-type-detector.ts
git commit -m "feat(akuisisi): akuisisi-processor as thin fork of perubahan (force peralihan saham)"
Task 3: Wire the dispatch + reMatchAndValidate to the akuisisi processor
Files:
- Modify: backend/src/services/submission-dispatch.ts (ProcessorKind L4-13, processorKindForType L22-48, dispatchProcessor L56-110)
- Modify: backend/src/services/submission-processor.ts (reMatchAndValidate L709-763)
- Test: backend/src/routes/__tests__/akuisisi-routing.test.ts (extend)
Interfaces:
- Consumes: processAkuisisiSubmission, reMatchAndValidateAkuisisi (Task 2).
- Produces: processorKindForType("AKUISISI_PT") === "akuisisi-pt".
- [ ] Step 1: Write the failing test (append)
import { processorKindForType } from "../../services/submission-dispatch";
describe("AKUISISI_PT dispatch", () => {
it("routes AKUISISI_PT to the akuisisi processor kind", () => {
expect(processorKindForType("AKUISISI_PT")).toBe("akuisisi-pt");
});
});
- [ ] Step 2: Run to verify it fails
Run: cd backend && bun test src/routes/__tests__/akuisisi-routing.test.ts
Expected: FAIL — processorKindForType has no AKUISISI_PT case (and tsc would flag the never switch).
- [ ] Step 3: Implement
submission-dispatch.ts — add | "akuisisi-pt" to ProcessorKind; add case to processorKindForType:
case "AKUISISI_PT":
return "akuisisi-pt";
add case to dispatchProcessor:
case "akuisisi-pt": {
const { processAkuisisiSubmission } = await import("./akuisisi-processor");
return processAkuisisiSubmission(submissionId);
}
submission-processor.ts — in reMatchAndValidate, after the PERBAIKAN_DATA_PT branch (before the PP flows), add:
if (submission?.type === "AKUISISI_PT") {
const { reMatchAndValidateAkuisisi } = await import("./akuisisi-processor");
return reMatchAndValidateAkuisisi(submissionId);
}
- [ ] Step 4: Run test + full typecheck (the
neverswitches must now compile)
Run: cd backend && bunx tsc --noEmit && bun test src/routes/__tests__/akuisisi-routing.test.ts
Expected: tsc clean (exhaustiveness satisfied); PASS.
- [ ] Step 5: Commit
git add backend/src/services/submission-dispatch.ts backend/src/services/submission-processor.ts backend/src/routes/__tests__/akuisisi-routing.test.ts
git commit -m "feat(akuisisi): dispatch + reMatchAndValidate route AKUISISI_PT (exhaustiveness + no validation-wipe)"
Task 4: Thread classificationOverride into the akta extraction path
Files:
- Modify: backend/src/services/document-processor.ts (processAktaViaAzureOnPrem L370-418, processAktaViaGpuServer, the akta dispatch L2416-2420)
- Test: backend/src/services/__tests__/akuisisi-processor.test.ts (extend — pure-ish: assert the subtype resolution honors the override)
Interfaces:
- Produces: when processDocument(documentId, { classificationOverride: "AKTA_AKUISISI" }) runs the akta path, the extraction uses akta_subtype "perubahan" (via getAktaSubtype) rather than re-classifying.
- [ ] Step 1: Write the failing test
import { resolveAktaSubtype } from "../document-processor";
describe("akta subtype honors classificationOverride", () => {
it("uses the override label over a re-classify", () => {
expect(resolveAktaSubtype("AKTA_AKUISISI", { classification: "AKTA_PERUBAHAN", confidence: 1 })).toBe("perubahan");
expect(resolveAktaSubtype(undefined, { classification: "AKTA_PENDIRIAN", confidence: 1 })).toBe("pendirian");
});
});
- [ ] Step 2: Run to verify it fails
Run: cd backend && bun test src/services/__tests__/akuisisi-processor.test.ts
Expected: FAIL — resolveAktaSubtype not exported.
- [ ] Step 3: Implement — in
document-processor.ts, add + use a small exported helper, and threadoptionsinto the akta path:
import { getAktaSubtype, type ClassificationResult } from "../ocr/classifier";
export function resolveAktaSubtype(override: string | undefined, classified: ClassificationResult) {
return getAktaSubtype(override ? { classification: override, confidence: 1 } : classified);
}
In processAktaViaAzureOnPrem(documentId, fileBuffer, filename, options?) (add the options?: { classificationOverride?: string } param), replace const aktaSubtype = getAktaSubtype(classificationResult); with const aktaSubtype = resolveAktaSubtype(options?.classificationOverride, classificationResult);. In the akta dispatch (L2416-2420) pass options through to processAktaViaAzureOnPrem/processAktaViaGpuServer.
- [ ] Step 4: Run test + typecheck
Run: cd backend && bunx tsc --noEmit && bun test src/services/__tests__/akuisisi-processor.test.ts
Expected: PASS.
- [ ] Step 5: Commit
git add backend/src/services/document-processor.ts backend/src/services/__tests__/akuisisi-processor.test.ts
git commit -m "feat(akuisisi): akta extraction honors classificationOverride (AKTA_AKUISISI extracts as perubahan)"
Task 5: Focused koran extraction (extractKoranViaFocusedLlm)
Files:
- Modify: backend/src/schema/akta-perubahan.ts (add tanggal_koran_akuisisi/nama_koran_akuisisi to AktaPerubahanData + schema doc)
- Create: backend/src/services/akta-koran-extract.ts
- Modify: backend/src/services/document-processor.ts (call the koran pass on the akuisisi akta after transform)
- Test: backend/src/services/__tests__/akta-koran-extract.test.ts
Interfaces:
- Produces: extractKoranViaFocusedLlm(rawText: string): Promise<{ tanggal_koran: string | null; nama_koran: string | null }> — a grounded single-field pass mirroring apostille recovery: reads CLEANUP_LLM_URL/CLEANUP_LLM_MODEL; returns nulls when unset/absent; rejects values not present verbatim in rawText.
- [ ] Step 1: Write the failing test (fetch-mock pattern, from
doc-forensic-client.test.ts)
// backend/src/services/__tests__/akta-koran-extract.test.ts
import { describe, it, expect, afterEach, mock } from "bun:test";
const realFetch = globalThis.fetch;
afterEach(() => { globalThis.fetch = realFetch; delete process.env.CLEANUP_LLM_URL; });
async function fresh() { return await import("../akta-koran-extract"); }
describe("extractKoranViaFocusedLlm", () => {
it("returns nulls when LLM url unset (graceful)", async () => {
const { extractKoranViaFocusedLlm } = await fresh();
expect(await extractKoranViaFocusedLlm("apa pun")).toEqual({ tanggal_koran: null, nama_koran: null });
});
it("extracts + grounds koran fields present in OCR text", async () => {
process.env.CLEANUP_LLM_URL = "http://llm.local/v1";
globalThis.fetch = mock(async () =>
new Response(JSON.stringify({ choices: [{ message: { content: '{"tanggal_koran":"10 Januari 2026","nama_koran":"Berita Negara"}' } }] })),
) as never;
const { extractKoranViaFocusedLlm } = await fresh();
const r = await extractKoranViaFocusedLlm("...diumumkan dalam Berita Negara tanggal 10 Januari 2026...");
expect(r.nama_koran).toBe("Berita Negara");
expect(r.tanggal_koran).toContain("Januari");
});
it("drops a hallucinated koran name not present in OCR text", async () => {
process.env.CLEANUP_LLM_URL = "http://llm.local/v1";
globalThis.fetch = mock(async () =>
new Response(JSON.stringify({ choices: [{ message: { content: '{"tanggal_koran":null,"nama_koran":"Kompas"}' } }] })),
) as never;
const { extractKoranViaFocusedLlm } = await fresh();
const r = await extractKoranViaFocusedLlm("deed text without that newspaper");
expect(r.nama_koran).toBeNull();
});
});
- [ ] Step 2: Run to verify it fails
Run: cd backend && bun test src/services/__tests__/akta-koran-extract.test.ts
Expected: FAIL — module not found.
- [ ] Step 3: Implement
akta-koran-extract.ts:
/** Focused, grounded koran extraction for AKTA_AKUISISI (mirrors apostille single-field recovery). */
export async function extractKoranViaFocusedLlm(
rawText: string,
): Promise<{ tanggal_koran: string | null; nama_koran: string | null }> {
const url = process.env.CLEANUP_LLM_URL || "";
const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
const NONE = { tanggal_koran: null, nama_koran: null };
if (!url || !rawText.trim()) return NONE;
const system =
"From an Indonesian akta pengambilalihan (akuisisi), extract the newspaper announcement (pengumuman surat kabar): " +
"tanggal_koran (publication date) and nama_koran (newspaper/gazette name, e.g. 'Berita Negara'). " +
"Copy VERBATIM from the text; if absent, return null. Never invent. Return ONLY JSON {tanggal_koran, nama_koran}.";
try {
const res = await fetch(`${url}/chat/completions`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model, temperature: 0,
messages: [{ role: "system", content: system }, { role: "user", content: rawText.slice(0, 12000) }],
guided_json: { type: "object", properties: { tanggal_koran: { type: ["string", "null"] }, nama_koran: { type: ["string", "null"] } }, required: ["tanggal_koran", "nama_koran"], additionalProperties: false },
}),
signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
});
if (!res.ok) return NONE;
const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
if (!m) return NONE;
const parsed = JSON.parse(m[0]) as { tanggal_koran?: string | null; nama_koran?: string | null };
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
const hay = norm(rawText);
const ground = (v: string | null | undefined) => { const s = v ? String(v).trim() : ""; return s && hay.includes(norm(s)) ? s : null; };
return { tanggal_koran: ground(parsed.tanggal_koran), nama_koran: ground(parsed.nama_koran) };
} catch { return NONE; }
}
Add the two fields to AktaPerubahanData in akta-perubahan.ts (after peralihan_saham) + a one-line note in the schema doc. In document-processor.ts, after transforming an akuisisi akta (subtype perubahan AND override was AKTA_AKUISISI), call extractKoranViaFocusedLlm(ocrResult.rawText) and persist the two values as ExtractedFields (tanggal_koran_akuisisi, nama_koran_akuisisi) on the akta document (reuse the existing ExtractedField upsert used for other akta fields).
-
[ ] Step 4: Run test + typecheck — Expected: PASS + tsc clean.
-
[ ] Step 5: Commit
git add backend/src/services/akta-koran-extract.ts backend/src/schema/akta-perubahan.ts backend/src/services/document-processor.ts backend/src/services/__tests__/akta-koran-extract.test.ts
git commit -m "feat(akuisisi): focused grounded koran extraction + akta schema fields"
Task 6: AKUISISI_KORAN_WINDOW advisory validation
Files:
- Modify: backend/src/services/cross-validator.ts (add validateAkuisisiKoranWindow; add tanggalKoran to ValidationInput)
- Modify: backend/src/services/akuisisi-processor.ts (runAkuisisiCrossValidation: populate tanggalKoran + push the rule result)
- Test: backend/src/services/__tests__/akuisisi-koran-window.test.ts
Interfaces:
- Consumes: RuleResult ({ ruleCode, ruleLabel, status: "PASS"|"WARNING"|"FAIL"|"SKIPPED", message, details? }).
- Produces: validateAkuisisiKoranWindow(input: { tanggalKoran: string | null; tanggalRups: string | null }): RuleResult — ruleCode: "AKUISISI_KORAN_WINDOW"; never FAIL: SKIPPED if either date missing/invalid; PASS if koran ≥30 days before RUPS; WARNING otherwise (Pasal 127(8) exempts direct acquisitions, so late/absent windows are advisory).
- [ ] Step 1: Write the failing test
// backend/src/services/__tests__/akuisisi-koran-window.test.ts
import { describe, it, expect } from "bun:test";
import { validateAkuisisiKoranWindow } from "../cross-validator";
describe("validateAkuisisiKoranWindow — advisory, never FAIL", () => {
it("PASS when koran >= 30 days before RUPS", () => {
const r = validateAkuisisiKoranWindow({ tanggalKoran: "2026-01-01", tanggalRups: "2026-02-15" });
expect(r.status).toBe("PASS");
expect(r.ruleCode).toBe("AKUISISI_KORAN_WINDOW");
});
it("WARNING (not FAIL) when koran is inside the 30-day window", () => {
const r = validateAkuisisiKoranWindow({ tanggalKoran: "2026-02-10", tanggalRups: "2026-02-15" });
expect(r.status).toBe("WARNING");
});
it("SKIPPED when a date is missing", () => {
expect(validateAkuisisiKoranWindow({ tanggalKoran: null, tanggalRups: "2026-02-15" }).status).toBe("SKIPPED");
});
});
-
[ ] Step 2: Run to verify it fails — Expected: FAIL, function not exported.
-
[ ] Step 3: Implement in
cross-validator.ts(clone thevalidateAktaDateWindowshape):
export function validateAkuisisiKoranWindow(
input: { tanggalKoran: string | null; tanggalRups: string | null },
): RuleResult {
const ruleCode = "AKUISISI_KORAN_WINDOW";
const ruleLabel = "Pengumuman Koran ≥30 Hari sebelum RUPS";
const k = input.tanggalKoran ? new Date(input.tanggalKoran) : null;
const r = input.tanggalRups ? new Date(input.tanggalRups) : null;
if (!k || !r || isNaN(k.getTime()) || isNaN(r.getTime()))
return { ruleCode, ruleLabel, status: "SKIPPED", message: "Tanggal koran atau RUPS tidak tersedia" };
const days = Math.round((r.getTime() - k.getTime()) / 86400000);
if (days >= 30)
return { ruleCode, ruleLabel, status: "PASS", message: `Pengumuman ${days} hari sebelum RUPS`, details: { days } };
return {
ruleCode, ruleLabel, status: "WARNING",
message: `Pengumuman hanya ${days} hari sebelum RUPS (ideal ≥30; Pasal 127(8) mengecualikan pengambilalihan langsung)`,
details: { days },
};
}
Add tanggalKoran: string | null; to ValidationInput. In runAkuisisiCrossValidation, after runAllValidations, populate tanggalKoran from the akta's tanggal_koran_akuisisi ExtractedField and tanggalRups from the extracted rups.tanggal_rups, then results.push(validateAkuisisiKoranWindow({ tanggalKoran, tanggalRups })); (before the upsert loop).
-
[ ] Step 4: Run test + typecheck — Expected: PASS + tsc clean.
-
[ ] Step 5: Commit
git add backend/src/services/cross-validator.ts backend/src/services/akuisisi-processor.ts backend/src/services/__tests__/akuisisi-koran-window.test.ts
git commit -m "feat(akuisisi): AKUISISI_KORAN_WINDOW advisory validation (WARNING, Pasal 127(8))"
Phase B — Backend routes
Task 7: Relax perubahan route guards to the perubahan-family (reuse for akuisisi)
Files:
- Modify: backend/src/routes/perubahan.ts (the submission.type !== "PERUBAHAN_PT" guards on the SHARED endpoints: /lookup, /old-data, /select-company, /review-data, /selected-jenis, /sections/*, /data-kontak/*, /retry-flow)
- Test: backend/src/routes/__tests__/akuisisi-routes.test.ts
Interfaces:
- Produces: the shared perubahan endpoints accept an AKUISISI_PT submission (guard becomes PERUBAHAN_FAMILY.has(submission.type) where PERUBAHAN_FAMILY = new Set(["PERUBAHAN_PT","AKUISISI_PT"])). The select-company handler dispatches to the correct processor (already handled by dispatchProcessor). Do NOT relax /attestations or /submit (Task 8 handles akuisisi's differing shape).
- [ ] Step 1: Write the failing test (integration: create an AKUISISI_PT submission, hit a shared endpoint, assert it is not rejected as wrong-type)
// backend/src/routes/__tests__/akuisisi-routes.test.ts — uses the Hono app + db fixture
// (mirror the perubahan route test setup; seed a Submission {type:"AKUISISI_PT"} + oldData,
// GET /api/perubahan/submissions/:id/review-data, assert 200 not 400 "bukan Perubahan").
(Write the concrete fixture mirroring the existing perubahan route test; assert the shared endpoint returns 200 for an AKUISISI_PT submission and still 400 for an unrelated type.)
-
[ ] Step 2: Run to verify it fails — Expected: FAIL (guard rejects AKUISISI_PT).
-
[ ] Step 3: Implement — introduce
const PERUBAHAN_FAMILY = new Set(["PERUBAHAN_PT", "AKUISISI_PT"]);at the top ofperubahan.tsand replace each shared-endpoint guardif (submission.type !== "PERUBAHAN_PT")→if (!PERUBAHAN_FAMILY.has(submission.type)). Leave/attestationsand/submitguards as PERUBAHAN_PT-only for now. -
[ ] Step 4: Run test + typecheck — Expected: PASS.
-
[ ] Step 5: Commit
git add backend/src/routes/perubahan.ts backend/src/routes/__tests__/akuisisi-routes.test.ts
git commit -m "feat(akuisisi): shared perubahan endpoints accept the perubahan-family (AKUISISI_PT)"
Task 8: Akuisisi attestation shape + submit gate
Files:
- Modify: backend/src/routes/perubahan.ts (/attestations + /submit: branch on AKUISISI_PT)
- Modify: backend/src/routes/klasifikasi.ts (AKUISISI_DOC_TYPES + ?type=akuisisi)
- Test: backend/src/routes/__tests__/akuisisi-routes.test.ts (extend)
Interfaces:
- Produces: for AKUISISI_PT, /attestations persists Submission.akuisisiAttestation = { jenisPerseroan, caraPengambilalihan, deedType } (no s1-s5/rupsType/syaratUtama); /submit gate for AKUISISI_PT = all appearing sections approved (via PerubahanSectionApproval) + creates/resets the shared VerifikasiPerubahan row. It does NOT require s1-s5 or rupsType. Koran + attestation absence surface as review gaps upstream, not hard submit FAILs (koran window is WARNING).
-
[ ] Step 1: Write the failing test — seed an AKUISISI_PT submission, POST akuisisi attestation, assert
akuisisiAttestationpersisted; POST submit with all sections approved, assert COMPLETED + aVerifikasiPerubahanrow exists; assert submit does NOT require s1-s5. -
[ ] Step 2: Run to verify it fails.
-
[ ] Step 3: Implement — in
/attestations,if (submission.type === "AKUISISI_PT")persistakuisisiAttestationfrom{ jenisPerseroan, caraPengambilalihan, deedType }and return; else existing perubahan path. In/submit, branchAKUISISI_PTto a gate that checks section approvals + creates theVerifikasiPerubahanrow (reuse the perubahan finalize helper, skipping the s1-s5/rupsType checks). AddAKUISISI_DOC_TYPEStoklasifikasi.ts(Akta Akuisisi required + KTP/NPWP/Domisili/Data Kontak optional) andif (type === "akuisisi") return c.json(AKUISISI_DOC_TYPES);in/labels, and include it inLABEL_MAP. -
[ ] Step 4: Run test + typecheck — Expected: PASS.
-
[ ] Step 5: Commit
git add backend/src/routes/perubahan.ts backend/src/routes/klasifikasi.ts backend/src/routes/__tests__/akuisisi-routes.test.ts
git commit -m "feat(akuisisi): attestation-lite persistence + submit gate + AKUISISI_DOC_TYPES"
Phase C — Frontend
Task 9: Klasifikasi surfacing (detect + route AKTA_AKUISISI)
Files:
- Modify: frontend/src/lib/classifier-labels.ts (AKTA_AKUISISI: "Akta Akuisisi")
- Modify: frontend/src/components/klasifikasi/detect-banner.tsx (DetectedFlow += "akuisisi"; FLOW_LABELS/FLOW_DESCRIPTIONS)
- Modify: frontend/src/pages/KlasifikasiPage.tsx (detectedFlow L250-267 detect akuisisi; createSubmission.onSuccess L468-478 route AKUISISI_PT → /akuisisi/:id/extraction; computedLabelType include akuisisi)
- Test: frontend/src/pages/__tests__/* (extend the KlasifikasiPage test if present; else a small detect-banner unit test)
Interfaces:
- Produces: an AKTA_AKUISISI classification yields detectedFlow==="akuisisi" and navigates to /akuisisi/:submissionId/extraction.
- [ ] Steps 1–5: TDD a
detect-banner/detectedFlowunit assertion (akuisisi label + description present; detection returns "akuisisi" when an AKTA_AKUISISI result is present), implement the exact additions the frontend reader enumerated, runcd frontend && bunx tsc --noEmit && bun test, commit.
git add frontend/src/lib/classifier-labels.ts frontend/src/components/klasifikasi/detect-banner.tsx frontend/src/pages/KlasifikasiPage.tsx
git commit -m "feat(akuisisi): klasifikasi surfacing — detect + route AKTA_AKUISISI"
Task 10: Flow config + routes
Files:
- Modify: frontend/src/lib/flow-steps.ts (AKUISISI_LABELS + akuisisi branch + AKUISISI_VERIFIKASI_STEP)
- Modify: frontend/src/lib/destination-for.ts (AKUISISI_PT → "akuisisi" base + type union)
- Modify: frontend/src/routes.tsx (akuisisi extraction/review/status routes)
- Test: frontend/src/lib/__tests__/* (destination-for + flow-steps unit tests if present)
- [ ] Steps 1–5: TDD
computeFlowStep/destinationForfor anAKUISISI_PTsubmission (mirrors PERUBAHAN_PT verifikasi-driven stages), implement the exact additions,bunx tsc --noEmit && bun test, commit.
git add frontend/src/lib/flow-steps.ts frontend/src/lib/destination-for.ts frontend/src/routes.tsx
git commit -m "feat(akuisisi): flow steps + destination routing + routes"
Task 11: Akuisisi extraction + review pages (thin mirrors)
Files:
- Create: frontend/src/pages/AkuisisiExtractionPage.tsx, frontend/src/pages/AkuisisiDeltaReviewPage.tsx
- Modify: reuse frontend/src/hooks/use-perubahan.ts (parameterize the review-data/company hooks by submissionType if hardcoded to PERUBAHAN_PT; else reuse as-is since the API is shared)
Interfaces:
- Consumes: the shared /api/perubahan/* endpoints (Task 7) via the perubahan hooks; the akuisisi attestation form posts { jenisPerseroan, caraPengambilalihan, deedType } (Task 8).
- Produces: the two pages rendered by the Task-10 routes.
- [ ] Steps 1–5: create the pages as thin mirrors of
PerubahanExtractionPage/PerubahanDeltaReviewPage(same hooks + components; the review page swaps the perubahan s1-s5 attestation panel for the akuisisi legal-path radios + shows the koran field + theAKUISISI_KORAN_WINDOWadvisory). Where a perubahan hook hardcodes the type or URL, add asubmissionType/basePathparam and pass"AKUISISI_PT"/"akuisisi". Runcd frontend && bunx tsc --noEmit && bun test; manually smoke via the run skill if available; commit.
git add frontend/src/pages/AkuisisiExtractionPage.tsx frontend/src/pages/AkuisisiDeltaReviewPage.tsx frontend/src/hooks/use-perubahan.ts
git commit -m "feat(akuisisi): extraction + delta-review pages (thin mirrors; legal-path radios + koran advisory)"
Self-Review
- Spec coverage: §4.1 SubmissionType+fork → Tasks 1-3. §4.2 data flow → Tasks 2-6 (backend) + 9-11 (frontend). §4.3 hybrid extraction → Tasks 1 (subtype map) + 4 (override) + 5 (koran). §4.4 pengumuman → Tasks 5 (extract) + 6 (window WARNING) + 8 (required-in-review). §4.5 attestation-lite → Task 8 (persist) + 11 (radios). §4.6 forced peralihan + single-subject → Task 2. §5 wiring checklist → Tasks 1,3,8,9,10. §6 data model → Task 1 (
akuisisiAttestation) + 5 (koran fields). §7 gotchas → Tasks 3 (reMatch/exhaustiveness) + 8 (section approval). §8 tests → every task TDD. - Placeholder scan: the fork tasks (2, 7, 11) use "copy file + apply these exact diffs / mirror" — a complete, precise instruction for a thin fork of a large existing file, not a hand-wave; the genuinely-new code (koran pass, window rule, wiring, schema) is given in full. Tasks 7-11's fixture/test bodies say "mirror the existing perubahan route/page test" — the implementer has the exact endpoint/props from the plan + the sibling test to copy; acceptable for integration-test scaffolds where reproducing the whole harness inline would be noise. Flag for the reviewer: confirm those fixtures assert real behavior.
- Type consistency:
processAkuisisiSubmission/continueAkuisisiProcessing/runAkuisisiCrossValidation/reMatchAndValidateAkuisisi,forcePeralihanOn,resolveAktaSubtype,extractKoranViaFocusedLlm,validateAkuisisiKoranWindow,PERUBAHAN_FAMILY,akuisisiAttestation {jenisPerseroan,caraPengambilalihan,deedType}are used identically across tasks.RuleResult/ValidationStatusmatch the codebase.