Pembubaran PT (Dissolution, PEMBUBARAN_PT) 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 the FULL PEMBUBARAN_PT OCR flow (upload → classify → extract → validate → review → submit to VERIFIKATOR → verifikasi state machine) as the THIRD engine:'v2-generic' tenant of the flow-engine FLOW_REGISTRY — one defineFlow config + a tuned rule pack + two focused passes (ONE genuinely new: the grounded 3B Surat Likuidator extractor; the BUKTI_PENGUMUMAN clipping extractor is SHARED, built by Peleburan, consumed here via one map entry) + the single-source company lookup the engine already ships. No new processor fork; no multi-source machinery touched.
Architecture: The flow is a CONFIG + RULES + HOOKS consumer of backend/src/flow-engine/ (registry/processor/rematch/validation-runner/fail-gate/focused-passes all landed with LAPORAN_RUPS_TAHUNAN + PELEBURAN_PT on feat/pt-flows-registry). The deed rides the perubahan extractor via extraction.overrideByClassifiedType: { AKTA_PEMBUBARAN: "perubahan" } (raw-subtype identity rows already exist in AKTA_SUBTYPE_MAP, ocr/classifier.ts:64-65). Genuinely new pieces: (1) surat-likuidator-extract.ts — deterministic-regex-first + grounded 3B, 12 ExtractedField rows, NO satellite table; (2) the deed-inline koran fallback via the SHARED parameterized extractKoranFields(rawText, { docContext }) (built in Peleburan — reused, never re-implemented); (3) the RUPS registry-roster attendance fallback (Submission.rupsKehadiranRegistry Json? — SABH's attendance checkboxes are against the REGISTRY list, the primary path for a dissolution); (4) the ≥-comparator option on the shared rupsQuorumRule wrapper (Ps.89(1) "paling sedikit 3/4" — the inherited deferred trigger); (5) the Berakhirnya doc-set predicate in inferSubmissionType (this flow is its first consumer and owner); (6) dual-deed (akta_kedua) transformer mappings. Validation = tuned matrix PEMBUBARAN_RULE_SET (13 wrapped rules + the ¾-quorum wrapper + 4 flow rules incl. the WARNING-NEVER-FAIL PEMBUBARAN_KORAN and the FAIL PT_ALREADY_DISSOLVED local double-dissolution guard). Submit is a new thin route family (routes/pembubaran.ts, routeFamily 'pembubaran'): explicit section approvals + checkFailGate (block-with-override) → COMPLETED + VerifikasiPerubahan row (MENUNGGU_VERIFIKATOR) — this flow IS verifikator-staged (peleburan/akuisisi twin; contrast terminal LAPORAN_RUPS_TAHUNAN). Voter roster for the verifikator's initial APPROVE = Submission.oldData.pemegang_saham via resolveVoterRoster's existing fallback (AKTA_PEMBUBARAN is deliberately NOT added to ROSTER_AKTA_TYPES; pinned by test). Frontend = thin mirrors (extraction page copied from LaporanRupsExtractionPage — single-company CompanySelectionModal, exactly 3 flow-specific tokens; a new flat review page with Kehadiran/Likuidator/Koran cards; a verifikasi-aware status page; a compact verifikator page per the VerifikatorPeleburanReviewPage precedent). Everything stays dark until BUILT_AKTA_TXN_TYPES += "pembubaran" flips (Task 7) — the launch switch.
Tech Stack: Bun + Hono backend; Prisma 7 (client generated to src/generated/prisma/); PostgreSQL 16; backend tests via bun test against the isolated ahu_ocr_test DB (bunfig preload; bun run db:push:test after schema changes); frontend React 19 + Vite 6 + TanStack Query v5 + jotai, tests via cd frontend && bunx vitest run; typecheck via bunx tsc --noEmit in each package.
Global Constraints
Binding values — copy verbatim; do not paraphrase:
- Enum spellings (exact). SubmissionType
PEMBUBARAN_PT(SABHid_aksi_transaksi = 12, UUPT Ps.142-147) — DISTINCT fromPEMBUBARAN_PP(Perseroan Perorangan, UU CK regime). Exact-equality checks ONLY — neverincludes("PEMBUBARAN")/startsWith("PEMBUBARAN")anywhere (routes, queues, dashboards, rules); regression tests guard both directions. Carrier akta labelAKTA_PEMBUBARANalready exists (AKTA_TXN_TYPE_TO_LABEL,akta-txn-classifier.ts:22; title rule^AKTA\s+PEMBUBARANat:56;KEYWORD_CONFIRM.pembubaran = /pembubaran|likuidasi/iat:101). Classifier taxonomy value"pembubaran".ValidationStatusis"PASS" | "WARNING" | "FAIL" | "SKIPPED"(NO-ED). DocumentTypesSURAT_LIKUIDATORandBUKTI_PENGUMUMANalready exist in Prisma (registry-slice six-type batch,schema.prisma:52-57) — do NOT re-add them;LAPORAN_LIKUIDASIis Berakhirnya's DISTINCT type (penunjukan-vs-laporan distinction is load-bearing for routing — C2). - VERIFIKATOR-STAGED — not terminal. Submit → status
COMPLETED+ aVerifikasiPerubahanrow (MENUNGGU_VERIFIKATOR) in ONE transaction, mirroring the PELEBURAN_PT submit arm (routes/peleburan.ts:340-394), incl. thePERBAIKANre-submit arm (clearsaiReport/aiRecommendation/aiGeneratedAt) andrecordAuditEvent(NOTARIS_SUBMIT/NOTARIS_RESUBMIT).PEMBUBARAN_PTis NEVER added toTERMINAL_TYPESinfrontend/src/lib/destination-for.ts; post-COMPLETED navigation is verifikasi-driven exactly like PELEBURAN_PT/AKUISISI_PT. - Canonical route envelopes (template rules — binding). FAIL gate = HTTP 422 +
{ error: "Validasi FAIL harus diselesaikan atau di-override sebelum submit", code: "VALIDATION_FAILED", failing }via the NAMED stepcheckFailGate(submissionId, pembubaranPtFlow)(backend/src/flow-engine/fail-gate.ts). guardReady = EVERY mutating endpoint INCLUDING submit returns 409 +{ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" }when status is outside its allowed set. (This deliberately TIGHTENS the peleburan submit arm, whose 409 used a submit-specific error text — recorded ledger minor.) Allowed sets: kehadiran / koran / likuidator edits =READYonly; section approve/unapprove =READYonly; submit =READYonly. Company selection rides the EXISTING type-agnosticPOST /api/perubahan/select-company/:submissionId(no family guard; its v2 branch resumescontinueFlow—routes/perubahan.ts:186-195); pembubaran adds NO select route. submissionDocument.createseeds MUST includeconfidence(required Float, no default). Every seed in this plan carriesconfidence: 1.- DO-NOT-COPY warnings (Peleburan whole-flow review — bake in, do not re-learn):
1. Never make a status-gated dialog/panel the ONLY management surface for correctable state — every correctable row/field (likuidator fields, koran fields, kehadiran ticks) gets a READY-state entry point on the review page (Tasks 9/13).
2. No all-or-nothing SKIP/PASS tiers over partially-verified sets — mixed verified/unknown must emit WARNING naming the unchecked items (LIKUIDATOR_SURAT_COMPLETElists the missing field keys;PEMBUBARAN_KORANnames the unverifiable date).
3. Staged flows MUST test verifikator decide-APPROVE end-to-end and declare their voter-roster source. Declared:Submission.oldData.pemegang_sahamviaresolveVoterRoster's existing fallback —AKTA_PEMBUBARANis deliberately NOT added toROSTER_AKTA_TYPES(shareholder-voting.ts:101): the registry roster IS the legal voter set for a dissolution, and a deed-restated roster must not hijack it. Pinned by tests in Task 10 (APPROVE → votes from oldData names; deed rows present → still oldData; roster-empty → 422). If you think you need to widenROSTER_AKTA_TYPES— you don't; and if a future flow does, widen roster-LOCALLY (the const is roster-local already), never the sharedPERUBAHAN_FAMILY_AKTA.
4.companyLookup: true(single-source). Do NOT touch the engine'smulti-sourcebranch (processor.ts:58-77) orpeleburan-sources.ts— that wiring is peleburan-coupled by documented design. - Inherited deferred triggers landing HERE:
- (a) RUPS-quorum ≥-comparator. Pembubaran cites UUPT Ps.89(1) "paling sedikit 3/4" (≥, not >). The shared
rupsQuorumRulewrapper (flow-engine/rules/pt-akta/rups-quorum.ts) currently encodes>semantics viaratio <= threshold→ WARNING. Task 5 adds acomparator?: ">" | ">="option AT THE WRAPPER (bodyvalidateRupsAttendanceQuorumuntouched; default">"preserved for every existing consumer): with">=", WARNING fires onratio < threshold, so exactly 75% attendance is compliant. Pembubaran consumesrupsQuorumRule({ quorumThreshold: 3 / 4, comparator: ">=" }). - (b) Shared bukti-pengumuman extractor (
services/bukti-pengumuman-extract.ts, canonical nullable keysnama_koran/tanggal_pengumuman/nomor_pengumuman) is CONSUMED via ONEfocusedPassesmap entry — never re-implemented, never prompt-broadened. The clipping-extractor count-guard partial-write hardening trigger FIRES here (ledger: "trigger = Pembubaran's clipping adoption"): Task 2 convertsrunBuktiPengumumanPass's per-key create loop into ONE atomiccreateMany(all-or-nothing, so the any-key→skip-ALL idempotency guard becomes sound), and builds the NEW likuidator pass atomic from day one. - The tuned validation matrix (severities binding, spec §4.6):
- Wrapped bit-for-bit via
pickRules(PT_AKTA_RULE_SET, …)— 13 codes:NIK_KTP_AKTA,NAMA_NPWP_KTP,KTP_COMPLETENESS,NPWP_COMPLETENESS,NAMA_PT_CONSISTENCY,DOMISILI_NAMA,NPWP_PERSEROAN_FUZZY,CONTACT_INFO_COMPLETE,PASSPORT_AKTA,AKTA_DATE_WINDOW(30-day: shared context setsisPerubahan: true),OLD_DATA_CONSISTENCY,PERSEROAN_STATE(FAIL onstatus_transaksi5/6/≠1/blocked —cross-validator.ts:1418; the registry "PT must be ACTIVE, not already dissolved" gate),NOTARIS_TERAKHIR. Plus the parameterizedrupsQuorumRule({ quorumThreshold: 3/4, comparator: ">=" })(WARNING tier — attendance is heuristic name-matching, never hard-block). - Net-new 4:
PT_ALREADY_DISSOLVED(FAIL, async — aCOMPLETEDPEMBUBARAN_PTfor the sameoldData.perseroan.id_perseroan,pp-pembubaran.ts:475pattern, exact-equality type match),LIKUIDATOR_SURAT_COMPLETE(WARNING naming missing fieldKeys — OCR gaps are completable in review; telepon/email often absent from the letter, SABH types them manually today),LIKUIDATOR_KTP_MATCH(FAIL on a plausible-but-conflicting best match (tokenScore 0.5–0.8 band) / WARNING on absence (<0.5 or no KTPs — "KTP likuidator belum diunggah"; the likuidator may be a pengurus whose KTP already matched — spec open-question 4 resolution: an existing identity-match KTP is accepted, PASS-on-doubling falls out of scoring the whole pool) / PASS ≥0.8),PEMBUBARAN_KORAN(WARNING — NEVER FAIL,severityFloor: "WARNING"+ structural test: UUPT Ps.147(1) makes the announcement a LIKUIDATOR obligation within 30 days FROM the dissolution — it may legitimately not exist at filing time. Do NOT copyPELEBURAN_KORAN_WINDOW's FAIL — that is Ps.127(2) pre-RUPS semantics, a different statute). - EXCLUDED — deliberately, guard with a set-composition test: the whole cap-table/modal/KBLI group (
MODAL_BUKTI_SETOR,PEMEGANG_SAHAM_BUKTI_SETOR,PP29_MODAL,KBLI_VALID,PERUBAHAN_KBLI,PERUBAHAN_MODAL,SHARES_TRANSFER_BALANCE,SHARES_TOTAL_LEMBAR,SHARES_SUM_CONSISTENCY,MODAL_HIERARCHY,NOMINAL_RECONCILE,PS_TOTAL_100,PEMEGANG_SAHAM_CONTACT) — a dissolution touches no cap table; the jenis machinery (DATA_ACTUALLY_CHANGED,JENIS_SELECTION_NONEMPTY,JENIS_DESELECT_REASONS_COMPLETE,CONTRADICTORY_JENIS) — no jenis selection exists;REQUIRED_SUPPORTING_DOCS+UPLOADED_DOC_UNUSED— adjudicated DEVIATION from the spec matrix: their bodies are JENIS-scoped (cross-validator.ts:1235/:1742key required/unused docs offeffectiveJenis.nonpad), so for a jenis-less flow they emit noise-SKIPs at best and a FALSE WARNING at worst (BERITA_ACARA_RUPSflagged "unused" — but the BA is a recommended pembubaran doc). The required-doc surface is the?type=pembubaranboard set + the presence tiers insideLIKUIDATOR_SURAT_COMPLETE/PEMBUBARAN_KORAN(peleburan-precedent exclusion class). Omission-by-config, not input-shape-SKIPPED. - FAIL rules block submission per the engine's block-with-override gate (program decision 2026-07-02);
ValidationResult.overriddenis the override bit;failGatestays default'on'(config key absent). - Ladder position (canonical):
Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > PEMBUBARAN > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT. Pembubaran's rung goes BELOW Akuisisi (and below Peleburan) ininferSubmissionType(routes/submissions.ts) and in the frontend detection chains — note this is the OPPOSITE of Peleburan's above-Akuisisi rung; do not copy its position. TheBERAKHIRNYA_PACKAGEdoc-set predicate ships HERE (its first consumer, owned ONCE insubmissions.ts, quoted identically by the Berakhirnya spec — C5): a Berakhirnya package (AKTA_BERAKHIRNYA, or AKTA_PEMBUBARAN + laporan/permohonan labels, or AKTA_PEMBUBARAN + ≥2 docs mapping to theBUKTI_PENGUMUMANcarrier viamapToDocumentType— count the CARRIER, never the raw label string) must gate to "belum didukung" (supported: false, detectedType: "berakhirnya"), never silently route into PEMBUBARAN_PT. A SINGLE clipping (the Ps.147 announcement) does NOT trip it. Pembubaran wins only bareAKTA_PEMBUBARAN. - Classifier gating = the launch switch: everything stays dark ("Terdeteksi: Pembubaran (belum didukung)") until
BUILT_AKTA_TXN_TYPES += "pembubaran"(Task 7). Tasks 1-6 must not make the flow reachable. Task 7 also updates the tests that usepembubaranas the unbuilt case (klasifikasi-akta-txn.test.ts:35-36,laporan-rups-routing.test.ts:14) toberakhirnya. - Model routing: the likuidator pass and all koran passes = the 3B (
CLEANUP_LLM_URL/CLEANUP_LLM_MODEL, cleanup-3b @8003),temperature: 0, guided-JSON, every value verbatim-substring-grounded (norm()as inakta-koran-extract.ts:56);dasar_penunjukangrounds TOKEN-WISE (≥80% of normalized tokens present) or it always nulls out. Deterministic regexes (RT/RW, labeled kodepos, telepon, email) WIN over LLM output. Hallucination → null, never an ERROR. This flow adds NO 35B pass (no list-of-objects extraction — the peleburan sources pass is not a template here). - rawText gotcha (inherited): the GPU-server akta path stores
rawText: "". Focused passes receive whateverDocument.rawTextholds; empty →console.warn+ skip → fields absent → manual entry viaPUT /likuidator/ the review cards, never a processing failure. - Test-DB isolation: all backend DB tests target
ahu_ocr_testvia the bunfig preload. NEVER runbun testagainst the dev DB. Runcd backend && bun run db:push:testafter Task 1's schema change.test-setup.tsalready blanks SABH creds — company lookups in tests degrade to no-match (AWAITING_COMPANY_SELECTION), which the integration test handles env-robustly. - Frontend = thin mirrors. NO ReviewEngine / FrontendReviewDescriptor / ReviewData reshape. Frontend tests via
cd frontend && bunx vitest run(NEVERbun test). Page tests need theIOStubIntersectionObserver stub + jsdom patterns fromfrontend/src/pages/__tests__/LaporanRupsReviewPage.test.tsx:26-30. base-ui Button link idiom isrender={<Link to="..." />}(noasChild— this project uses @base-ui, not Radix). - No auto-confirm / no auto-accept: section approvals are EXPLICIT persisted
PerubahanSectionApprovalrows — never derived from child confirmed state (regression c0698d8). The likuidator/koran grounded values persist at the 0.75 "please review" tier — never auto-LOCK. The SURAT_LIKUIDATOR re-check pass (Task 8) is suggestion-only, applied through the existing re-map mechanism. - Do not touch v1 forks: perubahan/akuisisi/pendirian processors, the akuisisi inline koran call sites (
document-processor.ts:416/472),extractKoranViaFocusedLlm, and every existing route behaviour stay byte-identical. Thefield-transformer.tsakta_keduamappings (Task 3) are provably inert for v1 flows (null-parent skip logic emits ZERO rows whenakta_keduais null — regression-tested). The verifikator queue widening (Task 10) addsPEMBUBARAN_PTwithout changing what existing rows return.
File Structure
| File | Create/Modify | Responsibility |
|---|---|---|
backend/prisma/schema.prisma |
Modify | SubmissionType += PEMBUBARAN_PT; Submission.rupsKehadiranRegistry Json? (NO new tables — likuidator/koran are ExtractedField rows, spec §6) |
backend/src/flow-engine/registry.ts |
Modify (twice) | Task 1: temporary v1-fork stub; Task 6: real pembubaranPtFlow entry |
backend/src/services/submission-dispatch.ts |
Modify | processorKindForType case PEMBUBARAN_PT → "v2-generic" (satisfies the never tripwire) |
backend/src/services/surat-likuidator-extract.ts |
Create | Grounded 3B 12-field likuidator extractor (penunjukan shape ONLY) + runSuratLikuidatorPass (atomic ExtractedField persistence, idempotent-once) |
backend/src/services/bukti-pengumuman-extract.ts |
Modify | Partial-write hardening: per-key create loop → ONE atomic createMany (ledger trigger) |
backend/src/flow-engine/focused-passes.ts |
Modify | FOCUSED_PASS_HANDLERS["surat-likuidator"] entry + stale-comment fix (ledger minor) |
backend/src/services/document-processor.ts |
Modify | OCR-only case for documentType === "SURAT_LIKUIDATOR" (case 6k, after the BUKTI_PENGUMUMAN case) |
backend/src/services/akta-pembubaran-deed-passes.ts |
Create | hooks.afterAktaExtraction body: deed-inline koran fallback (SHARED extractKoranFields, pembubaran docContext, zero-clipping guard, idempotent-once) |
backend/src/services/field-transformer.ts |
Modify | PERUBAHAN_FIELD_MAPPING += the 3 akta_kedua_* dual-deed keys (null-parent skip keeps v1 byte-identical) |
backend/src/services/rups-attendance-matcher.ts |
Modify | AKTA_PEMBUBARAN joins the akta filter + PEMBUBARAN_PT-scoped registry-roster fallback → Submission.rupsKehadiranRegistry |
backend/src/flow-engine/context/pembubaran-context.ts |
Create | PembubaranCtx (extends PtAktaCtx) + buildPembubaranContext (likuidator / koran / registry-quorum override / submissionId) |
backend/src/flow-engine/rules/pt-akta/rups-quorum.ts |
Modify | comparator?: ">" \| ">=" wrapper param (body untouched; default ">") |
backend/src/flow-engine/rules/pembubaran/index.ts |
Create | 4 flow rules + PEMBUBARAN_SHARED_RULE_CODES + assembled PEMBUBARAN_RULE_SET |
backend/src/flow-engine/flows/pembubaran-pt.ts |
Create | The real defineFlow<PembubaranCtx> config — THIRD v2-generic registry entry |
backend/src/flow-engine/types.ts |
Modify | RouteFamily union += "pembubaran" |
backend/src/ocr/classifier.ts |
Modify | AKTA_SUBTYPE_MAP["AKTA_PEMBUBARAN"] = "perubahan" (defensive label row; identity rows exist) |
backend/src/services/akta-txn-classifier.ts |
Modify | BUILT_AKTA_TXN_TYPES += "pembubaran" (LAUNCH SWITCH) + appoints-vs-accepts SYSTEM_PROMPT cue |
backend/src/routes/submissions.ts |
Modify | BERAKHIRNYA_PACKAGE predicate (owned here) + detectUnsupportedAktaType package check + inferSubmissionType rung BELOW Akuisisi + return union; mapToDocumentType/getProcessingOrder for SURAT_LIKUIDATOR |
backend/src/routes/klasifikasi.ts |
Modify | PEMBUBARAN_DOC_TYPES + ?type=pembubaran + LABEL_MAP spread; POST /recheck-likuidator scoped re-check endpoint |
backend/src/services/supporting-doc-classifier.ts |
Modify | suggestSuratLikuidator (deterministic tiers) + isSuratPenunjukanLikuidatorViaLlm (focused 3B tail) |
backend/src/routes/pembubaran.ts |
Create | Route family: review-data, likuidator/kehadiran/koran edits, section approve/unapprove, submit→verifikator (canonical envelopes) |
backend/src/index.ts |
Modify | Mount /api/pembubaran |
backend/src/routes/verifikator.ts |
Modify | Queue where admits PEMBUBARAN_PT; tagged item type |
backend/src/services/shareholder-voting.ts |
Modify (comment only) | Document the deliberate NON-widening of ROSTER_AKTA_TYPES for AKTA_PEMBUBARAN |
backend/src/flow-engine/__tests__/{pembubaran-schema,pembubaran-context,rules-pembubaran,pembubaran-flow,pembubaran-integration}.test.ts |
Create | Per-task TDD |
backend/src/flow-engine/__tests__/registry.test.ts |
Modify | V2_SHIPPED += "PEMBUBARAN_PT" (Task 6) |
backend/src/services/__tests__/{surat-likuidator-extract,akta-pembubaran-deed-passes,akta-kedua-transformer,pembubaran-rups-fallback}.test.ts |
Create | Extractor grounding + pass persistence + transformer inertness + matcher fallback |
backend/src/routes/__tests__/{pembubaran-routing,pembubaran-routes,verifikator-pembubaran,klasifikasi-recheck-likuidator}.test.ts |
Create | Routing/gating + route family + queue/decide-APPROVE + re-check |
backend/src/routes/__tests__/{klasifikasi-akta-txn,laporan-rups-routing}.test.ts |
Modify | Stale "pembubaran is unbuilt" fixtures → berakhirnya |
frontend/src/lib/classifier-labels.ts |
Modify | AKTA_PEMBUBARAN + SURAT_LIKUIDATOR display names |
frontend/src/lib/pembubaran-detect.ts |
Create | Pure FE mirrors: isBerakhirnyaPackage, pickLikuidatorRecheckCandidates |
frontend/src/components/klasifikasi/detect-banner.tsx |
Modify | DetectedFlow += "pembubaran" + labels/descriptions |
frontend/src/pages/KlasifikasiPage.tsx |
Modify | Detect chains (rung BELOW akuisisi) + label set + create-route + supported:false gate toast + re-check trigger |
frontend/src/lib/flow-steps.ts |
Modify | PEMBUBARAN_LABELS (5 steps incl. verifikator stage) + type union + staged arm |
frontend/src/lib/destination-for.ts |
Modify | Type union + base pembubaran (NOT terminal) |
frontend/src/lib/pembubaran-review.ts |
Create | Pure gate helpers (6 required section keys, missingSections) |
frontend/src/pages/PembubaranExtractionPage.tsx |
Create | Copy of LaporanRupsExtractionPage.tsx + exactly 3 token diffs (single-company modal flow) |
frontend/src/pages/PembubaranReviewPage.tsx |
Create | Flat sectioned review: Perseroan(SABH)/Akta(+dual-deed)/Kehadiran/Likuidator/Koran/Identitas/Kontak + submit-to-verifikator |
frontend/src/pages/PembubaranStatusPage.tsx |
Create | Verifikasi-aware status page (peleburan mirror) |
frontend/src/pages/VerifikatorPembubaranReviewPage.tsx |
Create | Compact verifikator surface (VerifikatorPeleburanReviewPage precedent) |
frontend/src/hooks/use-verifikator.ts |
Modify | Queue item type union += "PEMBUBARAN_PT" |
frontend/src/pages/VerifikatorDashboardPage.tsx |
Modify | Route PEMBUBARAN_PT items to the pembubaran verifikator page |
frontend/src/routes.tsx |
Modify | pembubaran/:submissionId/{extraction,review,status} + verifikator/pembubaran/:submissionId |
Task 1: Prisma schema + compile-gate (enum, rupsKehadiranRegistry, registry stub, ProcessorKind case)
Adds PEMBUBARAN_PT to the Prisma SubmissionType enum — which FORCES two compile-time additions: a FLOW_REGISTRY key (temporary v1-fork stub, replaced by Task 6) and a processorKindForType case (returns "v2-generic"). Adds ONE Submission column: rupsKehadiranRegistry Json? (the registry-roster attendance fallback, spec §4.3). NO new tables — the dispatch brief's "likuidator satellite" is resolved against the spec authority (§6: "NO new tables — likuidator + koran values are ExtractedField rows"), which buys confirm/bbox/override/rematch for free. SURAT_LIKUIDATOR/BUKTI_PENGUMUMAN DocumentTypes already exist — do not touch DocumentType.
Files
- backend/prisma/schema.prisma — Modify (enum SubmissionType ends at PELEBURAN_PT ~line 101; model Submission Json columns ~line 750-758)
- backend/src/flow-engine/registry.ts — Modify (stub key after the PELEBURAN_PT entry)
- backend/src/services/submission-dispatch.ts — Modify (processorKindForType, add case before default:)
- backend/src/flow-engine/__tests__/pembubaran-schema.test.ts — Create
Interfaces
- Produces: generated enum SubmissionType.PEMBUBARAN_PT; Submission.rupsKehadiranRegistry: Prisma.JsonValue | null; processorKindForType("PEMBUBARAN_PT") === "v2-generic"; FLOW_REGISTRY.PEMBUBARAN_PT defined.
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/pembubaran-schema.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { SubmissionType } from "../../generated/prisma/enums";
import { processorKindForType } from "../../services/submission-dispatch";
import { FLOW_REGISTRY } from "../registry";
/**
* Task 1 — PEMBUBARAN_PT schema slice + the two compile-forced wiring points
* (registry key + ProcessorKind case). The registry entry is a TEMPORARY
* v1-fork stub until Task 6 registers the real defineFlow config. NO new
* tables (spec §6): likuidator + koran live as ExtractedField rows; the one
* new column is the registry-roster attendance JSON.
*/
describe("PEMBUBARAN_PT schema + compile-gate", () => {
const ids: string[] = [];
afterAll(async () => {
await db.submission.deleteMany({ where: { id: { in: ids } } });
});
it("exposes the new enum value (distinct from PEMBUBARAN_PP)", () => {
expect(SubmissionType.PEMBUBARAN_PT).toBe("PEMBUBARAN_PT");
expect(SubmissionType.PEMBUBARAN_PP).toBe("PEMBUBARAN_PP"); // both exist, never aliased
});
it("persists rupsKehadiranRegistry rows on Submission", async () => {
const rows = [
{ nama: "BUDI SANTOSO", persentase: 60, kehadiran: true },
{ nama: "SITI AMINAH", persentase: 40, kehadiran: null },
];
const sub = await db.submission.create({
data: { type: "PEMBUBARAN_PT", status: "CREATED", rupsKehadiranRegistry: rows },
});
ids.push(sub.id);
const fresh = await db.submission.findUnique({ where: { id: sub.id } });
expect(fresh?.rupsKehadiranRegistry).toEqual(rows);
// default null
const bare = await db.submission.create({ data: { type: "PEMBUBARAN_PT", status: "CREATED" } });
ids.push(bare.id);
expect(bare.rupsKehadiranRegistry).toBeNull();
});
it("routes the new type to the v2-generic ProcessorKind and a total registry", () => {
expect(processorKindForType("PEMBUBARAN_PT")).toBe("v2-generic");
expect(FLOW_REGISTRY.PEMBUBARAN_PT).toBeDefined();
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/pembubaran-schema.test.ts. Expected: fails —PEMBUBARAN_PTnot on the enum (typecheck of the test file itself errors). - [ ] Minimal implementation A —
backend/prisma/schema.prisma:
1. Insideenum SubmissionType { ... }, append afterPELEBURAN_PT:
/// Pembubaran PT (SABH id_aksi_transaksi = 12, UUPT Ps.142-147): dissolution of a
/// REGISTERED PT — the deed dissolves the PT and APPOINTS a likuidator (liquidation
/// STARTS; contrast Berakhirnya id_aksi 36, which ACCEPTS the likuidator's final
/// accounting and ENDS it). DISTINCT from PEMBUBARAN_PP (Perseroan Perorangan,
/// UU CK regime) — exact-equality checks only, never substring matching.
/// THIRD engine:'v2-generic' FLOW_REGISTRY tenant. VERIFIKATOR-STAGED (submit
/// creates a VerifikasiPerubahan row; voter roster = oldData.pemegang_saham).
PEMBUBARAN_PT
- In
model Submission, next to the existing perubahan-family Json columns (changeTypeResult/selectedJenisPerubahan, ~line 752-758), add:
/// PEMBUBARAN_PT registry-roster RUPS-attendance fallback (spec §4.3):
/// [{ nama, persentase, kehadiran }] written by matchRupsAttendance when the
/// dissolution deed restates no shareholders — SABH's attendance checkboxes are
/// against the REGISTRY list, so this is the flow's PRIMARY attendance path.
/// Consumed by buildPembubaranContext (quorum) and the Kehadiran review card.
rupsKehadiranRegistry Json?
- [ ] Minimal implementation B —
backend/src/flow-engine/registry.ts, append toFLOW_REGISTRYafter thePELEBURAN_PTline:
// TEMPORARY v1-fork stub — satisfies Record totality until Task 6 registers the
// real defineFlow(...) entry. processorKindForType already returns "v2-generic",
// so a premature legacy dispatch throws LOUD (unreachable while the classifier
// gate keeps the flow dark — BUILT_AKTA_TXN_TYPES flips in Task 7).
PEMBUBARAN_PT: { engine: "v1-fork", type: "PEMBUBARAN_PT" },
- [ ] Minimal implementation C —
backend/src/services/submission-dispatch.ts, inprocessorKindForType, add after thePELEBURAN_PTcase:
case "PEMBUBARAN_PT":
// v2-generic: dispatch resolves FLOW_REGISTRY BEFORE this switch; the
// switch's "v2-generic" case below is a deliberate unreachable-throw.
return "v2-generic";
- [ ] Regenerate + sync test DB:
cd backend && bunx prisma generate && bun run db:push:test. - [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/pembubaran-schema.test.ts. Expected: 3 pass. - [ ] Typecheck + stub-safety:
cd backend && bunx tsc --noEmit && bun test src/flow-engine/__tests__/registry.test.ts src/flow-engine/__tests__/dispatch-routing.test.ts. Expected: clean —registry.test.ts'sV2_SHIPPEDset (LAPORAN_RUPS_TAHUNAN,PELEBURAN_PT) treats the stub as v1-fork NOW; Task 6 addsPEMBUBARAN_PTto that set. - [ ] Commit:
git add backend/prisma/schema.prisma backend/src/generated/prisma backend/src/flow-engine/registry.ts backend/src/services/submission-dispatch.ts backend/src/flow-engine/__tests__/pembubaran-schema.test.ts && git commit -m "feat(pembubaran): PEMBUBARAN_PT enum + rupsKehadiranRegistry + compile-gate wiring"
Task 2: Surat Likuidator grounded extractor + focused-pass handler + OCR-only doc handler + clipping partial-write hardening
The flow's definitional net-new extractor (spec §4.3). Penunjukan shape ONLY — the laporan-akhir/pertanggungjawaban is the DISTINCT LAPORAN_LIKUIDASI DocumentType (built in Berakhirnya); the §4.2 doc-set predicate keys on that distinction (C2). Deterministic regexes first (RT/RW, labeled kodepos, telepon, email — they WIN over LLM output); ONE focused 3B guided-JSON call fills the rest; EVERY value grounded (dasar_penunjukan token-wise ≥80% — it spans lines and whole-substring grounding would always null it). Persists 12 ExtractedField rows in ONE atomic createMany (0.75 tier / FLAGGED-null), idempotent-once. Wired as FOCUSED_PASS_HANDLERS["surat-likuidator"]. Plus the document-processor.ts OCR-only case — without it a SURAT_LIKUIDATOR letter falls through to the generic akta handler and burns an akta-VLM extraction. Also the inherited ledger trigger: runBuktiPengumumanPass's per-key create loop becomes ONE atomic createMany (a crash mid-loop used to leave a permanent partial row set that the any-key idempotency guard then preserved forever).
Files
- backend/src/services/surat-likuidator-extract.ts — Create
- backend/src/services/bukti-pengumuman-extract.ts — Modify (atomic createMany)
- backend/src/flow-engine/focused-passes.ts — Modify (one map entry + stale-comment fix)
- backend/src/services/document-processor.ts — Modify (insert case 6k directly after the BUKTI_PENGUMUMAN case, ~line 2500)
- backend/src/services/__tests__/surat-likuidator-extract.test.ts — Create
Interfaces
- Consumes: CLEANUP_LLM_URL/CLEANUP_LLM_MODEL/CLEANUP_LLM_TIMEOUT_MS env (3B); db.
- Produces:
- const LIKUIDATOR_EXTRACTED_FIELD_KEYS: readonly string[] — the 12 canonical fieldKeys likuidator_nama, likuidator_nomor_surat, likuidator_tanggal_surat, likuidator_alamat, likuidator_rt, likuidator_rw, likuidator_kode_pos, likuidator_kelurahan, likuidator_kecamatan, likuidator_telepon, likuidator_email, likuidator_dasar_penunjukan
- interface SuratLikuidatorFields (12 nullable strings, unprefixed keys)
- async function extractSuratLikuidator(rawText: string): Promise<SuratLikuidatorFields>
- async function runSuratLikuidatorPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> — idempotent-once (any existing key → skip ALL; sound because the write is atomic)
- FOCUSED_PASS_HANDLERS["surat-likuidator"]
Steps
- [ ] Write failing test. Create
backend/src/services/__tests__/surat-likuidator-extract.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import {
extractSuratLikuidator,
runSuratLikuidatorPass,
LIKUIDATOR_EXTRACTED_FIELD_KEYS,
} from "../surat-likuidator-extract";
const RAW =
"SURAT PENUNJUKAN LIKUIDATOR\nNomor: 017/SPL/V/2026\nJakarta, 2 Mei 2026\n" +
"Menunjuk Saudara BUDI HARTONO, S.H. selaku likuidator Perseroan\n" +
"beralamat di Jalan Melati Raya No. 12, RT. 004/RW. 007,\n" +
"Kelurahan Cempaka Putih, Kecamatan Menteng, Kode Pos 10310\n" +
"Telepon: 081234567890, Email: budi.hartono@example.co.id\n" +
"Dasar penunjukan: Keputusan Rapat Umum Pemegang Saham tanggal 28 April 2026\n" +
"sebagaimana dimuat dalam Akta Pembubaran Nomor 5.";
function mockLlm(content: object | null, status = 200) {
const orig = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(
JSON.stringify({ choices: [{ message: { content: content ? JSON.stringify(content) : "" } }] }),
{ status },
)) as typeof fetch;
return () => { globalThis.fetch = orig; };
}
const LLM_FULL = {
nama: "BUDI HARTONO, S.H.", nomor_surat: "017/SPL/V/2026", tanggal_surat: "2 Mei 2026",
alamat: "Jalan Melati Raya No. 12", rt: "999", rw: "999", kode_pos: "99999",
kelurahan: "Cempaka Putih", kecamatan: "Menteng",
telepon: "089999999999", email: "salah@salah.com",
dasar_penunjukan: "Keputusan Rapat Umum Pemegang Saham tanggal 28 April 2026 sebagaimana dimuat dalam Akta Pembubaran Nomor 5",
};
describe("extractSuratLikuidator (deterministic-first grounded 3B)", () => {
const origEnv = process.env.CLEANUP_LLM_URL;
afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });
it("deterministic RT/RW/kodepos/telepon/email WIN over conflicting LLM output", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm(LLM_FULL);
try {
const r = await extractSuratLikuidator(RAW);
expect(r.rt).toBe("004");
expect(r.rw).toBe("007");
expect(r.kode_pos).toBe("10310");
expect(r.telepon).toBe("081234567890");
expect(r.email).toBe("budi.hartono@example.co.id");
} finally { restore(); }
});
it("grounds LLM values verbatim; token-wise grounding accepts the multi-line dasar_penunjukan", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm(LLM_FULL);
try {
const r = await extractSuratLikuidator(RAW);
expect(r.nama).toBe("BUDI HARTONO, S.H.");
expect(r.nomor_surat).toBe("017/SPL/V/2026");
expect(r.kelurahan).toBe("Cempaka Putih");
// spans two OCR lines — whole-substring grounding would null it; token-wise ≥80% accepts it
expect(r.dasar_penunjukan).toContain("Keputusan Rapat Umum Pemegang Saham");
} finally { restore(); }
});
it("rejects hallucinated values (not grounded in rawText) → null", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm({ ...LLM_FULL, nama: "SINTA HALUSINASI", kelurahan: "Tanah Abang" });
try {
const r = await extractSuratLikuidator(RAW);
expect(r.nama).toBeNull();
expect(r.kelurahan).toBeNull();
} finally { restore(); }
});
it("returns all-null LLM fields on outage/empty text — deterministic fields still fire; never throws", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm(null, 500);
try {
const r = await extractSuratLikuidator(RAW);
expect(r.nama).toBeNull(); // LLM-only field
expect(r.rt).toBe("004"); // deterministic survives outage
const empty = await extractSuratLikuidator("");
expect(Object.values(empty).every((v) => v === null)).toBe(true);
} finally { restore(); }
});
});
describe("runSuratLikuidatorPass persistence (12 ExtractedField rows, atomic, idempotent-once)", () => {
const docIds: string[] = [];
afterAll(async () => { await db.document.deleteMany({ where: { id: { in: docIds } } }); });
async function seedDoc() {
const doc = await db.document.create({
data: {
filename: "spl.pdf", originalName: "spl.pdf", fileSize: 0, filePath: "/tmp/spl.pdf",
documentType: "SURAT_LIKUIDATOR", status: "VERIFICATION_READY", rawText: RAW,
},
});
docIds.push(doc.id);
return doc.id;
}
it("persists ALL 12 canonical rows in one shot (0.75 grounded tier; null → FLAGGED)", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm(LLM_FULL);
try {
const documentId = await seedDoc();
await runSuratLikuidatorPass({ submissionId: "s", documentId, rawText: RAW });
const rows = await db.extractedField.findMany({ where: { documentId } });
expect(rows.length).toBe(LIKUIDATOR_EXTRACTED_FIELD_KEYS.length);
const nama = rows.find((r) => r.fieldKey === "likuidator_nama");
expect(nama?.value).toBe("BUDI HARTONO, S.H.");
expect(nama?.confidence).toBe(0.75); // "please review" tier — never auto-LOCK
expect(nama?.status).toBe("EDITABLE");
const alamat = rows.find((r) => r.fieldKey === "likuidator_alamat");
expect(alamat?.value).toBe("Jalan Melati Raya No. 12");
} finally { restore(); }
});
it("is idempotent-once: NEVER clobbers existing rows (operator edit survives resume)", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm(LLM_FULL);
try {
const documentId = await seedDoc();
await db.extractedField.create({
data: { documentId, fieldKey: "likuidator_nama", value: "EDIT OPERATOR", confidence: 1, status: "MANUAL" },
});
await runSuratLikuidatorPass({ submissionId: "s", documentId, rawText: RAW });
const rows = await db.extractedField.findMany({ where: { documentId } });
expect(rows.length).toBe(1); // any-key → skip-ALL (sound: the write below is atomic)
expect(rows[0]?.value).toBe("EDIT OPERATOR");
} finally { restore(); }
});
it("skips loudly (no rows) on empty rawText — manual entry path, never an ERROR", async () => {
const documentId = await seedDoc();
await runSuratLikuidatorPass({ submissionId: "s", documentId, rawText: "" });
expect(await db.extractedField.findMany({ where: { documentId } })).toEqual([]);
});
});
- [ ] Run to verify RED:
cd backend && bun test src/services/__tests__/surat-likuidator-extract.test.ts. Expected: fails — module not found. - [ ] Minimal implementation A — create
backend/src/services/surat-likuidator-extract.ts:
/**
* Focused, grounded SURAT_LIKUIDATOR extractor — PEMBUBARAN_PT's definitional
* net-new block (spec §4.3). PENUNJUKAN SHAPE ONLY: the laporan-akhir /
* pertanggungjawaban is the DISTINCT LAPORAN_LIKUIDASI DocumentType (built in
* the Berakhirnya flow) — the §4.2 doc-set predicate keys on that distinction,
* so this extractor must never swallow it (C2).
*
* Strategy (memory reference_apostille_extraction_llm — the 3B is brittle on
* broad prompts, so this is a dedicated single-purpose call, never a widening
* of any shared prompt):
* 1. Deterministic regexes FIRST (RT/RW, labeled kodepos, telepon, email) —
* they win over LLM output unconditionally.
* 2. ONE 3B guided-JSON call (CLEANUP_LLM, temperature 0) fills the rest.
* 3. EVERY LLM value is grounded against rawText: whole-normalized-substring
* for scalars; TOKEN-WISE (≥80% of normalized tokens present) for
* dasar_penunjukan, which spans lines and would otherwise always null out.
* Persistence: 12 ExtractedField rows (spec §6 — NO satellite table; buys
* confirm/bbox/override/rematch wholesale), grounded 0.75 "please review" tier
* (never auto-LOCK), ONE atomic createMany, IDEMPOTENT-ONCE.
*/
import { db } from "../lib/db";
export interface SuratLikuidatorFields {
nama: string | null;
nomor_surat: string | null;
tanggal_surat: string | null;
alamat: string | null;
rt: string | null;
rw: string | null;
kode_pos: string | null;
kelurahan: string | null;
kecamatan: string | null;
telepon: string | null;
email: string | null;
dasar_penunjukan: string | null;
}
const FIELD_ORDER = [
"nama", "nomor_surat", "tanggal_surat", "alamat", "rt", "rw", "kode_pos",
"kelurahan", "kecamatan", "telepon", "email", "dasar_penunjukan",
] as const;
/** Canonical ExtractedField keys — `likuidator_` + the schema key. Exported for
* the context builder, rules, routes and tests (ONE source of truth). */
export const LIKUIDATOR_EXTRACTED_FIELD_KEYS = FIELD_ORDER.map((k) => `likuidator_${k}`) as readonly string[];
const NONE: SuratLikuidatorFields = Object.fromEntries(FIELD_ORDER.map((k) => [k, null])) as SuratLikuidatorFields;
/** Deterministic tier — regex-extract BEFORE the LLM; these win unconditionally. */
export function extractLikuidatorDeterministic(rawText: string): Partial<SuratLikuidatorFields> {
const out: Partial<SuratLikuidatorFields> = {};
const rtRw = rawText.match(/RT\s*\.?\s*(\d{1,3})\s*[\/.]?\s*RW\s*\.?\s*(\d{1,3})/i);
if (rtRw) { out.rt = rtRw[1]!; out.rw = rtRw[2]!; }
const kodePos = rawText.match(/kode\s*pos\s*:?\s*(\d{5})\b/i);
if (kodePos) out.kode_pos = kodePos[1]!;
const telepon = rawText.match(/(?:\+62|62|08)\d{7,12}/);
if (telepon) out.telepon = telepon[0];
const email = rawText.match(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/);
if (email) out.email = email[0];
return out;
}
const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
/** Token-wise grounding for free text spanning OCR lines: ≥80% of the value's
* normalized tokens must appear in the haystack. */
function groundTokens(value: string, hay: string): boolean {
const tokens = value.split(/\s+/).map(norm).filter((t) => t.length > 0);
if (tokens.length === 0) return false;
const present = tokens.filter((t) => hay.includes(t)).length;
return present / tokens.length >= 0.8;
}
export async function extractSuratLikuidator(rawText: string): Promise<SuratLikuidatorFields> {
if (!rawText.trim()) return { ...NONE };
const deterministic = extractLikuidatorDeterministic(rawText);
const url = process.env.CLEANUP_LLM_URL || "";
const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
let llm: Partial<Record<keyof SuratLikuidatorFields, string | null>> = {};
if (url) {
const system =
"From an Indonesian surat penunjukan likuidator (liquidator-appointment letter for a PT dissolution), extract: " +
"nama (the appointed likuidator's full name incl. titles), nomor_surat (the letter number), tanggal_surat (the letter date as written), " +
"alamat (the likuidator's street address line, WITHOUT RT/RW/kelurahan/kecamatan/kode pos), rt, rw, kode_pos, kelurahan, kecamatan, " +
"telepon, email, dasar_penunjukan (the legal basis of the appointment, e.g. the RUPS resolution or court order recital). " +
"Copy VERBATIM from the text; if absent, return null. Never invent. " +
"Return ONLY JSON {nama, nomor_surat, tanggal_surat, alamat, rt, rw, kode_pos, kelurahan, kecamatan, telepon, email, dasar_penunjukan}.";
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: Object.fromEntries(FIELD_ORDER.map((k) => [k, { type: ["string", "null"] }])),
required: [...FIELD_ORDER],
additionalProperties: false,
},
}),
signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
});
if (res.ok) {
const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
if (m) llm = JSON.parse(m[0]) as Partial<Record<keyof SuratLikuidatorFields, string | null>>;
}
} catch (err) {
console.warn("[surat-likuidator] 3B extraction failed:", err);
}
}
const hay = norm(rawText);
const ground = (v: string | null | undefined): string | null => {
const s = v ? String(v).trim() : "";
return s && hay.includes(norm(s)) ? s : null;
};
const out: SuratLikuidatorFields = { ...NONE };
for (const key of FIELD_ORDER) {
const det = deterministic[key];
if (det) { out[key] = det; continue; } // deterministic wins
const raw = llm[key] ? String(llm[key]).trim() : "";
if (!raw) continue;
if (key === "dasar_penunjukan") {
out[key] = groundTokens(raw, hay) ? raw : null; // token-wise — spans lines
} else {
out[key] = ground(raw);
}
}
return out;
}
/** The 'surat-likuidator' focused-pass body (invoked by flow-engine focusedPasses). */
export async function runSuratLikuidatorPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> {
const { documentId, rawText } = args;
if (!rawText.trim()) {
console.warn(`[surat-likuidator] no rawText for doc ${documentId} — focused pass skipped (manual entry via PUT /likuidator)`);
return;
}
const existing = await db.extractedField.findMany({
where: { documentId, fieldKey: { in: [...LIKUIDATOR_EXTRACTED_FIELD_KEYS] } },
select: { id: true },
});
// IDEMPOTENT-ONCE, any-key→skip-ALL. Sound because the write below is ONE atomic
// createMany: a partial row set can no longer exist (the Peleburan-ledger
// crash-mid-write class is closed for this consumer by construction).
if (existing.length > 0) return;
const r = await extractSuratLikuidator(rawText);
await db.extractedField.createMany({
data: FIELD_ORDER.map((k) => {
const value = r[k];
return {
documentId,
fieldKey: `likuidator_${k}`,
value,
confidence: value ? 0.75 : 0, // grounded "please review" tier — never auto-LOCK
status: value ? "EDITABLE" : "FLAGGED",
};
}),
});
}
- [ ] Minimal implementation B —
backend/src/flow-engine/focused-passes.ts: add toFOCUSED_PASS_HANDLERSafter the"bukti-pengumuman"entry:
"surat-likuidator": async (args) => {
const { runSuratLikuidatorPass } = await import("../services/surat-likuidator-extract");
await runSuratLikuidatorPass(args);
},
and fix the stale header comment (ledger minor): change Later flows only add entries (bukti-pengumuman is already live; e.g. surat-likuidator lands with Pembubaran). to Later flows only add entries (laporan-keuangan, bukti-pengumuman and surat-likuidator are live).
- [ ] Minimal implementation C — backend/src/services/document-processor.ts: insert directly AFTER the BUKTI_PENGUMUMAN OCR-only case (// 6j. block) and before // 7. Akta handling:
// 6k. SURAT_LIKUIDATOR (surat penunjukan likuidator, Pembubaran PT) — OCR-ONLY.
// The grounded 12-field extraction runs as the flow-engine 'surat-likuidator'
// focusedPasses pass, NOT here. Without this case the letter falls through to
// the generic Akta handler and burns an akta-VLM extraction on a 1-2 page letter.
if (doc.documentType === "SURAT_LIKUIDATOR") {
const layoutProvider = await getLayoutProvider("paddleocr");
const ocr = await layoutProvider.extractTextWithWords(fileBuffer, { unwarp: false, orient: false });
await db.document.update({
where: { id: documentId },
data: { rawText: ocr.rawText ?? "", pageCount: ocr.pageCount ?? 1, status: "VERIFICATION_READY" },
});
return;
}
- [ ] Minimal implementation D —
backend/src/services/bukti-pengumuman-extract.ts: inrunBuktiPengumumanPass, replace the trailing per-key loop
const r = await extractBuktiPengumuman(rawText);
for (const fieldKey of KEYS) {
const value = r[fieldKey];
await db.extractedField.create({
data: {
documentId, fieldKey, value,
confidence: value ? 0.75 : 0, // grounded-3B "please review" tier — never auto-LOCK territory
status: value ? "EDITABLE" : "FLAGGED",
},
});
}
with ONE atomic createMany:
const r = await extractBuktiPengumuman(rawText);
// ATOMIC single createMany (was a per-key create loop): a crash mid-loop left a
// permanent partial row set that the any-key idempotency guard above then
// preserved forever (Peleburan-ledger carry; hardening trigger = Pembubaran's
// clipping adoption). All-or-nothing now, so the guard is sound.
await db.extractedField.createMany({
data: KEYS.map((fieldKey) => {
const value = r[fieldKey];
return {
documentId, fieldKey, value,
confidence: value ? 0.75 : 0, // grounded-3B "please review" tier — never auto-LOCK territory
status: value ? "EDITABLE" : "FLAGGED",
};
}),
});
- [ ] Run GREEN:
cd backend && bun test src/services/__tests__/surat-likuidator-extract.test.ts src/services/__tests__/bukti-pengumuman-extract.test.ts src/flow-engine/__tests__/focused-passes.test.ts. Expected: all pass (the existing bukti-pengumuman suite proves the hardening is behavior-preserving). - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/services/surat-likuidator-extract.ts backend/src/services/bukti-pengumuman-extract.ts backend/src/flow-engine/focused-passes.ts backend/src/services/document-processor.ts backend/src/services/__tests__/surat-likuidator-extract.test.ts && git commit -m "feat(pembubaran): grounded surat-likuidator extractor + focused pass + clipping partial-write hardening"
Task 3: Deed seams — inline-koran hook + dual-deed transformer mappings + RUPS registry-roster fallback
Three small extraction-side seams. (a) runPembubaranDeedPasses — the flow's hooks.afterAktaExtraction body: the deed-inline koran fallback via the SHARED parameterized extractKoranFields(rawText, { docContext }) (built in Peleburan — consumed, never re-implemented), fired ONLY when zero BUKTI_PENGUMUMAN clippings are uploaded (spec §4.3/§8 — the clipping is the better source; the guard also avoids stale deed values shadowing it) and idempotent-once. (b) PERUBAHAN_FIELD_MAPPING gains the 3 akta_kedua_* keys — spec open question 2's dual-deed data (akta pembubaran + BA/PKR RUPS as second deed) is extracted by the perubahan LLM schema (schema/akta-perubahan.ts:467) but currently DROPPED at persistence; the transformer's null-parent skip logic (field-transformer.ts:545-560) provably emits ZERO rows for single-deed v1 extractions. (c) matchRupsAttendance — AKTA_PEMBUBARAN joins the akta filter and, scoped to type === "PEMBUBARAN_PT", the registry-roster fallback writes Submission.rupsKehadiranRegistry when the deed restates no shareholders (the flow's PRIMARY attendance path — SABH's checkboxes are against the registry list). First run fires automatically: document-processor.ts:1972 calls the matcher after every BA-RUPS extraction (Phase 3, after Phase-2 lookup has set oldData); re-runs ride rematch.rupsAttendance: true (Task 6).
Files
- backend/src/services/akta-pembubaran-deed-passes.ts — Create
- backend/src/services/field-transformer.ts — Modify (3 mapping entries)
- backend/src/services/rups-attendance-matcher.ts — Modify
- backend/src/services/__tests__/akta-pembubaran-deed-passes.test.ts — Create
- backend/src/services/__tests__/akta-kedua-transformer.test.ts — Create
- backend/src/services/__tests__/pembubaran-rups-fallback.test.ts — Create
Interfaces
- Produces: runPembubaranDeedPasses(a: { submissionId: string; aktaDocumentId: string; rawText: string }): Promise<void>; ExtractedField keys akta_kedua_nomor / akta_kedua_tanggal / akta_kedua_nama_notaris on dual-deed extractions; matchRupsAttendance unchanged signature, new side-effect Submission.rupsKehadiranRegistry = [{ nama, persentase, kehadiran }] (PEMBUBARAN_PT only).
Steps
- [ ] Write failing tests. Create
backend/src/services/__tests__/akta-pembubaran-deed-passes.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import { runPembubaranDeedPasses } from "../akta-pembubaran-deed-passes";
const RAW =
"AKTA PEMBUBARAN PT MAJU MUNDUR\nNomor 5, tanggal 30 April 2026\n" +
"RUPS memutuskan membubarkan Perseroan dan menunjuk likuidator.\n" +
"Pengumuman telah dimuat dalam surat kabar harian Media Indonesia tanggal 20 April 2026.";
function mockLlm(content: object) {
const orig = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify(content) } }] }), { status: 200 })) as typeof fetch;
return () => { globalThis.fetch = orig; };
}
async function seed(withClipping: boolean) {
const sub = await db.submission.create({ data: { type: "PEMBUBARAN_PT", status: "EXTRACTING" } });
const akta = await db.document.create({
data: { filename: "akta.pdf", originalName: "akta.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY", rawText: RAW },
});
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
});
let clipId: string | null = null;
if (withClipping) {
const clip = await db.document.create({
data: { filename: "koran.pdf", originalName: "koran.pdf", fileSize: 0, filePath: "/tmp/k.pdf", documentType: "BUKTI_PENGUMUMAN", status: "VERIFICATION_READY", rawText: "PENGUMUMAN PEMBUBARAN" },
});
clipId = clip.id;
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: clip.id, classifiedType: "BUKTI_PENGUMUMAN", confidence: 1, processingOrder: 6, extractionStatus: "DONE" },
});
}
return { sub, akta, clipId };
}
describe("runPembubaranDeedPasses (deed-inline koran fallback, SHARED extractKoranFields)", () => {
const subIds: string[] = [];
const docIds: string[] = [];
const origEnv = process.env.CLEANUP_LLM_URL;
afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });
afterAll(async () => {
await db.submission.deleteMany({ where: { id: { in: subIds } } });
await db.document.deleteMany({ where: { id: { in: docIds } } });
});
it("fires ONLY when zero clippings are uploaded; persists canonical keys on the deed", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm({ tanggal_koran: "20 April 2026", nama_koran: "Media Indonesia" });
try {
const { sub, akta } = await seed(false);
subIds.push(sub.id); docIds.push(akta.id);
await runPembubaranDeedPasses({ submissionId: sub.id, aktaDocumentId: akta.id, rawText: RAW });
const nama = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: akta.id, fieldKey: "nama_koran" } } });
const tgl = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: akta.id, fieldKey: "tanggal_pengumuman" } } });
expect(nama?.value).toBe("Media Indonesia");
expect(nama?.confidence).toBe(0.75);
expect(tgl?.value).toBe("20 April 2026");
} finally { restore(); }
});
it("SKIPS the inline pass when a BUKTI_PENGUMUMAN clipping exists (clipping is the better source)", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm({ tanggal_koran: "20 April 2026", nama_koran: "Media Indonesia" });
try {
const { sub, akta, clipId } = await seed(true);
subIds.push(sub.id); docIds.push(akta.id, clipId!);
await runPembubaranDeedPasses({ submissionId: sub.id, aktaDocumentId: akta.id, rawText: RAW });
expect(await db.extractedField.findMany({ where: { documentId: akta.id } })).toEqual([]);
} finally { restore(); }
});
it("is idempotent-once (operator edit survives) and skips loudly on empty rawText", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm({ tanggal_koran: "20 April 2026", nama_koran: "Media Indonesia" });
try {
const { sub, akta } = await seed(false);
subIds.push(sub.id); docIds.push(akta.id);
await db.extractedField.create({ data: { documentId: akta.id, fieldKey: "nama_koran", value: "EDIT OPERATOR", confidence: 1, status: "MANUAL" } });
await runPembubaranDeedPasses({ submissionId: sub.id, aktaDocumentId: akta.id, rawText: RAW });
const rows = await db.extractedField.findMany({ where: { documentId: akta.id } });
expect(rows.length).toBe(1);
expect(rows[0]?.value).toBe("EDIT OPERATOR");
// empty rawText path
const { sub: s2, akta: a2 } = await seed(false);
subIds.push(s2.id); docIds.push(a2.id);
await runPembubaranDeedPasses({ submissionId: s2.id, aktaDocumentId: a2.id, rawText: "" });
expect(await db.extractedField.findMany({ where: { documentId: a2.id } })).toEqual([]);
} finally { restore(); }
});
});
- [ ] Create
backend/src/services/__tests__/akta-kedua-transformer.test.ts:
import { describe, it, expect } from "bun:test";
import { transformAktaPerubahanResult } from "../field-transformer";
import type { AktaPerubahanData } from "../../schema/akta-perubahan";
/** Minimal AktaPerubahanData factory — mirror of field-transformer.test.ts's shape. */
function makeData(overrides: Partial<AktaPerubahanData> = {}): AktaPerubahanData {
return {
jenis_dokumen: "Akta Pembubaran", akta: { nomor_akta: "5", tanggal_akta: "2026-04-30", judul_akta: null },
notaris: null, perseroan: null, rups: null, jenis_perubahan: [], modal_baru: null,
direksi_baru: null, komisaris_baru: null, pemegang_saham_baru: null,
perubahan_nama: null, perubahan_alamat: null, perubahan_kbli: null,
perubahan_jangka_waktu: null, perubahan_status_perseroan: null, perubahan_jenis_perseroan: null,
peralihan_saham: null, pengurus_change_scope: null, pengangkatan_kembali: null,
penghadap: [], pasal_yang_diubah: null, referensi_akta_pendirian: null,
referensi_akta_perubahan_sebelumnya: null, saksi_saksi: null, akta_kedua: null,
...overrides,
} as unknown as AktaPerubahanData;
}
describe("akta_kedua dual-deed transformer mappings", () => {
it("emits the 3 akta_kedua_* rows when the block is populated (dual-deed pembubaran)", () => {
const r = transformAktaPerubahanResult(
makeData({ akta_kedua: { nomor_akta: "6", tanggal_akta: "2026-04-30", nama_notaris: "Sari, S.H., M.Kn." } as never }),
{},
);
const byKey = new Map(r.fields.map((f) => [f.fieldKey, f.value]));
expect(byKey.get("akta_kedua_nomor")).toBe("6");
expect(byKey.get("akta_kedua_tanggal")).toBe("2026-04-30");
expect(byKey.get("akta_kedua_nama_notaris")).toBe("Sari, S.H., M.Kn.");
});
it("emits ZERO akta_kedua rows when the block is null — v1 perubahan/akuisisi stay byte-identical", () => {
const r = transformAktaPerubahanResult(makeData(), {});
expect(r.fields.some((f) => f.fieldKey.startsWith("akta_kedua_"))).toBe(false);
});
});
NOTE: if transformAktaPerubahanResult's real signature differs (e.g. requires boundingBoxes), mirror the call shape used in the existing field-transformer.test.ts — adapt the factory, keep the two assertions.
- [ ] Create backend/src/services/__tests__/pembubaran-rups-fallback.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { matchRupsAttendance } from "../rups-attendance-matcher";
describe("matchRupsAttendance — PEMBUBARAN_PT registry-roster fallback (spec §4.3)", () => {
const subIds: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
await db.submission.deleteMany({ where: { id: { in: subIds } } });
await db.document.deleteMany({ where: { id: { in: docIds } } });
});
async function seed(opts: { type: "PEMBUBARAN_PT" | "PERUBAHAN_PT"; deedPs: boolean }) {
const sub = await db.submission.create({
data: {
type: opts.type, status: "EXTRACTING",
oldData: { perseroan: { nama: "PT MAJU MUNDUR", id_perseroan: 4242 }, pemegang_saham: [
{ nama: "BUDI SANTOSO", persentase: 60 },
{ nama: "SITI AMINAH", persentase: 40 },
] },
},
});
subIds.push(sub.id);
const akta = await db.document.create({
data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY" },
});
docIds.push(akta.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
});
if (opts.deedPs) {
await db.aktaPemegangSaham.create({
data: { documentId: akta.id, namaAtauBadanHukum: "BUDI SANTOSO", persentase: 60, orderIndex: 0 },
});
}
const ba = await db.document.create({
data: { filename: "ba.pdf", originalName: "ba.pdf", fileSize: 0, filePath: "/tmp/b.pdf", documentType: "BERITA_ACARA_RUPS", status: "VERIFICATION_READY" },
});
docIds.push(ba.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: ba.id, classifiedType: "BERITA_ACARA_RUPS", confidence: 1, processingOrder: 6, extractionStatus: "DONE" },
});
await db.beritaAcaraRupsExtraction.create({
data: {
documentId: ba.id,
attendees: [
{ nama: "Bpk. BUDI SANTOSO", kehadiran: "hadir" },
{ nama: "SITI AMINAH", kehadiran: "tidak_hadir" },
],
},
});
return { sub, akta };
}
it("writes rupsKehadiranRegistry from oldData.pemegang_saham when the deed restates no roster", async () => {
const { sub } = await seed({ type: "PEMBUBARAN_PT", deedPs: false });
const r = await matchRupsAttendance(sub.id, db);
expect(r.matched).toBe(2);
const fresh = await db.submission.findUnique({ where: { id: sub.id } });
expect(fresh?.rupsKehadiranRegistry).toEqual([
{ nama: "BUDI SANTOSO", persentase: 60, kehadiran: true }, // honorific-stripped name match
{ nama: "SITI AMINAH", persentase: 40, kehadiran: false },
]);
});
it("deed-roster path still wins when the deed DOES restate shareholders (AKTA_PEMBUBARAN now accepted)", async () => {
const { sub, akta } = await seed({ type: "PEMBUBARAN_PT", deedPs: true });
const r = await matchRupsAttendance(sub.id, db);
expect(r.matched).toBe(1);
const ps = await db.aktaPemegangSaham.findFirst({ where: { documentId: akta.id } });
expect(ps?.kehadiranRups).toBe(true);
const fresh = await db.submission.findUnique({ where: { id: sub.id } });
expect(fresh?.rupsKehadiranRegistry).toBeNull(); // fallback did not fire
});
it("fallback is PEMBUBARAN_PT-scoped: a roster-less PERUBAHAN_PT never writes the JSON", async () => {
const { sub } = await seed({ type: "PERUBAHAN_PT", deedPs: false });
await matchRupsAttendance(sub.id, db);
const fresh = await db.submission.findUnique({ where: { id: sub.id } });
expect(fresh?.rupsKehadiranRegistry).toBeNull();
});
});
NOTE: classifiedType: "AKTA_PEMBUBARAN" seeds rely on the Task-3 matcher widening; the PERUBAHAN_PT case seeds the same classifiedType shape but its own type — the scope check is on submission.type. If aktaPemegangSaham/beritaAcaraRupsExtraction require extra non-null columns, add the minimal values the schema demands (check schema.prisma) without changing the assertions.
- [ ] Run to verify RED: cd backend && bun test src/services/__tests__/akta-pembubaran-deed-passes.test.ts src/services/__tests__/akta-kedua-transformer.test.ts src/services/__tests__/pembubaran-rups-fallback.test.ts. Expected: module-not-found / missing-mapping / fallback-not-written failures.
- [ ] Minimal implementation A — create backend/src/services/akta-pembubaran-deed-passes.ts:
/**
* PEMBUBARAN_PT hooks.afterAktaExtraction body (spec §4.3). ONE pass: the
* deed-INLINE koran fallback via the SHARED parameterized extractKoranFields
* (built in Peleburan — consumed here, never re-implemented; never broaden the
* prompt, pass docContext). Fires ONLY when zero BUKTI_PENGUMUMAN clippings are
* uploaded: the clipping pass (focusedPasses) is the better source, and skipping
* saves a 3B call + avoids stale deed values shadowing the clipping in review.
* IDEMPOTENT-ONCE + atomic createMany (engine resume never clobbers operator
* edits). Log-and-continue is enforced by the engine (processor.ts try/catch).
* NOTE: no source-refs pass here — pembubaran is SINGLE-company (companyLookup:
* true); the peleburan 35B sources pass is NOT a template for this flow.
*/
import { db } from "../lib/db";
import { extractKoranFields } from "./akta-koran-extract";
export async function runPembubaranDeedPasses(a: { submissionId: string; aktaDocumentId: string; rawText: string }): Promise<void> {
const { submissionId, aktaDocumentId, rawText } = a;
if (!rawText.trim()) {
console.warn(`[pembubaran-deed] no rawText for akta ${aktaDocumentId} — inline koran pass skipped (manual entry in the Koran card)`);
return;
}
const clippingCount = await db.submissionDocument.count({
where: { submissionId, document: { documentType: "BUKTI_PENGUMUMAN" } },
});
if (clippingCount > 0) return; // clipping wins — inline fallback only at zero clippings (spec §4.3)
const existing = await db.extractedField.findMany({
where: { documentId: aktaDocumentId, fieldKey: { in: ["nama_koran", "tanggal_pengumuman"] } },
select: { id: true },
});
if (existing.length > 0) return; // idempotent-once — operator edits survive resume
const koran = await extractKoranFields(rawText, { docContext: "akta pembubaran perseroan terbatas (likuidasi)" });
await db.extractedField.createMany({
data: [
{ documentId: aktaDocumentId, fieldKey: "nama_koran", value: koran.nama_koran, confidence: koran.nama_koran ? 0.75 : 0, status: koran.nama_koran ? "EDITABLE" : "FLAGGED" },
{ documentId: aktaDocumentId, fieldKey: "tanggal_pengumuman", value: koran.tanggal_koran, confidence: koran.tanggal_koran ? 0.75 : 0, status: koran.tanggal_koran ? "EDITABLE" : "FLAGGED" },
],
});
}
- [ ] Minimal implementation B —
backend/src/services/field-transformer.ts: append toPERUBAHAN_FIELD_MAPPING(after theref_pendirian_*block, ~line 520):
// Dual-deed block (AktaKedua, schema/akta-perubahan.ts:467) — populated for
// two-deed transactions (e.g. PEMBUBARAN_PT filed as akta pembubaran + BA/PKR
// RUPS second deed). The null-parent skip logic below emits ZERO rows when
// akta_kedua is null, so single-deed v1 perubahan/akuisisi extractions stay
// byte-identical (regression-tested in akta-kedua-transformer.test.ts).
akta_kedua_nomor: ["akta_kedua", "nomor_akta"],
akta_kedua_tanggal: ["akta_kedua", "tanggal_akta"],
akta_kedua_nama_notaris: ["akta_kedua", "nama_notaris"],
- [ ] Minimal implementation C —
backend/src/services/rups-attendance-matcher.ts:
1. Widen the akta filter:
const aktaDocIds = subDocs
.filter((d) => d.classifiedType === "AKTA_PERUBAHAN" || d.classifiedType === "AKTA" || d.classifiedType === "AKTA_PEMBUBARAN")
.map((d) => d.documentId);
- Insert the registry fallback AFTER the
attendanceByNamemap is built and BEFORE thefor (const ps of psRows)loop:
// Registry-roster fallback (spec §4.3 — the PRIMARY attendance path for
// PEMBUBARAN_PT): SABH's kehadiran checkboxes are against the REGISTRY
// shareholder list and a dissolution deed often restates no roster. When the
// deed carries no PS rows, match BA attendees against oldData.pemegang_saham
// and persist Submission.rupsKehadiranRegistry ([{ nama, persentase, kehadiran }]),
// consumed by buildPembubaranContext's quorum override and the Kehadiran card.
// SCOPED to PEMBUBARAN_PT so roster-less v1 flows never grow a stray JSON blob.
if (psRows.length === 0) {
const submission = await tx.submission.findUnique({
where: { id: submissionId },
select: { type: true, oldData: true },
});
if (submission?.type !== "PEMBUBARAN_PT") return { matched: 0, unmatched: [] };
const oldPs = ((submission.oldData as { pemegang_saham?: { nama?: string | null; persentase?: number | null }[] } | null)?.pemegang_saham ?? [])
.filter((p): p is { nama: string; persentase: number | null } => !!p.nama && p.nama.trim().length > 0);
if (oldPs.length === 0) return { matched: 0, unmatched: [] };
let regMatched = 0;
const regUnmatched: string[] = [];
const rows = oldPs.map((p) => {
const flag = attendanceByName.get(normalizeName(p.nama));
if (flag === undefined) {
regUnmatched.push(p.nama);
return { nama: p.nama, persentase: p.persentase ?? null, kehadiran: null };
}
regMatched++;
return { nama: p.nama, persentase: p.persentase ?? null, kehadiran: flag };
});
await tx.submission.update({
where: { id: submissionId },
data: { rupsKehadiranRegistry: rows as unknown as Prisma.InputJsonValue },
});
return { matched: regMatched, unmatched: regUnmatched };
}
(Prisma is already imported as a type — import type { PrismaClient, Prisma } from "../generated/prisma/client"; — the cast is type-only.)
- [ ] Run GREEN: cd backend && bun test src/services/__tests__/akta-pembubaran-deed-passes.test.ts src/services/__tests__/akta-kedua-transformer.test.ts src/services/__tests__/pembubaran-rups-fallback.test.ts. Then regression: bun test src/services/__tests__/field-transformer.test.ts src/services/__tests__/rups-attendance-matcher.test.ts 2>/dev/null; bun test src/services/__tests__ --timeout 60000 | tail -5 (existing matcher/transformer suites must stay green).
- [ ] Typecheck: cd backend && bunx tsc --noEmit.
- [ ] Commit: git add backend/src/services/akta-pembubaran-deed-passes.ts backend/src/services/field-transformer.ts backend/src/services/rups-attendance-matcher.ts backend/src/services/__tests__/akta-pembubaran-deed-passes.test.ts backend/src/services/__tests__/akta-kedua-transformer.test.ts backend/src/services/__tests__/pembubaran-rups-fallback.test.ts && git commit -m "feat(pembubaran): deed-inline koran hook + akta_kedua mappings + registry-roster attendance fallback"
Task 4: Context builder — buildPembubaranContext
PembubaranCtx extends the shared PtAktaCtx (which pre-resolves ALL registry IO — blokir state, notaris ID — so wrapped rules run sync). Adds: submissionId (the async PT_ALREADY_DISSOLVED rule needs self-exclusion), the 12 likuidator values (ExtractedField rows on the SURAT_LIKUIDATOR doc), the koran resolution (clipping-doc-first, deed-inline second — canonical keys, peleburan-context parity), and the registry-quorum override: when the deed carries no PS rows, rupsQuorum.pemegangSaham is rebuilt from Submission.rupsKehadiranRegistry so the shared quorum body evaluates the registry roster unchanged.
Files
- backend/src/flow-engine/context/pembubaran-context.ts — Create
- backend/src/flow-engine/__tests__/pembubaran-context.test.ts — Create
Interfaces
- Consumes: buildPtAktaContext(submissionId, { aktaClassifiedType: "AKTA_PEMBUBARAN" }); LIKUIDATOR_EXTRACTED_FIELD_KEYS (Task 2); Submission.rupsKehadiranRegistry (Task 1/3).
- Produces:
export interface PembubaranCtx extends PtAktaCtx {
submissionId: string;
likuidator: { hasDoc: boolean; documentId: string | null; fields: Record<string, string | null> }; // keyed by the 12 canonical fieldKeys
koran: { namaKoran: string | null; tanggalKoran: string | null; source: "clipping" | "akta" | null };
rupsRosterSource: "deed" | "registry" | null;
}
export async function buildPembubaranContext(submissionId: string): Promise<PembubaranCtx>;
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/pembubaran-context.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { buildPembubaranContext } from "../context/pembubaran-context";
describe("buildPembubaranContext", () => {
const subIds: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
await db.submission.deleteMany({ where: { id: { in: subIds } } });
await db.document.deleteMany({ where: { id: { in: docIds } } });
});
async function seed(opts: { likuidatorDoc?: boolean; clipping?: boolean; deedKoran?: boolean; registryKehadiran?: boolean } = {}) {
const sub = await db.submission.create({
data: {
type: "PEMBUBARAN_PT", status: "VALIDATING",
oldData: { perseroan: { nama: "PT MAJU MUNDUR", id_perseroan: 4242, status_transaksi: 1 } },
...(opts.registryKehadiran
? { rupsKehadiranRegistry: [
{ nama: "BUDI SANTOSO", persentase: 60, kehadiran: true },
{ nama: "SITI AMINAH", persentase: 40, kehadiran: false },
] }
: {}),
},
});
subIds.push(sub.id);
const akta = await db.document.create({
data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY" },
});
docIds.push(akta.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
});
await db.extractedField.createMany({
data: [
{ documentId: akta.id, fieldKey: "nama_perseroan", value: "PT MAJU MUNDUR", confidence: 0.9, status: "EDITABLE" },
{ documentId: akta.id, fieldKey: "tanggal_akta", value: "2026-04-30", confidence: 0.9, status: "EDITABLE" },
...(opts.deedKoran
? [
{ documentId: akta.id, fieldKey: "nama_koran", value: "Media Indonesia", confidence: 0.75, status: "EDITABLE" },
{ documentId: akta.id, fieldKey: "tanggal_pengumuman", value: "20 April 2026", confidence: 0.75, status: "EDITABLE" },
]
: []),
],
});
if (opts.likuidatorDoc) {
const lik = await db.document.create({
data: { filename: "spl.pdf", originalName: "spl.pdf", fileSize: 0, filePath: "/tmp/s.pdf", documentType: "SURAT_LIKUIDATOR", status: "VERIFICATION_READY" },
});
docIds.push(lik.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: lik.id, classifiedType: "SURAT_LIKUIDATOR", confidence: 1, processingOrder: 4, extractionStatus: "DONE" },
});
await db.extractedField.createMany({
data: [
{ documentId: lik.id, fieldKey: "likuidator_nama", value: "BUDI HARTONO, S.H.", confidence: 0.75, status: "EDITABLE" },
{ documentId: lik.id, fieldKey: "likuidator_telepon", value: "081234567890", confidence: 0.75, status: "EDITABLE" },
],
});
}
if (opts.clipping) {
const clip = await db.document.create({
data: { filename: "k.pdf", originalName: "k.pdf", fileSize: 0, filePath: "/tmp/k.pdf", documentType: "BUKTI_PENGUMUMAN", status: "VERIFICATION_READY" },
});
docIds.push(clip.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: clip.id, classifiedType: "BUKTI_PENGUMUMAN", confidence: 1, processingOrder: 6, extractionStatus: "DONE" },
});
await db.extractedField.createMany({
data: [
{ documentId: clip.id, fieldKey: "nama_koran", value: "Harian Kompas", confidence: 0.75, status: "EDITABLE" },
{ documentId: clip.id, fieldKey: "tanggal_pengumuman", value: "21 April 2026", confidence: 0.75, status: "EDITABLE" },
],
});
}
return sub;
}
it("exposes submissionId, likuidator fields (all 12 keys, absent → null) and hasDoc", async () => {
const sub = await seed({ likuidatorDoc: true });
const ctx = await buildPembubaranContext(sub.id);
expect(ctx.submissionId).toBe(sub.id);
expect(ctx.likuidator.hasDoc).toBe(true);
expect(ctx.likuidator.fields.likuidator_nama).toBe("BUDI HARTONO, S.H.");
expect(ctx.likuidator.fields.likuidator_email).toBeNull();
expect(Object.keys(ctx.likuidator.fields).length).toBe(12);
});
it("koran: clipping doc wins over deed-inline; deed-inline used when no clipping; null when neither", async () => {
const both = await seed({ clipping: true, deedKoran: true });
const c1 = await buildPembubaranContext(both.id);
expect(c1.koran).toEqual({ namaKoran: "Harian Kompas", tanggalKoran: "21 April 2026", source: "clipping" });
const deedOnly = await seed({ deedKoran: true });
const c2 = await buildPembubaranContext(deedOnly.id);
expect(c2.koran).toEqual({ namaKoran: "Media Indonesia", tanggalKoran: "20 April 2026", source: "akta" });
const neither = await seed({});
const c3 = await buildPembubaranContext(neither.id);
expect(c3.koran.source).toBeNull();
});
it("quorum override: deed restates no roster → rupsQuorum rebuilt from rupsKehadiranRegistry", async () => {
const sub = await seed({ registryKehadiran: true });
const ctx = await buildPembubaranContext(sub.id);
expect(ctx.rupsRosterSource).toBe("registry");
expect(ctx.rupsQuorum.pemegangSaham).toEqual([
{ persentase: 60, kehadiranRups: true },
{ persentase: 40, kehadiranRups: false },
]);
const bare = await seed({});
const ctxBare = await buildPembubaranContext(bare.id);
expect(ctxBare.rupsRosterSource).toBeNull();
expect(ctxBare.rupsQuorum.pemegangSaham).toEqual([]);
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/pembubaran-context.test.ts. - [ ] Minimal implementation — create
backend/src/flow-engine/context/pembubaran-context.ts:
import { db } from "../../lib/db";
import { buildPtAktaContext, type PtAktaCtx } from "./pt-akta-context";
import { LIKUIDATOR_EXTRACTED_FIELD_KEYS } from "../../services/surat-likuidator-extract";
export interface PembubaranCtx extends PtAktaCtx {
/** Needed by PT_ALREADY_DISSOLVED for self-exclusion (PtAktaCtx has no id). */
submissionId: string;
/** ExtractedField rows on the SURAT_LIKUIDATOR doc (spec §6, no satellite). */
likuidator: { hasDoc: boolean; documentId: string | null; fields: Record<string, string | null> };
/** Clipping (BUKTI_PENGUMUMAN doc) preferred; deed-inline akta fields as fallback
* (canonical keys — peleburan-context parity). */
koran: { namaKoran: string | null; tanggalKoran: string | null; source: "clipping" | "akta" | null };
/** Where the quorum roster came from. "registry" = the rupsKehadiranRegistry
* fallback (the PRIMARY pembubaran path — SABH checkboxes are registry-based). */
rupsRosterSource: "deed" | "registry" | null;
}
export async function buildPembubaranContext(submissionId: string): Promise<PembubaranCtx> {
const base = await buildPtAktaContext(submissionId, { aktaClassifiedType: "AKTA_PEMBUBARAN" });
// Likuidator — the 12 canonical ExtractedField rows.
const likSubDoc = await db.submissionDocument.findFirst({
where: { submissionId, document: { documentType: "SURAT_LIKUIDATOR" } },
select: {
documentId: true,
document: { select: { fields: { where: { fieldKey: { in: [...LIKUIDATOR_EXTRACTED_FIELD_KEYS] } } } } },
},
});
const likFields = Object.fromEntries(
LIKUIDATOR_EXTRACTED_FIELD_KEYS.map((k) => [k, likSubDoc?.document.fields.find((f) => f.fieldKey === k)?.value ?? null]),
) as Record<string, string | null>;
const likuidator = { hasDoc: !!likSubDoc, documentId: likSubDoc?.documentId ?? null, fields: likFields };
// Koran — clipping doc first, deed-inline second (canonical keys, spec §4.3).
const subDocs = await db.submissionDocument.findMany({
where: { submissionId },
select: {
documentId: true, classifiedType: true,
document: { select: { documentType: true, fields: { where: { fieldKey: { in: ["nama_koran", "tanggal_pengumuman"] } } } } },
},
});
const pick = (match: (d: (typeof subDocs)[number]) => boolean): { namaKoran: string | null; tanggalKoran: string | null } | null => {
for (const d of subDocs) {
if (!match(d)) continue;
const nama = d.document.fields.find((f) => f.fieldKey === "nama_koran")?.value ?? null;
const tanggal = d.document.fields.find((f) => f.fieldKey === "tanggal_pengumuman")?.value ?? null;
if (nama || tanggal) return { namaKoran: nama, tanggalKoran: tanggal };
}
return null;
};
const clipping = pick((d) => d.document.documentType === "BUKTI_PENGUMUMAN");
const inline = clipping ? null : pick((d) => d.classifiedType === "AKTA_PEMBUBARAN");
const koran = clipping
? { ...clipping, source: "clipping" as const }
: inline
? { ...inline, source: "akta" as const }
: { namaKoran: null, tanggalKoran: null, source: null };
// Quorum roster override — deed PS rows when present; else the registry
// fallback JSON, mapped 1:1 into the SAME shape the shared quorum body reads
// (validateRupsAttendanceQuorum is unchanged; only its input roster differs).
let rupsRosterSource: PembubaranCtx["rupsRosterSource"] = base.rupsQuorum.pemegangSaham.length > 0 ? "deed" : null;
if (base.rupsQuorum.pemegangSaham.length === 0) {
const sub = await db.submission.findUnique({ where: { id: submissionId }, select: { rupsKehadiranRegistry: true } });
const rows = (sub?.rupsKehadiranRegistry as { nama?: string; persentase?: number | null; kehadiran?: boolean | null }[] | null) ?? [];
if (rows.length > 0) {
base.rupsQuorum = {
pemegangSaham: rows.map((r) => ({ persentase: r.persentase ?? null, kehadiranRups: r.kehadiran ?? null })),
hasBaRupsDoc: base.rupsQuorum.hasBaRupsDoc,
};
rupsRosterSource = "registry";
}
}
return { ...base, submissionId, likuidator, koran, rupsRosterSource };
}
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/pembubaran-context.test.ts. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/flow-engine/context/pembubaran-context.ts backend/src/flow-engine/__tests__/pembubaran-context.test.ts && git commit -m "feat(pembubaran): PembubaranCtx context builder (likuidator + koran + registry-quorum override)"
Task 5: Rules — the ≥-comparator quorum param + PEMBUBARAN_RULE_SET (the tuned matrix)
Two deliverables. (1) The inherited deferred trigger: rupsQuorumRule gains comparator?: ">" | ">=" — the wrapper currently encodes > semantics via ratio <= threshold → WARNING; UUPT Ps.89(1) "paling sedikit 3/4" means exactly 75% attendance is COMPLIANT, so the ">=" mode warns only on ratio < threshold. Wrapper param only; validateRupsAttendanceQuorum's body is untouched; default ">" preserves every existing consumer bit-for-bit. (2) The tuned matrix: 13 wrapped shared codes + the ¾-quorum wrapper + 4 flow rules. Severity discipline per Global Constraints (PEMBUBARAN_KORAN WARNING-NEVER-FAIL with a structural severityFloor pin; LIKUIDATOR_SURAT_COMPLETE names every missing key — warning-2 discipline).
Files
- backend/src/flow-engine/rules/pt-akta/rups-quorum.ts — Modify
- backend/src/flow-engine/rules/pembubaran/index.ts — Create
- backend/src/flow-engine/__tests__/rules-pembubaran.test.ts — Create
Interfaces
- Produces: rupsQuorumRule(opts?: { quorumThreshold?: number; comparator?: ">" | ">=" }); PEMBUBARAN_SHARED_RULE_CODES; PEMBUBARAN_RULE_SET: Rule<PembubaranCtx>[] (18 rules); exported rule objects ptAlreadyDissolvedRule, likuidatorSuratCompleteRule, likuidatorKtpMatchRule, pembubaranKoranRule.
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/rules-pembubaran.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { rupsQuorumRule } from "../rules/pt-akta/rups-quorum";
import {
PEMBUBARAN_RULE_SET, PEMBUBARAN_SHARED_RULE_CODES,
ptAlreadyDissolvedRule, likuidatorSuratCompleteRule, likuidatorKtpMatchRule, pembubaranKoranRule,
} from "../rules/pembubaran/index";
import { LIKUIDATOR_EXTRACTED_FIELD_KEYS } from "../../services/surat-likuidator-extract";
import type { PembubaranCtx } from "../context/pembubaran-context";
/** Minimal ctx factory — only the fields the rules under test read. */
function makeCtx(overrides: Partial<PembubaranCtx> = {}): PembubaranCtx {
return {
submissionId: "ctx-sub",
input: {
identityMatches: [], aktaPenghadap: [], ktpFields: new Map(), npwpFields: new Map(),
aktaModalDisetor: null, buktiSetorAmounts: [], aktaKedudukan: null, aktaTempatKedudukan: null,
checklistItems: [], aktaModalDasar: null, aktaJenisPerseroan: null, aktaStatusPerseroan: null,
kbliCodes: [], aktaNamaPerseroan: "PT MAJU MUNDUR", domisiliNamaEntitas: null, buktiSetorEntityName: null,
contactInfoEntityName: null, pemegangSahamPersentase: [], identityMatchContacts: [], knownPeopleNames: [],
aktaNewPengurus: [], contactInfoEntries: [], uploadedNpwpNames: [], isPerubahan: true,
oldData: { modal_dasar: null, modal_ditempatkan: null, nama: "PT MAJU MUNDUR", id_perseroan: 4242, status_transaksi: 1, id_notaris: null },
perseroanBlokir: null, aktaNotarisId: null, perubahanModalBaru: null, perubahanModalLama: null,
perubahanKbliCodes: [], changeTypeChecked: null, selectedJenisPerubahan: null, effectiveJenis: null,
perubahanNewValues: { nama: null, tempatKedudukan: null, statusPerseroan: null, jenisPerseroan: null, jangkaWaktu: null },
peralihanSaham: [], aktaJumlahSahamModalDitempatkan: null, pemegangSahamLembarTotal: null,
uploadedDocTypes: [], aktaTanggal: "2026-04-30", tanggalKoran: null, aktaPemegangSaham: [],
buktiSetorEntries: [], namaAkta: "PT MAJU MUNDUR", npwpPerseroanNama: null,
aktaPeopleWna: [], passportExtractions: [], kitasExtractions: [],
} as PembubaranCtx["input"],
rupsQuorum: { pemegangSaham: [], hasBaRupsDoc: false },
aktaFields: [],
likuidator: {
hasDoc: true, documentId: "doc-lik",
fields: Object.fromEntries(LIKUIDATOR_EXTRACTED_FIELD_KEYS.map((k) => [k, null])) as Record<string, string | null>,
},
koran: { namaKoran: null, tanggalKoran: null, source: null },
rupsRosterSource: null,
...overrides,
} as PembubaranCtx;
}
const asOne = (r: unknown) => (Array.isArray(r) ? r[0] : r) as { status: string; message: string };
describe("rupsQuorumRule ≥-comparator (Ps.89(1) 'paling sedikit 3/4')", () => {
const roster = (present: number) => ({
pemegangSaham: [
{ persentase: present, kehadiranRups: true },
{ persentase: 100 - present, kehadiranRups: false },
],
hasBaRupsDoc: true,
});
it('">=" 3/4: exactly 75% PASSes; 74% WARNs with the ≥75% message', async () => {
const rule = rupsQuorumRule({ quorumThreshold: 3 / 4, comparator: ">=" });
const pass = asOne(await rule.run(makeCtx({ rupsQuorum: roster(75) })));
expect(pass.status).toBe("PASS");
const warn = asOne(await rule.run(makeCtx({ rupsQuorum: roster(74) })));
expect(warn.status).toBe("WARNING");
expect(warn.message).toContain("≥75%");
});
it('default ">" semantics preserved: threshold 3/4 without comparator WARNs at exactly 75%', async () => {
const rule = rupsQuorumRule({ quorumThreshold: 3 / 4 });
const warn = asOne(await rule.run(makeCtx({ rupsQuorum: roster(75) })));
expect(warn.status).toBe("WARNING");
expect(warn.message).toContain(">75%");
});
it("no roster at all → SKIPPED (body semantics untouched)", async () => {
const rule = rupsQuorumRule({ quorumThreshold: 3 / 4, comparator: ">=" });
expect(asOne(await rule.run(makeCtx())).status).toBe("SKIPPED");
});
});
describe("PT_ALREADY_DISSOLVED (FAIL — local double-dissolution guard)", () => {
const ids: string[] = [];
afterAll(async () => { await db.submission.deleteMany({ where: { id: { in: ids } } }); });
it("FAILs on a seeded COMPLETED PEMBUBARAN_PT for the same id_perseroan; PASSes otherwise; exact-spelling vs PEMBUBARAN_PP", async () => {
const prior = await db.submission.create({
data: { type: "PEMBUBARAN_PT", status: "COMPLETED", oldData: { perseroan: { id_perseroan: 4242 } } },
});
ids.push(prior.id);
const ppNoise = await db.submission.create({
data: { type: "PEMBUBARAN_PP", status: "COMPLETED", oldData: { perseroan: { id_perseroan: 777 } } },
});
ids.push(ppNoise.id);
expect(asOne(await ptAlreadyDissolvedRule.run(makeCtx())).status).toBe("FAIL"); // ctx id_perseroan = 4242
const other = makeCtx();
(other.input.oldData as { id_perseroan: number }).id_perseroan = 777; // only the PP row has 777 → must PASS
expect(asOne(await ptAlreadyDissolvedRule.run(other)).status).toBe("PASS");
// self-exclusion: the prior submission evaluating ITSELF must not self-FAIL
const self = makeCtx({ submissionId: prior.id });
expect(asOne(await ptAlreadyDissolvedRule.run(self)).status).toBe("PASS");
// no oldData → SKIPPED
const noOld = makeCtx();
(noOld.input as { oldData: null }).oldData = null;
expect(asOne(await ptAlreadyDissolvedRule.run(noOld)).status).toBe("SKIPPED");
});
});
describe("LIKUIDATOR_SURAT_COMPLETE (WARNING naming the missing keys)", () => {
it("WARNs listing every missing fieldKey; PASSes when all 12 filled; SKIPs without the doc", async () => {
const partial = makeCtx();
partial.likuidator.fields.likuidator_nama = "BUDI HARTONO";
const warn = asOne(await likuidatorSuratCompleteRule.run(partial));
expect(warn.status).toBe("WARNING");
expect(warn.message).toContain("likuidator_telepon"); // names the unchecked (warning-2 discipline)
const full = makeCtx();
for (const k of LIKUIDATOR_EXTRACTED_FIELD_KEYS) full.likuidator.fields[k] = "x";
expect(asOne(await likuidatorSuratCompleteRule.run(full)).status).toBe("PASS");
const noDoc = makeCtx({ likuidator: { hasDoc: false, documentId: null, fields: makeCtx().likuidator.fields } });
expect(asOne(await likuidatorSuratCompleteRule.run(noDoc)).status).toBe("SKIPPED");
expect(likuidatorSuratCompleteRule.severityFloor).toBe("WARNING");
});
});
describe("LIKUIDATOR_KTP_MATCH (FAIL contradiction / WARNING absence / PASS incl. pengurus-doubling)", () => {
it("scores the WHOLE KTP pool", async () => {
const base = makeCtx();
base.likuidator.fields.likuidator_nama = "BUDI HARTONO";
// absence: no KTPs at all → WARNING
expect(asOne(await likuidatorKtpMatchRule.run(base)).status).toBe("WARNING");
// pengurus-doubling: the pengurus KTP matches the likuidator → PASS
const doubling = makeCtx();
doubling.likuidator.fields.likuidator_nama = "BUDI HARTONO";
doubling.input.ktpFields = new Map([["ktp-1", { nama: "BUDI HARTONO", nik: "3171000000000001" }]]);
expect(asOne(await likuidatorKtpMatchRule.run(doubling)).status).toBe("PASS");
// contradiction: plausible-but-conflicting best match (shares one token) → FAIL
const conflict = makeCtx();
conflict.likuidator.fields.likuidator_nama = "BUDI HARTONO";
conflict.input.ktpFields = new Map([["ktp-1", { nama: "BUDI SANTOSO", nik: "3171000000000002" }]]);
expect(asOne(await likuidatorKtpMatchRule.run(conflict)).status).toBe("FAIL");
// unreadable likuidator name → SKIPPED
expect(asOne(await likuidatorKtpMatchRule.run(makeCtx())).status).toBe("SKIPPED");
});
});
describe("PEMBUBARAN_KORAN (WARNING — NEVER FAIL; Ps.147(1) post-deed obligation)", () => {
it("absent → WARNING; in-window → PASS; out-of-window → WARNING; unparseable → WARNING; floor pinned", async () => {
expect(pembubaranKoranRule.severityFloor).toBe("WARNING"); // structural never-FAIL pin
expect(asOne(await pembubaranKoranRule.run(makeCtx())).status).toBe("WARNING"); // no koran entry
const inWindow = makeCtx({ koran: { namaKoran: "Media Indonesia", tanggalKoran: "2026-05-15", source: "akta" } });
expect(asOne(await pembubaranKoranRule.run(inWindow)).status).toBe("PASS"); // akta 2026-04-30 + 15d
const late = makeCtx({ koran: { namaKoran: "Media Indonesia", tanggalKoran: "2026-06-15", source: "clipping" } });
const lateR = asOne(await pembubaranKoranRule.run(late));
expect(lateR.status).toBe("WARNING"); // > 30d after the deed — non-compliance signal, still not our gate
const garbled = makeCtx({ koran: { namaKoran: "Media Indonesia", tanggalKoran: "tanggal tidak terbaca", source: "clipping" } });
const g = asOne(await pembubaranKoranRule.run(garbled));
expect(g.status).toBe("WARNING"); // present-but-unverifiable — WARN naming it, never a silent PASS
});
});
describe("PEMBUBARAN_RULE_SET composition (tuned matrix — omission-by-config)", () => {
it("contains exactly the tuned set; cap-table/jenis/doc-usage rules are ABSENT", () => {
const codes = PEMBUBARAN_RULE_SET.map((r) => r.code);
expect(new Set(codes).size).toBe(codes.length);
expect(codes.length).toBe(18);
for (const c of ["PT_ALREADY_DISSOLVED", "LIKUIDATOR_SURAT_COMPLETE", "LIKUIDATOR_KTP_MATCH", "PEMBUBARAN_KORAN", "RUPS_ATTENDANCE_QUORUM"]) {
expect(codes).toContain(c);
}
for (const c of PEMBUBARAN_SHARED_RULE_CODES) expect(codes).toContain(c);
for (const excluded of [
"MODAL_BUKTI_SETOR", "PEMEGANG_SAHAM_BUKTI_SETOR", "PP29_MODAL", "KBLI_VALID", "PERUBAHAN_KBLI",
"PERUBAHAN_MODAL", "SHARES_TRANSFER_BALANCE", "SHARES_TOTAL_LEMBAR", "SHARES_SUM_CONSISTENCY",
"MODAL_HIERARCHY", "NOMINAL_RECONCILE", "PS_TOTAL_100", "PEMEGANG_SAHAM_CONTACT",
"DATA_ACTUALLY_CHANGED", "JENIS_SELECTION_NONEMPTY", "JENIS_DESELECT_REASONS_COMPLETE",
"CONTRADICTORY_JENIS", "REQUIRED_SUPPORTING_DOCS", "UPLOADED_DOC_UNUSED",
]) {
expect(codes).not.toContain(excluded);
}
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/rules-pembubaran.test.ts. - [ ] Minimal implementation A —
backend/src/flow-engine/rules/pt-akta/rups-quorum.ts: parameterize the comparator (body untouched):
export function rupsQuorumRule(opts?: { quorumThreshold?: number; comparator?: ">" | ">=" }): Rule<PtAktaCtx> {
const threshold = opts?.quorumThreshold ?? 0.5;
const comparator = opts?.comparator ?? ">";
and inside the re-tier block replace if (ratio <= threshold) { with:
// ">" — quorum only ABOVE threshold (Ps.86(1) "lebih dari 1/2" class).
// ">=" — quorum AT threshold is compliant (Ps.89(1) "paling sedikit 3/4":
// exactly 75% attendance satisfies it — Pembubaran's consumer).
const belowQuorum = comparator === ">=" ? ratio < threshold : ratio <= threshold;
if (belowQuorum) {
and the message line becomes:
message = `Kehadiran RUPS ${present.toFixed(2)}% dari total ${total.toFixed(2)}% (kuorum legal ${comparator === ">=" ? "≥" : ">"}${pct}%)`;
Also update the doc comment's "Pembubaran passes 3/4 (Ps.89(1))" sentence to note it passes { quorumThreshold: 3/4, comparator: ">=" }.
- [ ] Minimal implementation B — create backend/src/flow-engine/rules/pembubaran/index.ts:
import { db } from "../../../lib/db";
import type { Rule, RuleResult } from "../../types";
import type { PembubaranCtx } from "../../context/pembubaran-context";
import { LIKUIDATOR_EXTRACTED_FIELD_KEYS } from "../../../services/surat-likuidator-extract";
import { PT_AKTA_RULE_SET, pickRules } from "../pt-akta/index";
import { rupsQuorumRule } from "../pt-akta/rups-quorum";
import { parseFlexibleDate } from "../laporan-rups/index";
import { normalizeName, tokenScore } from "../../../services/identity-matcher";
const rr = (ruleCode: string, ruleLabel: string, status: RuleResult["status"], message: string, details?: unknown): RuleResult =>
({ ruleCode, ruleLabel, status, message, ...(details !== undefined ? { details: details as never } : {}) });
/** FAIL — PoC-local double-dissolution guard (async: ONE local-DB lookup, so the
* runner's Promise.all partition absorbs it). A COMPLETED PEMBUBARAN_PT for the
* same oldData.perseroan.id_perseroan means the dissolution is already FILED —
* COMPLETED persists through the verifikator stages, so a second filing while the
* first sits in the queue is equally a double-dissolution (PEMBUBARAN_PP precedent
* pp-pembubaran.ts:475; the submission IS the local dissolution record — no
* onFinalize hook needed, nothing invokes hooks.onFinalize today). Complements
* PERSEROAN_STATE, which only sees SABH's (laggier) view. Exact-equality type
* match — NEVER substring (PEMBUBARAN_PP collision). */
export const ptAlreadyDissolvedRule: Rule<PembubaranCtx> = {
code: "PT_ALREADY_DISSOLVED",
label: "PT Belum Pernah Dibubarkan (Registri Lokal)",
async: true,
run: async (ctx) => {
const L = "PT Belum Pernah Dibubarkan (Registri Lokal)";
const idPerseroan = ctx.input.oldData?.id_perseroan ?? null;
if (idPerseroan == null)
return rr("PT_ALREADY_DISSOLVED", L, "SKIPPED", "Tidak ada data SABH (id_perseroan) untuk diperiksa");
const prior = await db.submission.findFirst({
where: {
type: "PEMBUBARAN_PT",
status: "COMPLETED",
oldData: { path: ["perseroan", "id_perseroan"], equals: idPerseroan },
NOT: { id: ctx.submissionId },
},
select: { id: true },
});
if (prior)
return rr("PT_ALREADY_DISSOLVED", L, "FAIL",
`Pembubaran untuk PT ini sudah pernah diajukan (submission ${prior.id}) — pembubaran ganda tidak diizinkan`,
{ priorSubmissionId: prior.id });
return rr("PT_ALREADY_DISSOLVED", L, "PASS", "Belum ada pembubaran tercatat untuk PT ini");
},
};
/** WARNING — NEVER FAIL. Completeness of the 12 likuidator fields, NAMING every
* missing key (whole-flow-review warning 2: partially-verified sets must WARN
* naming the unchecked, never a clean PASS/silent SKIP). WARNING because OCR
* gaps are completable in review — telepon/email are often absent from the
* letter and SABH's wizard has them hand-typed today too. */
export const likuidatorSuratCompleteRule: Rule<PembubaranCtx> = {
code: "LIKUIDATOR_SURAT_COMPLETE",
label: "Kelengkapan Data Likuidator",
severityFloor: "WARNING",
run: (ctx) => {
const L = "Kelengkapan Data Likuidator";
if (!ctx.likuidator.hasDoc)
return rr("LIKUIDATOR_SURAT_COMPLETE", L, "SKIPPED", "Surat penunjukan likuidator belum diunggah");
const missing = LIKUIDATOR_EXTRACTED_FIELD_KEYS.filter((k) => !(ctx.likuidator.fields[k] ?? "").trim());
if (missing.length > 0)
return rr("LIKUIDATOR_SURAT_COMPLETE", L, "WARNING",
`Data likuidator belum lengkap: ${missing.join(", ")} — lengkapi pada kartu Likuidator`, { missing });
return rr("LIKUIDATOR_SURAT_COMPLETE", L, "PASS", "Seluruh 12 field likuidator terisi");
},
};
/** FAIL on contradiction / WARNING on absence. likuidator_nama is scored against
* the WHOLE KTP pool with the identity-matcher scorer (normalizeName+tokenScore —
* the same tiers matchKtpToPeople uses: AUTO ≥0.8, plausible band ≥0.5):
* best ≥ 0.8 → PASS (incl. pengurus-doubling: an already-matched
* pengurus KTP serves the likuidator too — spec open-
* question 4 resolution: accepted).
* 0.5 ≤ best < 0.8 → FAIL: a plausible-but-conflicting near-miss is more
* likely the wrong person / a bad OCR read than a
* coincidence — surface it hard; override exists.
* best < 0.5 / no KTPs → WARNING "KTP likuidator belum diunggah" (absence is
* reviewable, not a hard defect). */
export const likuidatorKtpMatchRule: Rule<PembubaranCtx> = {
code: "LIKUIDATOR_KTP_MATCH",
label: "KTP Likuidator Sesuai Surat Penunjukan",
run: (ctx) => {
const L = "KTP Likuidator Sesuai Surat Penunjukan";
const nama = ctx.likuidator.fields.likuidator_nama;
if (!nama?.trim())
return rr("LIKUIDATOR_KTP_MATCH", L, "SKIPPED", "Nama likuidator belum terbaca dari surat penunjukan");
const likTokens = normalizeName(nama);
let best: { nama: string; score: number } | null = null;
for (const [, ktp] of ctx.input.ktpFields) {
if (!ktp.nama) continue;
const score = tokenScore(likTokens, normalizeName(ktp.nama));
if (!best || score > best.score) best = { nama: ktp.nama, score };
}
if (!best || best.score < 0.5)
return rr("LIKUIDATOR_KTP_MATCH", L, "WARNING",
`KTP likuidator "${nama}" belum diunggah — likuidator mungkin pengurus yang KTP-nya sudah cocok; mohon verifikasi manual`,
{ bestScore: best?.score ?? 0 });
if (best.score < 0.8)
return rr("LIKUIDATOR_KTP_MATCH", L, "FAIL",
`KTP terdekat ("${best.nama}") tidak cocok penuh dengan nama likuidator "${nama}" (skor ${(best.score * 100).toFixed(0)}%) — kemungkinan salah orang atau salah baca OCR`,
{ bestNama: best.nama, bestScore: best.score });
return rr("LIKUIDATOR_KTP_MATCH", L, "PASS",
`KTP atas nama "${best.nama}" cocok dengan likuidator (skor ${(best.score * 100).toFixed(0)}%)`);
},
};
/** WARNING — NEVER FAIL (severityFloor clamp + structural test). UUPT Ps.147(1):
* the LIKUIDATOR must announce the dissolution in a newspaper (and Berita Negara)
* within 30 days counted FROM the dissolution — a POST-DEED obligation, so at
* filing time the announcement may legitimately not exist yet. Do NOT copy
* PELEBURAN_KORAN_WINDOW's FAIL: that rule enforces Ps.127(2) PRE-RUPS notice, a
* different statute with opposite timing. Tiers: absent → WARNING; present but a
* date unreadable → WARNING naming it (warning-2 discipline — never a silent
* PASS); > deed+30d → WARNING (likuidator non-compliance signal, still not our
* gate); before the deed → WARNING (suspicious — likely a mis-filed RUPS-summons
* clipping); within [deed, deed+30d] → PASS. Advisory precedent:
* AKUISISI_KORAN_WINDOW / project_pp_nik_age_advisory. */
export const pembubaranKoranRule: Rule<PembubaranCtx> = {
code: "PEMBUBARAN_KORAN",
label: "Pengumuman Koran Pembubaran (Ps.147)",
severityFloor: "WARNING",
run: (ctx) => {
const L = "Pengumuman Koran Pembubaran (Ps.147)";
if (!ctx.koran.namaKoran && !ctx.koran.tanggalKoran)
return rr("PEMBUBARAN_KORAN", L, "WARNING",
"Belum ada bukti/keterangan pengumuman koran — Ps.147(1): kewajiban likuidator dalam 30 hari SEJAK pembubaran (boleh menyusul)");
const koran = parseFlexibleDate(ctx.koran.tanggalKoran);
const akta = parseFlexibleDate(ctx.input.aktaTanggal);
if (!koran || !akta)
return rr("PEMBUBARAN_KORAN", L, "WARNING",
`Jendela 30 hari Ps.147 tidak dapat diverifikasi — ${!koran ? "tanggal pengumuman" : "tanggal akta"} tidak terbaca (perbaiki pada kartu Pengumuman Koran)`);
const diffDays = Math.round((koran.getTime() - akta.getTime()) / 86_400_000);
if (diffDays < 0)
return rr("PEMBUBARAN_KORAN", L, "WARNING",
`Pengumuman (${ctx.koran.tanggalKoran}) MENDAHULUI tanggal akta pembubaran — periksa apakah kliping yang diunggah adalah pengumuman Ps.147`, { diffDays });
if (diffDays > 30)
return rr("PEMBUBARAN_KORAN", L, "WARNING",
`Pengumuman ${diffDays} hari setelah akta — melewati jendela 30 hari Ps.147(1) (sinyal keterlambatan likuidator, bukan penghalang pengajuan)`, { diffDays });
return rr("PEMBUBARAN_KORAN", L, "PASS", `Pengumuman ${diffDays} hari setelah akta pembubaran (≤30, Ps.147(1))`);
},
};
// ── the assembled tuned matrix ────────────────────────────────────────────────
/** Wrapped shared rules (bodies bit-for-bit in cross-validator.ts). DELIBERATE
* EXCLUSIONS (do not "fix"): the whole cap-table/modal/KBLI group + the jenis
* machinery (a dissolution touches no cap table and selects no jenis) AND —
* adjudicated deviation from the spec §4.6 matrix — REQUIRED_SUPPORTING_DOCS +
* UPLOADED_DOC_UNUSED: their bodies are JENIS-scoped (cross-validator.ts:1235/
* :1742 key doc usage off effectiveJenis.nonpad), so for a jenis-less flow they
* emit noise-SKIPs at best and a FALSE WARNING at worst (a pembubaran's BA RUPS
* would be flagged "unused"). The required-doc surface is the ?type=pembubaran
* board set + LIKUIDATOR_SURAT_COMPLETE / PEMBUBARAN_KORAN presence tiers
* (peleburan-precedent exclusion class). Omission-by-config, guard-tested. */
export const PEMBUBARAN_SHARED_RULE_CODES = [
"NIK_KTP_AKTA", "NAMA_NPWP_KTP", "KTP_COMPLETENESS", "NPWP_COMPLETENESS",
"NAMA_PT_CONSISTENCY", "DOMISILI_NAMA", "NPWP_PERSEROAN_FUZZY",
"CONTACT_INFO_COMPLETE", "PASSPORT_AKTA", "AKTA_DATE_WINDOW",
"OLD_DATA_CONSISTENCY", "PERSEROAN_STATE", "NOTARIS_TERAKHIR",
] as const;
export const PEMBUBARAN_RULE_SET: Rule<PembubaranCtx>[] = [
ptAlreadyDissolvedRule,
likuidatorSuratCompleteRule,
likuidatorKtpMatchRule,
pembubaranKoranRule,
// Ps.89(1) "paling sedikit 3/4" — the ≥ comparator (inherited deferred trigger);
// WARNING tier by body design: attendance is heuristic name-matching, never a hard block.
rupsQuorumRule({ quorumThreshold: 3 / 4, comparator: ">=" }),
...pickRules(PT_AKTA_RULE_SET, PEMBUBARAN_SHARED_RULE_CODES),
];
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/rules-pembubaran.test.ts. Then regression on the shared wrapper's existing consumers:bun test src/flow-engine/__tests__/rules-laporan-rups.test.ts src/flow-engine/__tests__/rules-pt-akta.test.ts src/flow-engine/__tests__/wrapper-fidelity.test.ts. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/flow-engine/rules/pt-akta/rups-quorum.ts backend/src/flow-engine/rules/pembubaran/index.ts backend/src/flow-engine/__tests__/rules-pembubaran.test.ts && git commit -m "feat(pembubaran): PEMBUBARAN_RULE_SET tuned matrix + rups-quorum >= comparator (Ps.89(1))"
Task 6: The real FlowConfig — registry entry + routeFamily union + subtype label row + dispatch/rematch proof
Registers pembubaranPtFlow (replacing the Task-1 stub), widens the RouteFamily union with "pembubaran" — an adjudicated DEVIATION from spec §4.4's routeFamily: 'perubahan': the spec predates the landed template, where every v2 tenant owns a thin route family (laporan-rups, peleburan) and the v1 routes/perubahan.ts fork stays untouched (Global Constraints "do not touch v1 forks"; the only perubahan route this flow uses — select-company — is type-agnostic and needs NO family membership. PERUBAHAN_FAMILY in perubahan.ts:33 is NOT widened). Adds the defensive AKTA_SUBTYPE_MAP label row (engine passes the raw subtype "perubahan", resolved by the identity rows; the label row guards direct-label calls — akuisisi precedent). Proves dispatch + the rematch wipe-class (reference_rematch_dispatch): a field edit must re-validate, not wipe.
Files
- backend/src/flow-engine/flows/pembubaran-pt.ts — Create
- backend/src/flow-engine/types.ts — Modify (RouteFamily += "pembubaran")
- backend/src/flow-engine/registry.ts — Modify (stub → real entry)
- backend/src/ocr/classifier.ts — Modify (label row)
- backend/src/flow-engine/__tests__/registry.test.ts — Modify (V2_SHIPPED += "PEMBUBARAN_PT")
- backend/src/flow-engine/__tests__/pembubaran-flow.test.ts — Create
Interfaces
- Produces: pembubaranPtFlow: FlowConfig<PembubaranCtx>; FLOW_REGISTRY.PEMBUBARAN_PT = pembubaranPtFlow; typesForRouteFamily("pembubaran") === ["PEMBUBARAN_PT"].
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/pembubaran-flow.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { FLOW_REGISTRY, flowFor, typesForRouteFamily } from "../registry";
import { pembubaranPtFlow } from "../flows/pembubaran-pt";
import { queueReMatchAndValidate } from "../../services/submission-processor";
describe("pembubaranPtFlow — THIRD v2-generic registry tenant", () => {
it("is registered (stub replaced) with the binding config shape", () => {
expect(FLOW_REGISTRY.PEMBUBARAN_PT).toBe(pembubaranPtFlow);
expect(pembubaranPtFlow.engine).toBe("v2-generic");
expect(pembubaranPtFlow.primaryAkta?.classifiedType).toBe("AKTA_PEMBUBARAN");
expect(pembubaranPtFlow.extraction.overrideByClassifiedType).toEqual({ AKTA_PEMBUBARAN: "perubahan" });
expect(pembubaranPtFlow.extraction.focusedPasses).toEqual({
SURAT_LIKUIDATOR: "surat-likuidator",
BUKTI_PENGUMUMAN: "bukti-pengumuman", // SHARED — one map entry, never re-implemented
});
expect(pembubaranPtFlow.companyLookup).toBe(true); // SINGLE-source — warning 4
expect(pembubaranPtFlow.requiredDocs).toEqual(["AKTA", "SURAT_LIKUIDATOR", "KTP", "NPWP", "DOMISILI", "DATA_KONTAK"]);
expect(pembubaranPtFlow.rematch).toEqual({
checklistResetDocTypes: ["KTP", "NPWP", "SURAT_PERNYATAAN_DOMISILI"],
standardPtMatchers: true,
rupsAttendance: true,
});
expect(pembubaranPtFlow.routeFamily).toBe("pembubaran");
expect(typesForRouteFamily("pembubaran")).toEqual(["PEMBUBARAN_PT"]);
// block-with-override gate stays ON — explicit default pin (ledger minor: not.toBe('off') is weak)
expect(pembubaranPtFlow.failGate).toBeUndefined();
expect(pembubaranPtFlow.hooks?.mapChangeTypes).toBeUndefined(); // no jenis machinery by design
expect(pembubaranPtFlow.hooks?.onFinalize).toBeUndefined(); // COMPLETED submissions ARE the local record
});
it("declares the 6 required approvable sections (submit-gate source of truth)", () => {
const required = pembubaranPtFlow.sections.filter((s) => s.approvable && s.required).map((s) => s.key);
expect(required).toEqual(["perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak"]);
const koran = pembubaranPtFlow.sections.find((s) => s.key === "pengumuman_koran");
expect(koran?.approvable).toBe(true);
expect(koran?.required).toBe(false); // advisory-only content (Ps.147)
});
});
describe("dispatch + rematch wipe-class proof (reference_rematch_dispatch)", () => {
const ids: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
await db.submission.deleteMany({ where: { id: { in: ids } } });
await db.document.deleteMany({ where: { id: { in: docIds } } });
});
it("flowFor routes v2-generic; a queued re-match re-validates instead of wiping", async () => {
expect(flowFor("PEMBUBARAN_PT").engine).toBe("v2-generic");
const sub = await db.submission.create({
data: { type: "PEMBUBARAN_PT", status: "READY", oldData: { perseroan: { nama: "PT X", id_perseroan: 991 } } },
});
ids.push(sub.id);
const akta = await db.document.create({
data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY" },
});
docIds.push(akta.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
});
await queueReMatchAndValidate(sub.id); // serialized — resolves when the queued run finishes
const results = await db.validationResult.findMany({ where: { submissionId: sub.id } });
expect(results.length).toBeGreaterThan(0); // the tuned matrix persisted — NOT silently wiped
expect(results.some((r) => r.ruleCode === "PEMBUBARAN_KORAN")).toBe(true);
expect(results.some((r) => r.ruleCode === "MODAL_BUKTI_SETOR")).toBe(false); // tuned-out rows genuinely absent
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/pembubaran-flow.test.ts. - [ ] Minimal implementation A —
backend/src/flow-engine/types.ts: widen the union:
export type RouteFamily =
| "pendirian" | "perubahan" | "perbaikan" | "pp" | "peralihan-pp-pt" | "apostille"
| "laporan-rups" | "peleburan" | "pembubaran" | "berakhirnya";
- [ ] Minimal implementation B — create
backend/src/flow-engine/flows/pembubaran-pt.ts:
import { defineFlow } from "../types";
import { buildPembubaranContext, type PembubaranCtx } from "../context/pembubaran-context";
import { PEMBUBARAN_RULE_SET } from "../rules/pembubaran/index";
/**
* Pembubaran PT (SABH id_aksi_transaksi = 12; UUPT Ps.142-147) — the THIRD
* engine:'v2-generic' FLOW_REGISTRY tenant (ship order: LAPORAN_RUPS_TAHUNAN →
* PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT).
*
* NO cap-table diff: the review is a flat sectioned review against the registry
* snapshot (deed + RUPS attendance + likuidator + koran + identities). The deed
* rides the PERUBAHAN extractor (classification-override, akuisisi precedent);
* SINGLE-source companyLookup (the engine's existing branch — never the
* peleburan-coupled multi-source wiring). Likuidator = 12 ExtractedField rows on
* the SURAT_LIKUIDATOR doc (focused pass built here); koran = the SHARED
* BUKTI_PENGUMUMAN clipping pass (built in Peleburan) + deed-inline fallback.
*
* VERIFIKATOR-STAGED: routes/pembubaran.ts submit → COMPLETED +
* VerifikasiPerubahan (MENUNGGU_VERIFIKATOR). Voter roster = oldData.pemegang_saham
* via resolveVoterRoster's fallback (AKTA_PEMBUBARAN deliberately NOT in
* ROSTER_AKTA_TYPES — the registry roster is the legal voter set for a
* dissolution). FAIL gate = block-with-override (checkFailGate; default 'on').
* PEMBUBARAN_PT ≠ PEMBUBARAN_PP — exact-equality checks only, everywhere.
*/
export const pembubaranPtFlow = defineFlow<PembubaranCtx>({
type: "PEMBUBARAN_PT",
engine: "v2-generic",
primaryAkta: {
classifiedType: "AKTA_PEMBUBARAN",
missingError: "Akta Pembubaran tidak ditemukan pada submission",
extractError: "Ekstraksi Akta Pembubaran gagal",
},
skipExtractionTypes: ["OTHER"],
companyLookup: true, // single-source: nama_perseroan + ref_pendirian_nomor_sk (SK terakhir)
applyContactInfo: true,
extraction: {
// Hybrid (akuisisi precedent): the dissolution deed's header/RUPS/penghadap
// payload rides the perubahan schema — incl. tanggal_rups, notaris_pengganti
// and the akta_kedua dual-deed block (Task 3 transformer mappings).
overrideByClassifiedType: { AKTA_PEMBUBARAN: "perubahan" },
focusedPasses: {
SURAT_LIKUIDATOR: "surat-likuidator", // built HERE (penunjukan shape ONLY — C2)
BUKTI_PENGUMUMAN: "bukti-pengumuman", // SHARED clipping extractor (built in Peleburan)
},
},
buildContext: buildPembubaranContext,
rules: PEMBUBARAN_RULE_SET,
// DocumentType enum values (requiredDocs contract) — DOMISILI is the existing
// enum the SURAT_PERNYATAAN_DOMISILI label maps to (types.ts erratum applies to
// the CHECKLIST vocabulary below, not here).
requiredDocs: ["AKTA", "SURAT_LIKUIDATOR", "KTP", "NPWP", "DOMISILI", "DATA_KONTAK"],
sections: [
{ key: "perseroan", label: "Data Perseroan (SABH)", approvable: true, required: true },
{ key: "akta_pembubaran", label: "Akta Pembubaran", approvable: true, required: true },
{ key: "rups_kehadiran", label: "RUPS & Kehadiran Pemegang Saham", approvable: true, required: true },
{ key: "likuidator", label: "Likuidator", approvable: true, required: true },
// approvable but NOT required — advisory-only content (Ps.147 post-deed obligation)
{ key: "pengumuman_koran", label: "Pengumuman Koran", approvable: true, required: false },
{ key: "identitas", label: "Dokumen Identitas (KTP/NPWP)", approvable: true, required: true },
{ key: "data_kontak", label: "Data Kontak", approvable: true, required: true },
{ key: "validasi", label: "Hasil Validasi", approvable: false, required: false },
],
rematch: {
// CHECKLIST-LABEL vocabulary (types.ts:31-47 erratum) — never the Prisma enum.
// No SURAT_LIKUIDATOR/BUKTI_PENGUMUMAN rows: the checklist registers no such
// rows (spec-sketch deviation — there is nothing to reset for them).
checklistResetDocTypes: ["KTP", "NPWP", "SURAT_PERNYATAAN_DOMISILI"],
standardPtMatchers: true,
rupsAttendance: true, // re-runs matchRupsAttendance incl. the registry-roster fallback (Task 3)
},
hooks: {
afterAktaExtraction: async (a) => {
const { runPembubaranDeedPasses } = await import("../../services/akta-pembubaran-deed-passes");
await runPembubaranDeedPasses(a);
},
// onFinalize deliberately ABSENT: nothing invokes hooks.onFinalize yet, and
// PT_ALREADY_DISSOLVED reads COMPLETED PEMBUBARAN_PT submissions directly —
// the submission IS the local dissolution record (pp-pembubaran.ts:475 pattern).
},
routeFamily: "pembubaran",
labels: { akta: "Akta Pembubaran", flow: "Pembubaran PT" },
});
- [ ] Minimal implementation C —
backend/src/flow-engine/registry.ts: importpembubaranPtFlowand replace the stub line with:
PEMBUBARAN_PT: pembubaranPtFlow, // THIRD v2-generic tenant (single-source lookup; verifikator-staged)
- [ ] Minimal implementation D —
backend/src/ocr/classifier.ts: inAKTA_SUBTYPE_MAP, after theAKTA_PELEBURANrow:
// Hybrid: pembubaran rides the PERUBAHAN extractor (deed header + RUPS +
// penghadap + akta_kedua dual-deed block; spec §4.3). Defensive label row —
// the engine passes the raw subtype, resolved by the identity rows below.
AKTA_PEMBUBARAN: "perubahan",
- [ ] Minimal implementation E —
backend/src/flow-engine/__tests__/registry.test.ts: add"PEMBUBARAN_PT"to theV2_SHIPPEDset. - [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/pembubaran-flow.test.ts src/flow-engine/__tests__/registry.test.ts src/flow-engine/__tests__/dispatch-routing.test.ts src/flow-engine/__tests__/pembubaran-schema.test.ts. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/flow-engine/flows/pembubaran-pt.ts backend/src/flow-engine/types.ts backend/src/flow-engine/registry.ts backend/src/ocr/classifier.ts backend/src/flow-engine/__tests__/registry.test.ts backend/src/flow-engine/__tests__/pembubaran-flow.test.ts && git commit -m "feat(pembubaran): real FlowConfig registered — third v2-generic tenant + rematch proof"
Task 7: Routing + gating — BUILT flip, Berakhirnya doc-set predicate, ladder rung BELOW Akuisisi, doc-type maps, labels endpoint (THE LAUNCH SWITCH)
Flips BUILT_AKTA_TXN_TYPES += "pembubaran" — from this task on, an AKTA_PEMBUBARAN upload routes into the flow, so everything before must already work. Also: the appoints-vs-accepts classifier prompt cue; the BERAKHIRNYA_PACKAGE predicate (owned ONCE in submissions.ts; first consumer HERE) wired into BOTH detectUnsupportedAktaType (the create-route supported:false gate — the package must surface as "Terdeteksi: Berakhirnya Status Badan Hukum (belum didukung)", never silently route) AND inferSubmissionType (belt-and-braces rung guard); the pembubaran rung BELOW Akuisisi (canonical ladder — note this is the OPPOSITE of peleburan's rung placement); mapToDocumentType/getProcessingOrder for SURAT_LIKUIDATOR (without the map line it falls to the "AKTA" fallback and the focused pass never fires); the ?type=pembubaran board doc-set; and the stale-fixture updates (pembubaran was the canonical unbuilt case — becomes berakhirnya).
Files
- backend/src/services/akta-txn-classifier.ts — Modify (BUILT flip + SYSTEM_PROMPT cue)
- backend/src/routes/submissions.ts — Modify (predicate + gate + rung + maps + return union)
- backend/src/routes/klasifikasi.ts — Modify (PEMBUBARAN_DOC_TYPES + labels endpoint + LABEL_MAP spread)
- backend/src/routes/__tests__/pembubaran-routing.test.ts — Create
- backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts — Modify (stale fixture)
- backend/src/routes/__tests__/laporan-rups-routing.test.ts — Modify (stale fixture)
Interfaces
- Produces: BERAKHIRNYA_PACKAGE(files: InferableFile[]): boolean (exported from submissions.ts — the Berakhirnya spec quotes it identically, C5); inferSubmissionType return union += "PEMBUBARAN_PT"; mapToDocumentType("SURAT_LIKUIDATOR") === "SURAT_LIKUIDATOR"; getProcessingOrder("SURAT_LIKUIDATOR") === 4; GET /api/klasifikasi/labels?type=pembubaran.
Steps
- [ ] Write failing test. Create
backend/src/routes/__tests__/pembubaran-routing.test.ts:
import { describe, it, expect } from "bun:test";
import {
inferSubmissionType, detectUnsupportedAktaType, BERAKHIRNYA_PACKAGE,
mapToDocumentType, getProcessingOrder,
} from "../submissions";
import { BUILT_AKTA_TXN_TYPES } from "../../services/akta-txn-classifier";
import { app } from "../../index";
const f = (classification: string, userOverride?: string) => ({ classification, userOverride });
describe("PEMBUBARAN_PT routing + gating (LAUNCH SWITCH)", () => {
it("pembubaran is BUILT; berakhirnya stays gated", () => {
expect(BUILT_AKTA_TXN_TYPES.has("pembubaran")).toBe(true);
expect(BUILT_AKTA_TXN_TYPES.has("berakhirnya")).toBe(false);
expect(detectUnsupportedAktaType([f("AKTA_PEMBUBARAN")])).toBeNull();
expect(detectUnsupportedAktaType([f("AKTA_BERAKHIRNYA")])).toBe("berakhirnya");
});
it("bare AKTA_PEMBUBARAN → PEMBUBARAN_PT (incl. via userOverride — confident-wrong guard)", () => {
expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("KTP")])).toBe("PEMBUBARAN_PT");
// GPU confidently-wrong AKTA_PERUBAHAN, operator re-mapped → override wins
expect(inferSubmissionType([f("AKTA_PERUBAHAN", "AKTA_PEMBUBARAN")])).toBe("PEMBUBARAN_PT");
});
it("ladder: BELOW Peleburan/Akuisisi, ABOVE Perubahan/LaporanRups; Perbaikan wins all", () => {
expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("AKTA_PELEBURAN")])).toBe("PELEBURAN_PT");
expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("AKTA_AKUISISI")])).toBe("AKUISISI_PT");
expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("AKTA_PERUBAHAN")])).toBe("PEMBUBARAN_PT");
expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("AKTA_RUPS_TAHUNAN")])).toBe("PEMBUBARAN_PT");
expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("SURAT_PERNYATAAN_PERBAIKAN")])).toBe("PERBAIKAN_DATA_PT");
// regressions: existing rungs untouched
expect(inferSubmissionType([f("AKTA_PERUBAHAN")])).toBe("PERUBAHAN_PT");
expect(inferSubmissionType([f("AKTA_RUPS_TAHUNAN")])).toBe("LAPORAN_RUPS_TAHUNAN");
expect(inferSubmissionType([f("SURAT_PERNYATAAN_PENDIRIAN_PP")])).toBe("PENDIRIAN_PP");
});
it("BERAKHIRNYA_PACKAGE: the 6-doc package gates AWAY; a single Ps.147 clipping does NOT", () => {
expect(BERAKHIRNYA_PACKAGE([f("AKTA_BERAKHIRNYA")])).toBe(true);
expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("LAPORAN_LIKUIDASI")])).toBe(true);
expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("SURAT_PERMOHONAN")])).toBe(true);
expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN_152")])).toBe(true);
// ≥2 docs mapping to the CARRIER DocumentType — counted via mapToDocumentType,
// incl. one arriving as a userOverride re-map (never the raw label string)
expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN"), f("OTHER", "BUKTI_PENGUMUMAN")])).toBe(true);
expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN")])).toBe(false); // single clipping
expect(BERAKHIRNYA_PACKAGE([f("AKTA_PEMBUBARAN"), f("KTP"), f("NPWP")])).toBe(false);
// wired into the gate + the rung
expect(detectUnsupportedAktaType([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN"), f("BUKTI_PENGUMUMAN")])).toBe("berakhirnya");
expect(inferSubmissionType([f("AKTA_PEMBUBARAN"), f("BUKTI_PENGUMUMAN")])).toBe("PEMBUBARAN_PT");
});
it("create route gates a Berakhirnya package with supported:false (never a mis-routed Submission)", async () => {
const res = await app.request("/api/submissions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
files: [
{ fileId: "x1", classification: "AKTA_PEMBUBARAN", confidence: 0.9 },
{ fileId: "x2", classification: "BUKTI_PENGUMUMAN", confidence: 0.9 },
{ fileId: "x3", classification: "BUKTI_PENGUMUMAN", confidence: 0.9 },
],
}),
});
expect(res.status).toBe(200);
const body = (await res.json()) as { supported?: boolean; detectedType?: string };
expect(body.supported).toBe(false);
expect(body.detectedType).toBe("berakhirnya");
});
it("doc-type maps: SURAT_LIKUIDATOR carrier + processing order; AKTA_PEMBUBARAN → AKTA", () => {
expect(mapToDocumentType("SURAT_LIKUIDATOR")).toBe("SURAT_LIKUIDATOR");
expect(getProcessingOrder("SURAT_LIKUIDATOR")).toBe(4);
expect(mapToDocumentType("AKTA_PEMBUBARAN")).toBe("AKTA"); // startsWith("AKTA_") rule — already true
expect(getProcessingOrder("AKTA_PEMBUBARAN")).toBe(0);
});
it("labels endpoint serves the pembubaran doc set", async () => {
const res = await app.request("/api/klasifikasi/labels?type=pembubaran");
expect(res.status).toBe(200);
const labels = (await res.json()) as { key: string; required: boolean; maxCount?: number }[];
const byKey = new Map(labels.map((l) => [l.key, l]));
expect(byKey.get("AKTA_PEMBUBARAN")?.required).toBe(true);
expect(byKey.get("AKTA_PEMBUBARAN")?.maxCount).toBe(1);
expect(byKey.get("SURAT_LIKUIDATOR")?.required).toBe(true);
expect(byKey.get("KTP")?.required).toBe(true);
expect(byKey.get("NPWP")?.required).toBe(true);
expect(byKey.get("SURAT_PERNYATAAN_DOMISILI")?.required).toBe(true);
expect(byKey.get("DATA_KONTAK")?.required).toBe(true);
expect(byKey.get("BERITA_ACARA_RUPS")?.required).toBe(false); // recommended
expect(byKey.get("BUKTI_PENGUMUMAN")?.required).toBe(false); // optional (Ps.147 may post-date filing)
});
});
- [ ] Run to verify RED:
cd backend && bun test src/routes/__tests__/pembubaran-routing.test.ts. - [ ] Minimal implementation A —
backend/src/services/akta-txn-classifier.ts:
1.BUILT_AKTA_TXN_TYPESgains:
"pembubaran", // LAUNCH SWITCH — PEMBUBARAN_PT shipped (third v2-generic tenant)
- In
SYSTEM_PROMPT, replacepembubaran (the PT is dissolved/liquidated),+berakhirnya (the PT's legal personhood ends after liquidation),with the content-level disambiguation cue (spec §4.2):
"pembubaran (the deed DISSOLVES the PT and APPOINTS a likuidator — liquidation BEGINS), " +
"berakhirnya (the deed ACCEPTS the likuidator's final accounting/pertanggungjawaban and declares the badan hukum ended — liquidation ENDS), " +
- [ ] Minimal implementation B —
backend/src/routes/submissions.ts:
1. Directly abovedetectUnsupportedAktaType, add the predicate (uses the file's existingInferableFile+mapToDocumentType):
/**
* Berakhirnya doc-set predicate (classifier design §6.2) — owned ONCE here;
* the Berakhirnya spec quotes it identically (C5). Pembubaran (id_aksi 12)
* STARTS liquidation; Berakhirnya (id_aksi 36) ENDS it — the deeds overlap, the
* PACKAGE separates them: laporan akhir likuidasi / surat permohonan / the
* Pasal 149+152 pengumuman PAIR. The count clause matches the CARRIER
* DocumentType via mapToDocumentType (never the raw label string) so it keeps
* firing after Berakhirnya introduces the _149/_152 fine labels. The
* LAPORAN_LIKUIDASI / SURAT_PERMOHONAN / BUKTI_PENGUMUMAN_152 clauses activate
* when that flow adds those labels — written now so it only appends names.
* A SINGLE clipping (the Ps.147 dissolution announcement) does NOT trip it.
*/
export const BERAKHIRNYA_PACKAGE = (files: InferableFile[]): boolean => {
const eff = (f: InferableFile) => f.userOverride ?? f.classification;
return (
files.some((fl) => eff(fl) === "AKTA_BERAKHIRNYA") ||
(files.some((fl) => eff(fl) === "AKTA_PEMBUBARAN") &&
(files.some((fl) => eff(fl) === "LAPORAN_LIKUIDASI" || eff(fl) === "SURAT_PERMOHONAN") ||
files.some((fl) => eff(fl) === "BUKTI_PENGUMUMAN_152") ||
files.filter((fl) => mapToDocumentType(eff(fl)) === "BUKTI_PENGUMUMAN").length >= 2))
);
};
NOTE: mapToDocumentType("BUKTI_PENGUMUMAN_152") currently hits the "AKTA" fallback — the explicit BUKTI_PENGUMUMAN_152 clause covers it until Berakhirnya maps the fine labels; do NOT add the fine-label mapping here.
2. detectUnsupportedAktaType — package check FIRST:
export function detectUnsupportedAktaType(files: InferableFile[]): AktaTxnType | null {
// Berakhirnya doc-set predicate runs FIRST: a Berakhirnya PACKAGE must gate to
// "belum didukung" even though its AKTA_PEMBUBARAN deed label is built —
// NEVER silently routed into PEMBUBARAN_PT (spec §4.2).
if (BERAKHIRNYA_PACKAGE(files)) return "berakhirnya";
for (const f of files) {
const eff = f.userOverride ?? f.classification;
const t = AKTA_LABEL_TO_TXN_TYPE[eff];
if (t && !BUILT_AKTA_TXN_TYPES.has(t)) return t;
}
return null;
}
inferSubmissionType— widen the return union with| "PEMBUBARAN_PT", update the precedence doc-comment toPerbaikan > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > Pendirian PP > Pendirian PT(built rungs of the canonical ladder), and insert BETWEEN theAKTA_AKUISISIandAKTA_PERUBAHANrungs:
// BELOW Akuisisi/Peleburan, ABOVE Perubahan (canonical ladder — Pembubaran sits
// BELOW Akuisisi, opposite of Peleburan's rung). Guarded by the Berakhirnya
// doc-set predicate — the create-route unsupported gate fires first; this guard
// is belt-and-braces so the rung can never steal a Berakhirnya package.
if (files.some((f) => eff(f) === "AKTA_PEMBUBARAN") && !BERAKHIRNYA_PACKAGE(files)) return "PEMBUBARAN_PT";
mapToDocumentType— add next to theBUKTI_PENGUMUMANline:
if (classification === "SURAT_LIKUIDATOR") return "SURAT_LIKUIDATOR";
getProcessingOrder— add next to the domisili line (order 4 — after the deed + cards, spec §4.5):
if (classification === "SURAT_LIKUIDATOR") return 4;
- [ ] Minimal implementation C —
backend/src/routes/klasifikasi.ts:
1. AfterPELEBURAN_DOC_TYPES, add (spec §4.6 table + akuisisi aa4551d precedent — identity/address/kontak REQUIRED so cross-validation always has inputs):
const PEMBUBARAN_DOC_TYPES: DocTypeLabel[] = [
{ key: "AKTA_PEMBUBARAN", displayName: "Akta Pembubaran", required: true, maxCount: 1 },
{ key: "SURAT_LIKUIDATOR", displayName: "Surat Penunjukan Likuidator", required: true, maxCount: 1, description: "Wajib — blok likuidator wajib pada wizard SABH (nama, surat, alamat, kontak, dasar penunjukan)" },
{ key: "KTP", displayName: "KTP", required: true, description: "Wajib — identitas pengurus/penghadap + likuidator (cross-validation)" },
{ key: "NPWP", displayName: "NPWP Pengurus Perseroan", required: true, description: "Wajib — verifikasi NPWP pengurus (cross-validation)" },
{ key: "SURAT_PERNYATAAN_DOMISILI", displayName: "Surat Pernyataan Domisili", required: true, maxCount: 1, description: "Wajib — verifikasi alamat/kedudukan perseroan (cross-validation)" },
{ key: "DATA_KONTAK", displayName: "Data Kontak Pengurus dan Pemegang Saham", required: true, description: "Wajib — telepon, email, NPWP pengurus & pemegang saham (cross-validation)" },
{ key: "BERITA_ACARA_RUPS", displayName: "Berita Acara RUPS", required: false, description: "Direkomendasikan — akta dapat berupa PKR yang memuat RUPS, atau BA menjadi akta kedua" },
{ key: "BUKTI_PENGUMUMAN", displayName: "Bukti Pengumuman Koran", required: false, maxCount: 1, description: "Opsional — pengumuman Ps.147 adalah kewajiban likuidator 30 hari SEJAK pembubaran (boleh menyusul); ≥2 kliping menandakan paket Berakhirnya" },
{ key: "PASSPORT", displayName: "Paspor", required: false, description: "Untuk pengurus/likuidator WNA (alternatif KTP)" },
{ key: "KITAS", displayName: "KITAS / KITAP", required: false, description: "Untuk pengurus/likuidator WNA berdomisili di Indonesia" },
{ key: "OTHER", displayName: "Dokumen lain bukan persyaratan", required: false, description: "Misal penetapan pengadilan pada jalur pembubaran pengadilan (tidak diekstraksi fase 1)" },
];
- Add
PEMBUBARAN_DOC_TYPESto theLABEL_MAPspread array. - Labels endpoint: add
if (type === "pembubaran") return c.json(PEMBUBARAN_DOC_TYPES);and extend the route comment's type list.
- [ ] Stale fixtures (the canonical unbuilt example moves toberakhirnya):
-backend/src/routes/__tests__/laporan-rups-routing.test.ts:14:expect(detectUnsupportedAktaType([f("AKTA_PEMBUBARAN")])).toBe("pembubaran")→expect(detectUnsupportedAktaType([f("AKTA_BERAKHIRNYA")])).toBe("berakhirnya"); // others stay gated. Also fix the stale describe-title ladder text in that file if it enumerates the ladder (ledger minor).
-backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts:35-36: the "still unbuilt" assertion flips toAKTA_BERAKHIRNYA/berakhirnya(update the comment: "berakhirnya is still unbuilt (pembubaran became built once its flow shipped)"). Any test in that file asserting a pembubaran refine result keeps its label expectations (AKTA_PEMBUBARANunchanged) — onlysupported-style assertions flip totrue.
- [ ] Run GREEN:cd backend && bun test src/routes/__tests__/pembubaran-routing.test.ts src/routes/__tests__/klasifikasi-akta-txn.test.ts src/routes/__tests__/laporan-rups-routing.test.ts src/routes/__tests__/peleburan-routing.test.ts src/routes/__tests__/akuisisi-routing.test.ts. Expected: all pass (peleburan/akuisisi rungs regression-proven).
- [ ] Typecheck:cd backend && bunx tsc --noEmit.
- [ ] Commit:git add backend/src/services/akta-txn-classifier.ts backend/src/routes/submissions.ts backend/src/routes/klasifikasi.ts backend/src/routes/__tests__/pembubaran-routing.test.ts backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts backend/src/routes/__tests__/laporan-rups-routing.test.ts && git commit -m "feat(pembubaran): LAUNCH SWITCH — BUILT flip + Berakhirnya doc-set predicate + ladder rung below akuisisi"
Task 8: Scoped SURAT_LIKUIDATOR re-check pass (klasifikasi endpoint + focused 3B tail)
The GPU classifier (22 labels, frozen w.r.t. new labels) will NOT abstain on a surat likuidator — it confidently mislabels (likely SURAT_PERNYATAAN_*) ABOVE the 0.6 gate, so the existing low-confidence refineSupportingLabel tier never sees it, and a missed SURAT_LIKUIDATOR silently kills the likuidator extraction + KTP cross-check (the flow's definitional net-new). Spec §4.5: a scoped re-check pass — deterministic tiers first (suggestByKeyword already owns the C2 penunjukan-vs-laporan distinction at 0.85; plus a /\bLIKUIDATOR\b/ title-window net), then a focused 3B yes/no tail for OCR-mangled headings. Suggestion-only, NEVER auto-assigned — surfaced through the existing amber suggestion-chip + re-map mechanism (ClassificationResult.suggestion, built by Peleburan). Scope: the FRONTEND gates the call to sets containing an AKTA_PEMBUBARAN (Task 11 wires the trigger), so other flows' classifications are never disturbed.
Files
- backend/src/services/supporting-doc-classifier.ts — Modify (suggestSuratLikuidator + isSuratPenunjukanLikuidatorViaLlm)
- backend/src/routes/klasifikasi.ts — Modify (POST /recheck-likuidator)
- backend/src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts — Create
Interfaces
- Produces: suggestSuratLikuidator(rawText: string): Promise<boolean>; POST /api/klasifikasi/recheck-likuidator body { fileIds: string[] } → { suggestions: { fileId: string; docType: "SURAT_LIKUIDATOR"; displayName: string }[] }.
Steps
- [ ] Write failing test. Create
backend/src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts:
import { describe, it, expect, afterEach } from "bun:test";
import { suggestSuratLikuidator } from "../../services/supporting-doc-classifier";
import { app } from "../../index";
function mockLlm(content: object | null, status = 200) {
const orig = globalThis.fetch;
globalThis.fetch = (async () =>
new Response(JSON.stringify({ choices: [{ message: { content: content ? JSON.stringify(content) : "" } }] }), { status })) as typeof fetch;
return () => { globalThis.fetch = orig; };
}
describe("suggestSuratLikuidator (scoped re-check tiers — spec §4.5)", () => {
const origEnv = process.env.CLEANUP_LLM_URL;
afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });
it("deterministic title rule: SURAT PENUNJUKAN LIKUIDATOR → true (no LLM call)", async () => {
expect(await suggestSuratLikuidator("SURAT PENUNJUKAN LIKUIDATOR\nNomor 017")).toBe(true);
});
it("C2 distinction: a LAPORAN/PERTANGGUNGJAWABAN LIKUIDASI shape → false (that is LAPORAN_LIKUIDASI)", async () => {
expect(await suggestSuratLikuidator("LAPORAN AKHIR LIKUIDASI PT X")).toBe(false);
expect(await suggestSuratLikuidator("PERTANGGUNGJAWABAN LIKUIDATOR atas hasil likuidasi")).toBe(false);
});
it("title-window net: LIKUIDATOR in the first 800 chars without laporan-shape → true", async () => {
expect(await suggestSuratLikuidator("PENUNJUKAN LIKUIDATOR PERSEROAN\ndengan ini menunjuk BUDI")).toBe(true);
});
it("3B tail: mangled heading, 'likuidator' only deep in the body → LLM decides; outage → false", async () => {
const body = "S U R A T\n(judul tidak terbaca)\n" + "x".repeat(900) + "\nmenunjuk Saudara BUDI selaku likuidator Perseroan";
process.env.CLEANUP_LLM_URL = "http://mock";
let restore = mockLlm({ is_surat_penunjukan_likuidator: true });
try { expect(await suggestSuratLikuidator(body)).toBe(true); } finally { restore(); }
restore = mockLlm(null, 500);
try { expect(await suggestSuratLikuidator(body)).toBe(false); } finally { restore(); }
// grounding-by-construction: no "likuidator" anywhere → never even asks the LLM
expect(await suggestSuratLikuidator("SURAT PERNYATAAN DOMISILI PERUSAHAAN")).toBe(false);
});
});
describe("POST /api/klasifikasi/recheck-likuidator", () => {
it("returns empty suggestions for unknown fileIds (best-effort, no error)", async () => {
const res = await app.request("/api/klasifikasi/recheck-likuidator", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fileIds: ["nonexistent-1", "nonexistent-2"] }),
});
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ suggestions: [] });
});
it("rejects a missing/invalid body shape gracefully", async () => {
const res = await app.request("/api/klasifikasi/recheck-likuidator", { method: "POST" });
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ suggestions: [] });
});
});
- [ ] Run to verify RED:
cd backend && bun test src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts. - [ ] Minimal implementation A —
backend/src/services/supporting-doc-classifier.ts, append:
/** Focused 3B yes/no for the SURAT_LIKUIDATOR re-check tail (spec §4.5): the
* heading is OCR-mangled but the body appoints a likuidator. Callers only invoke
* when /likuidator/i appears in the OCR text (grounding by construction). Outage/
* ambiguity → false. NEVER auto-accepted — proposal only. */
export async function isSuratPenunjukanLikuidatorViaLlm(rawText: string): Promise<boolean> {
const url = process.env.CLEANUP_LLM_URL || "";
const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
if (!url || !rawText.trim()) return false;
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:
"You are shown OCR text of an Indonesian corporate document. Answer whether it is a SURAT PENUNJUKAN " +
"LIKUIDATOR — a letter APPOINTING a liquidator for a PT dissolution (header + appointee identity/address/" +
"contact block). It is NOT one if it is a laporan akhir likuidasi / pertanggungjawaban likuidator " +
"(the liquidator's final accounting) or any other document. Return ONLY JSON {is_surat_penunjukan_likuidator}.",
},
{ role: "user", content: rawText.slice(0, 4000) },
],
guided_json: {
type: "object",
properties: { is_surat_penunjukan_likuidator: { type: "boolean" } },
required: ["is_surat_penunjukan_likuidator"],
additionalProperties: false,
},
}),
signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
});
if (!res.ok) return false;
const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
if (!m) return false;
return (JSON.parse(m[0]) as { is_surat_penunjukan_likuidator?: unknown }).is_surat_penunjukan_likuidator === true;
} catch (err) {
console.warn("[supporting-doc-classifier] likuidator 3B tail failed:", err);
return false;
}
}
/** SCOPED SURAT_LIKUIDATOR re-check (spec §4.5) — the frontend gates invocation
* to sets containing an AKTA_PEMBUBARAN. Tiers: (1) suggestByKeyword (owns the
* C2 penunjukan-vs-laporan distinction — a DIFFERENT confident keyword shape
* vetoes); (2) /\bLIKUIDATOR\b/ in the 800-char title window without a laporan
* shape; (3) the focused 3B tail when "likuidator" appears only deeper in the body. */
export async function suggestSuratLikuidator(rawText: string): Promise<boolean> {
const kw = suggestByKeyword(rawText);
if (kw?.docType === "SURAT_LIKUIDATOR") return true;
if (kw) return false; // a different confident shape (LAPORAN_LIKUIDASI, BUKTI_PENGUMUMAN, …)
const head = (rawText || "").slice(0, 800);
if (/\bLIKUIDATOR\b/i.test(head) && !/(LAPORAN|PERTANGGUNGJAWABAN)/i.test(head)) return true;
if (/likuidator/i.test(rawText)) return isSuratPenunjukanLikuidatorViaLlm(rawText);
return false;
}
- [ ] Minimal implementation B —
backend/src/routes/klasifikasi.ts: importsuggestSuratLikuidatorand add before the labels endpoint:
// POST /recheck-likuidator — SCOPED re-check pass (spec §4.5). The GPU classifier
// has no surat-likuidator label and will NOT abstain (confident-wrong above the
// 0.6 gate), so the low-confidence refine tier never fires; a missed
// SURAT_LIKUIDATOR silently kills the likuidator extraction + KTP cross-check.
// The FRONTEND calls this only when the staged set contains an AKTA_PEMBUBARAN
// (scope — other flows' classifications are never disturbed). Suggestion-only:
// NEVER auto-assigns; applied through the existing re-map mechanism.
klasifikasi.post("/recheck-likuidator", async (c) => {
const body = await c.req.json<{ fileIds?: string[] }>().catch(() => ({}) as { fileIds?: string[] });
const fileIds = Array.isArray(body.fileIds) ? body.fileIds.slice(0, 20) : [];
const suggestions: { fileId: string; docType: "SURAT_LIKUIDATOR"; displayName: string }[] = [];
for (const fileId of fileIds) {
try {
const metaPath = path.join(TEMP_DIR, `${fileId}.json`);
if (!fs.existsSync(metaPath)) continue;
const meta = JSON.parse(fs.readFileSync(metaPath, "utf-8"));
if (!fs.existsSync(meta.storedPath)) continue;
const layout = await getLayoutProvider("paddleocr");
const ocr = await layout.extractTextWithWords(fs.readFileSync(meta.storedPath), { unwarp: false, orient: false });
if (await suggestSuratLikuidator(ocr.rawText ?? "")) {
suggestions.push({ fileId, docType: "SURAT_LIKUIDATOR", displayName: getDisplayName("SURAT_LIKUIDATOR") });
}
} catch { /* best-effort per file — a failed OCR never fails the batch */ }
}
return c.json({ suggestions });
});
- [ ] Run GREEN:
cd backend && bun test src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/services/supporting-doc-classifier.ts backend/src/routes/klasifikasi.ts backend/src/routes/__tests__/klasifikasi-recheck-likuidator.test.ts && git commit -m "feat(pembubaran): scoped SURAT_LIKUIDATOR re-check pass (suggestion-only, never auto-assigned)"
Task 9: Route family routes/pembubaran.ts — review-data, likuidator/kehadiran/koran edits, approvals, submit → VERIFIKATOR
The thin route family (peleburan.ts mirror, minus everything multi-source). Company selection deliberately rides the EXISTING type-agnostic POST /api/perubahan/select-company/:submissionId (its v2 branch resumes continueFlow — no new select route, proven by test). Every mutating endpoint carries the CANONICAL guardReady envelope — including submit (tightens the peleburan submit-arm text; Global Constraints). READY-state management surfaces (whole-flow-review warning 1): the likuidator bulk-fill PUT /likuidator (the safety net when the focused pass produced ZERO rows — empty rawText — so the generic per-field confirm would P2025), the kehadiran editor PUT /kehadiran (deed-roster AND registry-roster paths), and the koran editor PUT /koran — all live on the review page in READY, none gated behind a status-locked dialog. Per-field likuidator confirm/edit rides the generic /api/submissions/... field endpoints (ExtractedField rows — spec §6, confirm/bbox for free).
Files
- backend/src/routes/pembubaran.ts — Create
- backend/src/index.ts — Modify (mount)
- backend/src/routes/__tests__/pembubaran-routes.test.ts — Create
Interfaces
- Produces (all under /api/pembubaran):
- GET /submissions/:submissionId/review-data — composite payload (submission incl. kontak, perseroan snapshot, akta fields + PS kehadiran, likuidator 12 rows, kehadiran block, koran, documents, validationResults, sections, sectionApprovals)
- PUT /submissions/:submissionId/likuidator — bulk upsert of canonical likuidator fieldKeys (MANUAL, confidence 1) — READY only
- PUT /submissions/:submissionId/kehadiran — attendance ticks, deed-roster or registry-roster path — READY only
- PUT /submissions/:submissionId/koran — canonical koran keys, clipping-doc-first target — READY only
- POST /submissions/:submissionId/sections/:sectionKey/approve|unapprove — READY only
- POST /submissions/:submissionId/submit — staged submit (409 canonical / 400 SECTIONS_UNAPPROVED / 422 VALIDATION_FAILED / COMPLETED + VerifikasiPerubahan)
Steps
- [ ] Write failing test. Create
backend/src/routes/__tests__/pembubaran-routes.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import type { SubmissionStatus } from "../../generated/prisma/enums";
import { db } from "../../lib/db";
import { app } from "../../index";
describe("pembubaran route family", () => {
const ids: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
// submit seeds VerifikasiPerubahan + a VerifikasiAuditEvent; the latter is
// DB-trigger append-only (installAuditTrailGuards), so drop audit rows first
// under session_replication_role=replica (peleburan-routes.test.ts pattern).
if (ids.length) {
await db.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica;`);
await tx.$executeRawUnsafe(
`DELETE FROM "VerifikasiAuditEvent" WHERE "verifikasiId" IN (SELECT id FROM "VerifikasiPerubahan" WHERE "submissionId" = ANY($1::text[]))`,
ids,
);
});
await db.submission.deleteMany({ where: { id: { in: ids } } });
}
await db.document.deleteMany({ where: { id: { in: docIds } } });
});
async function seed(status: SubmissionStatus = "READY", opts: { likuidatorRows?: boolean; clipping?: boolean; registryKehadiran?: boolean; approveAll?: boolean; failRow?: boolean } = {}) {
const sub = await db.submission.create({
data: {
type: "PEMBUBARAN_PT", status,
teleponPerseroan: "0215550001", emailPerseroan: "corp@majumundur.co.id",
oldData: { perseroan: { nama: "PT MAJU MUNDUR", id_perseroan: 555, status_transaksi: 1 }, pemegang_saham: [{ nama: "BUDI SANTOSO", persentase: 100 }] },
...(opts.registryKehadiran ? { rupsKehadiranRegistry: [{ nama: "BUDI SANTOSO", persentase: 100, kehadiran: null }] } : {}),
},
});
ids.push(sub.id);
const akta = await db.document.create({
data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY" },
});
docIds.push(akta.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
});
await db.extractedField.createMany({
data: [
{ documentId: akta.id, fieldKey: "nama_perseroan", value: "PT MAJU MUNDUR", confidence: 0.9, status: "EDITABLE" },
{ documentId: akta.id, fieldKey: "nomor_akta", value: "5", confidence: 0.9, status: "EDITABLE" },
{ documentId: akta.id, fieldKey: "tanggal_akta", value: "2026-04-30", confidence: 0.9, status: "EDITABLE" },
],
});
const lik = await db.document.create({
data: { filename: "spl.pdf", originalName: "spl.pdf", fileSize: 0, filePath: "/tmp/s.pdf", documentType: "SURAT_LIKUIDATOR", status: "VERIFICATION_READY" },
});
docIds.push(lik.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: lik.id, classifiedType: "SURAT_LIKUIDATOR", confidence: 1, processingOrder: 4, extractionStatus: "DONE" },
});
if (opts.likuidatorRows) {
await db.extractedField.create({
data: { documentId: lik.id, fieldKey: "likuidator_nama", value: "BUDI HARTONO", confidence: 0.75, status: "EDITABLE" },
});
}
if (opts.clipping) {
const clip = await db.document.create({
data: { filename: "k.pdf", originalName: "k.pdf", fileSize: 0, filePath: "/tmp/k.pdf", documentType: "BUKTI_PENGUMUMAN", status: "VERIFICATION_READY" },
});
docIds.push(clip.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: clip.id, classifiedType: "BUKTI_PENGUMUMAN", confidence: 1, processingOrder: 6, extractionStatus: "DONE" },
});
}
if (opts.approveAll) {
for (const key of ["perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak"]) {
await db.perubahanSectionApproval.create({ data: { submissionId: sub.id, sectionKey: key, approved: true, approvedAt: new Date() } });
}
}
if (opts.failRow) {
await db.validationResult.create({
data: { submissionId: sub.id, ruleCode: "LIKUIDATOR_KTP_MATCH", ruleLabel: "KTP Likuidator Sesuai Surat Penunjukan", status: "FAIL", message: "seeded" },
});
}
return { sub, akta, lik };
}
const json = (body: unknown) => ({ method: "PUT" as const, headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
it("review-data returns the composite payload (perseroan snapshot, likuidator 12 keys, kehadiran, koran, sections)", async () => {
const { sub } = await seed("READY", { likuidatorRows: true, registryKehadiran: true });
const res = await app.request(`/api/pembubaran/submissions/${sub.id}/review-data`);
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, any>;
expect(body.submission.type).toBe("PEMBUBARAN_PT");
expect(body.perseroan).toEqual({ nama: "PT MAJU MUNDUR", statusTransaksi: 1, statusPerseroan: null });
expect(Object.keys(body.likuidator.fields).length).toBe(12);
expect(body.likuidator.fields.likuidator_nama.value).toBe("BUDI HARTONO");
expect(body.likuidator.fields.likuidator_email.value).toBeNull(); // absent key still present, null row
expect(body.kehadiran).toEqual({ source: "registry", rows: [{ nama: "BUDI SANTOSO", persentase: 100, kehadiran: null }] });
expect(body.kontak).toEqual({ teleponPerseroan: "0215550001", emailPerseroan: "corp@majumundur.co.id" });
expect(body.sections.some((s: { key: string }) => s.key === "likuidator")).toBe(true);
// wrong type guard
const pp = await db.submission.create({ data: { type: "PEMBUBARAN_PP", status: "READY" } });
ids.push(pp.id);
expect((await app.request(`/api/pembubaran/submissions/${pp.id}/review-data`)).status).toBe(400); // exact-spelling: PP never aliases
});
it("PUT /likuidator upserts MANUAL rows (safety net for a zero-row pass) and 409s outside READY", async () => {
const { sub, lik } = await seed("READY");
const ok = await app.request(`/api/pembubaran/submissions/${sub.id}/likuidator`, json({ fields: { likuidator_nama: "BUDI HARTONO", likuidator_telepon: "081234567890", not_a_key: "ignored" } }));
expect(ok.status).toBe(200);
const rows = await db.extractedField.findMany({ where: { documentId: lik.id } });
expect(rows.length).toBe(2); // only canonical keys accepted
expect(rows.find((r) => r.fieldKey === "likuidator_nama")?.status).toBe("MANUAL");
const { sub: busy } = await seed("EXTRACTING");
const blocked = await app.request(`/api/pembubaran/submissions/${busy.id}/likuidator`, json({ fields: { likuidator_nama: "X" } }));
expect(blocked.status).toBe(409);
expect(await blocked.json()).toEqual({ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" });
});
it("PUT /kehadiran updates the registry rows (registry path) and the deed PS rows (deed path)", async () => {
const { sub } = await seed("READY", { registryKehadiran: true });
const res = await app.request(`/api/pembubaran/submissions/${sub.id}/kehadiran`, json({ entries: [{ nama: "BUDI SANTOSO", kehadiran: true }] }));
expect(res.status).toBe(200);
const fresh = await db.submission.findUnique({ where: { id: sub.id } });
expect(fresh?.rupsKehadiranRegistry).toEqual([{ nama: "BUDI SANTOSO", persentase: 100, kehadiran: true }]);
// deed path
const { sub: s2, akta } = await seed("READY");
await db.aktaPemegangSaham.create({ data: { documentId: akta.id, namaAtauBadanHukum: "SITI AMINAH", persentase: 100, orderIndex: 0 } });
const res2 = await app.request(`/api/pembubaran/submissions/${s2.id}/kehadiran`, json({ entries: [{ nama: "Ibu SITI AMINAH", kehadiran: false }] }));
expect(res2.status).toBe(200);
const ps = await db.aktaPemegangSaham.findFirst({ where: { documentId: akta.id } });
expect(ps?.kehadiranRups).toBe(false); // honorific-stripped name match
});
it("PUT /koran lands on the clipping doc when present, else the deed (canonical keys)", async () => {
const { sub } = await seed("READY", { clipping: true });
const res = await app.request(`/api/pembubaran/submissions/${sub.id}/koran`, json({ namaKoran: "Media Indonesia", tanggalPengumuman: "2026-05-10" }));
expect(res.status).toBe(200);
const body = (await res.json()) as { documentId: string; updated: string[] };
const doc = await db.document.findUnique({ where: { id: body.documentId } });
expect(doc?.documentType).toBe("BUKTI_PENGUMUMAN");
expect(body.updated).toEqual(["nama_koran", "tanggal_pengumuman"]);
});
it("submit: canonical 409 outside READY; 400 SECTIONS_UNAPPROVED naming missing; koran NOT required", async () => {
const { sub } = await seed("EXTRACTING");
const blocked = await app.request(`/api/pembubaran/submissions/${sub.id}/submit`, { method: "POST" });
expect(blocked.status).toBe(409);
expect(await blocked.json()).toEqual({ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" });
const { sub: s2 } = await seed("READY");
await db.perubahanSectionApproval.create({ data: { submissionId: s2.id, sectionKey: "perseroan", approved: true, approvedAt: new Date() } });
const missing = await app.request(`/api/pembubaran/submissions/${s2.id}/submit`, { method: "POST" });
expect(missing.status).toBe(400);
const mb = (await missing.json()) as { code: string; missingSections: string[] };
expect(mb.code).toBe("SECTIONS_UNAPPROVED");
expect(mb.missingSections).toEqual(["akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak"]);
expect(mb.missingSections).not.toContain("pengumuman_koran"); // advisory-only section never gates
});
it("submit: 422 VALIDATION_FAILED on un-overridden FAIL; override unblocks; COMPLETED + MENUNGGU_VERIFIKATOR in one tx", async () => {
const { sub } = await seed("READY", { approveAll: true, failRow: true });
const fail = await app.request(`/api/pembubaran/submissions/${sub.id}/submit`, { method: "POST" });
expect(fail.status).toBe(422);
const fb = (await fail.json()) as { code: string; failing: string[] };
expect(fb.code).toBe("VALIDATION_FAILED");
expect(fb.failing).toEqual(["LIKUIDATOR_KTP_MATCH"]);
await db.validationResult.updateMany({
where: { submissionId: sub.id, ruleCode: "LIKUIDATOR_KTP_MATCH" },
data: { overridden: true, overrideReason: "verified manually" },
});
const ok = await app.request(`/api/pembubaran/submissions/${sub.id}/submit`, { method: "POST" });
expect(ok.status).toBe(200);
expect(((await ok.json()) as { status: string }).status).toBe("COMPLETED");
const fresh = await db.submission.findUnique({ where: { id: sub.id }, include: { verifikasi: true } });
expect(fresh?.status).toBe("COMPLETED");
expect(fresh?.verifikasi?.status).toBe("MENUNGGU_VERIFIKATOR"); // staged, NOT terminal
});
it("PERBAIKAN re-submit arm: re-arms the existing verifikasi row and clears the AI report", async () => {
const { sub } = await seed("READY", { approveAll: true });
await db.verifikasiPerubahan.create({
data: { submissionId: sub.id, status: "PERBAIKAN", aiRecommendation: "WARN", aiGeneratedAt: new Date() },
});
const res = await app.request(`/api/pembubaran/submissions/${sub.id}/submit`, { method: "POST" });
expect(res.status).toBe(200);
const v = await db.verifikasiPerubahan.findUnique({ where: { submissionId: sub.id } });
expect(v?.status).toBe("MENUNGGU_VERIFIKATOR");
expect(v?.aiRecommendation).toBeNull();
});
it("sections approve/unapprove persist EXPLICIT rows; unknown key 400; READY-only 409", async () => {
const { sub } = await seed("READY");
const ok = await app.request(`/api/pembubaran/submissions/${sub.id}/sections/likuidator/approve`, { method: "POST" });
expect(ok.status).toBe(200);
const row = await db.perubahanSectionApproval.findUnique({ where: { submissionId_sectionKey: { submissionId: sub.id, sectionKey: "likuidator" } } });
expect(row?.approved).toBe(true);
expect((await app.request(`/api/pembubaran/submissions/${sub.id}/sections/nonsense/approve`, { method: "POST" })).status).toBe(400);
const { sub: busy } = await seed("VALIDATING");
const blocked = await app.request(`/api/pembubaran/submissions/${busy.id}/sections/likuidator/approve`, { method: "POST" });
expect(blocked.status).toBe(409);
});
});
- [ ] Run to verify RED:
cd backend && bun test src/routes/__tests__/pembubaran-routes.test.ts. Expected: 404s (route not mounted). - [ ] Minimal implementation — create
backend/src/routes/pembubaran.ts:
import { Hono } from "hono";
import { db } from "../lib/db";
import { Prisma } from "../generated/prisma/client";
import { queueReMatchAndValidate } from "../services/submission-processor";
import { checkFailGate } from "../flow-engine/fail-gate";
import { pembubaranPtFlow } from "../flow-engine/flows/pembubaran-pt";
import { LIKUIDATOR_EXTRACTED_FIELD_KEYS } from "../services/surat-likuidator-extract";
import { normalizeName } from "../services/rups-attendance-matcher";
import { recordAuditEvent } from "../services/audit-event";
import { getRequestContext } from "../middleware/request-context";
/**
* Route family 'pembubaran' — Pembubaran PT (dissolution). VERIFIKATOR-STAGED:
* submit → COMPLETED + VerifikasiPerubahan (MENUNGGU_VERIFIKATOR) in ONE
* transaction (peleburan-arm mirror). Field edit/confirm/override ride the
* generic /api/submissions endpoints (queueReMatchAndValidate reaches this flow
* via the FLOW_REGISTRY). Company selection rides the type-agnostic
* POST /api/perubahan/select-company (v2 branch resumes continueFlow) — this
* family adds NO select route. Every mutating endpoint INCLUDING submit uses
* the canonical WRONG_STATUS envelope (Global Constraints; tightens the
* peleburan submit-arm text). PEMBUBARAN_PT ≠ PEMBUBARAN_PP — exact equality.
*/
const pembubaran = new Hono();
const APPROVABLE_KEYS = new Set(pembubaranPtFlow.sections.filter((s) => s.approvable).map((s) => s.key));
const READY_ONLY = ["READY"] as const;
async function loadTyped(submissionId: string) {
const submission = await db.submission.findUnique({ where: { id: submissionId } });
if (!submission) return { error: "not_found" as const, submission: null };
if (submission.type !== "PEMBUBARAN_PT") return { error: "wrong_type" as const, submission: null };
return { error: null, submission };
}
function guardStatus(submission: { status: string }, allowed: readonly string[]) {
return allowed.includes(submission.status) ? { blocked: false as const } : { blocked: true as const };
}
const WRONG_STATUS = { error: "submission cannot be edited in its current status", code: "WRONG_STATUS" } as const;
type KehadiranRow = { nama: string; persentase: number | null; kehadiran: boolean | null };
// GET /submissions/:submissionId/review-data — the composite review payload.
pembubaran.get("/submissions/:submissionId/review-data", async (c) => {
const submissionId = c.req.param("submissionId");
const submission = await db.submission.findUnique({
where: { id: submissionId },
include: {
documents: {
include: { document: { include: { fields: true, aktaPemegangSaham: true, aktaPenghadap: true } } },
orderBy: { processingOrder: "asc" },
},
validationResults: { orderBy: { createdAt: "asc" } },
perubahanSectionApprovals: true,
},
});
if (!submission) return c.json({ error: "Submission not found" }, 404);
if (submission.type !== "PEMBUBARAN_PT") return c.json({ error: "Not a Pembubaran PT submission" }, 400);
const aktaSubDoc = submission.documents.find((d) => d.classifiedType === "AKTA_PEMBUBARAN");
const likSubDoc = submission.documents.find((d) => d.document.documentType === "SURAT_LIKUIDATOR");
const clipping = submission.documents.find((d) => d.document.documentType === "BUKTI_PENGUMUMAN");
// Perseroan snapshot (registry) — the "perseroan" section's content.
const old = submission.oldData as { perseroan?: { nama?: string | null; status_transaksi?: number | null; status_perseroan?: string | null } } | null;
const perseroan = old?.perseroan
? { nama: old.perseroan.nama ?? null, statusTransaksi: old.perseroan.status_transaksi ?? null, statusPerseroan: old.perseroan.status_perseroan ?? null }
: null;
// Likuidator — ALWAYS all 12 canonical keys (absent → null row) so the card
// renders a stable form even when the pass produced zero rows.
const likFields = Object.fromEntries(
LIKUIDATOR_EXTRACTED_FIELD_KEYS.map((k) => {
const f = likSubDoc?.document.fields.find((x) => x.fieldKey === k);
return [k, f ? { value: f.value, confidence: f.confidence, status: f.status } : { value: null, confidence: 0, status: "NOT_FOUND" }];
}),
);
// Kehadiran — deed roster first; registry fallback second (matcher output).
const deedPs = aktaSubDoc?.document.aktaPemegangSaham ?? [];
const registryRows = (submission.rupsKehadiranRegistry as KehadiranRow[] | null) ?? [];
const kehadiran = deedPs.length > 0
? { source: "deed" as const, rows: deedPs.map((p) => ({ nama: p.namaAtauBadanHukum, persentase: p.persentase, kehadiran: p.kehadiranRups })) }
: registryRows.length > 0
? { source: "registry" as const, rows: registryRows }
: { source: null, rows: [] as KehadiranRow[] };
// Koran — clipping-first then deed-inline (same resolution as the context).
const koranOf = (doc: typeof aktaSubDoc) => ({
namaKoran: doc?.document.fields.find((f) => f.fieldKey === "nama_koran")?.value ?? null,
tanggalKoran: doc?.document.fields.find((f) => f.fieldKey === "tanggal_pengumuman")?.value ?? null,
});
const clip = koranOf(clipping);
const inline = koranOf(aktaSubDoc);
const koran = clipping && (clip.namaKoran || clip.tanggalKoran)
? { ...clip, source: "clipping" as const }
: inline.namaKoran || inline.tanggalKoran
? { ...inline, source: "akta" as const }
: { namaKoran: null, tanggalKoran: null, source: null };
return c.json({
submission: { id: submission.id, status: submission.status, type: submission.type },
perseroan,
kontak: { teleponPerseroan: submission.teleponPerseroan, emailPerseroan: submission.emailPerseroan },
akta: aktaSubDoc
? {
documentId: aktaSubDoc.documentId,
fields: aktaSubDoc.document.fields.map((f) => ({ fieldKey: f.fieldKey, value: f.value, confidence: f.confidence, status: f.status })),
penghadap: aktaSubDoc.document.aktaPenghadap.map((p) => ({ nama: p.namaLengkap, nomorIdentitas: p.nomorIdentitas })),
}
: null,
likuidator: { hasDoc: !!likSubDoc, documentId: likSubDoc?.documentId ?? null, fields: likFields },
kehadiran,
koran,
documents: submission.documents.map((d) => ({
documentId: d.documentId, classifiedType: d.classifiedType,
extractionStatus: d.extractionStatus, originalName: d.document.originalName,
})),
validationResults: submission.validationResults,
sections: pembubaranPtFlow.sections,
sectionApprovals: Object.fromEntries(submission.perubahanSectionApprovals.map((a) => [a.sectionKey, a.approved])),
});
});
// PUT /submissions/:submissionId/likuidator — bulk manual upsert of the canonical
// likuidator fieldKeys (READY-state management surface, warning 1). The safety net
// for a zero-row focused pass (empty rawText): generic per-field confirm needs an
// existing row (P2025), this endpoint creates them as MANUAL.
pembubaran.put("/submissions/:submissionId/likuidator", async (c) => {
const submissionId = c.req.param("submissionId");
const { error, submission } = await loadTyped(submissionId);
if (error === "not_found") return c.json({ error: "Submission not found" }, 404);
if (error === "wrong_type") return c.json({ error: "Not a Pembubaran PT submission" }, 400);
if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
const likSubDoc = await db.submissionDocument.findFirst({
where: { submissionId, document: { documentType: "SURAT_LIKUIDATOR" } },
select: { documentId: true },
});
if (!likSubDoc) return c.json({ error: "Surat likuidator belum diunggah" }, 404);
type Body = { fields?: Record<string, string | null> };
const body = await c.req.json<Body>().catch(() => ({}) as Body);
const canonical = new Set(LIKUIDATOR_EXTRACTED_FIELD_KEYS);
const updated: string[] = [];
for (const [fieldKey, value] of Object.entries(body.fields ?? {})) {
if (!canonical.has(fieldKey)) continue; // ignore anything off-contract
await db.extractedField.upsert({
where: { documentId_fieldKey: { documentId: likSubDoc.documentId, fieldKey } },
update: { value: value ?? null, confidence: 1, status: "MANUAL" },
create: { documentId: likSubDoc.documentId, fieldKey, value: value ?? null, confidence: 1, status: "MANUAL" },
});
updated.push(fieldKey);
}
if (updated.length > 0) await queueReMatchAndValidate(submissionId).catch(console.error); // serialized — reference_rematch_dispatch
return c.json({ documentId: likSubDoc.documentId, updated });
});
// PUT /submissions/:submissionId/kehadiran — attendance ticks. Handles BOTH
// roster paths: deed PS rows (AktaPemegangSaham.kehadiranRups) and the registry
// fallback (Submission.rupsKehadiranRegistry). Name matching is honorific-stripped
// (same normalizeName the matcher uses).
pembubaran.put("/submissions/:submissionId/kehadiran", async (c) => {
const submissionId = c.req.param("submissionId");
const { error, submission } = await loadTyped(submissionId);
if (error === "not_found") return c.json({ error: "Submission not found" }, 404);
if (error === "wrong_type") return c.json({ error: "Not a Pembubaran PT submission" }, 400);
if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
type Body = { entries?: { nama?: string; kehadiran?: boolean | null }[] };
const body = await c.req.json<Body>().catch(() => ({}) as Body);
const entries = (body.entries ?? []).filter((e): e is { nama: string; kehadiran: boolean | null } => !!e.nama?.trim());
if (entries.length === 0) return c.json({ error: "entries wajib diisi" }, 422);
const byName = new Map(entries.map((e) => [normalizeName(e.nama), e.kehadiran ?? null]));
const aktaSubDoc = await db.submissionDocument.findFirst({
where: { submissionId, classifiedType: "AKTA_PEMBUBARAN" },
select: { documentId: true },
});
const deedPs = aktaSubDoc
? await db.aktaPemegangSaham.findMany({ where: { documentId: aktaSubDoc.documentId } })
: [];
let updated = 0;
if (deedPs.length > 0) {
for (const ps of deedPs) {
const key = normalizeName(ps.namaAtauBadanHukum);
if (!byName.has(key)) continue;
await db.aktaPemegangSaham.update({ where: { id: ps.id }, data: { kehadiranRups: byName.get(key) ?? null } });
updated++;
}
} else {
const rows = ((submission!.rupsKehadiranRegistry as KehadiranRow[] | null) ?? []).map((r) => {
const key = normalizeName(r.nama);
if (!byName.has(key)) return r;
updated++;
return { ...r, kehadiran: byName.get(key) ?? null };
});
await db.submission.update({
where: { id: submissionId },
data: { rupsKehadiranRegistry: rows as unknown as Prisma.InputJsonValue },
});
}
if (updated > 0) await queueReMatchAndValidate(submissionId).catch(console.error);
return c.json({ updated, source: deedPs.length > 0 ? "deed" : "registry" });
});
// PUT /submissions/:submissionId/koran — operator-editable koran fields (the
// PEMBUBARAN_KORAN advisory is correctable by design). Lands on the clipping doc
// when present, else the deed — the SAME canonical keys the context reads.
pembubaran.put("/submissions/:submissionId/koran", async (c) => {
const submissionId = c.req.param("submissionId");
const { error, submission } = await loadTyped(submissionId);
if (error === "not_found") return c.json({ error: "Submission not found" }, 404);
if (error === "wrong_type") return c.json({ error: "Not a Pembubaran PT submission" }, 400);
if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
const clipping = await db.submissionDocument.findFirst({
where: { submissionId, document: { documentType: "BUKTI_PENGUMUMAN" } }, select: { documentId: true },
});
const akta = clipping
? null
: await db.submissionDocument.findFirst({ where: { submissionId, classifiedType: "AKTA_PEMBUBARAN" }, select: { documentId: true } });
const documentId = clipping?.documentId ?? akta?.documentId;
if (!documentId) return c.json({ error: "Tidak ada dokumen pembawa data koran" }, 404);
type KoranBody = { namaKoran?: string | null; tanggalPengumuman?: string | null };
const body = await c.req.json<KoranBody>().catch(() => ({}) as KoranBody);
const pairs: Array<[string, string | null]> = [];
if ("namaKoran" in body) pairs.push(["nama_koran", body.namaKoran ?? null]);
if ("tanggalPengumuman" in body) pairs.push(["tanggal_pengumuman", body.tanggalPengumuman ?? null]);
for (const [fieldKey, value] of pairs) {
await db.extractedField.upsert({
where: { documentId_fieldKey: { documentId, fieldKey } },
update: { value, confidence: 1, status: "MANUAL" },
create: { documentId, fieldKey, value, confidence: 1, status: "MANUAL" },
});
}
await queueReMatchAndValidate(submissionId).catch(console.error);
return c.json({ documentId, updated: pairs.map(([k]) => k) });
});
// POST /submissions/:submissionId/sections/:sectionKey/approve | /unapprove
// EXPLICIT persisted rows — never derived from child confirmed state (c0698d8).
for (const [suffix, approved] of [["approve", true], ["unapprove", false]] as const) {
pembubaran.post(`/submissions/:submissionId/sections/:sectionKey/${suffix}`, async (c) => {
const submissionId = c.req.param("submissionId");
const sectionKey = c.req.param("sectionKey");
const { error, submission } = await loadTyped(submissionId);
if (error === "not_found") return c.json({ error: "Submission not found" }, 404);
if (error === "wrong_type") return c.json({ error: "Not a Pembubaran PT submission" }, 400);
if (guardStatus(submission!, READY_ONLY).blocked) return c.json(WRONG_STATUS, 409);
if (!APPROVABLE_KEYS.has(sectionKey)) return c.json({ error: `Unknown section: ${sectionKey}` }, 400);
const row = await db.perubahanSectionApproval.upsert({
where: { submissionId_sectionKey: { submissionId, sectionKey } },
update: { approved, approvedAt: approved ? new Date() : null },
create: { submissionId, sectionKey, approved, approvedAt: approved ? new Date() : null },
});
return c.json({ sectionKey: row.sectionKey, approved: row.approved });
});
}
/**
* POST /submissions/:submissionId/submit — VERIFIKATOR STAGING.
* Order: canonical status guard → required section approvals → checkFailGate
* (block-with-override, NAMED engine step, canonical 422 envelope) → ONE
* transaction: COMPLETED + VerifikasiPerubahan create / PERBAIKAN re-submit +
* audit event. NO runAdvisory (the advisory engine builds a perubahan-shaped
* diff report; a dissolution has no diff — aiReport stays null, the verifikator
* reads the validation list). NO auto-confirm anywhere.
*/
pembubaran.post("/submissions/:submissionId/submit", async (c) => {
const submissionId = c.req.param("submissionId");
const submission = await db.submission.findUnique({
where: { id: submissionId },
include: { perubahanSectionApprovals: true, verifikasi: true },
});
if (!submission) return c.json({ error: "Submission not found" }, 404);
if (submission.type !== "PEMBUBARAN_PT") return c.json({ error: "Not a Pembubaran PT submission" }, 400);
if (submission.status !== "READY") return c.json(WRONG_STATUS, 409);
// (1) required section approvals — explicit persisted rows only (6 keys;
// pengumuman_koran is approvable-but-not-required — Ps.147 advisory content).
const requiredKeys = pembubaranPtFlow.sections.filter((s) => s.approvable && s.required).map((s) => s.key);
const approvedSet = new Set(submission.perubahanSectionApprovals.filter((a) => a.approved).map((a) => a.sectionKey));
const missingSections = requiredKeys.filter((k) => !approvedSet.has(k));
if (missingSections.length > 0) {
return c.json({ error: "Semua bagian wajib harus disetujui", code: "SECTIONS_UNAPPROVED", missingSections }, 400);
}
// (2) FAIL gate — block-with-override, canonical envelope.
const { blocked, failing } = await checkFailGate(submissionId, pembubaranPtFlow);
if (blocked) {
return c.json({ error: "Validasi FAIL harus diselesaikan atau di-override sebelum submit", code: "VALIDATION_FAILED", failing }, 422);
}
// (3) verifikator staging — peleburan/akuisisi arm mirror.
const likSubDoc = await db.submissionDocument.findFirst({
where: { submissionId, document: { documentType: "SURAT_LIKUIDATOR" } },
select: { document: { select: { fields: { where: { fieldKey: { in: ["likuidator_nama", "likuidator_nomor_surat"] } } } } } },
});
await db.$transaction(async (tx) => {
await tx.submission.update({ where: { id: submissionId }, data: { status: "COMPLETED" } });
const existing = submission.verifikasi;
if (!existing) {
await tx.verifikasiPerubahan.create({ data: { submissionId, status: "MENUNGGU_VERIFIKATOR" } });
} else if (existing.status === "PERBAIKAN") {
await tx.verifikasiPerubahan.update({
where: { submissionId },
data: { status: "MENUNGGU_VERIFIKATOR", aiReport: Prisma.JsonNull, aiRecommendation: null, aiGeneratedAt: null },
});
}
const isResubmit = !!submission.verifikasi && submission.verifikasi.status === "PERBAIKAN";
await recordAuditEvent(tx, { submissionId }, {
eventType: isResubmit ? "NOTARIS_RESUBMIT" : "NOTARIS_SUBMIT",
actor: "NOTARIS",
payload: {
submissionType: "PEMBUBARAN_PT",
sectionApprovals: Object.fromEntries(submission.perubahanSectionApprovals.map((s) => [s.sectionKey, s.approved])),
likuidator: {
nama: likSubDoc?.document.fields.find((f) => f.fieldKey === "likuidator_nama")?.value ?? null,
nomorSurat: likSubDoc?.document.fields.find((f) => f.fieldKey === "likuidator_nomor_surat")?.value ?? null,
},
},
}, getRequestContext(c));
});
return c.json({ status: "COMPLETED" });
});
export default pembubaran;
- [ ] Mount —
backend/src/index.ts:import pembubaran from "./routes/pembubaran";next to the peleburan import, andapp.route("/api/pembubaran", pembubaran);next to the peleburan mount. - [ ] Run GREEN:
cd backend && bun test src/routes/__tests__/pembubaran-routes.test.ts. Also prove the select-company reuse:bun test src/routes/__tests__/pembubaran-routing.test.tsstill green (no perubahan.ts change was needed — if a test elsewhere assertsPERUBAHAN_FAMILYmembership for v2 flows, it should NOT include PEMBUBARAN_PT). - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/routes/pembubaran.ts backend/src/index.ts backend/src/routes/__tests__/pembubaran-routes.test.ts && git commit -m "feat(pembubaran): route family — review-data + READY-state edit surfaces + staged verifikator submit"
Task 10: Verifikator surface — queue admits PEMBUBARAN_PT + decide-APPROVE E2E + roster-source pin + compact page
Queue admission (the b92232e/677d7ea pattern — remember AKUISISI_PT was silently NEVER admitted for weeks because the where was type-listed; the invariant test prevents the same for PEMBUBARAN_PT), the tagged item type, the compact verifikator review page (VerifikatorPeleburanReviewPage precedent — no diff/AI-advisory; the validation list + likuidator/koran snapshots ARE the review), and the whole-flow-review warning-3 obligations: a decide-APPROVE end-to-end test plus the roster-source PIN — resolveVoterRoster must serve oldData.pemegang_saham for PEMBUBARAN_PT via its existing fallback (AKTA_PEMBUBARAN ∉ ROSTER_AKTA_TYPES, and a deed-restated roster must NOT hijack — the registry roster is the legal voter set for a dissolution). shareholder-voting.ts gets a documenting comment ONLY, no code change.
Files
- backend/src/routes/verifikator.ts — Modify (queue where + tagging)
- backend/src/services/shareholder-voting.ts — Modify (comment only, next to ROSTER_AKTA_TYPES)
- backend/src/routes/__tests__/verifikator-pembubaran.test.ts — Create
- frontend/src/hooks/use-verifikator.ts — Modify (item type union)
- frontend/src/pages/VerifikatorPembubaranReviewPage.tsx — Create
- frontend/src/pages/VerifikatorDashboardPage.tsx — Modify (routing ternary)
- frontend/src/routes.tsx — Modify (verifikator route)
- frontend/src/pages/__tests__/VerifikatorPembubaranReviewPage.test.tsx — Create
Interfaces
- Consumes: GET /api/verifikator/submissions, POST /api/verifikator/submissions/:id/decide; GET /api/pembubaran/submissions/:id/review-data (Task 9); useVerifikasiData/useDecide; DecisionPanel/AuditTrailPanel.
- Produces: queue items { type: "PEMBUBARAN_PT" } routed to /verifikator/pembubaran/:submissionId.
Steps
- [ ] Write failing backend test. Create
backend/src/routes/__tests__/verifikator-pembubaran.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";
/**
* Warning-3 obligations (Peleburan whole-flow review): queue admission invariant
* + decide-APPROVE end-to-end + the voter-roster-source PIN. Test DB has no SMTP
* host → emailDispatch.isDemoMode === true → the email gate is skipped and votes
* get rows with empty email (the roster-empty 422 still fires — that is the pin).
*/
describe("verifikator × PEMBUBARAN_PT", () => {
const ids: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
if (ids.length) {
await db.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica;`);
await tx.$executeRawUnsafe(
`DELETE FROM "VerifikasiAuditEvent" WHERE "verifikasiId" IN (SELECT id FROM "VerifikasiPerubahan" WHERE "submissionId" = ANY($1::text[]))`,
ids,
);
});
await db.submission.deleteMany({ where: { id: { in: ids } } });
}
await db.document.deleteMany({ where: { id: { in: docIds } } });
});
async function seedStaged(opts: { roster?: boolean; deedRoster?: boolean } = { roster: true }) {
const sub = await db.submission.create({
data: {
type: "PEMBUBARAN_PT", status: "COMPLETED",
oldData: {
perseroan: { nama: "PT MAJU MUNDUR", id_perseroan: 555 },
...(opts.roster !== false
? { pemegang_saham: [
{ nama: "BUDI SANTOSO", persentase: 60, jumlah_lembar: 600 },
{ nama: "SITI AMINAH", persentase: 40, jumlah_lembar: 400 },
] }
: {}),
},
},
});
ids.push(sub.id);
await db.verifikasiPerubahan.create({ data: { submissionId: sub.id, status: "MENUNGGU_VERIFIKATOR" } });
if (opts.deedRoster) {
const akta = await db.document.create({
data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY" },
});
docIds.push(akta.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PEMBUBARAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
});
await db.aktaPemegangSaham.create({
data: { documentId: akta.id, namaAtauBadanHukum: "PENYUSUP DARI AKTA", persentase: 100, orderIndex: 0 },
});
}
return sub;
}
it("queue admits PEMBUBARAN_PT tagged with its real type; PEMBUBARAN_PP is NEVER admitted (exact spelling)", async () => {
const sub = await seedStaged();
const pp = await db.submission.create({ data: { type: "PEMBUBARAN_PP", status: "COMPLETED" } });
ids.push(pp.id);
await db.verifikasiPerubahan.create({ data: { submissionId: pp.id, status: "MENUNGGU_VERIFIKATOR" } });
const res = await app.request("/api/verifikator/submissions?status=MENUNGGU_VERIFIKATOR&limit=100");
expect(res.status).toBe(200);
const body = (await res.json()) as { items: { submissionId: string; type: string }[] };
const mine = body.items.find((i) => i.submissionId === sub.id);
expect(mine?.type).toBe("PEMBUBARAN_PT");
expect(body.items.some((i) => i.submissionId === pp.id)).toBe(false);
});
it("decide-APPROVE end-to-end: MENUNGGU_VOTING + votes built from oldData.pemegang_saham (ROSTER-SOURCE PIN)", async () => {
const sub = await seedStaged({ roster: true, deedRoster: true }); // deed roster present AND must not hijack
const res = await app.request(`/api/verifikator/submissions/${sub.id}/decide`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: "APPROVE", notes: "roster pin", verifikatorId: "verifikator-dev" }),
});
expect(res.status).toBe(200);
const v = await db.verifikasiPerubahan.findUnique({ where: { submissionId: sub.id }, include: { votes: true } });
expect(v?.status).toBe("MENUNGGU_VOTING");
const names = v!.votes.map((x) => x.shareholderName).sort();
// oldData names, NOT the deed-restated "PENYUSUP DARI AKTA" — AKTA_PEMBUBARAN
// is deliberately NOT in ROSTER_AKTA_TYPES; the registry roster is the legal
// voter set for a dissolution.
expect(names).toEqual(["BUDI SANTOSO", "SITI AMINAH"]);
expect(v!.votes.find((x) => x.shareholderName === "BUDI SANTOSO")?.sharePercentage).toBe(60);
});
it("decide-APPROVE with an EMPTY roster → 422 (resolveVoterRoster returns null; no silent zero-voter voting)", async () => {
const sub = await seedStaged({ roster: false });
const res = await app.request(`/api/verifikator/submissions/${sub.id}/decide`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: "APPROVE", notes: "no roster", verifikatorId: "verifikator-dev" }),
});
expect(res.status).toBe(422);
});
});
- [ ] Run to verify RED:
cd backend && bun test src/routes/__tests__/verifikator-pembubaran.test.ts. Expected: queue-admission failures (type not in thewherelist). - [ ] Minimal implementation A —
backend/src/routes/verifikator.ts:
1. The queuewhere(~line 380) becomes:
// Perubahan-family queue + the v2 verifikator-staged flows. PERUBAHAN_PT rows
// are byte-identical; PELEBURAN_PT/PEMBUBARAN_PT rows carry no diff/advisory
// (aiReport null) and surface their validation lists on their per-family pages.
// AKUISISI_PT rows surface through the shared PERUBAHAN_PT verifikator review page.
const where: any = {
submission: { type: { in: ["PERUBAHAN_PT", "PELEBURAN_PT", "AKUISISI_PT", "PEMBUBARAN_PT"] }, status: "COMPLETED" },
};
- The tagging block (~line 593) gains a pembubaran set and branch:
const pembubaranIds = new Set(all.filter((v) => v.submission.type === "PEMBUBARAN_PT").map((v) => v.submissionId));
type: peleburanIds.has(it.submissionId)
? ("PELEBURAN_PT" as const)
: pembubaranIds.has(it.submissionId)
? ("PEMBUBARAN_PT" as const)
: akuisisiIds.has(it.submissionId)
? ("AKUISISI_PT" as const)
: ("PERUBAHAN_PT" as const),
- [ ] Minimal implementation B —
backend/src/services/shareholder-voting.ts: extend the comment block aboveROSTER_AKTA_TYPES(NO code change):
// Roster-LOCAL widening: PERUBAHAN_FAMILY_AKTA is consumed elsewhere (the
// dispatch-context finder, isPerubahanFamilyAkta) with perubahan-only semantics,
// so we widen here rather than the shared const.
// PEMBUBARAN_PT (deliberate NON-widening): AKTA_PEMBUBARAN is NOT added — a
// dissolution's voters are the CURRENT registered shareholders, i.e.
// oldData.pemegang_saham via the fallback below; a deed-restated roster must
// not hijack it (pinned by verifikator-pembubaran.test.ts).
- [ ] Minimal implementation C — frontend:
1.frontend/src/hooks/use-verifikator.ts: the queue item union becomestype: "PERUBAHAN_PT" | "PELEBURAN_PT" | "AKUISISI_PT" | "PERBAIKAN_DATA_PT" | "PEMBUBARAN_PT";
2.frontend/src/pages/VerifikatorDashboardPage.tsx: the navigate ternary gains a branch BEFORE the fall-through:
item.type === "PERBAIKAN_DATA_PT"
? `/verifikator/perbaikan/${item.submissionId}`
: item.type === "PELEBURAN_PT"
? `/verifikator/peleburan/${item.submissionId}`
: item.type === "PEMBUBARAN_PT"
? `/verifikator/pembubaran/${item.submissionId}`
: `/verifikator/review/${item.submissionId}`,
- Create
frontend/src/pages/VerifikatorPembubaranReviewPage.tsx(VerifikatorPeleburanReviewPage mirror — same imports/STATUS_STYLE; body):
import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, CheckCircle2, AlertTriangle, XCircle, MinusCircle, UserCheck } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { DecisionPanel } from "@/components/verifikator/review/DecisionPanel";
import { AuditTrailPanel } from "@/components/verifikator/review/AuditTrailPanel";
import { useVerifikasiData, useDecide } from "@/hooks/use-verifikator";
import { apiFetch } from "@/lib/api-client";
import { cn } from "@/lib/utils";
interface ValidationRow { id: string; ruleCode: string; ruleLabel: string; status: string; message: string; overridden?: boolean; overrideReason?: string | null }
interface ReviewData {
submission: { id: string; status: string };
perseroan: { nama: string | null; statusTransaksi: number | null; statusPerseroan: string | null } | null;
akta: { fields: { fieldKey: string; value: string | null }[] } | null;
likuidator: { hasDoc: boolean; fields: Record<string, { value: string | null }> };
kehadiran: { source: string | null; rows: { nama: string; persentase: number | null; kehadiran: boolean | null }[] };
koran: { namaKoran: string | null; tanggalKoran: string | null; source: string | null };
validationResults: ValidationRow[];
}
const STATUS_STYLE: Record<string, { icon: typeof CheckCircle2; cls: string }> = {
PASS: { icon: CheckCircle2, cls: "text-emerald-600 border-l-emerald-500" },
WARNING: { icon: AlertTriangle, cls: "text-amber-600 border-l-amber-500" },
FAIL: { icon: XCircle, cls: "text-red-600 border-l-red-500" },
SKIPPED: { icon: MinusCircle, cls: "text-muted-foreground border-l-gray-300" },
};
/** Compact verifikator surface for PEMBUBARAN_PT (per-family precedent:
* VerifikatorPeleburanReviewPage). No diff/AI-advisory — a dissolution has no
* old-vs-new diff; the validation list + likuidator/kehadiran/koran snapshots
* ARE the review. */
export function VerifikatorPembubaranReviewPage() {
const { submissionId } = useParams<{ submissionId: string }>();
const navigate = useNavigate();
const { data: verif } = useVerifikasiData(submissionId);
const decide = useDecide(submissionId);
const { data, isLoading } = useQuery<ReviewData>({
queryKey: ["pembubaran-review", submissionId],
queryFn: () => apiFetch<ReviewData>(`/api/pembubaran/submissions/${submissionId}/review-data`),
enabled: !!submissionId,
});
if (isLoading || !data || !verif) {
return <div className="flex items-center justify-center h-96"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>;
}
const f = (key: string) => data.akta?.fields.find((x) => x.fieldKey === key)?.value ?? "—";
const lik = (key: string) => data.likuidator.fields[key]?.value ?? "—";
return (
<div className="mx-auto max-w-4xl space-y-4 p-4 pb-16">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold">Pembubaran PT — Tinjauan Verifikator</h1>
<p className="text-sm text-muted-foreground">
{data.perseroan?.nama ?? f("nama_perseroan")} · Akta No. {f("nomor_akta")} · {f("nama_notaris")}
</p>
</div>
<Button variant="outline" size="sm" onClick={() => navigate("/verifikator")}>Kembali</Button>
</div>
{/* Likuidator */}
<section className="rounded-lg border bg-card p-4 space-y-1 text-sm">
<h2 className="flex items-center gap-1.5 text-sm font-semibold"><UserCheck className="h-4 w-4" /> Likuidator</h2>
<div>Nama: {lik("likuidator_nama")} · Surat: {lik("likuidator_nomor_surat")} ({lik("likuidator_tanggal_surat")})</div>
<div className="text-xs text-muted-foreground">
{lik("likuidator_alamat")} RT {lik("likuidator_rt")}/RW {lik("likuidator_rw")}, {lik("likuidator_kelurahan")}, {lik("likuidator_kecamatan")} {lik("likuidator_kode_pos")} · {lik("likuidator_telepon")} · {lik("likuidator_email")}
</div>
<div className="text-xs text-muted-foreground">Dasar penunjukan: {lik("likuidator_dasar_penunjukan")}</div>
</section>
{/* RUPS & Kehadiran */}
<section className="rounded-lg border bg-card p-4 space-y-1 text-sm">
<h2 className="text-sm font-semibold">Kehadiran RUPS <span className="text-xs font-normal text-muted-foreground">(sumber: {data.kehadiran.source ?? "—"} · kuorum Ps.89(1) ≥3/4)</span></h2>
{data.kehadiran.rows.map((r, i) => (
<div key={i} className="flex items-center justify-between border-b py-1 last:border-0">
<span>{r.nama}</span>
<span className="text-xs text-muted-foreground">
{r.persentase ?? "—"}% · <Badge variant="outline" className="text-[10px]">{r.kehadiran === true ? "HADIR" : r.kehadiran === false ? "TIDAK HADIR" : "?"}</Badge>
</span>
</div>
))}
</section>
{/* Pengumuman */}
<section className="rounded-lg border bg-card p-4 space-y-1 text-sm">
<h2 className="text-sm font-semibold">Pengumuman Koran (Ps.147)</h2>
<div>Koran: {data.koran.namaKoran ?? "—"} · Tanggal: {data.koran.tanggalKoran ?? "—"} · Sumber: {data.koran.source ?? "—"}</div>
</section>
{/* Hasil Validasi — the review substance (no AI advisory for this flow) */}
<section className="rounded-lg border bg-card p-4 space-y-1.5">
<h2 className="text-sm font-semibold">Hasil Validasi</h2>
{data.validationResults.map((v) => {
const S = STATUS_STYLE[v.status] ?? STATUS_STYLE.SKIPPED!;
const Icon = S.icon;
return (
<div key={v.id} className={cn("flex items-start gap-2 rounded border-l-4 bg-muted/30 p-2", S.cls)}>
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium">
{v.ruleLabel} {v.overridden && <span className="text-muted-foreground">(di-override: {v.overrideReason ?? "tanpa alasan"})</span>}
</div>
<div className="text-xs text-muted-foreground">{v.message}</div>
</div>
</div>
);
})}
</section>
<DecisionPanel
status={verif.verifikasi.status}
isPending={decide.isPending}
onDecide={(decision, notes) =>
decide.mutate(
{ decision, notes, verifikatorId: "verifikator-dev" },
{ onError: (e) => toast.error(e instanceof Error ? e.message : "Gagal menyimpan keputusan") },
)
}
/>
<AuditTrailPanel events={verif.auditEvents} />
</div>
);
}
frontend/src/routes.tsx: import the page and add inside the verifikator RoleGuard children:
{
path: "verifikator/pembubaran/:submissionId",
element: <ErrorBoundary><VerifikatorPembubaranReviewPage /></ErrorBoundary>,
},
- [ ] Write the FE page test. Create
frontend/src/pages/__tests__/VerifikatorPembubaranReviewPage.test.tsx(LaporanRupsReviewPage.test.tsx pattern:vi.mock("@/lib/api-client"), IOStub, MemoryRouter; also mock@/hooks/use-verifikatorreturning{ verifikasi: { status: "MENUNGGU_VERIFIKATOR" }, auditEvents: [] }and a no-opuseDecide): assert the header renders the perseroan name, the Likuidator section renderslikuidator_nama, the kehadiran rows render with HADIR/TIDAK HADIR badges, and the validation list renders a WARNING row. Keep it to 3-4 focused assertions. - [ ] Run GREEN:
cd backend && bun test src/routes/__tests__/verifikator-pembubaran.test.ts src/routes/__tests__/verifikator-peleburan.test.ts src/routes/__tests__/verifikator-list.test.ts && cd ../frontend && bunx vitest run src/pages/__tests__/VerifikatorPembubaranReviewPage.test.tsx. - [ ] Typecheck both:
cd backend && bunx tsc --noEmit && cd ../frontend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/routes/verifikator.ts backend/src/services/shareholder-voting.ts backend/src/routes/__tests__/verifikator-pembubaran.test.ts frontend/src/hooks/use-verifikator.ts frontend/src/pages/VerifikatorPembubaranReviewPage.tsx frontend/src/pages/VerifikatorDashboardPage.tsx frontend/src/routes.tsx frontend/src/pages/__tests__/VerifikatorPembubaranReviewPage.test.tsx && git commit -m "feat(pembubaran): verifikator queue admission + decide-APPROVE E2E + oldData roster pin + compact page"
Task 11: Frontend klasifikasi surfacing — detect chain, banner, labels, create-route, unsupported gate, re-check trigger
The board learns pembubaran: display labels, the DetectedFlow banner entry, the detect chains with the pembubaran rung BELOW akuisisi and the FE mirror of the Berakhirnya-package suppression (a package must never show a confident "Pembubaran PT" detection), the create-route branch, the supported:false create-response gate (the backend now returns it for Berakhirnya packages — surfaced as a toast, no undefined navigation), and the Task-8 re-check trigger (fires once per classified set containing an AKTA_PEMBUBARAN with no effective SURAT_LIKUIDATOR; merges suggestions into the existing amber chip mechanism — never auto-applies).
Files
- frontend/src/lib/classifier-labels.ts — Modify
- frontend/src/lib/pembubaran-detect.ts — Create
- frontend/src/components/klasifikasi/detect-banner.tsx — Modify
- frontend/src/pages/KlasifikasiPage.tsx — Modify
- frontend/src/lib/__tests__/pembubaran-detect.test.ts — Create
Steps
- [ ] Write failing test. Create
frontend/src/lib/__tests__/pembubaran-detect.test.ts(vitest):
import { describe, it, expect } from "vitest";
import { isBerakhirnyaPackage, pickLikuidatorRecheckCandidates } from "../pembubaran-detect";
const r = (classification: string, userOverride?: string, backendFileId = classification.toLowerCase()) =>
({ classification, userOverride, backendFileId }) as never;
describe("isBerakhirnyaPackage (FE mirror of the backend predicate — suppression only)", () => {
it("true on AKTA_BERAKHIRNYA / laporan-permohonan labels / ≥2 clippings; false on a single clipping", () => {
expect(isBerakhirnyaPackage([r("AKTA_BERAKHIRNYA")])).toBe(true);
expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("LAPORAN_LIKUIDASI")])).toBe(true);
expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("SURAT_PERMOHONAN")])).toBe(true);
expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("BUKTI_PENGUMUMAN"), r("OTHER", "BUKTI_PENGUMUMAN")])).toBe(true);
expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("BUKTI_PENGUMUMAN")])).toBe(false);
expect(isBerakhirnyaPackage([r("AKTA_PEMBUBARAN"), r("KTP")])).toBe(false);
});
});
describe("pickLikuidatorRecheckCandidates", () => {
it("selects non-card, non-akta, non-BA files; skips when a SURAT_LIKUIDATOR already exists", () => {
const set = [r("AKTA_PEMBUBARAN"), r("KTP"), r("NPWP"), r("BERITA_ACARA_RUPS"), r("SURAT_PERNYATAAN_DOMISILI"), r("OTHER")];
expect(pickLikuidatorRecheckCandidates(set)).toEqual(["surat_pernyataan_domisili", "other"]);
expect(pickLikuidatorRecheckCandidates([...set, r("SURAT_LIKUIDATOR")])).toEqual([]);
expect(pickLikuidatorRecheckCandidates([r("KTP"), r("OTHER")])).toEqual([]); // no AKTA_PEMBUBARAN → out of scope
});
});
- [ ] Run to verify RED:
cd frontend && bunx vitest run src/lib/__tests__/pembubaran-detect.test.ts. - [ ] Minimal implementation A — create
frontend/src/lib/pembubaran-detect.ts:
/** Pure FE helpers for the pembubaran klasifikasi surfacing. */
export interface DetectLite { classification: string; userOverride?: string; backendFileId?: string }
const eff = (r: DetectLite) => r.userOverride ?? r.classification;
/**
* FE mirror of the backend BERAKHIRNYA_PACKAGE predicate (routes/submissions.ts —
* the authority; this mirror only SUPPRESSES the confident "Pembubaran PT"
* detection so a Berakhirnya package never banners as pembubaran. The backend
* create gate — supported:false — is the enforcement).
*/
export function isBerakhirnyaPackage(results: DetectLite[]): boolean {
const isClipping = (r: DetectLite) => eff(r) === "BUKTI_PENGUMUMAN"; // FE approximation of the carrier map
return (
results.some((r) => eff(r) === "AKTA_BERAKHIRNYA") ||
(results.some((r) => eff(r) === "AKTA_PEMBUBARAN") &&
(results.some((r) => eff(r) === "LAPORAN_LIKUIDASI" || eff(r) === "SURAT_PERMOHONAN") ||
results.some((r) => eff(r) === "BUKTI_PENGUMUMAN_152") ||
results.filter(isClipping).length >= 2))
);
}
const RECHECK_EXCLUDED = new Set([
"KTP", "SIM", "NPWP", "NPWP_LAMA", "PASSPORT", "KITAS",
"BERITA_ACARA_RUPS", "DATA_KONTAK", "BUKTI_PENGUMUMAN", "SURAT_LIKUIDATOR",
]);
/**
* Candidate backendFileIds for the SCOPED SURAT_LIKUIDATOR re-check pass
* (spec §4.5). Only when the set contains an AKTA_PEMBUBARAN and no effective
* SURAT_LIKUIDATOR yet; cards/akta/BA/clippings are excluded (they cannot be
* the letter). Returns [] when out of scope.
*/
export function pickLikuidatorRecheckCandidates(results: DetectLite[]): string[] {
if (!results.some((r) => eff(r) === "AKTA_PEMBUBARAN")) return [];
if (results.some((r) => eff(r) === "SURAT_LIKUIDATOR")) return [];
return results
.filter((r) => !eff(r).startsWith("AKTA_") && !eff(r).startsWith("BUKTI_TRANSFER") && !RECHECK_EXCLUDED.has(eff(r)))
.map((r) => r.backendFileId)
.filter((id): id is string => !!id);
}
- [ ] Minimal implementation B —
frontend/src/lib/classifier-labels.ts: add afterAKTA_PELEBURAN:
AKTA_PEMBUBARAN: "Akta Pembubaran",
SURAT_LIKUIDATOR: "Surat Penunjukan Likuidator",
- [ ] Minimal implementation C —
frontend/src/components/klasifikasi/detect-banner.tsx:DetectedFlowunion +="pembubaran";FLOW_LABELS.pembubaran = "Pembubaran PT";FLOW_DESCRIPTIONS.pembubaran = "Akta Pembubaran terdeteksi. Alur Pembubaran PT (likuidasi — penunjukan likuidator) akan dijalankan.". - [ ] Minimal implementation D —
frontend/src/pages/KlasifikasiPage.tsx:
1. Import{ isBerakhirnyaPackage, pickLikuidatorRecheckCandidates }from@/lib/pembubaran-detect; extendKlasifikasiLabelTypewith"pembubaran".
2. Add next to the otherhasAkta*consts:
const berakhirnyaPaket = isBerakhirnyaPackage(results);
const hasAktaPembubaran = !berakhirnyaPaket && results.some(
(r) => (r.userOverride ?? r.classification) === "AKTA_PEMBUBARAN"
);
computedLabelTypechain — insert BELOW akuisisi, ABOVE perubahan:... : hasAktaAkuisisi ? "akuisisi" : hasAktaPembubaran ? "pembubaran" : hasAktaPerubahan ? "perubahan" : ...— and thedetectedFlowchain identically ("pembubaran"between"akuisisi"and"perubahan").- Create-route: in
createSubmission'sonSuccess, FIRST handle the unsupported gate, then the pembubaran branch:
onSuccess: (data) => {
if ((data as { supported?: boolean }).supported === false) {
toast.error("Paket dokumen terdeteksi sebagai alur yang belum didukung — periksa klasifikasi dokumen.");
return;
}
if (data.type === "PERBAIKAN_DATA_PT") {
(add import { toast } from "sonner"; if absent) and after the PELEBURAN_PT branch:
} else if (data.type === "PEMBUBARAN_PT") {
navigate(`/pembubaran/${data.submissionId}/extraction`);
- Re-check trigger — add an effect after the results/
flow.phasestate (fires ONCE per classified set; merges into the existingsuggestionchip, never auto-applies):
// SCOPED SURAT_LIKUIDATOR re-check (spec §4.5): only for sets containing an
// AKTA_PEMBUBARAN with no effective SURAT_LIKUIDATOR. Suggestion-only — the
// amber chip applies through the existing re-map mechanism.
const recheckedRef = useRef<string | null>(null);
useEffect(() => {
if (flow.phase !== "done") return;
const candidates = pickLikuidatorRecheckCandidates(results);
if (candidates.length === 0) return;
const signature = candidates.slice().sort().join("|");
if (recheckedRef.current === signature) return;
recheckedRef.current = signature;
apiFetch<{ suggestions: { fileId: string; docType: string; displayName: string }[] }>(
"/api/klasifikasi/recheck-likuidator",
{ method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fileIds: candidates }) },
)
.then(({ suggestions }) => {
if (suggestions.length === 0) return;
setResults((prev) => prev.map((r) =>
suggestions.some((s) => s.fileId === r.backendFileId) && !r.userOverride
? { ...r, suggestion: { docType: "SURAT_LIKUIDATOR", displayName: "Surat Penunjukan Likuidator" } }
: r,
));
})
.catch(() => {});
}, [flow.phase, results]);
(adapt setResults to the page's actual results setter — it is the jotai classificationResultsAtom writer already used by the classify handlers; useRef/useEffect imports as needed.)
- [ ] Run GREEN: cd frontend && bunx vitest run src/lib/__tests__/pembubaran-detect.test.ts && bunx vitest run. Expected: new tests pass, full FE suite stays green.
- [ ] Typecheck: cd frontend && bunx tsc --noEmit.
- [ ] Commit: git add frontend/src/lib/classifier-labels.ts frontend/src/lib/pembubaran-detect.ts frontend/src/components/klasifikasi/detect-banner.tsx frontend/src/pages/KlasifikasiPage.tsx frontend/src/lib/__tests__/pembubaran-detect.test.ts && git commit -m "feat(pembubaran): klasifikasi surfacing — detect rung below akuisisi + package suppression + re-check trigger"
Task 12: Frontend flow config + extraction page (steps, destination, gate helpers, single-company mirror)
The staged step labels (5 steps — verifikator stage; peleburan twin), the destinationFor base (NOT terminal — verifikasi-driven), the pure submit-gate helpers, and the extraction page. The extraction page is a copy of LaporanRupsExtractionPage.tsx — the RIGHT copy source: it is the v2 single-company page already wired to the type-agnostic CompanySelectionModal/select-company flow, and it carries exactly 3 flow-specific tokens (verified by grep: the function name at line 43, the computeFlowStep type literal at ~254, the review navigate at ~488). Do NOT copy PeleburanExtractionPage (its sources panel replaced the modal — wrong shape for a single-company flow).
Files
- frontend/src/lib/flow-steps.ts — Modify
- frontend/src/lib/destination-for.ts — Modify
- frontend/src/lib/pembubaran-review.ts — Create
- frontend/src/pages/PembubaranExtractionPage.tsx — Create (copy + 3 diffs)
- frontend/src/lib/__tests__/pembubaran-flow-config.test.ts — Create
Steps
- [ ] Write failing test. Create
frontend/src/lib/__tests__/pembubaran-flow-config.test.ts(vitest):
import { describe, it, expect } from "vitest";
import { computeFlowStep } from "../flow-steps";
import { destinationFor } from "../destination-for";
import { PEMBUBARAN_REQUIRED_SECTIONS, missingSections } from "../pembubaran-review";
describe("PEMBUBARAN_PT flow steps (verifikator-staged — 5 steps)", () => {
it("pre-submit drives from submission.status; post-COMPLETED from verifikasi", () => {
const ready = computeFlowStep({ type: "PEMBUBARAN_PT", status: "READY" }, null);
expect(ready.steps.length).toBe(5);
expect(ready.currentStep).toBe(2);
const waiting = computeFlowStep({ type: "PEMBUBARAN_PT", status: "COMPLETED" }, { status: "MENUNGGU_VERIFIKATOR" });
expect(waiting.currentStep).toBe(3);
const done = computeFlowStep({ type: "PEMBUBARAN_PT", status: "COMPLETED" }, { status: "SELESAI" });
expect(done.currentStep).toBe(5); // past the last index — final dot green
const rejected = computeFlowStep({ type: "PEMBUBARAN_PT", status: "COMPLETED" }, { status: "DITOLAK" });
expect(rejected.variant).toBe("error");
});
});
describe("destinationFor PEMBUBARAN_PT (NOT terminal — verifikasi-driven)", () => {
const sub = (status: string) => ({ id: "s1", type: "PEMBUBARAN_PT" as const, status });
it("pre-COMPLETED → review; COMPLETED → status; PERBAIKAN → review; SELESAI → status", () => {
expect(destinationFor(sub("READY"), null)).toBe("/pembubaran/s1/review");
expect(destinationFor(sub("COMPLETED"), null)).toBe("/pembubaran/s1/status");
expect(destinationFor(sub("COMPLETED"), { status: "PERBAIKAN" })).toBe("/pembubaran/s1/review");
expect(destinationFor(sub("COMPLETED"), { status: "SELESAI" })).toBe("/pembubaran/s1/status");
});
});
describe("pembubaran-review gate helpers", () => {
it("mirrors the backend's 6 required sections; koran never gates", () => {
expect([...PEMBUBARAN_REQUIRED_SECTIONS]).toEqual([
"perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak",
]);
expect(missingSections({ perseroan: true, akta_pembubaran: true, rups_kehadiran: true, likuidator: true, identitas: true, data_kontak: true })).toEqual([]);
expect(missingSections({ perseroan: true, pengumuman_koran: true })).toEqual([
"akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak",
]);
});
});
- [ ] Run to verify RED:
cd frontend && bunx vitest run src/lib/__tests__/pembubaran-flow-config.test.ts. - [ ] Minimal implementation A —
frontend/src/lib/flow-steps.ts:
1. AfterPELEBURAN_LABELS, add:
// Verifikator-staged (peleburan/akuisisi twin): submit creates a VerifikasiPerubahan
// row; post-COMPLETED stages drive from its status. PEMBUBARAN_PT ≠ PEMBUBARAN_PP
// (the PP flow has its own PP_STEP_LABELS entry below — never alias the two).
const PEMBUBARAN_LABELS = [
{ label: "Unggah", sub: "AI mengklasifikasi otomatis" },
{ label: "Ekstraksi", sub: "AI membaca dokumen" },
{ label: "Tinjauan", sub: "Verifikasi & kirim" },
{ label: "Menunggu Verifikasi", sub: "Sedang ditinjau verifikator" },
{ label: "Selesai", sub: "Pembubaran tercatat" },
] as const;
SubmissionLite.typeunion +="PEMBUBARAN_PT".- In
computeFlowStep:const isPembubaran = submission.type === "PEMBUBARAN_PT";, extend the labels chain (isPeleburan ? PELEBURAN_LABELS : isPembubaran ? PEMBUBARAN_LABELS : ...), and widen the staged arm condition toif (isAkuisisi || isPeleburan || isPembubaran) {.
- [ ] Minimal implementation B —frontend/src/lib/destination-for.ts:SubmissionLite.typeunion +="PEMBUBARAN_PT"; thebasechain gains: submission.type === "PEMBUBARAN_PT" ? "pembubaran"before the"pendirian"fallback. Do NOT touchTERMINAL_TYPES.
- [ ] Minimal implementation C — createfrontend/src/lib/pembubaran-review.ts:
/** Pure gate helpers for PembubaranReviewPage — mirror of the backend submit gate
* (routes/pembubaran.ts) so the UI pre-flights the exact conditions it will be
* blocked on. Approvals are EXPLICIT persisted rows (c0698d8 discipline).
* pengumuman_koran is approvable but NEVER required (Ps.147 advisory content). */
export const PEMBUBARAN_REQUIRED_SECTIONS = [
"perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak",
] as const;
export function missingSections(approvals: Record<string, boolean>): string[] {
return PEMBUBARAN_REQUIRED_SECTIONS.filter((k) => !approvals[k]);
}
- [ ] Minimal implementation D — create
frontend/src/pages/PembubaranExtractionPage.tsx: copyfrontend/src/pages/LaporanRupsExtractionPage.tsxbyte-for-byte, then apply EXACTLY these diffs:
1.export function LaporanRupsExtractionPage()→export function PembubaranExtractionPage().
2. ThecomputeFlowStepcall's type literal:{ type: "LAPORAN_RUPS_TAHUNAN", status: submission.status }→{ type: "PEMBUBARAN_PT", status: submission.status }.
3. The review navigate:navigate(`/laporan-rups/${submissionId}/review`)→navigate(`/pembubaran/${submissionId}/review`).
4. Add a file-head comment:/** Thin mirror of LaporanRupsExtractionPage — the v2 single-company extraction page (CompanySelectionModal → POST /api/perubahan/select-company, type-agnostic; resumes continueFlow). Exactly 3 token diffs. */
Thendiff <(sed 's/Pembubaran/LaporanRups/g; s/PEMBUBARAN_PT/LAPORAN_RUPS_TAHUNAN/g; s|/pembubaran/|/laporan-rups/|g' frontend/src/pages/PembubaranExtractionPage.tsx) frontend/src/pages/LaporanRupsExtractionPage.tsx— output must be the head-comment only (faithful-mirror proof; if the copy source has other flow-specific strings the grep missed, mirror them the same way and note it in the commit body). - [ ] Run GREEN:
cd frontend && bunx vitest run src/lib/__tests__/pembubaran-flow-config.test.ts && bunx vitest run(full FE suite — flow-steps/destination changes are additive). - [ ] Typecheck:
cd frontend && bunx tsc --noEmit(the new page is not yet routed — routes land in Task 13; tsc still checks it). - [ ] Commit:
git add frontend/src/lib/flow-steps.ts frontend/src/lib/destination-for.ts frontend/src/lib/pembubaran-review.ts frontend/src/pages/PembubaranExtractionPage.tsx frontend/src/lib/__tests__/pembubaran-flow-config.test.ts && git commit -m "feat(pembubaran): FE flow config (staged steps, non-terminal destination) + extraction page mirror"
Task 13: Frontend review + status pages + routes
The flat sectioned review page (PeleburanReviewPage skeleton, pembubaran content): Perseroan (SABH snapshot) / Akta Pembubaran (deed header + dual-deed + notaris pengganti + tanggal RUPS, per-field confirm via the generic endpoint) / RUPS & Kehadiran (roster rows with HADIR/TIDAK toggles → PUT /kehadiran; source badge) / Likuidator (the 12 fields — confirm-able rows + a manual editor saving through PUT /likuidator; READY-state surface, warning 1) / Pengumuman Koran (editable, approvable-but-not-required) / Identitas / Data Kontak / validation list with override / staged submit dialog with the 422 surfaced inline + the COMPLETED Navigate idiom (regression 46176de baked in). Plus the verifikasi-aware status page (peleburan mirror) and the route trio.
Files
- frontend/src/pages/PembubaranReviewPage.tsx — Create
- frontend/src/pages/PembubaranStatusPage.tsx — Create
- frontend/src/routes.tsx — Modify
- frontend/src/pages/__tests__/PembubaranReviewPage.test.tsx — Create
Interfaces
- Consumes: GET /api/pembubaran/submissions/:id/review-data, POST .../sections/:key/approve|unapprove, PUT .../likuidator, PUT .../kehadiran, PUT .../koran, POST .../submit (Task 9); generic POST /api/submissions/:id/documents/:docId/fields/confirm, POST /api/submissions/:id/override-validation; missingSections (Task 12); computeFlowStep; useVerifikasiData (status page).
- Produces: routes pembubaran/:submissionId/{extraction,review,status}.
Steps
- [ ] Write failing test. Create
frontend/src/pages/__tests__/PembubaranReviewPage.test.tsx(LaporanRupsReviewPage.test.tsx pattern —apiFetchmock, IOStub, MemoryRouter, qc-preseeded["pembubaran-review", id]cache):
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { PembubaranReviewPage } from "../PembubaranReviewPage";
const apiFetch = vi.fn();
vi.mock("@/lib/api-client", () => ({ apiFetch: (url: string, opts?: RequestInit) => apiFetch(url, opts) }));
class IOStub {
observe() {} disconnect() {} unobserve() {} takeRecords() { return []; }
root = null; rootMargin = ""; thresholds: number[] = [];
}
(globalThis as unknown as { IntersectionObserver: typeof IOStub }).IntersectionObserver = IOStub;
const SUBMISSION_ID = "pembubaran-sub-1";
const likField = (value: string | null) => ({ value, confidence: value ? 0.75 : 0, status: value ? "EDITABLE" : "NOT_FOUND" });
function makeReviewData(overrides: Record<string, unknown> = {}) {
return {
submission: { id: SUBMISSION_ID, status: "READY", type: "PEMBUBARAN_PT" },
perseroan: { nama: "PT MAJU MUNDUR", statusTransaksi: 1, statusPerseroan: "AKTIF" },
kontak: { teleponPerseroan: "0215550001", emailPerseroan: "corp@majumundur.co.id" },
akta: {
documentId: "doc-akta-1",
fields: [
{ fieldKey: "nama_perseroan", value: "PT MAJU MUNDUR", confidence: 0.9, status: "CONFIRMED" },
{ fieldKey: "nomor_akta", value: "5", confidence: 0.9, status: "EXTRACTED" },
{ fieldKey: "tanggal_akta", value: "2026-04-30", confidence: 0.9, status: "CONFIRMED" },
{ fieldKey: "nama_notaris", value: "Sari, S.H., M.Kn.", confidence: 0.9, status: "CONFIRMED" },
{ fieldKey: "tanggal_rups", value: "2026-04-28", confidence: 0.9, status: "CONFIRMED" },
{ fieldKey: "akta_kedua_nomor", value: "6", confidence: 0.9, status: "EXTRACTED" },
],
penghadap: [],
},
likuidator: {
hasDoc: true, documentId: "doc-lik-1",
fields: {
likuidator_nama: likField("BUDI HARTONO"), likuidator_nomor_surat: likField("017/SPL"),
likuidator_tanggal_surat: likField("2026-05-02"), likuidator_alamat: likField("Jl. Melati 12"),
likuidator_rt: likField("004"), likuidator_rw: likField("007"), likuidator_kode_pos: likField("10310"),
likuidator_kelurahan: likField("Cempaka Putih"), likuidator_kecamatan: likField("Menteng"),
likuidator_telepon: likField(null), likuidator_email: likField(null), likuidator_dasar_penunjukan: likField("Keputusan RUPS"),
},
},
kehadiran: { source: "registry", rows: [{ nama: "BUDI SANTOSO", persentase: 60, kehadiran: true }, { nama: "SITI AMINAH", persentase: 40, kehadiran: null }] },
koran: { namaKoran: "Media Indonesia", tanggalKoran: "2026-05-10", source: "akta" },
documents: [{ documentId: "doc-ktp-1", classifiedType: "KTP", extractionStatus: "DONE", originalName: "ktp.pdf" }],
validationResults: [
{ id: "v1", ruleCode: "PEMBUBARAN_KORAN", ruleLabel: "Pengumuman Koran Pembubaran (Ps.147)", status: "PASS", message: "ok" },
{ id: "v2", ruleCode: "LIKUIDATOR_KTP_MATCH", ruleLabel: "KTP Likuidator Sesuai Surat Penunjukan", status: "FAIL", message: "tidak cocok" },
],
sections: [],
sectionApprovals: { perseroan: true, akta_pembubaran: true, rups_kehadiran: true, likuidator: true, identitas: true, data_kontak: true },
...overrides,
};
}
function renderPage(data: Record<string, unknown>) {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
qc.setQueryData(["pembubaran-review", SUBMISSION_ID], data);
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={[`/pembubaran/${SUBMISSION_ID}/review`]}>
<Routes>
<Route path="/pembubaran/:submissionId/review" element={<PembubaranReviewPage />} />
<Route path="/pembubaran/:submissionId/status" element={<div>Status page</div>} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("PembubaranReviewPage", () => {
beforeEach(() => {
apiFetch.mockReset();
apiFetch.mockImplementation((url: string) =>
typeof url === "string" && url.includes("/review-data")
? Promise.resolve(makeReviewData())
: Promise.resolve({ ok: true }),
);
});
it("renders the 7 sections incl. Likuidator (12 fields) and Kehadiran with source badge", async () => {
renderPage(makeReviewData());
await waitFor(() => expect(screen.getByText("Data Perseroan (SABH)")).toBeTruthy());
expect(screen.getByText("Likuidator")).toBeTruthy();
expect(screen.getByText("BUDI HARTONO")).toBeTruthy();
expect(screen.getByText(/RUPS & Kehadiran/)).toBeTruthy();
expect(screen.getByText("BUDI SANTOSO")).toBeTruthy();
expect(screen.getByText("Pengumuman Koran")).toBeTruthy();
});
it("blocks submit on un-overridden FAIL and on missing approvals (gate pre-flight)", async () => {
renderPage(makeReviewData()); // has one FAIL row
await waitFor(() => expect(screen.getByText(/1 validasi FAIL/)).toBeTruthy());
const btn = screen.getByRole("button", { name: /Kirim ke Verifikator/ }) as HTMLButtonElement;
expect(btn.disabled).toBe(true);
});
it("surfaces the submit 422 inline in the dialog (never a silent failure)", async () => {
const clean = makeReviewData({ validationResults: [] });
apiFetch.mockImplementation((url: string, opts?: RequestInit) => {
if (typeof url === "string" && url.includes("/review-data")) return Promise.resolve(clean);
if (typeof url === "string" && url.endsWith("/submit") && opts?.method === "POST") {
return Promise.reject(Object.assign(new Error("Validasi FAIL"), { failing: ["LIKUIDATOR_KTP_MATCH"] }));
}
return Promise.resolve({ ok: true });
});
renderPage(clean);
await waitFor(() => expect(screen.getByRole("button", { name: /Kirim ke Verifikator/ })).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: /Kirim ke Verifikator/ }));
await waitFor(() => expect(screen.getByRole("button", { name: /Kirim Sekarang/ })).toBeTruthy());
fireEvent.click(screen.getByRole("button", { name: /Kirim Sekarang/ }));
await waitFor(() => expect(screen.getByRole("alert").textContent).toContain("Validasi gagal"));
});
it("COMPLETED → Navigate to the status page (46176de idiom)", async () => {
const done = makeReviewData({ submission: { id: SUBMISSION_ID, status: "COMPLETED", type: "PEMBUBARAN_PT" } });
apiFetch.mockImplementation(() => Promise.resolve(done));
renderPage(done);
await waitFor(() => expect(screen.getByText("Status page")).toBeTruthy());
});
it("kehadiran toggle PUTs to /kehadiran", async () => {
renderPage(makeReviewData());
await waitFor(() => expect(screen.getByText("SITI AMINAH")).toBeTruthy());
fireEvent.click(screen.getAllByRole("button", { name: "Hadir" })[1]!); // second row's Hadir toggle
await waitFor(() =>
expect(apiFetch.mock.calls.some(([u, o]) => String(u).endsWith("/kehadiran") && (o as RequestInit)?.method === "PUT")).toBe(true));
});
});
- [ ] Run to verify RED:
cd frontend && bunx vitest run src/pages/__tests__/PembubaranReviewPage.test.tsx. - [ ] Minimal implementation A — create
frontend/src/pages/PembubaranReviewPage.tsx(PeleburanReviewPage skeleton — same imports minus the sources panel, sameSTATUS_STYLE/ApproveButton/FieldList/submit-dialog idioms; the full component):
import { useState } from "react";
import { useParams, useNavigate, Navigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Loader2, AlertTriangle, XCircle, MinusCircle, ShieldCheck, UserCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import {
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
} from "@/components/ui/dialog";
import { StepIndicator } from "@/components/process/step-indicator";
import { computeFlowStep } from "@/lib/flow-steps";
import { apiFetch } from "@/lib/api-client";
import { missingSections } from "@/lib/pembubaran-review";
import { cn } from "@/lib/utils";
interface FieldRow { fieldKey: string; value: string | null; confidence: number; status: string }
interface LikuidatorField { value: string | null; confidence: number; status: string }
interface KehadiranRow { nama: string; persentase: number | null; kehadiran: boolean | null }
interface ValidationRow { id: string; ruleCode: string; ruleLabel: string; status: string; message: string; overridden?: boolean }
interface ReviewData {
submission: { id: string; status: string; type: string };
perseroan: { nama: string | null; statusTransaksi: number | null; statusPerseroan: string | null } | null;
kontak: { teleponPerseroan: string | null; emailPerseroan: string | null };
akta: { documentId: string; fields: FieldRow[]; penghadap: { nama: string | null; nomorIdentitas: string | null }[] } | null;
likuidator: { hasDoc: boolean; documentId: string | null; fields: Record<string, LikuidatorField> };
kehadiran: { source: "deed" | "registry" | null; rows: KehadiranRow[] };
koran: { namaKoran: string | null; tanggalKoran: string | null; source: string | null };
documents: { documentId: string; classifiedType: string; extractionStatus: string; originalName: string }[];
validationResults: ValidationRow[];
sections: { key: string; label: string; approvable: boolean; required: boolean }[];
sectionApprovals: Record<string, boolean>;
}
const AKTA_KEYS = ["nomor_akta", "tanggal_akta", "nama_notaris", "notaris_pengganti", "tanggal_rups", "akta_kedua_nomor", "akta_kedua_tanggal", "akta_kedua_nama_notaris"];
const LIKUIDATOR_KEYS = [
"likuidator_nama", "likuidator_nomor_surat", "likuidator_tanggal_surat", "likuidator_alamat",
"likuidator_rt", "likuidator_rw", "likuidator_kode_pos", "likuidator_kelurahan",
"likuidator_kecamatan", "likuidator_telepon", "likuidator_email", "likuidator_dasar_penunjukan",
];
const STATUS_STYLE: Record<string, { icon: typeof CheckCircle2; cls: string }> = {
PASS: { icon: CheckCircle2, cls: "text-emerald-600 border-l-emerald-500" },
WARNING: { icon: AlertTriangle, cls: "text-amber-600 border-l-amber-500" },
FAIL: { icon: XCircle, cls: "text-red-600 border-l-red-500" },
SKIPPED: { icon: MinusCircle, cls: "text-muted-foreground border-l-gray-300" },
};
export function PembubaranReviewPage() {
const { submissionId } = useParams<{ submissionId: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ["pembubaran-review", submissionId] });
const { data, isLoading } = useQuery<ReviewData>({
queryKey: ["pembubaran-review", submissionId],
queryFn: () => apiFetch<ReviewData>(`/api/pembubaran/submissions/${submissionId}/review-data`),
enabled: !!submissionId,
});
const sectionMut = useMutation({
mutationFn: ({ key, approve }: { key: string; approve: boolean }) =>
apiFetch(`/api/pembubaran/submissions/${submissionId}/sections/${key}/${approve ? "approve" : "unapprove"}`, { method: "POST" }),
onSuccess: invalidate,
});
const confirmMut = useMutation({
mutationFn: ({ documentId, fieldKey }: { documentId: string; fieldKey: string }) =>
apiFetch(`/api/submissions/${submissionId}/documents/${documentId}/fields/confirm`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fieldKey }),
}),
onSuccess: invalidate,
});
const overrideMut = useMutation({
mutationFn: ({ ruleCode, reason }: { ruleCode: string; reason: string }) =>
apiFetch(`/api/submissions/${submissionId}/override-validation`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ ruleCode, reason }),
}),
onSuccess: invalidate,
});
const likuidatorMut = useMutation({
mutationFn: (fields: Record<string, string | null>) =>
apiFetch(`/api/pembubaran/submissions/${submissionId}/likuidator`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ fields }),
}),
onSuccess: invalidate,
});
const kehadiranMut = useMutation({
mutationFn: (entries: { nama: string; kehadiran: boolean | null }[]) =>
apiFetch(`/api/pembubaran/submissions/${submissionId}/kehadiran`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ entries }),
}),
onSuccess: invalidate,
});
const koranMut = useMutation({
mutationFn: (body: { namaKoran?: string | null; tanggalPengumuman?: string | null }) =>
apiFetch(`/api/pembubaran/submissions/${submissionId}/koran`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
}),
onSuccess: invalidate,
});
const [submitError, setSubmitError] = useState<string | null>(null);
const submitMut = useMutation({
mutationFn: () => apiFetch(`/api/pembubaran/submissions/${submissionId}/submit`, { method: "POST" }),
onMutate: () => setSubmitError(null),
onSuccess: () => navigate(`/pembubaran/${submissionId}/status`),
onError: (err: unknown) => {
const hasFailing = !!(err as { failing?: unknown })?.failing;
setSubmitError(hasFailing ? "Validasi gagal — periksa Hasil Validasi" : "Terjadi kesalahan saat mengirim. Coba lagi.");
},
});
const [likDraft, setLikDraft] = useState<Record<string, string> | null>(null);
const [koranDraft, setKoranDraft] = useState<{ namaKoran: string; tanggalPengumuman: string } | null>(null);
const [submitOpen, setSubmitOpen] = useState(false);
if (isLoading) return <div className="flex items-center justify-center h-96"><Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /></div>;
if (!data) return <div className="p-8 text-center text-muted-foreground">Submission tidak ditemukan.</div>;
const { submission, perseroan, kontak, akta, likuidator, kehadiran, koran, documents, validationResults, sectionApprovals } = data;
if (submission.status === "COMPLETED") { return <Navigate to={`/pembubaran/${submissionId}/status`} replace />; }
const flow = computeFlowStep({ type: "PEMBUBARAN_PT", status: submission.status }, null);
const fails = validationResults.filter((v) => v.status === "FAIL" && !v.overridden);
const missing = missingSections(sectionApprovals);
const canSubmit = submission.status === "READY" && fails.length === 0 && missing.length === 0;
const f = (key: string) => akta?.fields.find((x) => x.fieldKey === key);
const koranBase = { namaKoran: koran.namaKoran ?? "", tanggalPengumuman: koran.tanggalKoran ?? "" };
const ApproveButton = ({ sectionKey }: { sectionKey: string }) => (
<Button
size="sm"
variant={sectionApprovals[sectionKey] ? "outline" : "default"}
onClick={() => sectionMut.mutate({ key: sectionKey, approve: !sectionApprovals[sectionKey] })}
>
{sectionApprovals[sectionKey] ? "Batalkan Persetujuan" : "Setujui Bagian"}
</Button>
);
const FieldList = ({ keys, documentId, fields }: { keys: string[]; documentId: string | null; fields: (key: string) => { value: string | null; status: string } | undefined }) => (
<>
{keys.map((key) => {
const field = fields(key);
return (
<div key={key} className="flex items-center justify-between gap-2 border-b py-1.5 last:border-0">
<div className="min-w-0">
<div className="text-xs text-muted-foreground">{key}</div>
<div className="text-sm truncate">{field?.value ?? <span className="text-muted-foreground">kosong</span>}</div>
</div>
{field && field.value != null && field.status !== "CONFIRMED" && field.status !== "LOCKED" && field.status !== "MANUAL" && documentId ? (
<Button size="sm" variant="outline" disabled={confirmMut.isPending}
onClick={() => confirmMut.mutate({ documentId, fieldKey: key })}>
Konfirmasi
</Button>
) : field && field.value != null ? (
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" />
) : null}
</div>
);
})}
</>
);
return (
<div className="space-y-6 pb-16">
<div className="bg-card border-b p-6">
<StepIndicator steps={flow.steps} currentStep={flow.currentStep} variant={flow.variant} />
</div>
{/* Data Perseroan (SABH) — the registry snapshot; there is NO diff */}
<section className="rounded-lg border bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Data Perseroan (SABH)</h2>
<ApproveButton sectionKey="perseroan" />
</div>
{perseroan ? (
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-3">
<div><div className="text-xs text-muted-foreground">Nama</div>{perseroan.nama ?? "—"}</div>
<div><div className="text-xs text-muted-foreground">Status Transaksi</div>{perseroan.statusTransaksi ?? "—"}</div>
<div><div className="text-xs text-muted-foreground">Status Perseroan</div>{perseroan.statusPerseroan ?? "—"}</div>
</div>
) : (
<p className="text-sm text-muted-foreground">Data registri belum tersedia (pilih perusahaan pada langkah Ekstraksi).</p>
)}
</section>
{/* Akta Pembubaran — deed header + dual-deed + notaris pengganti + tanggal RUPS */}
<section className="rounded-lg border bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Akta Pembubaran</h2>
<ApproveButton sectionKey="akta_pembubaran" />
</div>
{akta
? <FieldList keys={AKTA_KEYS} documentId={akta.documentId} fields={(k) => f(k)} />
: <div className="text-sm text-muted-foreground">Akta belum tersedia.</div>}
</section>
{/* RUPS & Kehadiran — editable ticks (deed or registry roster; Ps.89(1) ≥3/4) */}
<section className="rounded-lg border bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">
RUPS & Kehadiran Pemegang Saham{" "}
<Badge variant="outline" className="ml-1 text-[10px]">{kehadiran.source === "registry" ? "roster registri SABH" : kehadiran.source === "deed" ? "roster akta" : "—"}</Badge>
</h2>
<ApproveButton sectionKey="rups_kehadiran" />
</div>
{kehadiran.rows.length === 0 && <p className="text-sm text-muted-foreground">Belum ada roster pemegang saham (unggah BA RUPS / pilih perusahaan).</p>}
{kehadiran.rows.map((r) => (
<div key={r.nama} className="flex items-center justify-between border-b py-1 text-sm last:border-0">
<span>{r.nama} <span className="text-xs text-muted-foreground">· {r.persentase ?? "—"}%</span></span>
<span className="flex gap-1">
<Button size="sm" variant={r.kehadiran === true ? "default" : "outline"} disabled={kehadiranMut.isPending}
onClick={() => kehadiranMut.mutate([{ nama: r.nama, kehadiran: true }])}>Hadir</Button>
<Button size="sm" variant={r.kehadiran === false ? "default" : "outline"} disabled={kehadiranMut.isPending}
onClick={() => kehadiranMut.mutate([{ nama: r.nama, kehadiran: false }])}>Tidak Hadir</Button>
</span>
</div>
))}
<p className="text-xs text-muted-foreground">Kuorum pembubaran: paling sedikit 3/4 (UUPT Ps.89(1)) — dinilai pada Hasil Validasi (advisory).</p>
</section>
{/* Likuidator — 12 confirm-able fields + manual editor (READY-state surface) */}
<section className="rounded-lg border bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<h2 className="flex items-center gap-1.5 text-sm font-semibold"><UserCheck className="h-4 w-4" /> Likuidator</h2>
<ApproveButton sectionKey="likuidator" />
</div>
{!likuidator.hasDoc && <p className="text-sm text-amber-600">Surat penunjukan likuidator belum diunggah (dokumen wajib).</p>}
<FieldList keys={LIKUIDATOR_KEYS} documentId={likuidator.documentId} fields={(k) => likuidator.fields[k]} />
{likuidator.hasDoc && (
<div className="space-y-1 border-t pt-2">
<div className="text-xs font-medium text-muted-foreground">Lengkapi manual (field yang tidak terbaca OCR)</div>
<div className="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
{LIKUIDATOR_KEYS.map((k) => (
<div key={k} className="grid grid-cols-[140px_1fr] items-center gap-1.5">
<Label htmlFor={`lik-${k}`} className="truncate text-[11px]">{k.replace("likuidator_", "")}</Label>
<Input id={`lik-${k}`} className="h-7 text-xs" defaultValue={likuidator.fields[k]?.value ?? ""}
onChange={(e) => setLikDraft((d) => ({ ...(d ?? {}), [k]: e.target.value }))} />
</div>
))}
</div>
<div className="flex justify-end">
<Button size="sm" variant="outline" disabled={!likDraft || likuidatorMut.isPending}
onClick={() => likDraft && likuidatorMut.mutate(likDraft)}>Simpan Likuidator</Button>
</div>
</div>
)}
</section>
{/* Pengumuman Koran — approvable but NOT required (Ps.147 advisory) */}
<section className="rounded-lg border bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Pengumuman Koran <span className="text-xs font-normal text-muted-foreground">(opsional — Ps.147)</span></h2>
<ApproveButton sectionKey="pengumuman_koran" />
</div>
<div className="text-xs text-muted-foreground">
Terbaca: {koran.namaKoran ?? "—"} · {koran.tanggalKoran ?? "—"} · sumber: {koran.source === "clipping" ? "kliping koran" : koran.source === "akta" ? "tercantum di dalam akta" : "—"}
</div>
<div className="grid grid-cols-[160px_1fr] items-center gap-2 pt-1">
<Label htmlFor="nama-koran" className="text-xs">Nama Koran</Label>
<Input id="nama-koran" defaultValue={koran.namaKoran ?? ""}
onChange={(e) => setKoranDraft((d) => ({ ...(d ?? koranBase), namaKoran: e.target.value }))} />
<Label htmlFor="tanggal-pengumuman" className="text-xs">Tanggal Pengumuman</Label>
<Input id="tanggal-pengumuman" defaultValue={koran.tanggalKoran ?? ""}
onChange={(e) => setKoranDraft((d) => ({ ...(d ?? koranBase), tanggalPengumuman: e.target.value }))} />
</div>
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Ps.147(1): kewajiban likuidator 30 hari SEJAK pembubaran — boleh menyusul (tidak menghalangi submit)</span>
<Button size="sm" variant="outline" disabled={!koranDraft || koranMut.isPending}
onClick={() => koranDraft && koranMut.mutate(koranDraft)}>Simpan Koran</Button>
</div>
</section>
{/* Dokumen Identitas */}
<section className="rounded-lg border bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Dokumen Identitas (KTP/NPWP)</h2>
<ApproveButton sectionKey="identitas" />
</div>
{documents.filter((d) => ["KTP", "NPWP", "NPWP_LAMA", "PASSPORT", "KITAS"].includes(d.classifiedType)).map((d) => (
<div key={d.documentId} className="flex items-center justify-between border-b py-1 text-sm last:border-0">
<span>{d.originalName}</span>
<span className="text-xs text-muted-foreground">{d.classifiedType} · {d.extractionStatus}</span>
</div>
))}
<p className="text-xs text-muted-foreground">Kecocokan NIK/nama (termasuk KTP likuidator) divalidasi otomatis — lihat Hasil Validasi.</p>
</section>
{/* Data Kontak */}
<section className="rounded-lg border bg-card p-4 space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Data Kontak</h2>
<ApproveButton sectionKey="data_kontak" />
</div>
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm">
<div><div className="text-xs text-muted-foreground">Telepon Perseroan</div>{kontak.teleponPerseroan ?? "—"}</div>
<div><div className="text-xs text-muted-foreground">Email Perseroan</div>{kontak.emailPerseroan ?? "—"}</div>
</div>
</section>
{/* Hasil Validasi */}
<section className="rounded-lg border bg-card p-4 space-y-1.5">
<h2 className="text-sm font-semibold">Hasil Validasi</h2>
{validationResults.map((v) => {
const S = STATUS_STYLE[v.status] ?? STATUS_STYLE.SKIPPED!;
const Icon = S.icon;
return (
<div key={v.id} className={cn("flex items-start gap-2 rounded border-l-4 bg-muted/30 p-2", S.cls)}>
<Icon className="mt-0.5 h-4 w-4 shrink-0" />
<div className="min-w-0 flex-1">
<div className="text-xs font-medium">{v.ruleLabel} {v.overridden && <span className="text-muted-foreground">(di-override)</span>}</div>
<div className="text-xs text-muted-foreground">{v.message}</div>
</div>
{v.status === "FAIL" && !v.overridden && (
<Button size="sm" variant="outline" disabled={overrideMut.isPending}
onClick={() => {
const reason = window.prompt(`Alasan override untuk ${v.ruleCode}:`);
if (reason) overrideMut.mutate({ ruleCode: v.ruleCode, reason });
}}>
Override
</Button>
)}
</div>
);
})}
</section>
{/* Submit → verifikator */}
<div className="flex items-center justify-end gap-3">
{missing.length > 0 && <span className="text-xs text-amber-600">Bagian belum disetujui: {missing.join(", ")}</span>}
{fails.length > 0 && <span className="text-xs text-red-600">{fails.length} validasi FAIL menghalangi submit</span>}
<Button disabled={!canSubmit} onClick={() => setSubmitOpen(true)}>
<ShieldCheck className="mr-1.5 h-4 w-4" /> Kirim ke Verifikator
</Button>
</div>
<Dialog open={submitOpen} onOpenChange={setSubmitOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kirim Pembubaran PT ke Verifikator</DialogTitle>
<DialogDescription>
Pengajuan akan masuk antrean verifikator (Menunggu Verifikasi). Anda dapat
merevisi jika verifikator meminta perbaikan.
</DialogDescription>
</DialogHeader>
{submitError && <p role="alert" className="text-sm text-destructive">{submitError}</p>}
<DialogFooter>
<Button variant="outline" onClick={() => setSubmitOpen(false)}>Batal</Button>
<Button disabled={submitMut.isPending} onClick={() => submitMut.mutate()}>
{submitMut.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />} Kirim Sekarang
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
- [ ] Minimal implementation B — create
frontend/src/pages/PembubaranStatusPage.tsx: copyPeleburanStatusPage.tsxand apply the mechanical diffs — component namePembubaranStatusPage; queryKey/API["pembubaran-review", …]//api/pembubaran/...;computeFlowStep({ type: "PEMBUBARAN_PT", ... }); bothNavigatetargets/pembubaran/${submissionId}/review;VERIF_LABEL.SELESAI = "Disetujui — pembubaran tercatat"; headingPembubaran PT — {nama}wherenamafalls backdata.perseroan?.nama ?? akta nama_perseroan ?? "PT"(the review-data payload carriesperseroan); keep the base-uirender={<Link to="/dashboard" />}idiom. - [ ] Minimal implementation C —
frontend/src/routes.tsx: import the three pages; add in the notaris children next to the peleburan trio:
{ path: "pembubaran/:submissionId/extraction", element: <PembubaranExtractionPage /> },
{ path: "pembubaran/:submissionId/review", element: <PembubaranReviewPage /> },
{ path: "pembubaran/:submissionId/status", element: <PembubaranStatusPage /> },
- [ ] Run GREEN:
cd frontend && bunx vitest run src/pages/__tests__/PembubaranReviewPage.test.tsx && bunx vitest run. - [ ] Typecheck:
cd frontend && bunx tsc --noEmit. - [ ] Commit:
git add frontend/src/pages/PembubaranReviewPage.tsx frontend/src/pages/PembubaranStatusPage.tsx frontend/src/routes.tsx frontend/src/pages/__tests__/PembubaranReviewPage.test.tsx && git commit -m "feat(pembubaran): review + status pages (flat sections, staged submit, inline 422) + routes"
Task 14: End-to-end integration test (engine run → focused passes → tuned matrix → company select → approvals → FAIL-gate → verifikator staging)
One backend test that walks the WHOLE flow through the real engine + routes (peleburan-integration precedent): seeded DONE documents (so no OCR calls), a mocked 3B fetch, runFlow with the real registry entry, the env-robust company-selection pause (test-setup blanks SABH creds → the single-source lookup yields no high match → AWAITING_COMPANY_SELECTION → resume via the type-agnostic POST /api/perubahan/select-company — proving warning 4's "single-source only" wiring end-to-end), the focused likuidator pass + deed-inline koran, the tuned matrix persisted (advisory WARNING never FAIL; tuned-out rows absent), section approvals via routes, and the staged submit.
Files
- backend/src/flow-engine/__tests__/pembubaran-integration.test.ts — Create
Steps
- [ ] Write the test. Create
backend/src/flow-engine/__tests__/pembubaran-integration.test.ts:
import { describe, it, expect, afterAll, beforeAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";
import { runFlow } from "../processor";
import { pembubaranPtFlow } from "../flows/pembubaran-pt";
/**
* PEMBUBARAN_PT end-to-end (engine + routes; laporan-rups/peleburan-integration
* harness style). Docs seeded extractionStatus DONE (no OCR); global fetch mocked
* (3B likuidator + inline koran). SABH creds are blanked by test-setup.ts, so the
* single-source lookup pauses in AWAITING_COMPANY_SELECTION (env-robust: a READY
* short-circuit is also accepted) and resumes through the type-agnostic
* select-company route — the engine's single-source branch, never multi-source.
*/
const AKTA_RAW =
"AKTA PEMBUBARAN PT MAJU MUNDUR\nNomor 5, tanggal 30 April 2026\n" +
"RUPS tanggal 28 April 2026 memutuskan membubarkan Perseroan dan menunjuk likuidator.\n" +
"Pengumuman dimuat dalam surat kabar Media Indonesia tanggal 10 Mei 2026.";
const LIK_RAW =
"SURAT PENUNJUKAN LIKUIDATOR\nNomor: 017/SPL/V/2026\n" +
"Menunjuk BUDI HARTONO selaku likuidator, Jalan Melati Raya No. 12, RT. 004/RW. 007,\n" +
"Kelurahan Cempaka Putih, Kecamatan Menteng, Kode Pos 10310\n" +
"Dasar penunjukan: Keputusan RUPS tanggal 28 April 2026";
describe("PEMBUBARAN_PT integration", () => {
const ids: string[] = [];
const docIds: string[] = [];
let origFetch: typeof fetch;
const origLlmUrl = process.env.CLEANUP_LLM_URL;
beforeAll(() => {
origFetch = globalThis.fetch;
process.env.CLEANUP_LLM_URL = "http://mock-3b";
// Route ONLY the mocked 3B host; anything else in-process (app.request) never
// hits fetch. Likuidator call answered by field shape; koran by docContext.
globalThis.fetch = (async (url: unknown, init?: RequestInit) => {
const u = String(url);
if (!u.startsWith("http://mock-3b")) return new Response("{}", { status: 500 });
const body = String(init?.body ?? "");
if (body.includes("surat penunjukan likuidator")) {
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
nama: "BUDI HARTONO", nomor_surat: "017/SPL/V/2026", tanggal_surat: null,
alamat: "Jalan Melati Raya No. 12", rt: null, rw: null, kode_pos: null,
kelurahan: "Cempaka Putih", kecamatan: "Menteng", telepon: null, email: null,
dasar_penunjukan: "Keputusan RUPS tanggal 28 April 2026",
}) } }] }), { status: 200 });
}
return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({
tanggal_koran: "10 Mei 2026", nama_koran: "Media Indonesia",
}) } }] }), { status: 200 });
}) as typeof fetch;
});
afterAll(async () => {
globalThis.fetch = origFetch;
process.env.CLEANUP_LLM_URL = origLlmUrl;
if (ids.length) {
await db.$transaction(async (tx) => {
await tx.$executeRawUnsafe(`SET LOCAL session_replication_role = replica;`);
await tx.$executeRawUnsafe(
`DELETE FROM "VerifikasiAuditEvent" WHERE "verifikasiId" IN (SELECT id FROM "VerifikasiPerubahan" WHERE "submissionId" = ANY($1::text[]))`,
ids,
);
});
await db.submission.deleteMany({ where: { id: { in: ids } } });
}
await db.document.deleteMany({ where: { id: { in: docIds } } });
});
async function seedDoc(documentType: string, classifiedType: string, order: number, submissionId: string, rawText: string, fields: { fieldKey: string; value: string }[] = []) {
const doc = await db.document.create({
data: { filename: `${classifiedType}.pdf`, originalName: `${classifiedType}.pdf`, fileSize: 0, filePath: `/tmp/${classifiedType}.pdf`, documentType: documentType as never, status: "VERIFICATION_READY", rawText },
});
docIds.push(doc.id);
await db.submissionDocument.create({
data: { submissionId, documentId: doc.id, classifiedType, confidence: 1, processingOrder: order, extractionStatus: "DONE" },
});
if (fields.length) {
await db.extractedField.createMany({ data: fields.map((f) => ({ documentId: doc.id, ...f, confidence: 0.9, status: "EDITABLE" })) });
}
return doc;
}
it("runs upload→extract→pause→select→validate→approve→submit→verifikator end-to-end", async () => {
const sub = await db.submission.create({ data: { type: "PEMBUBARAN_PT", status: "CREATED" } });
ids.push(sub.id);
const akta = await seedDoc("AKTA", "AKTA_PEMBUBARAN", 0, sub.id, AKTA_RAW, [
{ fieldKey: "nama_perseroan", value: "PT MAJU MUNDUR" },
{ fieldKey: "nomor_akta", value: "5" },
{ fieldKey: "tanggal_akta", value: "2026-04-30" },
{ fieldKey: "tanggal_rups", value: "2026-04-28" },
{ fieldKey: "ref_pendirian_nomor_sk", value: "AHU-0001.AH.01.01.TAHUN 2019" },
]);
const lik = await seedDoc("SURAT_LIKUIDATOR", "SURAT_LIKUIDATOR", 4, sub.id, LIK_RAW);
await runFlow(sub.id, pembubaranPtFlow);
let fresh = await db.submission.findUnique({ where: { id: sub.id } });
// Env-robust single-source pause: no SABH in tests → AWAITING; resume via the
// TYPE-AGNOSTIC select-company route (warning 4: no multi-source machinery).
if (fresh?.status === "AWAITING_COMPANY_SELECTION") {
const sel = await app.request(`/api/perubahan/select-company/${sub.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ manual: true, nama: "PT MAJU MUNDUR", tahun: 2019 }),
});
expect(sel.status).toBe(200);
// continueFlow is fire-and-forget from the route — poll to READY.
for (let i = 0; i < 50 && fresh?.status !== "READY"; i++) {
await new Promise((r) => setTimeout(r, 100));
fresh = await db.submission.findUnique({ where: { id: sub.id } });
}
}
expect(fresh?.status).toBe("READY");
// Focused likuidator pass persisted grounded rows (deed passes ran via hook).
const likRows = await db.extractedField.findMany({ where: { documentId: lik.id } });
expect(likRows.length).toBe(12);
expect(likRows.find((r) => r.fieldKey === "likuidator_nama")?.value).toBe("BUDI HARTONO");
expect(likRows.find((r) => r.fieldKey === "likuidator_rt")?.value).toBe("004"); // deterministic tier
// Deed-inline koran (zero clippings uploaded).
const koranRow = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: akta.id, fieldKey: "nama_koran" } } });
expect(koranRow?.value).toBe("Media Indonesia");
// Tuned matrix persisted; advisory stays WARNING/PASS (never FAIL); tuned-out rows absent.
const results = await db.validationResult.findMany({ where: { submissionId: sub.id } });
const byCode = new Map(results.map((r) => [r.ruleCode, r.status]));
expect(byCode.has("PEMBUBARAN_KORAN")).toBe(true);
expect(byCode.get("PEMBUBARAN_KORAN")).not.toBe("FAIL");
expect(byCode.has("LIKUIDATOR_SURAT_COMPLETE")).toBe(true);
expect(byCode.has("LIKUIDATOR_KTP_MATCH")).toBe(true);
expect(byCode.has("RUPS_ATTENDANCE_QUORUM")).toBe(true);
expect(byCode.has("MODAL_BUKTI_SETOR")).toBe(false);
expect(byCode.has("REQUIRED_SUPPORTING_DOCS")).toBe(false);
// Approve the 6 required sections through the routes; then clear/override any
// FAILs the seeded data produced (manual selection → oldData null → PERSEROAN_STATE
// SKIPs; likuidator has no KTP → WARNING; so typically no FAILs here).
for (const key of ["perseroan", "akta_pembubaran", "rups_kehadiran", "likuidator", "identitas", "data_kontak"]) {
const res = await app.request(`/api/pembubaran/submissions/${sub.id}/sections/${key}/approve`, { method: "POST" });
expect(res.status).toBe(200);
}
const fails = await db.validationResult.findMany({ where: { submissionId: sub.id, status: "FAIL", overridden: false } });
for (const row of fails) {
await db.validationResult.update({ where: { id: row.id }, data: { overridden: true, overrideReason: "integration override" } });
}
// Staged submit → COMPLETED + MENUNGGU_VERIFIKATOR (never terminal).
const submit = await app.request(`/api/pembubaran/submissions/${sub.id}/submit`, { method: "POST" });
expect(submit.status).toBe(200);
const done = await db.submission.findUnique({ where: { id: sub.id }, include: { verifikasi: true } });
expect(done?.status).toBe("COMPLETED");
expect(done?.verifikasi?.status).toBe("MENUNGGU_VERIFIKATOR");
}, 30000);
});
- [ ] Run:
cd backend && bun test src/flow-engine/__tests__/pembubaran-integration.test.ts. Debug any cross-task seams it exposes (this test exists to catch them). If the engine's Phase-4 change-type detection needsaktaPerubahanincludes that the seeded doc lacks, it tolerates empty rows by design — do not seed jenis data. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/flow-engine/__tests__/pembubaran-integration.test.ts && git commit -m "test(pembubaran): end-to-end integration — engine run, single-source pause/resume, tuned matrix, staged submit"
Task 15: Full-suite + typecheck gate
The whole-repo green gate before handoff.
Steps
- [ ] Backend full suite:
cd backend && bun test 2>&1 | tail -20. Expected: green. KNOWN pre-existing flake:pp-perubahan-bo-crudhas an FK isolation flake in full-suite runs (passes isolated — Peleburan ledger note); if it is the ONLY failure, re-run it isolated to confirm and record in the commit body. Any pembubaran-owned failure must be fixed, not recorded. - [ ] Backend typecheck:
cd backend && bunx tsc --noEmit. Expected: zero errors. - [ ] Frontend full suite:
cd frontend && bunx vitest run 2>&1 | tail -10. Expected: green. - [ ] Frontend typecheck:
cd frontend && bunx tsc --noEmit. Expected: zero errors. - [ ] Exact-spelling audit (PEMBUBARAN_PP collision, spec §7):
grep -rn 'includes("PEMBUBARAN")\|startsWith("PEMBUBARAN")\|PEMBUBARAN_P[TP]"' backend/src frontend/src --include='*.ts' --include='*.tsx' | grep -v __tests__ | grep -iv 'PEMBUBARAN_PT\|PEMBUBARAN_PP'. Expected: no output (no substring matching on the shared prefix anywhere). - [ ] Commit (only if fixes were needed):
git add -A && git commit -m "test(pembubaran): full-suite + typecheck gate green for the Pembubaran PT flow". If nothing changed, record the gate evidence in the task handoff instead.
Self-Review
Spec coverage (2026-07-02-pembubaran-pt-design.md):
- §2/§4.4 FlowConfig: born v2-generic, no fork — Task 6. engine, primaryAkta, skipExtractionTypes, companyLookup: true, extraction.overrideByClassifiedType, focusedPasses (both), buildContext, rules, requiredDocs, sections, rematch, hooks.afterAktaExtraction, labels — all present. Deviations from the spec sketch, each adjudicated inline: routeFamily: "pembubaran" not 'perubahan' (landed per-family template; the union gains the value; the type-agnostic select-company route is reused, PERUBAHAN_FAMILY untouched); rematch.preValidationSteps: [matchRupsAttendance] replaced by the canonical rupsAttendance: true flag (the engine grew it after the spec); hooks.onFinalize: recordLocalDissolution dropped (nothing invokes onFinalize; COMPLETED submissions ARE the local record — pp-pembubaran precedent, PT_ALREADY_DISSOLVED queries them directly); checklistResetDocTypes uses the checklist-LABEL vocabulary and omits SURAT_LIKUIDATOR/BUKTI_PENGUMUMAN (no checklist rows exist for them).
- §4.2 routing: BUILT flip + prompt cue + BERAKHIRNYA_PACKAGE (canonical form, carrier-count clause, owned in submissions.ts) + rung BELOW Akuisisi — Task 7, with create-route supported:false test and FE suppression mirror (Task 11).
- §4.3 extraction: perubahan-override hybrid (Task 6), dual-deed akta_kedua persistence gap CLOSED (Task 3 — spec open question 2's fallback direction), tanggal_rups/notaris_pengganti already persisted (verified during planning), likuidator extractor penunjukan-only w/ deterministic-first + token-wise dasar grounding (Task 2), shared clipping extractor consumed via one map entry + deed-inline fallback w/ zero-clipping guard (Tasks 2/3/6), registry-roster attendance fallback + rupsKehadiranRegistry (Tasks 1/3/4).
- §4.5 classification: SURAT_LIKUIDATOR scoped re-check (deterministic tiers + 3B tail, suggestion-only) — Tasks 8/11; BUKTI_PENGUMUMAN keyword pre-suggester already shared (no change); mapToDocumentType/getProcessingOrder — Task 7.
- §4.6 matrix: 13 wrapped + ¾-quorum(≥) + 4 new — Task 5. REQUIRED_SUPPORTING_DOCS/UPLOADED_DOC_UNUSED exclusion is an explicit adjudicated deviation (jenis-scoped bodies; false-WARNING on the BA); PERSEROAN_STATE covers the active/not-dissolved/blocked registry gate; PEMBUBARAN_KORAN never-FAIL structurally pinned.
- §4.7 sections/review: 7 sections + validasi row; explicit PerubahanSectionApproval; submit gate = 6 static keys — Tasks 6/9/13. Thin mirrors, no ReviewEngine.
- §5 wiring checklist: items 1-12 mapped to Tasks 1,6,7,7,1,6(rematch-proof),7,2,9(own family instead of perubahan.ts widening — adjudicated),3,11-13,10 respectively.
- §7 gotchas: rematch dispatch (Task 6 test), explicit approvals, exhaustive switches (Task 1), PP collision (Tasks 5/9/10/15), koran advisory (Task 5), classifier scope (Tasks 8/11), extraction override never re-classifies (engine passes the subtype — Task 6), Berakhirnya gate (Task 7), registry outage degrades (async partition + SKIPs), newsprint best-effort (Task 2 semantics).
- §8 testing strategy: every listed case has a home (routing Task 7; registry/wipe Task 6; likuidator fixtures Task 2; koran Task 3/5/14; rules Task 5; RUPS fallback Task 3/4; submit gate Task 9; classification Task 8; regression Tasks 7/10/15). Whole-flow warnings: (1) READY-state surfaces = review-page cards + PUT endpoints (Tasks 9/13); (2) partial-verification WARNINGs name the unchecked (Task 5); (3) decide-APPROVE E2E + roster pin (Task 10); (4) single-source only, multi-source untouched (Tasks 6/14 assertions).
- Inherited triggers: ≥-comparator (Task 5, wrapper-only, default preserved + regression); shared clipping extractor consumed not rebuilt (Task 6) with the count-guard partial-write hardening APPLIED (Task 2 — atomic createMany, both consumers).
Placeholder scan: no TODO/TBD/...-elision inside code blocks; every test has RED and GREEN commands; every task ends in an exact commit. Two intentional adapt-notes flag schema-shape checks the implementer must verify against generated Prisma types (transformAktaPerubahanResult call shape in Task 3; satellite-row required columns in Task 3's seeds) — both bounded with "keep the assertions".
Type consistency: PembubaranCtx extends PtAktaCtx so pickRules(PT_AKTA_RULE_SET, …) assigns without casts (peleburan precedent); LIKUIDATOR_EXTRACTED_FIELD_KEYS is the single source of truth consumed by extractor/context/rules/routes/FE key lists; rupsKehadiranRegistry JSON shape {nama, persentase, kehadiran} is written by the matcher (Task 3), read by the context (Task 4), review-data + PUT /kehadiran (Task 9), and the FE (Tasks 10/13) — identical field names throughout; canonical envelopes are byte-identical strings in Tasks 9's code and tests; ValidationStatus spellings use no -ED anywhere; confidence: 1 present on every submissionDocument.create seed.
Risk notes for the executor: Task 7 is the point of no return (launch switch) — do not start it until Tasks 1-6 are green; Task 9's kehadiran deed-path test seeds aktaPemegangSaham (check required columns against schema); Task 14 is deliberately tolerant of the SABH-less environment (AWAITING→manual-select path) — if it short-circuits to READY on some env, the assertions still hold.