think
16px
820px

Peleburan PT (Konsolidasi, PELEBURAN_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 PELEBURAN_PT OCR flow (upload → classify → extract → validate → review → submit to VERIFIKATOR → verifikasi state machine) as the SECOND engine:'v2-generic' tenant of the flow-engine FLOW_REGISTRY — one defineFlow config + a tuned rule pack + two focused passes + the engine's first multi-source company lookup (N ≥ 2 source PTs, each looked up/resolved/blokir-checked). No new processor fork.

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 on feat/pt-flows-registry). The deed rides the pendirian extractor via extraction.overrideByClassifiedType (the akta peleburan's operative content IS the new PT's anggaran dasar). Three genuinely new pieces: (1) the grounded 35B source-PT-references pass (akta-peleburan-sources-extract.ts) persisting PeleburanSourcePt rows; (2) the multi-source lookup loop (services/peleburan-sources.ts + the engine's companyLookup.mode:'multi-source' branch, replacing the deliberate throw in processor.ts); (3) the SHARED BUKTI_PENGUMUMAN clipping extractor (bukti-pengumuman-extract.ts, first consumer — reused verbatim by Pembubaran/Berakhirnya) + the parameterized extractKoranFields deed-inline refactor. Validation = tuned matrix PELEBURAN_RULE_SET (16 wrapped pendirian-grade rules + 7 flow rules incl. the FAIL PELEBURAN_KORAN_WINDOW). Submit is a new thin route family (routes/peleburan.ts, routeFamily 'peleburan'): explicit section approvals + checkFailGate (block-with-override) → COMPLETED + VerifikasiPerubahan row (MENUNGGU_VERIFIKATOR) — this flow IS verifikator-staged (contrast LAPORAN_RUPS_TAHUNAN, which is terminal). Frontend = thin mirrors (extraction page copied from akuisisi with the CompanySelectionModal swapped for the new N-row PeleburanSourcesPanel; a new flat review page; a small verifikasi-aware status page; a compact verifikator review page following the VerifikatorPerbaikanReviewPage per-family precedent). Everything stays dark until BUILT_AKTA_TXN_TYPES += "peleburan" flips (Task 8) — 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 PELEBURAN_PT (SABH id_aksi_transaksi = 35, konsolidasi, new-entity). Carrier akta label AKTA_PELEBURAN already exists in AKTA_TXN_TYPE_TO_LABEL (akta-txn-classifier.ts:21); classifier taxonomy value "peleburan" (title rule ^AKTA\s+PELEBURAN already emits it, akta-txn-classifier.ts:53). ValidationStatus is "PASS" | "WARNING" | "FAIL" | "SKIPPED" (NO -ED). DocumentType BUKTI_PENGUMUMAN already exists in Prisma (registry-slice six-type batch, schema.prisma:57) — do NOT re-add it.
  • VERIFIKATOR-STAGED — not terminal. Spec §4.1/§4.7: submit → status COMPLETED + a VerifikasiPerubahan row (MENUNGGU_VERIFIKATOR) in ONE transaction, mirroring the AKUISISI_PT submit arm (routes/perubahan.ts:1314-1350), incl. the PERBAIKAN re-submit arm and recordAuditEvent(NOTARIS_SUBMIT/NOTARIS_RESUBMIT). PELEBURAN_PT is NEVER added to TERMINAL_TYPES in frontend/src/lib/destination-for.ts; post-COMPLETED navigation is verifikasi-driven exactly like AKUISISI_PT.
  • Canonical route envelopes (RUPS whole-flow-review rules). FAIL gate = HTTP 422 + { error: "Validasi FAIL harus diselesaikan atau di-override sebelum submit", code: "VALIDATION_FAILED", failing } via the NAMED step checkFailGate(submissionId, peleburanPtFlow) (backend/src/flow-engine/fail-gate.ts). Every mutating route gets the guard pattern: status outside its allowed set → 409 + { error: "submission cannot be edited in its current status", code: "WRONG_STATUS" }. Allowed sets: source-row + koran routes = READY or AWAITING_COMPANY_SELECTION (row selection is precisely what un-blocks that status); section approve/unapprove = READY only; submit = READY only (its own Cannot submit: status is X, expected READY 409, template parity).
  • PELEBURAN_KORAN_WINDOW = FAIL (user decision 2026-07-02, spec §10.7). FAIL when both dates parse and koran < 30 days before the (earliest) RUPS — UUPT Ps.127(2); the Ps.127(8) exemption does NOT cover peleburan (it is limited to pengambilalihan langsung dari pemegang saham, which is exactly why AKUISISI_KORAN_WINDOW is WARNING-only). SKIPPED when either date is unreadable. This is a FAIL rule behind the block-with-override gate; dates are operator-editable (PUT /koran, PUT /sources/:ordinal) so a wrong OCR date is correctable before it blocks anyone permanently.
  • The tuned validation matrix (severities binding, spec §4.6):
  • Wrapped bit-for-bit via pickRules(PT_AKTA_RULE_SET, …) — 16 codes: NIK_KTP_AKTA, NAMA_NPWP_KTP, KTP_COMPLETENESS, NPWP_COMPLETENESS, DOMISILI_NAMA, NAMA_PT_CONSISTENCY, PS_TOTAL_100, SHARES_TOTAL_LEMBAR, SHARES_SUM_CONSISTENCY, MODAL_HIERARCHY, NOMINAL_RECONCILE, CONTACT_INFO_COMPLETE, PEMEGANG_SAHAM_CONTACT, PASSPORT_AKTA, PP29_MODAL(async), KBLI_VALID(async).
  • Net-new 7: PELEBURAN_MIN_SOURCES (FAIL when < 2 sources; SKIPPED at 0 rows pre-confirmation), PELEBURAN_SOURCE_RESOLVED (FAIL per unresolved row), PELEBURAN_SOURCE_BLOKIR (FAIL on any blocked source; SKIPPED when SABH state unknown for all), PELEBURAN_SOURCE_STATUS (FAIL on berakhir/bubar — status_transaksi === 6 or status_perseroan matching /bubar|berakhir/i; WARNING on other non-active anomalies incl. status_transaksi === 5; mirrors validatePerseroanState semantics), PELEBURAN_RUPS_PER_SOURCE (WARNING, never FAILseverityFloor: "WARNING"; heuristic BA↔source name attribution), PELEBURAN_KORAN_WINDOW (FAIL per above), PELEBURAN_NAMA_BARU_UNIK (WARNING, never FAILseverityFloor: "WARNING"; pesan-nama out of OCR scope).
  • EXCLUDED — deliberately, guard with tests: MODAL_BUKTI_SETOR + PEMEGANG_SAHAM_BUKTI_SETOR (SABH's peleburan wizard has NO bukti-setor upload — excluded-from-config, not permanent SKIPPEDs); the whole perubahan-family set OLD_DATA_CONSISTENCY, DATA_ACTUALLY_CHANGED, SHARES_TRANSFER_BALANCE, PERUBAHAN_MODAL, PERUBAHAN_KBLI, JENIS_SELECTION_NONEMPTY, JENIS_DESELECT_REASONS_COMPLETE, CONTRADICTORY_JENIS (no old-vs-new diff for a brand-new entity); the single-registry set PERSEROAN_STATE, NOTARIS_TERAKHIR, NPWP_PERSEROAN_FUZZY (the NEW PT has no registry record — Submission.oldData stays null; source-side registry state is rules 3-4 of the new pack); REQUIRED_SUPPORTING_DOCS/UPLOADED_DOC_UNUSED (board-level checklist + AWAITING gate cover it); AKTA_DATE_WINDOW (verified during planning per spec §4.6's "verify during build": the body cross-validator.ts:1282 keys the window off input.isPerubahan — 30 days under the shared perubahan-shaped context, but a peleburan is a pendirian-grade application (60 days, UUPT Ps.10(1)); flipping isPerubahan to reach the 60-day branch would perturb other shared validators → excluded, guard-tested); RUPS_ATTENDANCE_QUORUM (not a row of the binding §4.6 matrix — PELEBURAN_RUPS_PER_SOURCE is the flow's RUPS surface).
  • Multi-source lookup lands HERE (first consumer). The engine currently THROWS on companyLookup: { mode: 'multi-source' } (deliberate guard, processor.ts:58-62). Task 4 replaces the throw with the resolve loop: per PeleburanSourcePt row → lookupCompany (+loadOldData+loadPerseroanBlokirState on exactly-one-high auto-select) → row states AUTO_SELECTED | AWAITING_SELECTION | MANUAL(SABH down); any unresolved row → submission AWAITING_COMPANY_SELECTION (reused enum, no new status); all resolved → continueFlow. No new SABH primitives — the loop composes the three existing company-lookup.ts functions.
  • Shared BUKTI_PENGUMUMAN components built HERE (first consumer): backend/src/services/bukti-pengumuman-extract.ts with canonical nullable field keys nama_koran / tanggal_pengumuman / nomor_pengumuman (nomor stays null here — only Berakhirnya consumes it), grounded 3B (CLEANUP_LLM_URL), persisted as ExtractedField rows (spec §6 — no satellite), idempotent-once (never clobbers operator edits on engine resume). Invoked via the EXISTING generic focusedPasses mechanism: ONE map entry in the FlowConfig + ONE handler in flow-engine/focused-passes.ts. The board keyword pre-suggester already exists (supporting-doc-classifier.ts::suggestByKeyword, autoAcceptable:false — NEVER auto-adopted); what remains is SURFACING the non-auto-acceptable suggestion (classify response suggestion field + amber apply-chip on the board, applied only through the existing re-map/reason mechanism) + the deed-INLINE fallback via the ONE parameterized extractKoranFields(rawText, { docContext }) refactor of extractKoranViaFocusedLlm (the akuisisi inline call SITES in document-processor.ts:416/472 stay byte-identical — only the prompt body is parameterized).
  • AKTA_PELEBURAN → akta subtype pendirian. Declared as extraction.overrideByClassifiedType: { AKTA_PELEBURAN: "pendirian" }; the raw-subtype identity rows already exist in AKTA_SUBTYPE_MAP (ocr/classifier.ts:61-62, landed with RUPS Task 6). Also add the defensive label row AKTA_PELEBURAN: "pendirian" (spec §5.4, akuisisi precedent AKTA_AKUISISI: "perubahan").
  • Ladder position (canonical): Perbaikan > Berakhirnya(doc-set predicate) > PELEBURAN > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT. Peleburan's rung goes ABOVE Akuisisi in inferSubmissionType (routes/submissions.ts:91) and in the frontend detection ladders (KlasifikasiPage.tsx computedLabelType + detectedFlow) — a peleburan bundle can legitimately contain pendirian/perubahan-looking content, so peleburan must win; only the (unbuilt) Perbaikan/Berakhirnya rungs stay above it.
  • Classifier gating = the launch switch: everything stays dark ("Terdeteksi: Peleburan (belum didukung)") until BUILT_AKTA_TXN_TYPES += "peleburan" (Task 8). Tasks 1-7 must not make the flow reachable. Task 8 also updates the tests that use peleburan as the unbuilt case (klasifikasi-akta-txn.test.ts:35-45, laporan-rups-routing.test.ts:14) to pembubaran.
  • Model routing: source-refs pass = the 35B endpoint (AKTA_TXN_CLASSIFIER_URL/AKTA_TXN_CLASSIFIER_MODEL env, Qwen3.6-35B-A3B-FP8 @8001 — list-of-objects extraction is exactly where the 3B is brittle; one call per submission). Koran/clipping passes = the 3B (CLEANUP_LLM_URL/CLEANUP_LLM_MODEL), temperature: 0, guided-JSON, every value verbatim-substring-grounded (ground() as in akta-koran-extract.ts); hallucinations → null, never an ERROR.
  • rawText gotcha (inherited): the GPU-server akta path stores rawText: "". Focused passes receive whatever Document.rawText holds (engine contract, processor.ts:51); empty → console.warn + skip → fields NOT_FOUND / zero source rows → manual entry in the panel, never a processing failure.
  • Test-DB isolation: all backend DB tests target ahu_ocr_test via the bunfig preload. NEVER run bun test against the dev DB. Run cd backend && bun run db:push:test after Task 1's schema change.
  • submissionDocument.create seeds MUST include confidence (required Float, no default — the plan-brief gap that bit 5 RUPS tasks). Every seed in this plan carries confidence: 1.
  • Frontend = thin mirrors. NO ReviewEngine / FrontendReviewDescriptor / ReviewData reshape. Extraction page = copy of AkuisisiExtractionPage.tsx + exact diffs (modal→panel swap). Page tests need the IOStub IntersectionObserver stub + jsdom patterns from frontend/src/pages/__tests__/LaporanRupsReviewPage.test.tsx:26-30. Frontend tests via cd frontend && bunx vitest run (NEVER bun test).
  • No auto-confirm / no auto-accept: section approvals are EXPLICIT persisted PerubahanSectionApproval rows — never derived from child confirmed state (regression c0698d8). The BUKTI_PENGUMUMAN suggestion is applied only through the operator re-map mechanism.
  • Do not touch v1 forks: perubahan/akuisisi processors, the akuisisi inline koran call sites, and every existing route behaviour stay byte-identical. The verifikator queue widening (Task 10) adds PELEBURAN_PT without changing what PERUBAHAN_PT rows return.

File Structure

File Create/Modify Responsibility
backend/prisma/schema.prisma Modify SubmissionType += PELEBURAN_PT; new model PeleburanSourcePt + Submission relation (NO new Submission columns; BUKTI_PENGUMUMAN already exists)
backend/src/flow-engine/registry.ts Modify (twice) Task 1: temporary v1-fork stub (compile-gate); Task 7: real peleburanPtFlow entry
backend/src/services/submission-dispatch.ts Modify processorKindForType case PELEBURAN_PT → "v2-generic" (satisfies the never tripwire)
backend/src/services/akta-koran-extract.ts Modify ONE parameterized extractKoranFields(rawText, { docContext }); extractKoranViaFocusedLlm becomes a thin akuisisi-context delegate (call sites untouched)
backend/src/services/bukti-pengumuman-extract.ts Create SHARED clipping extractor (nama_koran/tanggal_pengumuman/nomor_pengumuman) + runBuktiPengumumanPass (ExtractedField persistence, idempotent-once)
backend/src/flow-engine/focused-passes.ts Modify FOCUSED_PASS_HANDLERS["bukti-pengumuman"] entry
backend/src/services/document-processor.ts Modify OCR-only handler case for documentType === "BUKTI_PENGUMUMAN"
backend/src/services/akta-peleburan-sources-extract.ts Create Grounded 35B source-refs pass + runPeleburanDeedPasses (source rows + deed-inline koran; the flow's afterAktaExtraction hook)
backend/src/services/peleburan-sources.ts Create Multi-source resolve loop over company-lookup.ts primitives + row select/re-resolve + hasUnresolvedSources
backend/src/flow-engine/processor.ts Modify Replace the multi-source throw with the resolve-loop branch
backend/src/flow-engine/context/peleburan-context.ts Create PeleburanCtx (extends PtAktaCtx) + buildPeleburanContext (sources / koran / rupsBaDocs / kbliCodes)
backend/src/flow-engine/rules/peleburan/index.ts Create 7 flow rules + PELEBURAN_SHARED_RULE_CODES + assembled PELEBURAN_RULE_SET
backend/src/flow-engine/flows/peleburan-pt.ts Create The real defineFlow<PeleburanCtx> config — SECOND v2-generic registry entry
backend/src/ocr/classifier.ts Modify AKTA_SUBTYPE_MAP["AKTA_PELEBURAN"] = "pendirian" (label row; identity rows already exist)
backend/src/services/akta-txn-classifier.ts Modify BUILT_AKTA_TXN_TYPES += "peleburan" (LAUNCH SWITCH)
backend/src/routes/submissions.ts Modify inferSubmissionType rung ABOVE Akuisisi + return union; mapToDocumentType/getProcessingOrder for BUKTI_PENGUMUMAN
backend/src/routes/klasifikasi.ts Modify PELEBURAN_DOC_TYPES + ?type=peleburan + LABEL_MAP; classify response suggestion (keyword pre-suggester surfacing, never auto-adopted)
backend/src/routes/peleburan.ts Create Route family: review-data, sources CRUD/select, koran, section approve/unapprove, submit→verifikator
backend/src/index.ts Modify Mount /api/peleburan
backend/src/routes/verifikator.ts Modify Queue where admits PELEBURAN_PT; tagged item type from the real submission type
backend/src/flow-engine/__tests__/{peleburan-schema,peleburan-multi-source,peleburan-context,rules-peleburan,peleburan-flow,peleburan-integration}.test.ts Create Per-task TDD
backend/src/services/__tests__/{bukti-pengumuman-extract,akta-peleburan-sources-extract}.test.ts Create Extractor grounding + pass persistence
backend/src/routes/__tests__/{peleburan-routing,peleburan-routes,verifikator-peleburan}.test.ts Create Routing/gating + route family + queue
backend/src/routes/__tests__/{klasifikasi-akta-txn,laporan-rups-routing}.test.ts Modify Stale "peleburan is unbuilt" fixtures → pembubaran
frontend/src/lib/classifier-labels.ts Modify AKTA_PELEBURAN + BUKTI_PENGUMUMAN display names
frontend/src/store/klasifikasi.ts Modify ClassificationResult += suggestion
frontend/src/components/klasifikasi/detect-banner.tsx Modify DetectedFlow += "peleburan" + labels
frontend/src/components/klasifikasi/classification-list-view.tsx Modify Amber suggestion chip (apply via existing re-map)
frontend/src/pages/KlasifikasiPage.tsx Modify Detect chains (rung ABOVE akuisisi) + label set + suggestion capture + create-route
frontend/src/lib/flow-steps.ts Modify PELEBURAN_LABELS (5 steps incl. verifikator stage) + type union + akuisisi-style arm
frontend/src/lib/destination-for.ts Modify Type union + base peleburan (NOT terminal)
frontend/src/lib/peleburan-review.ts Create Pure gate helpers (required sections, unresolved sources)
frontend/src/components/peleburan/PeleburanSourcesPanel.tsx Create The N-row generalization of CompanySelectionModal
frontend/src/pages/PeleburanExtractionPage.tsx Create Copy of AkuisisiExtractionPage.tsx + exact diffs (panel swap)
frontend/src/pages/PeleburanReviewPage.tsx Create Flat pendirian-grade review + sumber_peleburan/rups_pengumuman sections + submit-to-verifikator
frontend/src/pages/PeleburanStatusPage.tsx Create Verifikasi-aware status page
frontend/src/pages/VerifikatorPeleburanReviewPage.tsx Create Compact verifikator surface (per-family precedent: VerifikatorPerbaikanReviewPage)
frontend/src/pages/VerifikatorDashboardPage.tsx Modify Route PELEBURAN_PT items to the peleburan verifikator page
frontend/src/routes.tsx Modify peleburan/:submissionId/{extraction,review,status} + verifikator/peleburan/:submissionId

Task 1: Prisma schema + compile-gate (enum, PeleburanSourcePt, registry stub, ProcessorKind case)

Adds PELEBURAN_PT to the Prisma SubmissionType enum — which FORCES two compile-time additions: a FLOW_REGISTRY key (temporary v1-fork stub, replaced by Task 7) and a processorKindForType case (returns "v2-generic"). Adds the PeleburanSourcePt model (spec §6 verbatim) + the Submission back-relation. NO new Submission columns (source data lives on the rows; AWAITING_COMPANY_SELECTION is reused; koran fields are ExtractedField rows). BUKTI_PENGUMUMAN DocumentType already exists — do not touch DocumentType.

Files
- backend/prisma/schema.prisma — Modify (enum SubmissionType ends at LAPORAN_RUPS_TAHUNAN ~line 94; new model next to model PerubahanSectionApproval ~line 1015; model Submission gains the relation array)
- backend/src/flow-engine/registry.ts — Modify (add stub key after LAPORAN_RUPS_TAHUNAN)
- backend/src/services/submission-dispatch.ts — Modify (processorKindForType, add case before default:)
- backend/src/flow-engine/__tests__/peleburan-schema.test.ts — Create

Interfaces
- Produces: generated enum SubmissionType.PELEBURAN_PT; model client db.peleburanSourcePt with @@unique([submissionId, ordinal]), resolutionStatus String @default("PENDING") (PENDING | AUTO_SELECTED | AWAITING_SELECTION | MANUAL), Json columns companyLookupResult/oldData/blokirState; processorKindForType("PELEBURAN_PT") === "v2-generic"; FLOW_REGISTRY.PELEBURAN_PT defined.

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/peleburan-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 — PELEBURAN_PT schema slice + the two compile-forced wiring points
 * (registry key + ProcessorKind case). The registry entry is a TEMPORARY
 * v1-fork stub until Task 7 registers the real defineFlow config.
 */
describe("PELEBURAN_PT schema + compile-gate", () => {
  const ids: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: ids } } });
  });

  it("exposes the new enum value", () => {
    expect(SubmissionType.PELEBURAN_PT).toBe("PELEBURAN_PT");
  });

  it("persists PeleburanSourcePt rows keyed by (submissionId, ordinal)", async () => {
    const sub = await db.submission.create({ data: { type: "PELEBURAN_PT", status: "CREATED" } });
    ids.push(sub.id);
    const row = await db.peleburanSourcePt.create({
      data: {
        submissionId: sub.id, ordinal: 0,
        namaPerseroan: "PT SUMBER SATU", nomorSk: "AHU-0001.AH.01.01.TAHUN 2020", tanggalRups: "10 Maret 2026",
      },
    });
    expect(row.resolutionStatus).toBe("PENDING");
    expect(row.companyLookupResult).toBeNull();
    expect(row.oldData).toBeNull();
    expect(row.blokirState).toBeNull();
    // @@unique([submissionId, ordinal])
    await expect(
      db.peleburanSourcePt.create({ data: { submissionId: sub.id, ordinal: 0, namaPerseroan: "PT DUP" } }),
    ).rejects.toThrow();
    // cascade delete rides the submission
    await db.submission.delete({ where: { id: sub.id } });
    ids.splice(ids.indexOf(sub.id), 1);
    expect(await db.peleburanSourcePt.findMany({ where: { submissionId: sub.id } })).toEqual([]);
  });

  it("routes the new type to the v2-generic ProcessorKind and a total registry", () => {
    expect(processorKindForType("PELEBURAN_PT")).toBe("v2-generic");
    expect(FLOW_REGISTRY.PELEBURAN_PT).toBeDefined();
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/peleburan-schema.test.ts. Expected: fails — PELEBURAN_PT not on the enum (typecheck of the test file itself errors).
  • [ ] Minimal implementation A — backend/prisma/schema.prisma:
    1. Inside enum SubmissionType { ... }, append after LAPORAN_RUPS_TAHUNAN:
  /// Peleburan PT (Konsolidasi, SABH id_aksi_transaksi = 35, UUPT Ps.122): two or
  /// more registered PTs consolidate into a BRAND-NEW PT; the sources' legal
  /// personhood ends by operation of law. NEW-ENTITY transaction: the deed carries
  /// a pendirian-grade payload; the N source PTs live on PeleburanSourcePt rows.
  /// SECOND engine:'v2-generic' FLOW_REGISTRY tenant. VERIFIKATOR-STAGED (submit
  /// creates a VerifikasiPerubahan row  contrast terminal LAPORAN_RUPS_TAHUNAN).
  PELEBURAN_PT
  1. Add the model directly after model PerubahanSectionApproval (~line 1027):
/// One row per source PT of a PELEBURAN_PT submission (spec §4.4/§6). Carries the
/// extracted deed reference, the SABH lookup result, the operator/auto selection,
/// the OldData snapshot and the blokir state. Multi-source generalization of the
/// single-company quintet on Submission (which stays null for this type).
model PeleburanSourcePt {
  id                     String     @id @default(cuid())
  submissionId           String
  submission             Submission @relation(fields: [submissionId], references: [id], onDelete: Cascade)
  ordinal                Int
  /// extracted (grounded) or manually entered
  namaPerseroan          String
  nomorSk                String?
  /// per-source RUPS approval date (word-form as recited in the deed)
  tanggalRups            String?
  /// LookupResponse (company-lookup.ts)
  companyLookupResult    Json?
  selectedNomorTransaksi String?
  selectedTahun          Int?
  /// OldData snapshot (company-lookup.ts)
  oldData                Json?
  /// PerseroanBlokirState (company-lookup.ts)
  blokirState            Json?
  /// PENDING | AUTO_SELECTED | AWAITING_SELECTION | MANUAL
  resolutionStatus       String     @default("PENDING")
  createdAt              DateTime   @default(now())
  updatedAt              DateTime   @updatedAt

  @@unique([submissionId, ordinal])
  @@index([submissionId])
}
  1. In model Submission, add alongside the existing relation arrays (next to perubahanSectionApprovals PerubahanSectionApproval[]):
  peleburanSources PeleburanSourcePt[]
  • [ ] Minimal implementation B — backend/src/flow-engine/registry.ts, append to FLOW_REGISTRY after the LAPORAN_RUPS_TAHUNAN line:
  // TEMPORARY v1-fork stub — satisfies Record totality until Task 7 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 8).
  PELEBURAN_PT: { engine: "v1-fork", type: "PELEBURAN_PT" },
  • [ ] Minimal implementation C — backend/src/services/submission-dispatch.ts, in processorKindForType, add before the default: arm:
    case "PELEBURAN_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__/peleburan-schema.test.ts. Expected: 3 pass.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit. Expected: no errors (registry totality + never tripwire both satisfied). NOTE: backend/src/flow-engine/__tests__/registry.test.ts asserts every type is v1-fork except V2_SHIPPED = new Set(["LAPORAN_RUPS_TAHUNAN"]) — the stub keeps it green NOW; Task 7 adds PELEBURAN_PT to that set. Run bun test src/flow-engine/__tests__/registry.test.ts src/flow-engine/__tests__/dispatch-routing.test.ts to confirm the stub breaks nothing.
  • [ ] 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__/peleburan-schema.test.ts && git commit -m "feat(peleburan): PELEBURAN_PT enum + PeleburanSourcePt + compile-gate wiring"

Task 2: SHARED bukti-pengumuman extractor + focused-pass handler + OCR-only doc handler + extractKoranFields refactor

Builds both SHARED koran pieces (first consumer; Pembubaran/Berakhirnya reuse verbatim): (a) the ONE parameterized extractKoranFields(rawText, { docContext }) — the current extractKoranViaFocusedLlm prompt hardcodes "akta pengambilalihan (akuisisi)"; the akuisisi call SITES in document-processor.ts:416/472 stay untouched (they keep calling extractKoranViaFocusedLlm, which becomes a one-line delegate); (b) the clipping extractor bukti-pengumuman-extract.ts with canonical keys nama_koran/tanggal_pengumuman/nomor_pengumuman, persisted as ExtractedField rows (idempotent-once), wired as FOCUSED_PASS_HANDLERS["bukti-pengumuman"]. Also the document-processor.ts OCR-only case — without it a BUKTI_PENGUMUMAN clipping falls through to the generic akta handler and burns an akta-VLM extraction on newsprint.

Files
- backend/src/services/akta-koran-extract.ts — Modify
- backend/src/services/bukti-pengumuman-extract.ts — Create
- backend/src/flow-engine/focused-passes.ts — Modify (one map entry)
- backend/src/services/document-processor.ts — Modify (insert case directly after the LAPORAN_KEUANGAN OCR-only case, ~line 2485)
- backend/src/services/__tests__/bukti-pengumuman-extract.test.ts — Create

Interfaces
- Consumes: CLEANUP_LLM_URL/CLEANUP_LLM_MODEL/CLEANUP_LLM_TIMEOUT_MS env (3B @8003); db; FocusedPassArgs from ../flow-engine/focused-passes.
- Produces:
- async function extractKoranFields(rawText: string, opts: { docContext: string }): Promise<{ tanggal_koran: string | null; nama_koran: string | null }> (grounded; extractKoranViaFocusedLlm(rawText)extractKoranFields(rawText, { docContext: "akta pengambilalihan (akuisisi)" }))
- interface BuktiPengumumanFields { nama_koran: string | null; tanggal_pengumuman: string | null; nomor_pengumuman: string | null }
- async function extractBuktiPengumuman(rawText: string): Promise<BuktiPengumumanFields>
- async function runBuktiPengumumanPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> — idempotent-once: returns untouched when any of the three ExtractedField rows already exist on the document
- FOCUSED_PASS_HANDLERS["bukti-pengumuman"]

Steps

  • [ ] Write failing test. Create backend/src/services/__tests__/bukti-pengumuman-extract.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import { extractBuktiPengumuman, runBuktiPengumumanPass } from "../bukti-pengumuman-extract";
import { extractKoranFields } from "../akta-koran-extract";

const RAW =
  "HARIAN KOMPAS\nJakarta, 12 Mei 2026\nPENGUMUMAN\nRINGKASAN RANCANGAN PELEBURAN\n" +
  "PT SUMBER SATU dan PT SUMBER DUA akan meleburkan diri ke dalam PT BARU HASIL LEBUR";

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("extractKoranFields (parameterized deed-inline refactor)", () => {
  const origEnv = process.env.CLEANUP_LLM_URL;
  afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });

  it("sends the caller's docContext in the prompt and grounds values", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    let sentBody = "";
    const orig = globalThis.fetch;
    globalThis.fetch = (async (_u: unknown, init?: RequestInit) => {
      sentBody = String(init?.body ?? "");
      return new Response(JSON.stringify({ choices: [{ message: { content: JSON.stringify({ tanggal_koran: "12 Mei 2026", nama_koran: "HARIAN HALUSINASI" }) } }] }), { status: 200 });
    }) as typeof fetch;
    try {
      const r = await extractKoranFields(RAW, { docContext: "akta peleburan (konsolidasi)" });
      expect(sentBody).toContain("akta peleburan (konsolidasi)");
      expect(r.tanggal_koran).toBe("12 Mei 2026");
      expect(r.nama_koran).toBeNull(); // not in rawText → grounded away
    } finally { globalThis.fetch = orig; }
  });
});

describe("extractBuktiPengumuman (grounded 3B clipping extractor)", () => {
  const origEnv = process.env.CLEANUP_LLM_URL;
  afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });

  it("grounds every value; hallucinated koran name → null; nomor stays null when absent", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ nama_koran: "HARIAN KOMPAS", tanggal_pengumuman: "12 Mei 2026", nomor_pengumuman: "No. 999/XY" });
    try {
      const r = await extractBuktiPengumuman(RAW);
      expect(r.nama_koran).toBe("HARIAN KOMPAS");
      expect(r.tanggal_pengumuman).toBe("12 Mei 2026");
      expect(r.nomor_pengumuman).toBeNull(); // not in rawText → grounded away
    } finally { restore(); }
  });

  it("returns all-null on empty rawText and never throws on LLM outage", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm(null, 500);
    try {
      expect(await extractBuktiPengumuman("")).toEqual({ nama_koran: null, tanggal_pengumuman: null, nomor_pengumuman: null });
      expect(await extractBuktiPengumuman(RAW)).toEqual({ nama_koran: null, tanggal_pengumuman: null, nomor_pengumuman: null });
    } finally { restore(); }
  });
});

describe("runBuktiPengumumanPass persistence (ExtractedField rows — spec §6, no satellite)", () => {
  const docIds: string[] = [];
  afterAll(async () => { await db.document.deleteMany({ where: { id: { in: docIds } } }); });

  async function seedDoc() {
    const doc = await db.document.create({
      data: { filename: "koran.pdf", originalName: "koran.pdf", fileSize: 0, filePath: "/tmp/koran.pdf", documentType: "BUKTI_PENGUMUMAN", status: "VERIFICATION_READY", rawText: RAW },
    });
    docIds.push(doc.id);
    return doc.id;
  }

  it("persists the three canonical ExtractedField rows (grounded 0.75 tier; null → FLAGGED)", async () => {
    process.env.CLEANUP_LLM_URL = "http://mock";
    const restore = mockLlm({ nama_koran: "HARIAN KOMPAS", tanggal_pengumuman: "12 Mei 2026", nomor_pengumuman: null });
    try {
      const documentId = await seedDoc();
      await runBuktiPengumumanPass({ submissionId: "s", documentId, rawText: RAW });
      const nama = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId, fieldKey: "nama_koran" } } });
      expect(nama?.value).toBe("HARIAN KOMPAS");
      expect(nama?.status).toBe("EDITABLE");
      expect(nama?.confidence).toBe(0.75);
      const nomor = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId, fieldKey: "nomor_pengumuman" } } });
      expect(nomor?.value).toBeNull();
      expect(nomor?.status).toBe("FLAGGED");
    } 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({ nama_koran: "HARIAN KOMPAS", tanggal_pengumuman: "12 Mei 2026", nomor_pengumuman: null });
    try {
      const documentId = await seedDoc();
      await db.extractedField.create({ data: { documentId, fieldKey: "nama_koran", value: "EDIT OPERATOR", confidence: 1, status: "MANUAL" } });
      await runBuktiPengumumanPass({ submissionId: "s", documentId, rawText: RAW });
      const nama = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId, fieldKey: "nama_koran" } } });
      expect(nama?.value).toBe("EDIT OPERATOR");
    } finally { restore(); }
  });

  it("skips loudly (no rows) on empty rawText", async () => {
    const documentId = await seedDoc();
    await runBuktiPengumumanPass({ submissionId: "s", documentId, rawText: "" });
    expect(await db.extractedField.findMany({ where: { documentId } })).toEqual([]);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/services/__tests__/bukti-pengumuman-extract.test.ts. Expected: fails — modules/exports not found.
  • [ ] Minimal implementation A — backend/src/services/akta-koran-extract.ts: replace the whole file with the parameterized version (the grounding/plumbing is byte-identical; only the prompt's document-context sentence is parameterized):
/**
 * Focused, grounded newspaper-announcement (koran) extraction (mirrors apostille
 * single-field recovery). ONE parameterized implementation serving every deed
 * that recites the pengumuman inline — akuisisi (existing call sites in
 * document-processor.ts:416/472, via the extractKoranViaFocusedLlm delegate),
 * peleburan, and later pembubaran. Never broaden this prompt per-flow — pass a
 * docContext instead (3B brittleness, memory reference_apostille_extraction_llm).
 */
export async function extractKoranFields(
  rawText: string,
  opts: { docContext: string },
): Promise<{ tanggal_koran: string | null; nama_koran: string | null }> {
  const url = process.env.CLEANUP_LLM_URL || "";
  const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
  const NONE = { tanggal_koran: null, nama_koran: null };
  if (!url) return NONE;
  if (!rawText.trim()) {
    // URL is configured but there's no OCR text to ground against — this is the
    // GPU-OCR path where the server returns no rawText. Surface it so the gap is
    // visible in logs instead of silently returning nulls.
    console.warn("[akta-koran] rawText empty — koran extraction skipped (GPU-OCR path?)");
    return NONE;
  }
  const system =
    `From an Indonesian ${opts.docContext}, extract the newspaper announcement (pengumuman surat kabar): ` +
    "tanggal_koran (publication date) and nama_koran (newspaper/gazette name, e.g. 'Berita Negara'). " +
    "Copy VERBATIM from the text; if absent, return null. Never invent. Return ONLY JSON {tanggal_koran, nama_koran}.";
  try {
    const res = await fetch(`${url}/chat/completions`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        model,
        temperature: 0,
        messages: [
          { role: "system", content: system },
          { role: "user", content: rawText.slice(0, 12000) },
        ],
        guided_json: {
          type: "object",
          properties: {
            tanggal_koran: { type: ["string", "null"] },
            nama_koran: { type: ["string", "null"] },
          },
          required: ["tanggal_koran", "nama_koran"],
          additionalProperties: false,
        },
      }),
      signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
    });
    if (!res.ok) return NONE;
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
    if (!m) return NONE;
    const parsed = JSON.parse(m[0]) as { tanggal_koran?: string | null; nama_koran?: string | null };
    const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
    const hay = norm(rawText);
    const ground = (v: string | null | undefined): string | null => {
      const s = v ? String(v).trim() : "";
      return s && hay.includes(norm(s)) ? s : null;
    };
    return { tanggal_koran: ground(parsed.tanggal_koran), nama_koran: ground(parsed.nama_koran) };
  } catch {
    return NONE;
  }
}

/** AKUISISI delegate — keeps document-processor.ts call sites byte-identical (v1 fork untouched). */
export async function extractKoranViaFocusedLlm(
  rawText: string,
): Promise<{ tanggal_koran: string | null; nama_koran: string | null }> {
  return extractKoranFields(rawText, { docContext: "akta pengambilalihan (akuisisi)" });
}
  • [ ] Minimal implementation B — Create backend/src/services/bukti-pengumuman-extract.ts:
/**
 * SHARED BUKTI_PENGUMUMAN clipping extractor (first consumer: PELEBURAN_PT;
 * reused verbatim by PEMBUBARAN_PT and BERAKHIRNYA_STATUS_PT). Canonical
 * nullable field keys: nama_koran / tanggal_pengumuman / nomor_pengumuman
 * (nomor stays null for peleburan — only Berakhirnya consumes it).
 * Grounded 3B (CLEANUP_LLM), temperature 0, guided-JSON, verbatim-substring
 * grounding. Persists ExtractedField rows (spec §6 — no satellite table),
 * IDEMPOTENT-ONCE: an engine resume must never clobber operator edits.
 */
import { db } from "../lib/db";

export interface BuktiPengumumanFields {
  nama_koran: string | null;
  tanggal_pengumuman: string | null;
  nomor_pengumuman: string | null;
}
const NONE: BuktiPengumumanFields = { nama_koran: null, tanggal_pengumuman: null, nomor_pengumuman: null };

export async function extractBuktiPengumuman(rawText: string): Promise<BuktiPengumumanFields> {
  const url = process.env.CLEANUP_LLM_URL || "";
  const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
  if (!url) return { ...NONE };
  if (!rawText.trim()) {
    console.warn("[bukti-pengumuman] rawText empty — clipping extraction skipped (GPU-OCR path?)");
    return { ...NONE };
  }
  const system =
    "From an Indonesian newspaper clipping (bukti pengumuman koran — Pasal 127(2) UUPT announcement), extract: " +
    "nama_koran (the newspaper masthead, e.g. 'Harian Kompas'), tanggal_pengumuman (the publication date as printed), " +
    "nomor_pengumuman (the announcement/edition number, if printed). " +
    "Copy VERBATIM from the text; if absent, return null. Never invent. " +
    "Return ONLY JSON {nama_koran, tanggal_pengumuman, nomor_pengumuman}.";
  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: {
            nama_koran: { type: ["string", "null"] },
            tanggal_pengumuman: { type: ["string", "null"] },
            nomor_pengumuman: { type: ["string", "null"] },
          },
          required: ["nama_koran", "tanggal_pengumuman", "nomor_pengumuman"],
          additionalProperties: false,
        },
      }),
      signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
    });
    if (!res.ok) return { ...NONE };
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
    if (!m) return { ...NONE };
    const parsed = JSON.parse(m[0]) as Partial<Record<keyof BuktiPengumumanFields, string | null>>;
    const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
    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;
    };
    return {
      nama_koran: ground(parsed.nama_koran),
      tanggal_pengumuman: ground(parsed.tanggal_pengumuman),
      nomor_pengumuman: ground(parsed.nomor_pengumuman),
    };
  } catch (err) {
    console.warn("[bukti-pengumuman] 3B extraction failed:", err);
    return { ...NONE };
  }
}

/** The 'bukti-pengumuman' focused-pass body (invoked by flow-engine focusedPasses). */
export async function runBuktiPengumumanPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> {
  const { documentId, rawText } = args;
  if (!rawText.trim()) {
    console.warn(`[bukti-pengumuman] no rawText for doc ${documentId} — focused pass skipped`);
    return;
  }
  const KEYS = ["nama_koran", "tanggal_pengumuman", "nomor_pengumuman"] as const;
  const existing = await db.extractedField.findMany({ where: { documentId, fieldKey: { in: [...KEYS] } } });
  if (existing.length > 0) return; // idempotent-once — operator edits survive engine resume
  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",
      },
    });
  }
}
  • [ ] Minimal implementation C — backend/src/flow-engine/focused-passes.ts: append to FOCUSED_PASS_HANDLERS after the "laporan-keuangan" entry:
  "bukti-pengumuman": async (args) => {
    const { runBuktiPengumumanPass } = await import("../services/bukti-pengumuman-extract");
    await runBuktiPengumumanPass(args);
  },
  • [ ] Minimal implementation D — backend/src/services/document-processor.ts: insert directly AFTER the closing brace of the LAPORAN_KEUANGAN OCR-only case (comment // 6i. LAPORAN_KEUANGAN…, case ends ~line 2489) and before the next case:
    // 6j. BUKTI_PENGUMUMAN (Pasal 127(2) newspaper clipping) — OCR-ONLY. The
    // grounded clipping extraction runs as the flow-engine 'bukti-pengumuman'
    // focusedPasses pass, NOT here. Without this case the clipping falls through
    // to the generic Akta handler and burns an akta-VLM extraction on newsprint.
    if (doc.documentType === "BUKTI_PENGUMUMAN") {
      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;
    }

(Mirror the LAPORAN_KEUANGAN case's exact call shape — same imports already present in the file.)
- [ ] Run GREEN: cd backend && bun test src/services/__tests__/bukti-pengumuman-extract.test.ts. Then regression: bun test src/services/__tests__/akta-koran-extract.test.ts src/flow-engine/__tests__/focused-passes.test.ts (if an akta-koran test file exists; otherwise run bun test src/services/__tests__ src/flow-engine/__tests__/focused-passes.test.ts — the delegate must keep akuisisi behaviour byte-identical).
- [ ] Typecheck: cd backend && bunx tsc --noEmit.
- [ ] Commit: git add backend/src/services/akta-koran-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__/bukti-pengumuman-extract.test.ts && git commit -m "feat(peleburan): shared bukti-pengumuman extractor + focused pass + parameterized extractKoranFields"


Task 3: Grounded 35B source-refs pass + runPeleburanDeedPasses hook

The flow's structurally new extraction: a grounded guided-JSON call returning { sources: [{ nama_perseroan, nomor_sk, tanggal_rups }] } from the deed's premisse/komparisi, on the 35B endpoint (AKTA_TXN_CLASSIFIER_URL — list-of-objects is where the 3B is brittle; one call per submission). runPeleburanDeedPasses is the flow's hooks.afterAktaExtraction body: (1) persist one PeleburanSourcePt row per grounded source (idempotent-once — rows exist → skip, so manual adds/selections survive engine resume); (2) deed-INLINE koran fallback via extractKoranFields(rawText, { docContext: "akta peleburan (konsolidasi)" }), persisted under the canonical keys nama_koran/tanggal_pengumuman on the AKTA document (skip when either row already exists).

Files
- backend/src/services/akta-peleburan-sources-extract.ts — Create
- backend/src/services/__tests__/akta-peleburan-sources-extract.test.ts — Create

Interfaces
- Consumes: AKTA_TXN_CLASSIFIER_URL/AKTA_TXN_CLASSIFIER_MODEL/AKTA_TXN_CLASSIFIER_TIMEOUT_MS env (35B — same endpoint config the txn classifier uses, akta-txn-classifier.ts:115-117); extractKoranFields (Task 2); db.peleburanSourcePt (Task 1).
- Produces:
- interface PeleburanSourceRef { nama_perseroan: string; nomor_sk: string | null; tanggal_rups: string | null }
- async function extractPeleburanSourcesViaLlm(rawText: string): Promise<PeleburanSourceRef[]> — a source whose nama_perseroan fails grounding is DROPPED; nomor_sk/tanggal_rups are individually grounded-or-null
- async function runPeleburanDeedPasses(a: { submissionId: string; aktaDocumentId: string; rawText: string }): Promise<void> — the afterAktaExtraction hook body

Steps

  • [ ] Write failing test. Create backend/src/services/__tests__/akta-peleburan-sources-extract.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import { extractPeleburanSourcesViaLlm, runPeleburanDeedPasses } from "../akta-peleburan-sources-extract";

const RAW =
  "AKTA PELEBURAN\nPERSEROAN TERBATAS PT BARU HASIL LEBUR\n" +
  "PT SUMBER SATU, berkedudukan di Jakarta, SK AHU-0001.AH.01.01.TAHUN 2020, RUPS tanggal 10 Maret 2026\n" +
  "PT SUMBER DUA, berkedudukan di Bandung, SK AHU-0002.AH.01.01.TAHUN 2019, RUPS tanggal 11 Maret 2026\n" +
  "sepakat meleburkan diri dengan mendirikan PT BARU HASIL LEBUR. Pengumuman surat kabar Harian Kompas tanggal 1 Februari 2026";

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("extractPeleburanSourcesViaLlm (grounded 35B)", () => {
  const origEnv = process.env.AKTA_TXN_CLASSIFIER_URL;
  afterEach(() => { process.env.AKTA_TXN_CLASSIFIER_URL = origEnv; });

  it("keeps verbatim sources, drops a fabricated one, nulls an ungrounded SK", async () => {
    process.env.AKTA_TXN_CLASSIFIER_URL = "http://mock";
    const restore = mockLlm({
      sources: [
        { nama_perseroan: "PT SUMBER SATU", nomor_sk: "AHU-0001.AH.01.01.TAHUN 2020", tanggal_rups: "10 Maret 2026" },
        { nama_perseroan: "PT SUMBER DUA", nomor_sk: "AHU-9999.FAKE", tanggal_rups: "11 Maret 2026" },
        { nama_perseroan: "PT HALUSINASI ABADI", nomor_sk: null, tanggal_rups: null },
      ],
    });
    try {
      const r = await extractPeleburanSourcesViaLlm(RAW);
      expect(r).toHaveLength(2); // fabricated source DROPPED
      expect(r[0]).toEqual({ nama_perseroan: "PT SUMBER SATU", nomor_sk: "AHU-0001.AH.01.01.TAHUN 2020", tanggal_rups: "10 Maret 2026" });
      expect(r[1]!.nomor_sk).toBeNull(); // ungrounded SK nulled, source kept
      expect(r[1]!.tanggal_rups).toBe("11 Maret 2026");
    } finally { restore(); }
  });

  it("returns [] on empty rawText, LLM outage, or unset URL — never throws", async () => {
    process.env.AKTA_TXN_CLASSIFIER_URL = "http://mock";
    const restore = mockLlm(null, 500);
    try {
      expect(await extractPeleburanSourcesViaLlm("")).toEqual([]);
      expect(await extractPeleburanSourcesViaLlm(RAW)).toEqual([]);
    } finally { restore(); }
    process.env.AKTA_TXN_CLASSIFIER_URL = "";
    expect(await extractPeleburanSourcesViaLlm(RAW)).toEqual([]);
  });
});

describe("runPeleburanDeedPasses (source rows + inline koran)", () => {
  const ids: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: ids } } });
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  async function seed() {
    const akta = await db.document.create({
      data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY", rawText: RAW },
    });
    docIds.push(akta.id);
    const sub = await db.submission.create({ data: { type: "PELEBURAN_PT", status: "EXTRACTING" } });
    ids.push(sub.id);
    return { subId: sub.id, aktaId: akta.id };
  }

  it("persists grounded source rows (ordinal order) + inline koran ExtractedFields on the akta", async () => {
    process.env.AKTA_TXN_CLASSIFIER_URL = "http://mock";
    process.env.CLEANUP_LLM_URL = "http://mock";
    // ONE mock serves both calls: guided_json keys distinguish them by response shape —
    // return a superset object; each extractor picks its own keys and grounds them.
    const restore = mockLlm({
      sources: [
        { nama_perseroan: "PT SUMBER SATU", nomor_sk: "AHU-0001.AH.01.01.TAHUN 2020", tanggal_rups: "10 Maret 2026" },
        { nama_perseroan: "PT SUMBER DUA", nomor_sk: null, tanggal_rups: "11 Maret 2026" },
      ],
      tanggal_koran: "1 Februari 2026",
      nama_koran: "Harian Kompas",
    });
    try {
      const { subId, aktaId } = await seed();
      await runPeleburanDeedPasses({ submissionId: subId, aktaDocumentId: aktaId, rawText: RAW });
      const rows = await db.peleburanSourcePt.findMany({ where: { submissionId: subId }, orderBy: { ordinal: "asc" } });
      expect(rows).toHaveLength(2);
      expect(rows[0]!.namaPerseroan).toBe("PT SUMBER SATU");
      expect(rows[0]!.resolutionStatus).toBe("PENDING");
      expect(rows[1]!.tanggalRups).toBe("11 Maret 2026");
      const koran = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: aktaId, fieldKey: "tanggal_pengumuman" } } });
      expect(koran?.value).toBe("1 Februari 2026");
      const nama = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: aktaId, fieldKey: "nama_koran" } } });
      expect(nama?.value).toBe("Harian Kompas");
    } finally { restore(); }
  });

  it("is idempotent-once: existing rows (e.g. manual adds/selections) are never recreated", async () => {
    process.env.AKTA_TXN_CLASSIFIER_URL = "http://mock";
    const restore = mockLlm({ sources: [{ nama_perseroan: "PT SUMBER SATU", nomor_sk: null, tanggal_rups: null }] });
    try {
      const { subId, aktaId } = await seed();
      await db.peleburanSourcePt.create({
        data: { submissionId: subId, ordinal: 0, namaPerseroan: "PT MANUAL OPERATOR", resolutionStatus: "MANUAL" },
      });
      await runPeleburanDeedPasses({ submissionId: subId, aktaDocumentId: aktaId, rawText: RAW });
      const rows = await db.peleburanSourcePt.findMany({ where: { submissionId: subId } });
      expect(rows).toHaveLength(1);
      expect(rows[0]!.namaPerseroan).toBe("PT MANUAL OPERATOR");
    } finally { restore(); }
  });

  it("degrades gracefully on empty rawText: zero rows, no throw (manual entry in the panel)", async () => {
    const { subId, aktaId } = await seed();
    await runPeleburanDeedPasses({ submissionId: subId, aktaDocumentId: aktaId, rawText: "" });
    expect(await db.peleburanSourcePt.findMany({ where: { submissionId: subId } })).toEqual([]);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/services/__tests__/akta-peleburan-sources-extract.test.ts. Expected: module not found.
  • [ ] Minimal implementation. Create backend/src/services/akta-peleburan-sources-extract.ts:
/**
 * Focused pass A — source-PT references of an AKTA PELEBURAN (spec §4.3).
 * A grounded guided-JSON call on the 35B endpoint (AKTA_TXN_CLASSIFIER_URL —
 * Qwen3.6-35B-A3B-FP8 @8001): list-of-objects extraction is exactly where the
 * 3B is brittle (memory reference_apostille_extraction_llm), and this is one
 * call per submission. Grounding: a source whose nama_perseroan is not a
 * verbatim substring of the (normalized) rawText is DROPPED; nomor_sk and
 * tanggal_rups are individually grounded-or-null. Fabricated sources are thus
 * structurally unlikely; extraction MISSES are covered by manual add in the
 * PeleburanSourcesPanel.
 */
import { db } from "../lib/db";
import { extractKoranFields } from "./akta-koran-extract";

export interface PeleburanSourceRef {
  nama_perseroan: string;
  nomor_sk: string | null;
  tanggal_rups: string | null;
}

export async function extractPeleburanSourcesViaLlm(rawText: string): Promise<PeleburanSourceRef[]> {
  const url = process.env.AKTA_TXN_CLASSIFIER_URL || "";
  const model = process.env.AKTA_TXN_CLASSIFIER_MODEL || "Qwen/Qwen3.6-35B-A3B-FP8";
  const timeoutMs = parseInt(process.env.AKTA_TXN_CLASSIFIER_TIMEOUT_MS || "30000", 10);
  if (!url) return [];
  if (!rawText.trim()) {
    console.warn("[peleburan-sources] rawText empty — source extraction skipped (GPU-OCR path?)");
    return [];
  }
  const system =
    "From an Indonesian akta peleburan (konsolidasi) deed, extract the SOURCE companies — the existing PTs that " +
    "meleburkan diri (merge themselves) into the new company. They are recited in the premisse/komparisi with their " +
    "SK Menkumham numbers and RUPS approval dates. For each source return: nama_perseroan (full PT name), " +
    "nomor_sk (its SK Menkumham number, e.g. 'AHU-0001.AH.01.01.TAHUN 2020', null if absent), " +
    "tanggal_rups (the date of that PT's RUPS approving the peleburan, as written, null if absent). " +
    "Do NOT include the NEW company being founded. Copy VERBATIM; never invent. " +
    'Return ONLY JSON {"sources": [{nama_perseroan, nomor_sk, tanggal_rups}]}.';
  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, 16000) },
        ],
        guided_json: {
          type: "object",
          properties: {
            sources: {
              type: "array",
              items: {
                type: "object",
                properties: {
                  nama_perseroan: { type: "string" },
                  nomor_sk: { type: ["string", "null"] },
                  tanggal_rups: { type: ["string", "null"] },
                },
                required: ["nama_perseroan", "nomor_sk", "tanggal_rups"],
                additionalProperties: false,
              },
            },
          },
          required: ["sources"],
          additionalProperties: false,
        },
      }),
      signal: AbortSignal.timeout(timeoutMs),
    });
    if (!res.ok) {
      console.error(`[peleburan-sources] LLM responded ${res.status}`);
      return [];
    }
    const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
    const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
    if (!m) return [];
    const parsed = JSON.parse(m[0]) as { sources?: Array<Partial<PeleburanSourceRef>> };
    if (!Array.isArray(parsed.sources)) return [];
    const norm = (s: string) => s.toLowerCase().replace(/[^a-z0-9]/g, "");
    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: PeleburanSourceRef[] = [];
    for (const src of parsed.sources) {
      const nama = ground(src.nama_perseroan);
      if (!nama) continue; // fabricated / ungroundable source — DROP
      out.push({ nama_perseroan: nama, nomor_sk: ground(src.nomor_sk), tanggal_rups: ground(src.tanggal_rups) });
    }
    return out;
  } catch (err) {
    console.error("[peleburan-sources] 35B extraction failed:", err);
    return [];
  }
}

/**
 * The flow's hooks.afterAktaExtraction body (spec §4.3 passes A + B-inline).
 * IDEMPOTENT-ONCE on both halves: existing PeleburanSourcePt rows (earlier run
 * OR manual adds/selections) and existing koran ExtractedFields are never
 * recreated/clobbered on engine resume. Log-and-continue is enforced by the
 * engine (processor.ts afterAktaExtraction try/catch) — this body may throw
 * only DB errors, never LLM ones.
 */
export async function runPeleburanDeedPasses(a: { submissionId: string; aktaDocumentId: string; rawText: string }): Promise<void> {
  const { submissionId, aktaDocumentId, rawText } = a;
  if (!rawText.trim()) {
    console.warn(`[peleburan-sources] no rawText for akta ${aktaDocumentId} — deed passes skipped (manual entry in the panel)`);
    return;
  }

  // Pass A — source refs → PeleburanSourcePt rows (idempotent-once).
  const existingRows = await db.peleburanSourcePt.count({ where: { submissionId } });
  if (existingRows === 0) {
    const refs = await extractPeleburanSourcesViaLlm(rawText);
    for (let i = 0; i < refs.length; i++) {
      const r = refs[i]!;
      await db.peleburanSourcePt.create({
        data: {
          submissionId, ordinal: i,
          namaPerseroan: r.nama_perseroan, nomorSk: r.nomor_sk, tanggalRups: r.tanggal_rups,
        },
      });
    }
  }

  // Pass B (deed-INLINE koran fallback) — canonical keys nama_koran / tanggal_pengumuman
  // on the AKTA document. The BUKTI_PENGUMUMAN clipping (focusedPasses) is the better
  // source when uploaded; context resolution prefers the clipping (peleburan-context.ts).
  const existingKoran = await db.extractedField.findMany({
    where: { documentId: aktaDocumentId, fieldKey: { in: ["nama_koran", "tanggal_pengumuman"] } },
  });
  if (existingKoran.length === 0) {
    const koran = await extractKoranFields(rawText, { docContext: "akta peleburan (konsolidasi)" });
    const pairs: Array<[string, string | null]> = [
      ["nama_koran", koran.nama_koran],
      ["tanggal_pengumuman", koran.tanggal_koran],
    ];
    for (const [fieldKey, value] of pairs) {
      await db.extractedField.create({
        data: { documentId: aktaDocumentId, fieldKey, value, confidence: value ? 0.75 : 0, status: value ? "EDITABLE" : "FLAGGED" },
      });
    }
  }
}
  • [ ] Run GREEN: cd backend && bun test src/services/__tests__/akta-peleburan-sources-extract.test.ts. Expected: all pass.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/services/akta-peleburan-sources-extract.ts backend/src/services/__tests__/akta-peleburan-sources-extract.test.ts && git commit -m "feat(peleburan): grounded 35B source-refs pass + deed passes hook (rows + inline koran)"

Task 4: Multi-source lookup — resolve loop service + the engine branch (removes the deliberate throw)

The flow's only structurally new backend machinery (spec §4.4). services/peleburan-sources.ts composes the three existing company-lookup.ts primitives per row: exactly ONE high-confidence match → AUTO_SELECTED (+ loadOldData + loadPerseroanBlokirState); ambiguous/empty → AWAITING_SELECTION (lookup result persisted for the panel); SABH unavailable → MANUAL (typed-in identity, source-side rules SKIP). The engine's Phase-2 multi-source branch replaces the throw at processor.ts:58-62: resolve all rows → any unresolved → submission AWAITING_COMPANY_SELECTION; all resolved → continueFlow. Lookup functions are injectable (tests never depend on a live SABH).

Files
- backend/src/services/peleburan-sources.ts — Create
- backend/src/flow-engine/processor.ts — Modify (the multi-source guard block, lines 57-62)
- backend/src/flow-engine/__tests__/peleburan-multi-source.test.ts — Create

Interfaces
- Consumes: lookupCompany(request, opts?) → Promise<LookupResponse>, loadOldData(nomorTransaksi, tahun) → Promise<OldData | null>, loadPerseroanBlokirState(idPerseroan) → Promise<PerseroanBlokirState> from ./company-lookup; isSabhAvailable() from ../lib/sabh-db; db.peleburanSourcePt.
- Produces:
- interface SourceResolveDeps { lookupCompany: typeof lookupCompany; loadOldData: typeof loadOldData; loadPerseroanBlokirState: typeof loadPerseroanBlokirState; sabhAvailable: () => boolean }
- async function resolvePeleburanSources(submissionId: string, deps?: Partial<SourceResolveDeps>): Promise<void> — processes rows with resolutionStatus === "PENDING" only (selected/manual/awaiting rows untouched)
- async function hasUnresolvedSources(submissionId: string): Promise<boolean> — true when any row is PENDING or AWAITING_SELECTION
- async function selectSourceRow(submissionId: string, ordinal: number, sel: { nomorTransaksi: string; tahun: number } | { manual: true }, deps?: Partial<SourceResolveDeps>): Promise<void>

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/peleburan-multi-source.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { resolvePeleburanSources, hasUnresolvedSources, selectSourceRow } from "../../services/peleburan-sources";
import { runFlow } from "../processor";
import { defineFlow, type FlowConfig } from "../types";

const HIGH = (nama: string, nomor: string) => ({
  confidence: "high" as const, nomor_transaksi: nomor, nama_perseroan: nama,
  kedudukan: "JAKARTA", nomor_sk: "AHU-0001", npwp_perseroan: null, tahun_transaksi: 2020,
});
const OLD_DATA = {
  perseroan: { nama: "PT SUMBER SATU", kedudukan: null, alamat: null, status_perseroan: "AKTIF", jenis_perseroan: null, jangka_waktu: null, npwp: null, telepon: null, email: null, id_perseroan: 77, status_transaksi: 1, id_notaris: null },
  modal: { modal_dasar: null, modal_ditempatkan: null, modal_disetor: null },
  kbli: [], direksi: [], komisaris: [], pemegang_saham: [],
};

function deps(over: Partial<Record<"matches" | "blocked", unknown>> = {}) {
  return {
    sabhAvailable: () => true,
    lookupCompany: async () => ({ matches: (over.matches as never) ?? [HIGH("PT SUMBER SATU", "TX-1")] }),
    loadOldData: async () => OLD_DATA as never,
    loadPerseroanBlokirState: async () => ({ blocked: (over.blocked as boolean) ?? false, reason: null }),
  };
}

describe("resolvePeleburanSources (per-row loop over the single-company primitives)", () => {
  const ids: string[] = [];
  afterAll(async () => { await db.submission.deleteMany({ where: { id: { in: ids } } }); });

  async function seed(rows: Array<{ ordinal: number; nama: string; status?: string }>) {
    const sub = await db.submission.create({ data: { type: "PELEBURAN_PT", status: "EXTRACTING" } });
    ids.push(sub.id);
    for (const r of rows) {
      await db.peleburanSourcePt.create({
        data: { submissionId: sub.id, ordinal: r.ordinal, namaPerseroan: r.nama, resolutionStatus: r.status ?? "PENDING" },
      });
    }
    return sub.id;
  }

  it("exactly-one-high → AUTO_SELECTED with oldData + blokirState persisted", async () => {
    const subId = await seed([{ ordinal: 0, nama: "PT SUMBER SATU" }]);
    await resolvePeleburanSources(subId, deps());
    const row = await db.peleburanSourcePt.findUnique({ where: { submissionId_ordinal: { submissionId: subId, ordinal: 0 } } });
    expect(row?.resolutionStatus).toBe("AUTO_SELECTED");
    expect(row?.selectedNomorTransaksi).toBe("TX-1");
    expect(row?.selectedTahun).toBe(2020);
    expect((row?.oldData as { perseroan: { id_perseroan: number } }).perseroan.id_perseroan).toBe(77);
    expect((row?.blokirState as { blocked: boolean }).blocked).toBe(false);
    expect(await hasUnresolvedSources(subId)).toBe(false);
  });

  it("ambiguous/empty matches → AWAITING_SELECTION with the lookup result persisted", async () => {
    const subId = await seed([{ ordinal: 0, nama: "PT SUMBER DUA" }]);
    await resolvePeleburanSources(subId, deps({ matches: [HIGH("A", "TX-1"), HIGH("B", "TX-2")] }));
    const row = await db.peleburanSourcePt.findUnique({ where: { submissionId_ordinal: { submissionId: subId, ordinal: 0 } } });
    expect(row?.resolutionStatus).toBe("AWAITING_SELECTION");
    expect((row?.companyLookupResult as { matches: unknown[] }).matches).toHaveLength(2);
    expect(await hasUnresolvedSources(subId)).toBe(true);
  });

  it("SABH unavailable → MANUAL (typed-in identity; source rules will SKIP)", async () => {
    const subId = await seed([{ ordinal: 0, nama: "PT SUMBER TIGA" }]);
    await resolvePeleburanSources(subId, { ...deps(), sabhAvailable: () => false });
    const row = await db.peleburanSourcePt.findUnique({ where: { submissionId_ordinal: { submissionId: subId, ordinal: 0 } } });
    expect(row?.resolutionStatus).toBe("MANUAL");
    expect(row?.oldData).toBeNull();
  });

  it("only PENDING rows are touched (idempotent for selected/manual rows)", async () => {
    const subId = await seed([
      { ordinal: 0, nama: "PT A", status: "MANUAL" },
      { ordinal: 1, nama: "PT B" },
    ]);
    await resolvePeleburanSources(subId, deps());
    const rows = await db.peleburanSourcePt.findMany({ where: { submissionId: subId }, orderBy: { ordinal: "asc" } });
    expect(rows[0]!.resolutionStatus).toBe("MANUAL");
    expect(rows[1]!.resolutionStatus).toBe("AUTO_SELECTED");
  });

  it("selectSourceRow resolves an AWAITING row (candidate) and supports manual", async () => {
    const subId = await seed([{ ordinal: 0, nama: "PT SUMBER DUA", status: "AWAITING_SELECTION" }]);
    await selectSourceRow(subId, 0, { nomorTransaksi: "TX-2", tahun: 2019 }, deps());
    let row = await db.peleburanSourcePt.findUnique({ where: { submissionId_ordinal: { submissionId: subId, ordinal: 0 } } });
    expect(row?.resolutionStatus).toBe("AUTO_SELECTED");
    expect(row?.selectedNomorTransaksi).toBe("TX-2");
    await selectSourceRow(subId, 0, { manual: true }, deps());
    row = await db.peleburanSourcePt.findUnique({ where: { submissionId_ordinal: { submissionId: subId, ordinal: 0 } } });
    expect(row?.resolutionStatus).toBe("MANUAL");
    expect(row?.selectedNomorTransaksi).toBeNull();
  });
});

describe("engine multi-source branch (the throw is GONE)", () => {
  const ids: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: ids } } });
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  function cfg(): FlowConfig<Record<string, never>> {
    return defineFlow<Record<string, never>>({
      type: "PELEBURAN_PT", engine: "v2-generic",
      primaryAkta: null, skipExtractionTypes: ["OTHER"],
      companyLookup: { mode: "multi-source" }, applyContactInfo: false,
      extraction: {},
      buildContext: async () => ({}),
      rules: [], requiredDocs: [], sections: [],
      rematch: { checklistResetDocTypes: [], standardPtMatchers: false },
      routeFamily: "peleburan", labels: { akta: "x", flow: "x" },
    });
  }

  it("pauses in AWAITING_COMPANY_SELECTION while any row is unresolved; resumes to READY when none", async () => {
    const sub = await db.submission.create({ data: { type: "PELEBURAN_PT", status: "CREATED" } });
    ids.push(sub.id);
    // A pre-seeded AWAITING row: resolvePeleburanSources only touches PENDING rows,
    // so the engine path is exercised without any SABH dependency.
    await db.peleburanSourcePt.create({
      data: { submissionId: sub.id, ordinal: 0, namaPerseroan: "PT AMBIGU", resolutionStatus: "AWAITING_SELECTION" },
    });
    await runFlow(sub.id, cfg());
    expect((await db.submission.findUnique({ where: { id: sub.id } }))?.status).toBe("AWAITING_COMPANY_SELECTION");

    // Resolve the row manually → runFlow again → continues through to READY.
    await db.peleburanSourcePt.update({
      where: { submissionId_ordinal: { submissionId: sub.id, ordinal: 0 } },
      data: { resolutionStatus: "MANUAL" },
    });
    await runFlow(sub.id, cfg());
    expect((await db.submission.findUnique({ where: { id: sub.id } }))?.status).toBe("READY");
  }, 30000);
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/peleburan-multi-source.test.ts. Expected: module not found; the engine test would hit the deliberate throw.
  • [ ] Minimal implementation A. Create backend/src/services/peleburan-sources.ts:
/**
 * Multi-source PT lookup for PELEBURAN_PT (spec §4.4) — the flow-engine's FIRST
 * companyLookup.mode:'multi-source' consumer. NO new SABH query primitives: the
 * loop composes lookupCompany / loadOldData / loadPerseroanBlokirState per
 * PeleburanSourcePt row. Row states:
 *   PENDING            → not yet looked up (fresh extraction / manual add / edited ref)
 *   AUTO_SELECTED      → exactly one high-confidence match; snapshot + blokir loaded
 *   AWAITING_SELECTION → ambiguous/empty matches; operator picks in the panel
 *   MANUAL             → SABH unavailable or operator chose manual — typed-in identity;
 *                        source-side registry rules emit SKIPPED, never FAIL
 * Per-row SABH calls ride the existing sabh-db resilience (mysql2 pool timeout /
 * circuit breaker — reference_apostille_registry_hang; N sequential lookups
 * amplify a wedged pool, so the loop is strictly sequential and per-row guarded).
 */
import { db } from "../lib/db";
import { Prisma } from "../generated/prisma/client";
import { isSabhAvailable } from "../lib/sabh-db";
import {
  lookupCompany as realLookupCompany,
  loadOldData as realLoadOldData,
  loadPerseroanBlokirState as realLoadBlokir,
} from "./company-lookup";

export interface SourceResolveDeps {
  lookupCompany: typeof realLookupCompany;
  loadOldData: typeof realLoadOldData;
  loadPerseroanBlokirState: typeof realLoadBlokir;
  sabhAvailable: () => boolean;
}
const defaults = (): SourceResolveDeps => ({
  lookupCompany: realLookupCompany,
  loadOldData: realLoadOldData,
  loadPerseroanBlokirState: realLoadBlokir,
  sabhAvailable: isSabhAvailable,
});

async function loadSnapshot(
  d: SourceResolveDeps,
  nomorTransaksi: string,
  tahun: number,
): Promise<{ oldData: Prisma.InputJsonValue | typeof Prisma.JsonNull; blokirState: Prisma.InputJsonValue | typeof Prisma.JsonNull }> {
  const oldData = await d.loadOldData(nomorTransaksi, tahun);
  const idPerseroan = oldData?.perseroan?.id_perseroan ?? null;
  const blokir = typeof idPerseroan === "number" && Number.isFinite(idPerseroan)
    ? await d.loadPerseroanBlokirState(idPerseroan)
    : null;
  return {
    oldData: oldData ? (oldData as unknown as Prisma.InputJsonValue) : Prisma.JsonNull,
    blokirState: blokir ? (blokir as unknown as Prisma.InputJsonValue) : Prisma.JsonNull,
  };
}

/** Resolve every PENDING row. Selected/manual/awaiting rows are never touched. */
export async function resolvePeleburanSources(submissionId: string, depsOver?: Partial<SourceResolveDeps>): Promise<void> {
  const d = { ...defaults(), ...depsOver };
  const rows = await db.peleburanSourcePt.findMany({
    where: { submissionId, resolutionStatus: "PENDING" },
    orderBy: { ordinal: "asc" },
  });
  for (const row of rows) {
    try {
      if (!d.sabhAvailable()) {
        await db.peleburanSourcePt.update({
          where: { id: row.id },
          data: { resolutionStatus: "MANUAL" },
        });
        continue;
      }
      const lookup = await d.lookupCompany({ nama_perseroan: row.namaPerseroan, nomor_sk_menkumham: row.nomorSk ?? "" });
      const high = lookup.matches.filter((m) => m.confidence === "high");
      if (high.length === 1) {
        const match = high[0]!;
        const snap = await loadSnapshot(d, match.nomor_transaksi, match.tahun_transaksi);
        await db.peleburanSourcePt.update({
          where: { id: row.id },
          data: {
            resolutionStatus: "AUTO_SELECTED",
            companyLookupResult: lookup as unknown as Prisma.InputJsonValue,
            selectedNomorTransaksi: match.nomor_transaksi,
            selectedTahun: match.tahun_transaksi,
            ...snap,
          },
        });
      } else {
        await db.peleburanSourcePt.update({
          where: { id: row.id },
          data: {
            resolutionStatus: "AWAITING_SELECTION",
            companyLookupResult: lookup as unknown as Prisma.InputJsonValue,
          },
        });
      }
    } catch (err) {
      // Per-row guard: a wedged SABH must not brick the run — the row stays
      // resolvable in the panel; the submission pauses in AWAITING_COMPANY_SELECTION.
      console.error(`[peleburan-sources] resolve failed for row ${row.ordinal} of ${submissionId}:`, err);
      await db.peleburanSourcePt.update({
        where: { id: row.id },
        data: { resolutionStatus: "AWAITING_SELECTION" },
      }).catch(() => {});
    }
  }
}

/** True while ANY row still needs attention (PENDING or AWAITING_SELECTION). */
export async function hasUnresolvedSources(submissionId: string): Promise<boolean> {
  const n = await db.peleburanSourcePt.count({
    where: { submissionId, resolutionStatus: { in: ["PENDING", "AWAITING_SELECTION"] } },
  });
  return n > 0;
}

/** Operator selection for one row: a lookup candidate, or explicit MANUAL. */
export async function selectSourceRow(
  submissionId: string,
  ordinal: number,
  sel: { nomorTransaksi: string; tahun: number } | { manual: true },
  depsOver?: Partial<SourceResolveDeps>,
): Promise<void> {
  const d = { ...defaults(), ...depsOver };
  if ("manual" in sel) {
    await db.peleburanSourcePt.update({
      where: { submissionId_ordinal: { submissionId, ordinal } },
      data: {
        resolutionStatus: "MANUAL",
        selectedNomorTransaksi: null, selectedTahun: null,
        oldData: Prisma.JsonNull, blokirState: Prisma.JsonNull,
      },
    });
    return;
  }
  const snap = await loadSnapshot(d, sel.nomorTransaksi, sel.tahun);
  await db.peleburanSourcePt.update({
    where: { submissionId_ordinal: { submissionId, ordinal } },
    data: {
      resolutionStatus: "AUTO_SELECTED",
      selectedNomorTransaksi: sel.nomorTransaksi, selectedTahun: sel.tahun,
      ...snap,
    },
  });
}
  • [ ] Minimal implementation B. Edit backend/src/flow-engine/processor.ts: replace the guard block (lines 57-62):
    const lookup = cfg.companyLookup;
    if (typeof lookup === "object" && lookup.mode === "multi-source") {
      // INERT seam — the N-source lookup loop ships with PELEBURAN_PT. Fail LOUD
      // rather than silently fall through to the single-source path below.
      throw new Error("companyLookup.mode 'multi-source' is not implemented yet — ships with PELEBURAN_PT");
    }
    if (lookup && aktaDoc) {

with:

    const lookup = cfg.companyLookup;
    if (typeof lookup === "object" && lookup.mode === "multi-source") {
      // Multi-source lookup (first consumer: PELEBURAN_PT, spec §4.4). Per-source
      // rows were created by hooks.afterAktaExtraction; each is resolved through
      // the same single-company primitives. Any unresolved row pauses the run in
      // AWAITING_COMPANY_SELECTION (reused status); the peleburan route family's
      // per-row select handlers call continueFlow when the last row resolves.
      const { resolvePeleburanSources, hasUnresolvedSources } = await import("../services/peleburan-sources");
      await resolvePeleburanSources(submissionId);
      if (await hasUnresolvedSources(submissionId)) {
        await db.submission.update({ where: { id: submissionId }, data: { status: "AWAITING_COMPANY_SELECTION", currentDocumentId: null } });
      } else {
        await continueFlow(submissionId, cfg);
      }
    } else if (lookup && aktaDoc) {

(The rest of the original if (lookup && aktaDoc) body and its else { await continueFlow(...) } tail are UNCHANGED — only the head becomes else if.)
- [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/peleburan-multi-source.test.ts. Then engine regression: bun test src/flow-engine/__tests__/processor.test.ts src/flow-engine/__tests__/processor-review-fixes.test.ts src/flow-engine/__tests__/flow-parity.test.ts src/flow-engine/__tests__/focused-passes.test.ts (single-source + no-lookup paths byte-identical).
- [ ] Typecheck: cd backend && bunx tsc --noEmit.
- [ ] Commit: git add backend/src/services/peleburan-sources.ts backend/src/flow-engine/processor.ts backend/src/flow-engine/__tests__/peleburan-multi-source.test.ts && git commit -m "feat(flow-engine): multi-source company lookup (first consumer: peleburan) — throw removed"


Task 5: Context builder — buildPeleburanContext

The flow's buildContext. Extends the shared PtAktaCtx (so wrapped PT_AKTA_RULE_SET rules run unchanged on ctx.input) with the peleburan surface: sources[] (from PeleburanSourcePt rows, with blokir/status flattened out of the Json columns), koran (clipping doc's ExtractedFields preferred, deed-inline fields as fallback), and rupsBaDocs (BA-RUPS satellites with heuristic source-name attribution over rawOcrText). Also fixes the ONE pendirian-grade seam in the shared builder's output: input.kbliCodes (the base sets [] — perubahan semantics) is repopulated from the akta's kbliEntries so the wrapped KBLI_VALID validates the NEW PT's maksud-tujuan.

Files
- backend/src/flow-engine/context/peleburan-context.ts — Create
- backend/src/flow-engine/__tests__/peleburan-context.test.ts — Create

Interfaces
- Consumes: buildPtAktaContext(submissionId, { aktaClassifiedType }), type PtAktaCtx from ./pt-akta-context; db.peleburanSourcePt, db.beritaAcaraRupsExtraction.
- Produces:
- interface PeleburanSourceState { ordinal: number; namaPerseroan: string; nomorSk: string | null; tanggalRups: string | null; resolutionStatus: "PENDING" | "AUTO_SELECTED" | "AWAITING_SELECTION" | "MANUAL"; selectedNomorTransaksi: string | null; blocked: boolean | null; blokirReason: string | null; statusTransaksi: number | null; statusPerseroan: string | null; registryNama: string | null }
- interface PeleburanCtx extends PtAktaCtx { sources: PeleburanSourceState[]; koran: { namaKoran: string | null; tanggalKoran: string | null; source: "clipping" | "akta" | null }; rupsBaDocs: { documentId: string; namaPerseroan: string | null; tanggalRups: string | null }[] }
- async function buildPeleburanContext(submissionId: string): Promise<PeleburanCtx>

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/peleburan-context.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { buildPeleburanContext } from "../context/peleburan-context";

describe("buildPeleburanContext", () => {
  const ids: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: ids } } });
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  async function seed(opts: { withClipping: boolean; withBa: boolean }) {
    const akta = await db.document.create({
      data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY", rawText: "" },
    });
    docIds.push(akta.id);
    await db.extractedField.createMany({
      data: [
        { documentId: akta.id, fieldKey: "nama_perseroan", value: "PT BARU HASIL LEBUR" },
        { documentId: akta.id, fieldKey: "nama_koran", value: "Harian Akta" },
        { documentId: akta.id, fieldKey: "tanggal_pengumuman", value: "1 Februari 2026" },
      ],
    });
    await db.kBLIEntry.create({ data: { documentId: akta.id, kbliCode: "62010", description: "Pemrograman", orderIndex: 0 } });
    const sub = await db.submission.create({ data: { type: "PELEBURAN_PT", status: "VALIDATING" } });
    ids.push(sub.id);
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PELEBURAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });
    await db.peleburanSourcePt.create({
      data: {
        submissionId: sub.id, ordinal: 0, namaPerseroan: "PT SUMBER SATU", tanggalRups: "10 Maret 2026",
        resolutionStatus: "AUTO_SELECTED", selectedNomorTransaksi: "TX-1", selectedTahun: 2020,
        oldData: { perseroan: { nama: "PT SUMBER SATU", status_transaksi: 1, status_perseroan: "AKTIF", id_perseroan: 7 } },
        blokirState: { blocked: true, reason: "sengketa" },
      },
    });
    await db.peleburanSourcePt.create({
      data: { submissionId: sub.id, ordinal: 1, namaPerseroan: "PT SUMBER DUA", resolutionStatus: "MANUAL" },
    });
    if (opts.withClipping) {
      const koran = await db.document.create({
        data: { filename: "k.pdf", originalName: "k.pdf", fileSize: 0, filePath: "/tmp/k.pdf", documentType: "BUKTI_PENGUMUMAN", status: "VERIFICATION_READY", rawText: "PENGUMUMAN" },
      });
      docIds.push(koran.id);
      await db.extractedField.createMany({
        data: [
          { documentId: koran.id, fieldKey: "nama_koran", value: "Harian Kompas" },
          { documentId: koran.id, fieldKey: "tanggal_pengumuman", value: "12 Mei 2026" },
          { documentId: koran.id, fieldKey: "nomor_pengumuman", value: null },
        ],
      });
      await db.submissionDocument.create({
        data: { submissionId: sub.id, documentId: koran.id, classifiedType: "BUKTI_PENGUMUMAN", confidence: 1, processingOrder: 6, extractionStatus: "DONE" },
      });
    }
    if (opts.withBa) {
      const ba = await db.document.create({
        data: { filename: "ba.pdf", originalName: "ba.pdf", fileSize: 0, filePath: "/tmp/ba.pdf", documentType: "BERITA_ACARA_RUPS", status: "VERIFICATION_READY", rawText: "" },
      });
      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, tanggalRups: "10 Maret 2026", attendees: [], rawOcrText: "RUPS PT SUMBER SATU menyetujui peleburan" },
      });
    }
    return sub.id;
  }

  it("assembles base PtAktaCtx + sources (blokir/status flattened) + clipping-first koran + BA attribution + kbliCodes", async () => {
    const id = await seed({ withClipping: true, withBa: true });
    const ctx = await buildPeleburanContext(id);
    expect(ctx.input.aktaNamaPerseroan).toBe("PT BARU HASIL LEBUR"); // base ran with AKTA_PELEBURAN
    expect(ctx.input.kbliCodes).toEqual(["62010"]); // pendirian-grade KBLI_VALID input
    expect(ctx.sources).toHaveLength(2);
    expect(ctx.sources[0]).toMatchObject({
      ordinal: 0, namaPerseroan: "PT SUMBER SATU", resolutionStatus: "AUTO_SELECTED",
      blocked: true, blokirReason: "sengketa", statusTransaksi: 1, statusPerseroan: "AKTIF", registryNama: "PT SUMBER SATU",
    });
    expect(ctx.sources[1]).toMatchObject({ ordinal: 1, resolutionStatus: "MANUAL", blocked: null, statusTransaksi: null });
    // Clipping beats the deed-inline fields.
    expect(ctx.koran).toEqual({ namaKoran: "Harian Kompas", tanggalKoran: "12 Mei 2026", source: "clipping" });
    expect(ctx.rupsBaDocs).toHaveLength(1);
    expect(ctx.rupsBaDocs[0]).toMatchObject({ namaPerseroan: "PT SUMBER SATU", tanggalRups: "10 Maret 2026" });
  });

  it("falls back to the deed-inline koran fields and is null-safe without rows/docs", async () => {
    const id = await seed({ withClipping: false, withBa: false });
    const ctx = await buildPeleburanContext(id);
    expect(ctx.koran).toEqual({ namaKoran: "Harian Akta", tanggalKoran: "1 Februari 2026", source: "akta" });
    expect(ctx.rupsBaDocs).toEqual([]);
    await db.peleburanSourcePt.deleteMany({ where: { submissionId: id } });
    const ctx2 = await buildPeleburanContext(id);
    expect(ctx2.sources).toEqual([]);
  });
});

(Delegate/field names verified against model KBLIEntry (schema.prisma:310): delegate db.kBLIEntry, columns kbliCode/description/orderIndex.)
- [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/peleburan-context.test.ts. Expected: cannot resolve ../context/peleburan-context.
- [ ] Minimal implementation. Create backend/src/flow-engine/context/peleburan-context.ts:

import { db } from "../../lib/db";
import { buildPtAktaContext, type PtAktaCtx } from "./pt-akta-context";

/** Flattened per-source state — the SABH round-trips happened in the RESOLVE
 *  LOOP (peleburan-sources.ts) and are persisted on the row; rules read this
 *  synchronously (same pre-resolution pattern as PERSEROAN_STATE via
 *  buildPtAktaContext). */
export interface PeleburanSourceState {
  ordinal: number;
  namaPerseroan: string;
  nomorSk: string | null;
  tanggalRups: string | null;
  resolutionStatus: "PENDING" | "AUTO_SELECTED" | "AWAITING_SELECTION" | "MANUAL";
  selectedNomorTransaksi: string | null;
  /** null = unknown (SABH unavailable / MANUAL row) */
  blocked: boolean | null;
  blokirReason: string | null;
  statusTransaksi: number | null;
  statusPerseroan: string | null;
  registryNama: string | null;
}

export interface PeleburanCtx extends PtAktaCtx {
  sources: PeleburanSourceState[];
  /** Clipping (BUKTI_PENGUMUMAN doc) preferred; deed-inline akta fields as fallback. */
  koran: { namaKoran: string | null; tanggalKoran: string | null; source: "clipping" | "akta" | null };
  /** BA-RUPS docs with HEURISTIC source attribution (name scan over rawOcrText) —
   *  feeds the never-FAIL PELEBURAN_RUPS_PER_SOURCE advisory only. */
  rupsBaDocs: { documentId: string; namaPerseroan: string | null; tanggalRups: string | null }[];
}

const normName = (s: string) => s.toUpperCase().replace(/^PT\.?\s*/, "").replace(/\s+/g, " ").trim();

export async function buildPeleburanContext(submissionId: string): Promise<PeleburanCtx> {
  const base = await buildPtAktaContext(submissionId, { aktaClassifiedType: "AKTA_PELEBURAN" });

  // Pendirian-grade KBLI: the shared builder hardcodes kbliCodes: [] (perubahan
  // semantics). The new PT's maksud-tujuan rides the akta's kbliEntries — feed
  // them to the wrapped KBLI_VALID. (PERUBAHAN_KBLI is excluded from this flow.)
  const aktaSubDoc = await db.submissionDocument.findFirst({
    where: { submissionId, classifiedType: "AKTA_PELEBURAN" },
    select: { documentId: true, document: { select: { kbliEntries: { select: { kbliCode: true } } } } },
  });
  base.input.kbliCodes = (aktaSubDoc?.document.kbliEntries ?? [])
    .map((k) => k.kbliCode)
    .filter((c): c is string => !!c && c.length === 5);

  // Sources — flatten the Json columns persisted by the resolve loop.
  const rows = await db.peleburanSourcePt.findMany({ where: { submissionId }, orderBy: { ordinal: "asc" } });
  const sources: PeleburanSourceState[] = rows.map((r) => {
    const blokir = r.blokirState as { blocked?: boolean; reason?: string | null } | null;
    const old = r.oldData as { perseroan?: { nama?: string | null; status_transaksi?: number | null; status_perseroan?: string | null } } | null;
    return {
      ordinal: r.ordinal,
      namaPerseroan: r.namaPerseroan,
      nomorSk: r.nomorSk,
      tanggalRups: r.tanggalRups,
      resolutionStatus: r.resolutionStatus as PeleburanSourceState["resolutionStatus"],
      selectedNomorTransaksi: r.selectedNomorTransaksi,
      blocked: typeof blokir?.blocked === "boolean" ? blokir.blocked : null,
      blokirReason: blokir?.reason ?? null,
      statusTransaksi: old?.perseroan?.status_transaksi ?? null,
      statusPerseroan: old?.perseroan?.status_perseroan ?? null,
      registryNama: old?.perseroan?.nama ?? null,
    };
  });

  // 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 = (docTypeMatch: (d: (typeof subDocs)[number]) => boolean): { namaKoran: string | null; tanggalKoran: string | null } | null => {
    for (const d of subDocs) {
      if (!docTypeMatch(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_PELEBURAN");
  const koran = clipping
    ? { ...clipping, source: "clipping" as const }
    : inline
      ? { ...inline, source: "akta" as const }
      : { namaKoran: null, tanggalKoran: null, source: null };

  // BA-RUPS docs + heuristic source attribution over the satellite's rawOcrText.
  const baDocIds = subDocs.filter((d) => d.document.documentType === "BERITA_ACARA_RUPS").map((d) => d.documentId);
  const baSats = baDocIds.length > 0
    ? await db.beritaAcaraRupsExtraction.findMany({ where: { documentId: { in: baDocIds } } })
    : [];
  const rupsBaDocs = baSats.map((s) => {
    const hay = (s.rawOcrText ?? "").toUpperCase();
    const matched = sources.find((src) => hay.includes(normName(src.namaPerseroan)));
    return { documentId: s.documentId, namaPerseroan: matched?.namaPerseroan ?? null, tanggalRups: s.tanggalRups };
  });

  return { ...base, sources, koran, rupsBaDocs };
}
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/peleburan-context.test.ts. Expected: 2 pass. NOTE: the base builder makes SABH-adjacent calls (lookupNotarisIdByName) that no-op when SABH is unavailable — same environment expectation as the existing pt-akta-context.test.ts.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/context/peleburan-context.ts backend/src/flow-engine/__tests__/peleburan-context.test.ts && git commit -m "feat(peleburan): PeleburanCtx context builder (sources + koran + BA attribution + pendirian KBLI)"

Task 6: Flow rules — PELEBURAN_RULE_SET (the tuned matrix)

The 7 flow rules (4 FAIL-capable + 1 conditional FAIL/WARNING + 2 never-FAIL advisories) + the 16 wrapped shared rules via pickRules. Severities are binding (Global Constraints). Fidelity notes baked into code comments: (a) the spec marks PELEBURAN_SOURCE_BLOKIR/PELEBURAN_SOURCE_STATUS async:true, but the SABH round-trips happen in the RESOLVE LOOP and are persisted on the rows — the rules are sync reads of ctx.sources (wrapper fidelity trumps, same deviation class as the RUPS plan); (b) RUPS_ATTENDANCE_QUORUM is NOT in the §4.6 matrix — PELEBURAN_RUPS_PER_SOURCE is the flow's RUPS surface; (c) parseFlexibleDate is REUSED from the laporan-rups rule module (exported there).

Files
- backend/src/flow-engine/rules/peleburan/index.ts — Create
- backend/src/flow-engine/__tests__/rules-peleburan.test.ts — Create

Interfaces
- Consumes: Rule, RuleResult from ../../types; PeleburanCtx, PeleburanSourceState from ../../context/peleburan-context; PT_AKTA_RULE_SET, pickRules from ../pt-akta/index; parseFlexibleDate from ../laporan-rups/index.
- Produces: 7 exported Rule<PeleburanCtx> consts (peleburanMinSourcesRule, peleburanSourceResolvedRule, peleburanSourceBlokirRule, peleburanSourceStatusRule, peleburanRupsPerSourceRule, peleburanKoranWindowRule, peleburanNamaBaruUnikRule); const PELEBURAN_SHARED_RULE_CODES: readonly string[] (16 codes); const PELEBURAN_RULE_SET: Rule<PeleburanCtx>[] (23 rules total).

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/rules-peleburan.test.ts:
import { describe, it, expect } from "bun:test";
import {
  peleburanMinSourcesRule, peleburanSourceResolvedRule, peleburanSourceBlokirRule,
  peleburanSourceStatusRule, peleburanRupsPerSourceRule, peleburanKoranWindowRule,
  peleburanNamaBaruUnikRule, PELEBURAN_RULE_SET,
} from "../rules/peleburan/index";
import type { PeleburanCtx, PeleburanSourceState } from "../context/peleburan-context";
import type { RuleResult } from "../types";

function src(over: Partial<PeleburanSourceState> = {}): PeleburanSourceState {
  return {
    ordinal: 0, namaPerseroan: "PT SUMBER SATU", nomorSk: null, tanggalRups: "10-03-2026",
    resolutionStatus: "AUTO_SELECTED", selectedNomorTransaksi: "TX-1",
    blocked: false, blokirReason: null, statusTransaksi: 1, statusPerseroan: "AKTIF", registryNama: "PT SUMBER SATU",
    ...over,
  };
}
function ctx(over: Partial<PeleburanCtx> = {}): PeleburanCtx {
  return {
    input: { aktaNamaPerseroan: "PT BARU HASIL LEBUR" } as never,
    rupsQuorum: { pemegangSaham: [], hasBaRupsDoc: false },
    aktaFields: [],
    sources: [src(), src({ ordinal: 1, namaPerseroan: "PT SUMBER DUA", tanggalRups: "11-03-2026" })],
    koran: { namaKoran: "Harian Kompas", tanggalKoran: "01-02-2026", source: "clipping" },
    rupsBaDocs: [],
    ...over,
  } as PeleburanCtx;
}
const run = (r: { run: (c: PeleburanCtx) => unknown }, c: PeleburanCtx) => r.run(c) as RuleResult;

describe("PELEBURAN_MIN_SOURCES (FAIL — UUPT Ps.1(10) jo. Ps.122: dua Perseroan atau lebih)", () => {
  it("PASS at 2, FAIL at 1, SKIPPED at 0 (pre-confirmation)", () => {
    expect(run(peleburanMinSourcesRule, ctx()).status).toBe("PASS");
    expect(run(peleburanMinSourcesRule, ctx({ sources: [src()] })).status).toBe("FAIL");
    expect(run(peleburanMinSourcesRule, ctx({ sources: [] })).status).toBe("SKIPPED");
  });
});

describe("PELEBURAN_SOURCE_RESOLVED (FAIL per unresolved row)", () => {
  it("FAILs listing unresolved ordinals; MANUAL counts as resolved; SKIPPED at 0 rows", () => {
    expect(run(peleburanSourceResolvedRule, ctx()).status).toBe("PASS");
    const c = ctx({ sources: [src(), src({ ordinal: 1, resolutionStatus: "AWAITING_SELECTION" }), src({ ordinal: 2, resolutionStatus: "PENDING" })] });
    const r = run(peleburanSourceResolvedRule, c);
    expect(r.status).toBe("FAIL");
    expect(r.message).toContain("#2");
    expect(r.message).toContain("#3");
    expect(run(peleburanSourceResolvedRule, ctx({ sources: [src({ resolutionStatus: "MANUAL" }), src({ ordinal: 1, resolutionStatus: "MANUAL" })] })).status).toBe("PASS");
    expect(run(peleburanSourceResolvedRule, ctx({ sources: [] })).status).toBe("SKIPPED");
  });
});

describe("PELEBURAN_SOURCE_BLOKIR (FAIL on any blocked source; SKIPPED when all unknown)", () => {
  it("FAIL / PASS / SKIPPED tiers", () => {
    expect(run(peleburanSourceBlokirRule, ctx()).status).toBe("PASS");
    const blocked = ctx({ sources: [src(), src({ ordinal: 1, blocked: true, blokirReason: "sengketa" })] });
    const r = run(peleburanSourceBlokirRule, blocked);
    expect(r.status).toBe("FAIL");
    expect(r.message).toContain("PT SUMBER SATU"); // named source in message (ordinal 1 uses default name)
    const unknown = ctx({ sources: [src({ blocked: null }), src({ ordinal: 1, blocked: null })] });
    expect(run(peleburanSourceBlokirRule, unknown).status).toBe("SKIPPED");
  });
});

describe("PELEBURAN_SOURCE_STATUS (FAIL on berakhir/bubar; WARNING on other anomalies)", () => {
  it("st=6 or bubar text → FAIL; st=5 or other non-1 → WARNING; MANUAL/no-oldData → SKIPPED contribution", () => {
    expect(run(peleburanSourceStatusRule, ctx()).status).toBe("PASS");
    expect(run(peleburanSourceStatusRule, ctx({ sources: [src({ statusTransaksi: 6 }), src({ ordinal: 1 })] })).status).toBe("FAIL");
    expect(run(peleburanSourceStatusRule, ctx({ sources: [src({ statusPerseroan: "BUBAR" }), src({ ordinal: 1 })] })).status).toBe("FAIL");
    expect(run(peleburanSourceStatusRule, ctx({ sources: [src({ statusTransaksi: 5 }), src({ ordinal: 1 })] })).status).toBe("WARNING");
    expect(run(peleburanSourceStatusRule, ctx({ sources: [src({ statusTransaksi: null, statusPerseroan: null, resolutionStatus: "MANUAL" }), src({ ordinal: 1, statusTransaksi: null, statusPerseroan: null, resolutionStatus: "MANUAL" })] })).status).toBe("SKIPPED");
  });
});

describe("PELEBURAN_RUPS_PER_SOURCE (WARNING — NEVER FAIL; severityFloor clamp)", () => {
  it("WARNs listing uncovered sources; PASSes when every source has an attributed BA", () => {
    const covered = ctx({
      rupsBaDocs: [
        { documentId: "d1", namaPerseroan: "PT SUMBER SATU", tanggalRups: "10-03-2026" },
        { documentId: "d2", namaPerseroan: "PT SUMBER DUA", tanggalRups: "11-03-2026" },
      ],
    });
    expect(run(peleburanRupsPerSourceRule, covered).status).toBe("PASS");
    const r = run(peleburanRupsPerSourceRule, ctx());
    expect(r.status).toBe("WARNING");
    expect(r.message).toContain("PT SUMBER SATU");
  });
  it("severityFloor is declared (a FAIL emission would be clamped by the runner)", () => {
    expect(peleburanRupsPerSourceRule.severityFloor).toBe("WARNING");
  });
});

describe("PELEBURAN_KORAN_WINDOW (FAIL — Ps.127(2); Ps.127(8) does NOT cover peleburan; user decision 2026-07-02)", () => {
  const c = (koranDate: string | null, rups0: string | null) =>
    ctx({
      koran: { namaKoran: "Harian Kompas", tanggalKoran: koranDate, source: "clipping" },
      sources: [src({ tanggalRups: rups0 }), src({ ordinal: 1, tanggalRups: "11-03-2026" })],
    });
  it("FAIL at 29 days, PASS at 30 days (earliest RUPS wins), FAIL when koran after RUPS", () => {
    expect(run(peleburanKoranWindowRule, c("09-02-2026", "10-03-2026")).status).toBe("FAIL");   // 29d
    expect(run(peleburanKoranWindowRule, c("08-02-2026", "10-03-2026")).status).toBe("PASS");   // 30d
    expect(run(peleburanKoranWindowRule, c("20-03-2026", "10-03-2026")).status).toBe("FAIL");   // negative
  });
  it("SKIPPED when either date is unreadable (spec §10.7 resolution)", () => {
    expect(run(peleburanKoranWindowRule, c(null, "10-03-2026")).status).toBe("SKIPPED");
    expect(run(peleburanKoranWindowRule, c("tanggal aneh", "10-03-2026")).status).toBe("SKIPPED");
    expect(run(peleburanKoranWindowRule, c("08-02-2026", null)).status).toBe("SKIPPED");
  });
});

describe("PELEBURAN_NAMA_BARU_UNIK (WARNING — NEVER FAIL; pesan-nama out of OCR scope)", () => {
  it("WARNs on collision with a source name; PASS otherwise; SKIPPED without a name", () => {
    expect(run(peleburanNamaBaruUnikRule, ctx()).status).toBe("PASS");
    const collide = ctx({ input: { aktaNamaPerseroan: "PT SUMBER SATU" } as never });
    expect(run(peleburanNamaBaruUnikRule, collide).status).toBe("WARNING");
    const noName = ctx({ input: { aktaNamaPerseroan: null } as never });
    expect(run(peleburanNamaBaruUnikRule, noName).status).toBe("SKIPPED");
    expect(peleburanNamaBaruUnikRule.severityFloor).toBe("WARNING");
  });
});

describe("PELEBURAN_RULE_SET composition (the tuned matrix)", () => {
  const codes = PELEBURAN_RULE_SET.map((r) => r.code);
  it("contains exactly the 23 tuned rules", () => {
    expect([...codes].sort()).toEqual([
      "CONTACT_INFO_COMPLETE", "DOMISILI_NAMA", "KBLI_VALID", "KTP_COMPLETENESS",
      "MODAL_HIERARCHY", "NAMA_NPWP_KTP", "NAMA_PT_CONSISTENCY", "NIK_KTP_AKTA",
      "NOMINAL_RECONCILE", "NPWP_COMPLETENESS", "PASSPORT_AKTA",
      "PELEBURAN_KORAN_WINDOW", "PELEBURAN_MIN_SOURCES", "PELEBURAN_NAMA_BARU_UNIK",
      "PELEBURAN_RUPS_PER_SOURCE", "PELEBURAN_SOURCE_BLOKIR", "PELEBURAN_SOURCE_RESOLVED",
      "PELEBURAN_SOURCE_STATUS", "PEMEGANG_SAHAM_CONTACT", "PP29_MODAL",
      "PS_TOTAL_100", "SHARES_SUM_CONSISTENCY", "SHARES_TOTAL_LEMBAR",
    ]);
  });
  it("EXCLUDES the bukti-setor pair (SABH's peleburan wizard has no bukti-setor upload)", () => {
    for (const off of ["MODAL_BUKTI_SETOR", "PEMEGANG_SAHAM_BUKTI_SETOR"]) expect(codes).not.toContain(off);
  });
  it("EXCLUDES the perubahan-family / registry / jenis / AKTA_DATE_WINDOW groups", () => {
    for (const off of ["OLD_DATA_CONSISTENCY", "DATA_ACTUALLY_CHANGED", "SHARES_TRANSFER_BALANCE", "PERUBAHAN_MODAL", "PERUBAHAN_KBLI", "JENIS_SELECTION_NONEMPTY", "JENIS_DESELECT_REASONS_COMPLETE", "CONTRADICTORY_JENIS", "PERSEROAN_STATE", "NOTARIS_TERAKHIR", "NPWP_PERSEROAN_FUZZY", "REQUIRED_SUPPORTING_DOCS", "UPLOADED_DOC_UNUSED", "AKTA_DATE_WINDOW", "RUPS_ATTENDANCE_QUORUM"]) {
      expect(codes).not.toContain(off);
    }
  });
  it("keeps the async partition on the SABH-hitting wrapped rules", () => {
    const async = PELEBURAN_RULE_SET.filter((r) => r.async).map((r) => r.code).sort();
    expect(async).toEqual(["KBLI_VALID", "PP29_MODAL"]);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/rules-peleburan.test.ts. Expected: cannot resolve ../rules/peleburan/index.
  • [ ] Minimal implementation. Create backend/src/flow-engine/rules/peleburan/index.ts:
import type { Rule, RuleResult } from "../../types";
import type { PeleburanCtx } from "../../context/peleburan-context";
import { PT_AKTA_RULE_SET, pickRules } from "../pt-akta/index";
import { parseFlexibleDate } from "../laporan-rups/index";

const rr = (ruleCode: string, ruleLabel: string, status: RuleResult["status"], message: string, details?: unknown): RuleResult =>
  ({ ruleCode, ruleLabel, status, message, ...(details !== undefined ? { details: details as never } : {}) });

const label = (s: PeleburanCtx["sources"][number]) => `#${s.ordinal + 1} ${s.namaPerseroan}`;
const normName = (s: string) => s.toUpperCase().replace(/^PT\.?\s*/, "").replace(/\s+/g, " ").trim();

/** FAIL — UUPT Ps.1 angka 10 jo. Ps.122: peleburan IS "dua Perseroan atau lebih
 *  meleburkan diri dengan cara mendirikan satu Perseroan baru". Fewer than 2
 *  sources is not a peleburan — hard defect. SKIPPED only at ZERO rows (extraction
 *  produced no refs and the operator hasn't confirmed the set yet — the panel and
 *  PELEBURAN_SOURCE_RESOLVED own that surface). */
export const peleburanMinSourcesRule: Rule<PeleburanCtx> = {
  code: "PELEBURAN_MIN_SOURCES",
  label: "Minimal Dua PT Sumber Peleburan (Ps.122)",
  run: (ctx) => {
    const L = "Minimal Dua PT Sumber Peleburan (Ps.122)";
    if (ctx.sources.length === 0)
      return rr("PELEBURAN_MIN_SOURCES", L, "SKIPPED", "Belum ada PT sumber terdeteksi — tambahkan melalui panel sumber peleburan");
    if (ctx.sources.length < 2)
      return rr("PELEBURAN_MIN_SOURCES", L, "FAIL",
        `Peleburan mensyaratkan ≥2 PT yang meleburkan diri (UUPT Ps.1(10) jo. Ps.122) — baru ${ctx.sources.length} sumber`, { count: ctx.sources.length });
    return rr("PELEBURAN_MIN_SOURCES", L, "PASS", `${ctx.sources.length} PT sumber tercatat`);
  },
};

/** FAIL per unresolved source — mirrors SABH's hard identify-the-PT gate; resolved
 *  by the selection panel (MANUAL is an explicit resolution). Enforced operationally
 *  by AWAITING_COMPANY_SELECTION; this rule is the persistent validation surface. */
export const peleburanSourceResolvedRule: Rule<PeleburanCtx> = {
  code: "PELEBURAN_SOURCE_RESOLVED",
  label: "Setiap PT Sumber Teridentifikasi di SABH",
  run: (ctx) => {
    const L = "Setiap PT Sumber Teridentifikasi di SABH";
    if (ctx.sources.length === 0)
      return rr("PELEBURAN_SOURCE_RESOLVED", L, "SKIPPED", "Belum ada PT sumber terdeteksi");
    const unresolved = ctx.sources.filter((s) => s.resolutionStatus === "PENDING" || s.resolutionStatus === "AWAITING_SELECTION");
    if (unresolved.length > 0)
      return rr("PELEBURAN_SOURCE_RESOLVED", L, "FAIL",
        `PT sumber belum teridentifikasi: ${unresolved.map(label).join(", ")}`, { ordinals: unresolved.map((s) => s.ordinal) });
    return rr("PELEBURAN_SOURCE_RESOLVED", L, "PASS", "Semua PT sumber teridentifikasi (atau manual eksplisit)");
  },
};

/** FAIL when any source is blocked — mirrors SabhHelpers::checkBlokirPerseroan
 *  (SABH refuses transactions on a blocked PT; filing anyway is wasted work).
 *  SKIPPED when the blokir state is unknown for EVERY source (SABH unavailable /
 *  all-manual). Sync by design: the SABH round-trip happened in the resolve loop
 *  and is persisted on the row (the spec's async:true note predates the loop). */
export const peleburanSourceBlokirRule: Rule<PeleburanCtx> = {
  code: "PELEBURAN_SOURCE_BLOKIR",
  label: "PT Sumber Tidak Terblokir di SABH",
  run: (ctx) => {
    const L = "PT Sumber Tidak Terblokir di SABH";
    if (ctx.sources.length === 0)
      return rr("PELEBURAN_SOURCE_BLOKIR", L, "SKIPPED", "Belum ada PT sumber terdeteksi");
    const blocked = ctx.sources.filter((s) => s.blocked === true);
    if (blocked.length > 0)
      return rr("PELEBURAN_SOURCE_BLOKIR", L, "FAIL",
        `PT sumber terblokir di SABH: ${blocked.map((s) => `${label(s)}${s.blokirReason ? ` (${s.blokirReason})` : ""}`).join(", ")}`,
        { ordinals: blocked.map((s) => s.ordinal) });
    if (ctx.sources.every((s) => s.blocked === null))
      return rr("PELEBURAN_SOURCE_BLOKIR", L, "SKIPPED", "Status blokir tidak dapat diperiksa (SABH tidak tersedia / entri manual)");
    return rr("PELEBURAN_SOURCE_BLOKIR", L, "PASS", "Tidak ada PT sumber yang terblokir");
  },
};

/** FAIL only on the unambiguous terminal states (status_transaksi === 6 /
 *  status_perseroan bubar-berakhir — a dissolved PT cannot meleburkan diri);
 *  WARNING on other anomalies (incl. status 5 "sudah dalam status peleburan" —
 *  SABH legacy status data is noisy; defer to the verifier). Mirrors
 *  validatePerseroanState's status semantics (cross-validator.ts:1418). Sync —
 *  see PELEBURAN_SOURCE_BLOKIR. */
export const peleburanSourceStatusRule: Rule<PeleburanCtx> = {
  code: "PELEBURAN_SOURCE_STATUS",
  label: "Status Registri PT Sumber",
  run: (ctx) => {
    const L = "Status Registri PT Sumber";
    if (ctx.sources.length === 0)
      return rr("PELEBURAN_SOURCE_STATUS", L, "SKIPPED", "Belum ada PT sumber terdeteksi");
    const withState = ctx.sources.filter((s) => s.statusTransaksi != null || s.statusPerseroan != null);
    if (withState.length === 0)
      return rr("PELEBURAN_SOURCE_STATUS", L, "SKIPPED", "Status registri tidak tersedia (SABH tidak tersedia / entri manual)");
    const terminal = withState.filter((s) => s.statusTransaksi === 6 || /bubar|berakhir/i.test(s.statusPerseroan ?? ""));
    if (terminal.length > 0)
      return rr("PELEBURAN_SOURCE_STATUS", L, "FAIL",
        `PT sumber sudah berakhir/bubar dan tidak dapat meleburkan diri: ${terminal.map(label).join(", ")}`,
        { ordinals: terminal.map((s) => s.ordinal) });
    const anomalies = withState.filter((s) => s.statusTransaksi != null && s.statusTransaksi !== 1);
    if (anomalies.length > 0)
      return rr("PELEBURAN_SOURCE_STATUS", L, "WARNING",
        `Status registri tidak aktif/anomali (mohon ditinjau verifikator): ${anomalies.map((s) => `${label(s)} (status ${s.statusTransaksi})`).join(", ")}`,
        { ordinals: anomalies.map((s) => s.ordinal) });
    return rr("PELEBURAN_SOURCE_STATUS", L, "PASS", "Semua PT sumber berstatus aktif di SABH");
  },
};

/** WARNING — NEVER FAIL (severityFloor clamp). UUPT Ps.89 requires RUPS approval
 *  per participating PT, but our BA-RUPS↔source attribution is heuristic name
 *  matching over OCR text and quorum validity is verifier judgment — same
 *  advisory posture as project_pp_nik_age_advisory. */
export const peleburanRupsPerSourceRule: Rule<PeleburanCtx> = {
  code: "PELEBURAN_RUPS_PER_SOURCE",
  label: "BA RUPS per PT Sumber (Ps.89)",
  severityFloor: "WARNING",
  run: (ctx) => {
    const L = "BA RUPS per PT Sumber (Ps.89)";
    if (ctx.sources.length === 0)
      return rr("PELEBURAN_RUPS_PER_SOURCE", L, "SKIPPED", "Belum ada PT sumber terdeteksi");
    const covered = new Set(ctx.rupsBaDocs.map((b) => (b.namaPerseroan ? normName(b.namaPerseroan) : "")));
    const missing = ctx.sources.filter((s) => !covered.has(normName(s.namaPerseroan)));
    if (missing.length > 0)
      return rr("PELEBURAN_RUPS_PER_SOURCE", L, "WARNING",
        `BA RUPS belum teratribusi untuk: ${missing.map(label).join(", ")} (atribusi berbasis nama — mohon verifikasi manual)`,
        { ordinals: missing.map((s) => s.ordinal) });
    return rr("PELEBURAN_RUPS_PER_SOURCE", L, "PASS", "Setiap PT sumber memiliki BA RUPS teratribusi");
  },
};

/** FAIL — UUPT Ps.127(2): pengumuman koran ≥30 hari sebelum pemanggilan RUPS.
 *  The Ps.127(8) exemption does NOT apply to peleburan (it is expressly limited
 *  to pengambilalihan langsung dari pemegang saham — which is why
 *  AKUISISI_KORAN_WINDOW is WARNING-only but this one FAILs). Checking against
 *  the RUPS date is a conservative proxy in the SAFE direction: pemanggilan must
 *  itself precede the RUPS by ≥14 days (Ps.82(1)), so koran <30d before the RUPS
 *  implies koran <16d before pemanggilan — an unambiguous 127(2) violation.
 *  SKIPPED when either date is unreadable (user decision 2026-07-02, spec §10.7);
 *  both dates are operator-editable, so a wrong OCR read is correctable. */
export const peleburanKoranWindowRule: Rule<PeleburanCtx> = {
  code: "PELEBURAN_KORAN_WINDOW",
  label: "Pengumuman Koran ≥30 Hari Sebelum RUPS (Ps.127(2))",
  run: (ctx) => {
    const L = "Pengumuman Koran ≥30 Hari Sebelum RUPS (Ps.127(2))";
    const rupsDates = ctx.sources
      .map((s) => parseFlexibleDate(s.tanggalRups))
      .filter((d): d is Date => d !== null);
    const koran = parseFlexibleDate(ctx.koran.tanggalKoran);
    if (rupsDates.length === 0 || !koran)
      return rr("PELEBURAN_KORAN_WINDOW", L, "SKIPPED",
        !koran ? "Tanggal pengumuman koran tidak terbaca" : "Tanggal RUPS PT sumber tidak terbaca");
    const earliest = rupsDates.reduce((a, b) => (a.getTime() <= b.getTime() ? a : b));
    const diffDays = Math.round((earliest.getTime() - koran.getTime()) / 86_400_000);
    if (diffDays < 30)
      return rr("PELEBURAN_KORAN_WINDOW", L, "FAIL",
        `Pengumuman koran (${ctx.koran.tanggalKoran}) hanya ${diffDays} hari sebelum RUPS paling awal — UUPT Ps.127(2) mensyaratkan ≥30 hari (pengecualian Ps.127(8) tidak berlaku untuk peleburan)`,
        { diffDays });
    return rr("PELEBURAN_KORAN_WINDOW", L, "PASS", `Pengumuman koran ${diffDays} hari sebelum RUPS paling awal (≥30)`);
  },
};

/** WARNING — NEVER FAIL (severityFloor clamp). The new PT needs a fresh reserved
 *  name; equality with a source name is suspicious, but name reservation (Pesan
 *  Nama Soundex/banned-word engine) is out of OCR scope — flag, never adjudicate. */
export const peleburanNamaBaruUnikRule: Rule<PeleburanCtx> = {
  code: "PELEBURAN_NAMA_BARU_UNIK",
  label: "Nama PT Baru Berbeda dari PT Sumber",
  severityFloor: "WARNING",
  run: (ctx) => {
    const L = "Nama PT Baru Berbeda dari PT Sumber";
    const nama = ctx.input.aktaNamaPerseroan;
    if (!nama?.trim())
      return rr("PELEBURAN_NAMA_BARU_UNIK", L, "SKIPPED", "Nama PT baru belum terbaca dari akta");
    const collide = ctx.sources.filter((s) => normName(s.namaPerseroan) === normName(nama));
    if (collide.length > 0)
      return rr("PELEBURAN_NAMA_BARU_UNIK", L, "WARNING",
        `Nama PT baru "${nama}" sama dengan PT sumber ${collide.map(label).join(", ")} — pastikan pesan nama baru`,
        { ordinals: collide.map((s) => s.ordinal) });
    return rr("PELEBURAN_NAMA_BARU_UNIK", L, "PASS", "Nama PT baru berbeda dari seluruh PT sumber");
  },
};

// ── the assembled tuned matrix ────────────────────────────────────────────────

/** Wrapped shared rules (bodies bit-for-bit in cross-validator.ts) — the
 *  pendirian-grade identity/modal/entity/KBLI groups of spec §4.6 rows 1-13.
 *  DELIBERATE EXCLUSIONS (do not "fix"): the bukti-setor pair (no bukti-setor
 *  upload in SABH's peleburan wizard, spec C8); the perubahan-family diff/jenis
 *  set (no old-vs-new diff for a brand-new entity); the single-registry set
 *  PERSEROAN_STATE/NOTARIS_TERAKHIR/NPWP_PERSEROAN_FUZZY (the NEW PT has no
 *  registry record — source-side registry state is PELEBURAN_SOURCE_BLOKIR/
 *  _STATUS); AKTA_DATE_WINDOW (its body keys 30-day semantics off
 *  input.isPerubahan=true — wrong window for a pendirian-grade filing, and
 *  flipping the flag would perturb other shared validators — spec §4.6 "verify
 *  during build" verdict: exclude); RUPS_ATTENDANCE_QUORUM (not a §4.6 matrix
 *  row — PELEBURAN_RUPS_PER_SOURCE is this flow's RUPS surface). */
export const PELEBURAN_SHARED_RULE_CODES = [
  "NIK_KTP_AKTA", "NAMA_NPWP_KTP", "KTP_COMPLETENESS", "NPWP_COMPLETENESS",
  "DOMISILI_NAMA", "NAMA_PT_CONSISTENCY", "PS_TOTAL_100", "SHARES_TOTAL_LEMBAR",
  "SHARES_SUM_CONSISTENCY", "MODAL_HIERARCHY", "NOMINAL_RECONCILE",
  "CONTACT_INFO_COMPLETE", "PEMEGANG_SAHAM_CONTACT", "PASSPORT_AKTA",
  "PP29_MODAL", "KBLI_VALID",
] as const;

export const PELEBURAN_RULE_SET: Rule<PeleburanCtx>[] = [
  peleburanMinSourcesRule,
  peleburanSourceResolvedRule,
  peleburanSourceBlokirRule,
  peleburanSourceStatusRule,
  peleburanRupsPerSourceRule,
  peleburanKoranWindowRule,
  peleburanNamaBaruUnikRule,
  ...pickRules(PT_AKTA_RULE_SET, PELEBURAN_SHARED_RULE_CODES),
];
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/rules-peleburan.test.ts. Expected: all pass. (Type note: pickRules returns Rule<PtAktaCtx>[]; PeleburanCtx extends PtAktaCtx and run(ctx) is method-position (bivariant), so the spread into Rule<PeleburanCtx>[] typechecks — the exact pattern RUPS_TAHUNAN_RULE_SET already relies on.)
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/flow-engine/rules/peleburan backend/src/flow-engine/__tests__/rules-peleburan.test.ts && git commit -m "feat(peleburan): tuned PELEBURAN_RULE_SET (7 flow rules incl. FAIL koran window + 16 wrapped)"

Task 7: The real FlowConfig — registry entry + subtype-map label row + dispatch/rematch proof

Replaces Task 1's stub with the SECOND engine:'v2-generic' registry entry. Engine-fidelity notes vs the spec's §4.2 sketch (record, don't drift): (a) rules: PELEBURAN_RULE_SET (the spec's PT_PENDIRIAN_RULE_SET name never existed — the engine's mechanism is pickRules(PT_AKTA_RULE_SET, …), already assembled in Task 6); (b) rematch.preValidationSteps carries ONLY resolvePeleburanSources — the spec's sketched matchRupsAttendance step is dropped because the binding §4.6 matrix has no RUPS_ATTENDANCE_QUORUM row, so attendance matching would populate data nothing consumes; (c) checklistResetDocTypes uses the CHECKLIST-LABEL vocabulary ("SURAT_PERNYATAAN_DOMISILI", NOT the Prisma DOMISILI enum — types.ts:31-47 erratum). Also adds the defensive AKTA_SUBTYPE_MAP label row (spec §5.4).

Files
- backend/src/flow-engine/flows/peleburan-pt.ts — Create
- backend/src/flow-engine/registry.ts — Modify (stub → real entry)
- backend/src/ocr/classifier.ts — Modify (AKTA_SUBTYPE_MAP, after the AKTA_AKUISISI row ~line 56)
- backend/src/flow-engine/__tests__/registry.test.ts — Modify (V2_SHIPPED set gains PELEBURAN_PT)
- backend/src/flow-engine/__tests__/peleburan-flow.test.ts — Create

Interfaces
- Consumes: defineFlow from ../types; buildPeleburanContext, PeleburanCtx (Task 5); PELEBURAN_RULE_SET (Task 6); runPeleburanDeedPasses (Task 3); resolvePeleburanSources (Task 4); resolveAktaSubtype from ../../services/document-processor (test only).
- Produces: const peleburanPtFlow: FlowConfig<PeleburanCtx>; FLOW_REGISTRY.PELEBURAN_PT === peleburanPtFlow; typesForRouteFamily("peleburan") === ["PELEBURAN_PT"]; resolveAktaSubtype("AKTA_PELEBURAN", …) === "pendirian".

Steps

  • [ ] Write failing test. Create backend/src/flow-engine/__tests__/peleburan-flow.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { flowFor, typesForRouteFamily } from "../registry";
import { peleburanPtFlow } from "../flows/peleburan-pt";
import { resolveAktaSubtype } from "../../services/document-processor";
import { reMatchAndValidate } from "../../services/submission-processor";

describe("peleburanPtFlow registry entry (SECOND v2-generic tenant)", () => {
  it("is registered as the real v2 config", () => {
    const entry = flowFor("PELEBURAN_PT");
    expect(entry).toBe(peleburanPtFlow as never);
    expect(entry.engine).toBe("v2-generic");
    expect(typesForRouteFamily("peleburan")).toEqual(["PELEBURAN_PT"]);
    expect(typesForRouteFamily("perubahan")).not.toContain("PELEBURAN_PT"); // NOT in the perubahan family
  });

  it("carries the canonical config values (spec §4.2, engine-fidelity deviations recorded)", () => {
    const f = peleburanPtFlow;
    expect(f.primaryAkta).toEqual({
      classifiedType: "AKTA_PELEBURAN",
      missingError: "Akta Peleburan tidak ditemukan pada submission",
      extractError: "Ekstraksi Akta Peleburan gagal",
    });
    expect(f.companyLookup).toEqual({ mode: "multi-source" });
    expect(f.applyContactInfo).toBe(true);
    expect(f.extraction.overrideByClassifiedType).toEqual({ AKTA_PELEBURAN: "pendirian" });
    expect(f.extraction.focusedPasses).toEqual({ BUKTI_PENGUMUMAN: "bukti-pengumuman" });
    expect(f.skipExtractionTypes).toEqual(["OTHER"]);
    expect(f.requiredDocs).toEqual(["AKTA", "KTP", "NPWP", "DOMISILI", "DATA_KONTAK", "BERITA_ACARA_RUPS"]);
    expect(f.failGate).not.toBe("off"); // block-with-override stays ON
    expect(f.routeFamily).toBe("peleburan");
    // Checklist-LABEL vocabulary (types.ts erratum): SURAT_PERNYATAAN_DOMISILI, never DOMISILI.
    expect(f.rematch.checklistResetDocTypes).toEqual(["KTP", "NPWP", "SURAT_PERNYATAAN_DOMISILI"]);
    expect(f.rematch.standardPtMatchers).toBe(true);
    expect(f.rematch.preValidationSteps).toHaveLength(1);
    expect(f.sections.map((s) => s.key)).toEqual([
      "data_perseroan", "akta_notaris", "modal_saham", "pengurus", "kbli",
      "sumber_peleburan", "rups_pengumuman", "validasi",
    ]);
    expect(f.hooks?.afterAktaExtraction).toBeDefined();
    // Matrix guards at the CONFIG level too.
    const codes = f.rules.map((r) => r.code);
    expect(codes).toContain("PELEBURAN_KORAN_WINDOW");
    expect(codes).not.toContain("AKTA_DATE_WINDOW");
    expect(codes).not.toContain("MODAL_BUKTI_SETOR");
  });

  it("AKTA_PELEBURAN resolves to the pendirian subtype (label row + identity rows)", () => {
    expect(resolveAktaSubtype("AKTA_PELEBURAN", { classification: "AKTA_PENDIRIAN", confidence: 1 } as never)).toBe("pendirian");
    expect(resolveAktaSubtype("pendirian", { classification: "AKTA_PERUBAHAN", confidence: 1 } as never)).toBe("pendirian");
  });
});

describe("rematch dispatch (reference_rematch_dispatch regression)", () => {
  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("a field-edit revalidation reaches reMatchAndValidateFlow and REWRITES (not wipes) validations", async () => {
    const akta = await db.document.create({
      data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY", rawText: "" },
    });
    docIds.push(akta.id);
    await db.extractedField.createMany({
      data: [
        { documentId: akta.id, fieldKey: "nama_perseroan", value: "PT REMATCH LEBUR" },
        { documentId: akta.id, fieldKey: "nomor_akta", value: "7" },
      ],
    });
    const sub = await db.submission.create({ data: { type: "PELEBURAN_PT", status: "READY" } });
    ids.push(sub.id);
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PELEBURAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });
    // ONE source row → PELEBURAN_MIN_SOURCES must FAIL (proves the tuned matrix ran).
    await db.peleburanSourcePt.create({
      data: { submissionId: sub.id, ordinal: 0, namaPerseroan: "PT SUMBER SATU", resolutionStatus: "MANUAL" },
    });

    await reMatchAndValidate(sub.id); // the queue wrapper delegates here
    const rows = await db.validationResult.findMany({ where: { submissionId: sub.id } });
    expect(rows.length).toBeGreaterThan(0); // NOT wiped
    const byCode = Object.fromEntries(rows.map((r) => [r.ruleCode, r.status]));
    expect(byCode.PELEBURAN_MIN_SOURCES).toBe("FAIL");
    expect(rows.map((r) => r.ruleCode)).not.toContain("AKTA_DATE_WINDOW");
    expect(rows.map((r) => r.ruleCode)).not.toContain("OLD_DATA_CONSISTENCY");
  }, 30000);
});
  • [ ] Run to verify RED: cd backend && bun test src/flow-engine/__tests__/peleburan-flow.test.ts. Expected: cannot resolve ../flows/peleburan-pt.
  • [ ] Minimal implementation A. Create backend/src/flow-engine/flows/peleburan-pt.ts:
import { defineFlow } from "../types";
import { buildPeleburanContext, type PeleburanCtx } from "../context/peleburan-context";
import { PELEBURAN_RULE_SET } from "../rules/peleburan/index";

/**
 * Peleburan PT — Konsolidasi (SABH id_aksi_transaksi = 35; UUPT Ps.122 jo.
 * Ps.1(10)) — the SECOND engine:'v2-generic' FLOW_REGISTRY tenant (ship order:
 * LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT).
 *
 * NEW-ENTITY transaction: the deed carries a pendirian-grade payload for the
 * NEW PT (extraction.overrideByClassifiedType → 'pendirian'); there is NO
 * old-vs-new diff. The registry work is entirely on the SOURCE side: N ≥ 2
 * PeleburanSourcePt rows, extracted by hooks.afterAktaExtraction (grounded 35B
 * pass) and resolved by companyLookup.mode:'multi-source' (this flow is the
 * mode's first consumer — engine branch in processor.ts).
 *
 * VERIFIKATOR-STAGED: routes/peleburan.ts submit → COMPLETED + VerifikasiPerubahan
 * (MENUNGGU_VERIFIKATOR) — akuisisi-arm mirror; NOT terminal (contrast
 * LAPORAN_RUPS_TAHUNAN). FAIL gate = block-with-override (checkFailGate).
 */
export const peleburanPtFlow = defineFlow<PeleburanCtx>({
  type: "PELEBURAN_PT",
  engine: "v2-generic",
  primaryAkta: {
    classifiedType: "AKTA_PELEBURAN",
    missingError: "Akta Peleburan tidak ditemukan pada submission",
    extractError: "Ekstraksi Akta Peleburan gagal",
  },
  skipExtractionTypes: ["OTHER"],
  companyLookup: { mode: "multi-source" }, // first consumer — N-source loop, spec §4.4
  applyContactInfo: true,
  extraction: {
    // Hybrid (akuisisi precedent, opposite direction): an akta peleburan's
    // operative content IS the new PT's anggaran dasar → pendirian schema.
    overrideByClassifiedType: { AKTA_PELEBURAN: "pendirian" },
    // SHARED clipping extractor (first consumer; Pembubaran/Berakhirnya reuse).
    focusedPasses: { BUKTI_PENGUMUMAN: "bukti-pengumuman" },
  },
  buildContext: buildPeleburanContext,
  rules: PELEBURAN_RULE_SET,
  // DocumentType enum values (requiredDocs contract) — DOMISILI is the existing
  // enum the SURAT_PERNYATAAN_DOMISILI label maps to. NO bukti-setor docs:
  // SABH's peleburan wizard has no bukti-setor upload (spec §4.6/C8).
  requiredDocs: ["AKTA", "KTP", "NPWP", "DOMISILI", "DATA_KONTAK", "BERITA_ACARA_RUPS"],
  sections: [
    { key: "data_perseroan",   label: "Data Perseroan Baru",     approvable: true,  required: true },
    { key: "akta_notaris",     label: "Akta Notaris",            approvable: true,  required: true },
    { key: "modal_saham",      label: "Modal & Saham",           approvable: true,  required: true },
    { key: "pengurus",         label: "Pengurus & Identitas",    approvable: true,  required: true },
    { key: "kbli",             label: "Maksud & Tujuan (KBLI)",  approvable: true,  required: true },
    { key: "sumber_peleburan", label: "PT Sumber Peleburan",     approvable: true,  required: true },
    { key: "rups_pengumuman",  label: "RUPS & Pengumuman Koran", 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.
    checklistResetDocTypes: ["KTP", "NPWP", "SURAT_PERNYATAAN_DOMISILI"],
    standardPtMatchers: true,
    // Re-resolve any PENDING source rows before validation (cheap no-op when all
    // resolved). NOTE: the spec §4.2 sketch also listed matchRupsAttendance —
    // dropped: the binding §4.6 matrix has no RUPS_ATTENDANCE_QUORUM row, so
    // attendance data would feed nothing (PELEBURAN_RUPS_PER_SOURCE is the RUPS
    // surface). Recorded deviation.
    preValidationSteps: [
      async (submissionId) => {
        const { resolvePeleburanSources } = await import("../../services/peleburan-sources");
        await resolvePeleburanSources(submissionId);
      },
    ],
  },
  hooks: {
    afterAktaExtraction: async (a) => {
      const { runPeleburanDeedPasses } = await import("../../services/akta-peleburan-sources-extract");
      await runPeleburanDeedPasses(a);
    },
    // onFinalize: SABH outbox seam (createLeburTransaksi) — PoC-wide deferral (spec §9).
  },
  routeFamily: "peleburan",
  labels: { akta: "Akta Peleburan", flow: "Peleburan PT (Konsolidasi)" },
});
  • [ ] Minimal implementation B. Edit backend/src/flow-engine/registry.ts: add import { peleburanPtFlow } from "./flows/peleburan-pt"; and replace the Task-1 stub line (drop its TEMPORARY comment block) with:
  PELEBURAN_PT: peleburanPtFlow, // SECOND v2-generic tenant (multi-source lookup's first consumer)
  • [ ] Minimal implementation C. Edit backend/src/ocr/classifier.ts AKTA_SUBTYPE_MAP: insert after the AKTA_AKUISISI: "perubahan", row:
  // Hybrid: peleburan (konsolidasi) reuses the PENDIRIAN extractor — the deed's
  // operative content is the NEW PT's anggaran dasar (spec §4.3/§5.4).
  AKTA_PELEBURAN: "pendirian",
  • [ ] Minimal implementation D. Edit backend/src/flow-engine/__tests__/registry.test.ts: extend the shipped-tenant set:
    const V2_SHIPPED = new Set(["LAPORAN_RUPS_TAHUNAN", "PELEBURAN_PT"]);
  • [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/peleburan-flow.test.ts src/flow-engine/__tests__/registry.test.ts. NOTE: the rematch test exercises buildPtAktaContext (SABH-adjacent) — same environment expectation as pt-akta-context.test.ts; if it fails there, fix the environment, do NOT weaken the test.
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Regression: cd backend && bun test src/flow-engine src/services/__tests__/akuisisi-processor.test.ts (subtype label row must not alter AKTA_PENDIRIAN/AKTA_PERUBAHAN/AKTA_AKUISISI paths; if no akuisisi-processor test file exists, run the whole src/services/__tests__ dir).
  • [ ] Commit: git add backend/src/flow-engine/flows/peleburan-pt.ts backend/src/flow-engine/registry.ts backend/src/ocr/classifier.ts backend/src/flow-engine/__tests__/peleburan-flow.test.ts backend/src/flow-engine/__tests__/registry.test.ts && git commit -m "feat(peleburan): register the second v2-generic FlowConfig + pendirian subtype row"

Task 8: Routing + gating — BUILT set flip, ladder rung, doc-type maps, labels endpoint, suggestion surfacing (THE LAUNCH SWITCH)

Everything that makes the flow REACHABLE: BUILT_AKTA_TXN_TYPES += "peleburan" (flips supported, removes it from detectUnsupportedAktaType), the inferSubmissionType rung at the canonical ladder position (ABOVE Akuisisi), mapToDocumentType/getProcessingOrder for BUKTI_PENGUMUMAN (today it falls through to the AKTA fallback!), the klasifikasi label set + ?type=peleburan, and the classify-response suggestion field surfacing the BUKTI_PENGUMUMAN keyword pre-suggestion (never auto-adopted — refineSupportingLabel already runs and correctly refuses to adopt autoAcceptable:false suggestions; the response field lets the board SHOW it). Also updates the two test files that use peleburan as the unbuilt fixture → pembubaran.

Files
- backend/src/services/akta-txn-classifier.ts — Modify (BUILT_AKTA_TXN_TYPES, lines 32-37)
- backend/src/routes/submissions.ts — Modify (mapToDocumentType L27-50, getProcessingOrder L53-69, inferSubmissionType L83-110)
- backend/src/routes/klasifikasi.ts — Modify (label set + labels route + LABEL_MAP + classify route suggestion)
- backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts — Modify (lines 35-36, 45)
- backend/src/routes/__tests__/laporan-rups-routing.test.ts — Modify (line 14)
- backend/src/routes/__tests__/peleburan-routing.test.ts — Create

Interfaces
- Consumes: classifySupportingDoc from ../services/supporting-doc-classifier (already imported in klasifikasi.ts:12); config.classifierMinConfidence; getDisplayName (klasifikasi.ts:185); the memoized ocrText closure (klasifikasi.ts:275-282).
- Produces: inferSubmissionType return union gains "PELEBURAN_PT"; mapToDocumentType("BUKTI_PENGUMUMAN") === "BUKTI_PENGUMUMAN"; GET /api/klasifikasi/labels?type=peleburan; classify response gains suggestion: { docType: string; displayName: string } | null.

Steps

  • [ ] Write failing test. Create backend/src/routes/__tests__/peleburan-routing.test.ts:
import { describe, it, expect } from "bun:test";
import { inferSubmissionType, detectUnsupportedAktaType, mapToDocumentType, getProcessingOrder } from "../submissions";
import { BUILT_AKTA_TXN_TYPES } from "../../services/akta-txn-classifier";
import { app } from "../../index";

const f = (classification: string, userOverride: string | null = null) => ({ classification, userOverride });

describe("gating flip (the launch switch)", () => {
  it("peleburan is BUILT — no longer 'belum didukung'; pembubaran stays gated", () => {
    expect(BUILT_AKTA_TXN_TYPES.has("peleburan")).toBe(true);
    expect(detectUnsupportedAktaType([f("AKTA_PELEBURAN")])).toBeNull();
    expect(detectUnsupportedAktaType([f("AKTA_PEMBUBARAN")])).toBe("pembubaran");
  });
});

describe("inferSubmissionType ladder (Perbaikan > PELEBURAN > Akuisisi > Perubahan > LaporanRups > PendirianPP > PendirianPT)", () => {
  it("routes a lone AKTA_PELEBURAN to PELEBURAN_PT", () => {
    expect(inferSubmissionType([f("AKTA_PELEBURAN"), f("KTP"), f("BUKTI_PENGUMUMAN")])).toBe("PELEBURAN_PT");
  });
  it("BEATS Akuisisi/Perubahan/Pendirian-looking content (spec §7 ladder gotcha)", () => {
    expect(inferSubmissionType([f("AKTA_PELEBURAN"), f("AKTA_AKUISISI")])).toBe("PELEBURAN_PT");
    expect(inferSubmissionType([f("AKTA_PELEBURAN"), f("AKTA_PERUBAHAN")])).toBe("PELEBURAN_PT");
    expect(inferSubmissionType([f("AKTA_PELEBURAN"), f("AKTA_PENDIRIAN")])).toBe("PELEBURAN_PT");
    expect(inferSubmissionType([f("AKTA_PELEBURAN"), f("AKTA_RUPS_TAHUNAN")])).toBe("PELEBURAN_PT");
  });
  it("stays BELOW Perbaikan; user override wins", () => {
    expect(inferSubmissionType([f("SURAT_PERNYATAAN_PERBAIKAN"), f("AKTA_PELEBURAN")])).toBe("PERBAIKAN_DATA_PT");
    expect(inferSubmissionType([f("AKTA_PELEBURAN", "AKTA_PERUBAHAN")])).toBe("PERUBAHAN_PT");
  });
});

describe("doc-type maps", () => {
  it("BUKTI_PENGUMUMAN → its own carrier (NOT the AKTA fallback); AKTA_PELEBURAN → AKTA", () => {
    expect(mapToDocumentType("BUKTI_PENGUMUMAN")).toBe("BUKTI_PENGUMUMAN");
    expect(mapToDocumentType("AKTA_PELEBURAN")).toBe("AKTA");
    expect(getProcessingOrder("AKTA_PELEBURAN")).toBe(0);
    expect(getProcessingOrder("BUKTI_PENGUMUMAN")).toBe(6); // supporting doc, after the deed + cards
  });
});

describe("labels endpoint", () => {
  it("?type=peleburan serves the peleburan label set (spec §4.6 doc table)", async () => {
    const res = await app.request("/api/klasifikasi/labels?type=peleburan");
    expect(res.status).toBe(200);
    const labels = (await res.json()) as { key: string; required: boolean }[];
    const keys = labels.map((l) => l.key);
    expect(keys[0]).toBe("AKTA_PELEBURAN");
    for (const req of ["AKTA_PELEBURAN", "KTP", "NPWP", "SURAT_PERNYATAAN_DOMISILI", "DATA_KONTAK", "BERITA_ACARA_RUPS"]) {
      expect(labels.find((l) => l.key === req)?.required).toBe(true);
    }
    for (const opt of ["BUKTI_PENGUMUMAN", "PASSPORT", "KITAS", "OTHER"]) {
      expect(labels.find((l) => l.key === opt)?.required).toBe(false);
    }
    // NO bukti-setor slots (SABH's peleburan wizard has none — spec C8).
    expect(keys.some((k) => k.startsWith("BUKTI_TRANSFER"))).toBe(false);
  });
});
  • [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/peleburan-routing.test.ts. Expected: fails (BUILT set, ladder, maps, labels all missing).
  • [ ] Minimal implementation A — backend/src/services/akta-txn-classifier.ts, BUILT_AKTA_TXN_TYPES:
export const BUILT_AKTA_TXN_TYPES: ReadonlySet<AktaTxnType> = new Set<AktaTxnType>([
  "pendirian",
  "perubahan",
  "akuisisi",
  "laporan_rups_tahunan", // LAUNCH SWITCH (flow-spec binding decision 6) — flipped with the shipped flow
  "peleburan",            // LAUNCH SWITCH — PELEBURAN_PT shipped (second v2-generic tenant)
]);
  • [ ] Minimal implementation B — backend/src/routes/submissions.ts:
    1. mapToDocumentType: insert BEFORE the if (classification.startsWith("AKTA_") && …) return "AKTA"; line (line 30 — order matters: BUKTI_PENGUMUMAN doesn't start with AKTA_, but keep the new carrier check adjacent to the other carrier checks; insert after the LAMPIRAN_ line ~48):
  if (classification === "BUKTI_PENGUMUMAN") return "BUKTI_PENGUMUMAN";
  1. getProcessingOrder: insert after the LAMPIRAN_ line (~67):
  if (classification === "BUKTI_PENGUMUMAN") return 6; // supporting doc, after the deed + cards
  1. inferSubmissionType: widen the return union to "PENDIRIAN_PT" | "PENDIRIAN_PP" | "PERUBAHAN_PT" | "PERBAIKAN_DATA_PT" | "AKUISISI_PT" | "LAPORAN_RUPS_TAHUNAN" | "PELEBURAN_PT", update the doc comment's precedence line to Perbaikan > Peleburan > Akuisisi > Perubahan > LaporanRupsTahunan > Pendirian PP > Pendirian PT (built rungs of the canonical ladder: Perbaikan > Berakhirnya > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT), and insert AFTER the SURAT_PERNYATAAN_PERBAIKAN check (line 102), BEFORE the AKTA_AKUISISI check (line 103):
  // ABOVE Akuisisi/Perubahan/Pendirian: a peleburan bundle legitimately contains
  // pendirian-grade content (and may carry other deeds) — peleburan must win.
  if (files.some((f) => eff(f) === "AKTA_PELEBURAN")) return "PELEBURAN_PT";
  • [ ] Minimal implementation C — backend/src/routes/klasifikasi.ts:
    1. Add the label set after LAPORAN_RUPS_DOC_TYPES (~line 171):
const PELEBURAN_DOC_TYPES: DocTypeLabel[] = [
  { key: "AKTA_PELEBURAN", displayName: "Akta Peleburan", required: true, maxCount: 1 },
  { key: "KTP", displayName: "KTP", required: true, description: "Wajib — identitas pengurus / pemegang saham / BO PT baru (cross-validation)" },
  { key: "NPWP", displayName: "NPWP Pengurus Perseroan", required: true, description: "Wajib — verifikasi NPWP pengurus/PS PT baru (cross-validation)" },
  { key: "SURAT_PERNYATAAN_DOMISILI", displayName: "Surat Pernyataan Domisili", required: true, maxCount: 1, description: "Wajib — kedudukan/alamat PT baru (cross-validation)" },
  { key: "DATA_KONTAK", displayName: "Data Kontak Pengurus dan Pemegang Saham", required: true, description: "Wajib — telepon, email, NPWP pengurus & PS (cross-validation)" },
  { key: "BERITA_ACARA_RUPS", displayName: "Berita Acara RUPS", required: true, description: "Persetujuan RUPS per PT sumber (≥1; cakupan per-sumber divalidasi aturan, bukan gerbang unggah)" },
  { key: "BUKTI_PENGUMUMAN", displayName: "Bukti Pengumuman Koran", required: false, maxCount: 1, description: "Kliping pengumuman Ps.127(2) — boleh juga tercantum di dalam akta" },
  { key: "PASSPORT", displayName: "Paspor", required: false, description: "Untuk pengurus/PS WNA (alternatif KTP)" },
  { key: "KITAS", displayName: "KITAS / KITAP", required: false, description: "Untuk pengurus/PS WNA berdomisili di Indonesia" },
  { key: "OTHER", displayName: "Dokumen lain bukan persyaratan", required: false, description: "Dokumen pendukung tambahan" },
];
  1. Extend the LABEL_MAP spread (line ~178) with ...PELEBURAN_DOC_TYPES.
  2. Labels route (line ~326): add if (type === "peleburan") return c.json(PELEBURAN_DOC_TYPES); after the laporan_rups line, and extend the route comment (line 325) with | peleburan.
  3. Classify route — surface the non-auto-acceptable keyword suggestion. Directly after the two-refine chain (refinedClassification = await refineSupportingLabel(…), ~line 284), insert:
    // Keyword pre-suggestion surfacing (spec §4.3.1): refineSupportingLabel adopts
    // ONLY autoAcceptable hits; a BUKTI_PENGUMUMAN (and other conservative) match
    // is returned here as a SUGGESTION for the board — never silently adopted; the
    // operator applies it through the existing re-map (userOverride + reason).
    let suggestion: { docType: string; displayName: string } | null = null;
    if (confidence < config.classifierMinConfidence && !refinedClassification.startsWith("AKTA_")) {
      try {
        const s = await classifySupportingDoc(await ocrText());
        if (s.docType && !s.autoAcceptable && s.docType !== refinedClassification) {
          suggestion = { docType: s.docType, displayName: getDisplayName(s.docType) };
        }
      } catch { /* suggestion is best-effort */ }
    }
 and add `suggestion,` to the returned `c.json({ … })` object (after `confident`).
  • [ ] Minimal implementation D — update the stale unbuilt-case fixtures:
    1. backend/src/routes/__tests__/klasifikasi-akta-txn.test.ts line 35-36: comment → // pembubaran is still unbuilt (peleburan became built once its flow shipped); assertion → expect(detectUnsupportedAktaType([{ classification: "AKTA_PEMBUBARAN" }])).toBe("pembubaran");. Line 45: AKTA_PELEBURANAKTA_PEMBUBARAN (override-to-supported case keeps working).
    2. backend/src/routes/__tests__/laporan-rups-routing.test.ts line 14: expect(detectUnsupportedAktaType([f("AKTA_PEMBUBARAN")])).toBe("pembubaran"); // others stay gated.
  • [ ] Run GREEN: cd backend && bun test src/routes/__tests__/peleburan-routing.test.ts src/routes/__tests__/klasifikasi-akta-txn.test.ts src/routes/__tests__/laporan-rups-routing.test.ts. Then regression: bun test src/routes/__tests__ src/services/__tests__/akta-txn-classifier.test.ts (PERUBAHAN/AKUISISI/RUPS inference + refine untouched; the title-rule test at akta-txn-classifier.test.ts:45 still expects txnType "peleburan" — unchanged, only its BUILT status flipped).
  • [ ] 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__/peleburan-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(peleburan): routing + gating — BUILT flip, ladder rung above akuisisi, doc maps, labels, suggestion surfacing"

Task 9: Route family routes/peleburan.ts — review-data, sources CRUD/select, koran, approvals, submit → VERIFIKATOR

The flow's own thin route file (routeFamily 'peleburan'). Field edit/confirm/override ride the existing generic /api/submissions/:id/... endpoints (revalidation reaches this flow via queueReMatchAndValidate → the registry). Company selection does NOT reuse POST /api/perubahan/select-company (that path is single-company on Submission columns) — the N-row panel drives the per-row routes here. Every source mutation funnels through afterSourcesChange: while AWAITING_COMPANY_SELECTION, resolving the LAST row fire-and-forgets continueFlow; post-READY edits queue queueReMatchAndValidate (serialized). The submit gate: status READY (409 template envelope) → required section approvals (explicit PerubahanSectionApproval rows) → checkFailGate (canonical 422) → ONE transaction: COMPLETED + VerifikasiPerubahan create/PERBAIKAN-update + recordAuditEvent — the akuisisi arm mirror (perubahan.ts:1314-1350). NO runAdvisory call (the AI advisory engine builds a perubahan-shaped diff report; peleburan has no diff — recorded deviation; aiReport stays null and the verifikator reads the validation list instead).

Files
- backend/src/routes/peleburan.ts — Create
- backend/src/index.ts — Modify (import + app.route("/api/peleburan", peleburan); after the /api/laporan-rups mount)
- backend/src/routes/__tests__/peleburan-routes.test.ts — Create

Interfaces
- Consumes: db; queueReMatchAndValidate from ../services/submission-processor; checkFailGate from ../flow-engine/fail-gate; peleburanPtFlow (sections vocabulary); resolvePeleburanSources, hasUnresolvedSources, selectSourceRow (Task 4); recordAuditEvent from ../services/audit-event; getRequestContext from ../middleware/request-context; Prisma.
- Produces routes (all under /api/peleburan):
- GET /submissions/:submissionId/review-data
- GET /submissions/:submissionId/sources
- POST /submissions/:submissionId/sources body { namaPerseroan: string; nomorSk?: string | null; tanggalRups?: string | null } (manual add — row starts PENDING and is immediately resolved)
- PUT /submissions/:submissionId/sources/:ordinal body { namaPerseroan?, nomorSk?, tanggalRups? } (edit ref → reset to PENDING → re-resolve that row)
- PUT /submissions/:submissionId/sources/:ordinal/select body { nomorTransaksi: string; tahun: number } | { manual: true }
- DELETE /submissions/:submissionId/sources/:ordinal
- PUT /submissions/:submissionId/koran body { namaKoran?: string | null; tanggalPengumuman?: string | null } (upserts the canonical ExtractedFields on the clipping doc if present, else the akta)
- POST /submissions/:submissionId/sections/:sectionKey/approve and /unapprove
- POST /submissions/:submissionId/submit (no body required)

Steps

  • [ ] Write failing test. Create backend/src/routes/__tests__/peleburan-routes.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";

describe("peleburan route family", () => {
  const ids: string[] = [];
  const docIds: string[] = [];
  afterAll(async () => {
    await db.submission.deleteMany({ where: { id: { in: ids } } });
    await db.document.deleteMany({ where: { id: { in: docIds } } });
  });

  async function seed(opts?: { status?: string; sources?: number }) {
    const akta = await db.document.create({
      data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY", rawText: "" },
    });
    docIds.push(akta.id);
    await db.extractedField.createMany({
      data: [
        { documentId: akta.id, fieldKey: "nama_perseroan", value: "PT BARU HASIL LEBUR" },
        { documentId: akta.id, fieldKey: "nomor_akta", value: "9" },
        { documentId: akta.id, fieldKey: "nama_koran", value: null },
        { documentId: akta.id, fieldKey: "tanggal_pengumuman", value: null },
      ],
    });
    const sub = await db.submission.create({ data: { type: "PELEBURAN_PT", status: opts?.status ?? "READY" } });
    ids.push(sub.id);
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PELEBURAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });
    const n = opts?.sources ?? 2;
    for (let i = 0; i < n; i++) {
      await db.peleburanSourcePt.create({
        data: { submissionId: sub.id, ordinal: i, namaPerseroan: `PT SUMBER ${i + 1}`, tanggalRups: "10-03-2026", resolutionStatus: "MANUAL" },
      });
    }
    return { subId: sub.id, aktaId: akta.id };
  }
  const req = (subId: string, path: string, method = "GET", body?: unknown) =>
    app.request(`/api/peleburan/submissions/${subId}${path}`, {
      method, headers: { "Content-Type": "application/json" }, body: body !== undefined ? JSON.stringify(body) : undefined,
    });
  const json = (r: Response) => r.json() as Promise<Record<string, unknown>>;

  it("review-data guards the type and returns the composite payload", async () => {
    const other = await db.submission.create({ data: { type: "PENDIRIAN_PT", status: "READY" } });
    ids.push(other.id);
    expect((await app.request(`/api/peleburan/submissions/${other.id}/review-data`)).status).toBe(400);

    const { subId } = await seed();
    const res = await req(subId, "/review-data");
    expect(res.status).toBe(200);
    const body = (await json(res)) as never as {
      submission: { status: string };
      akta: { documentId: string; fields: unknown[]; direksi: unknown[]; komisaris: unknown[]; pemegangSaham: unknown[]; modal: unknown; kbli: unknown[] } | null;
      sources: { ordinal: number; resolutionStatus: string }[];
      koran: { namaKoran: string | null; tanggalKoran: string | null; source: string | null };
      sections: { key: string }[];
      sectionApprovals: Record<string, boolean>;
      validationResults: unknown[];
    };
    expect(body.akta).not.toBeNull();
    expect(body.sources).toHaveLength(2);
    expect(body.sections.map((s) => s.key)).toContain("sumber_peleburan");
  });

  it("source add/edit/select/delete + WRONG_STATUS guard outside READY/AWAITING", async () => {
    const { subId } = await seed();
    // add (manual ref; SABH-unavailable env resolves it MANUAL — either way it lands resolved or awaiting)
    let res = await req(subId, "/sources", "POST", { namaPerseroan: "PT SUMBER TIGA", nomorSk: null });
    expect(res.status).toBe(200);
    let rows = await db.peleburanSourcePt.findMany({ where: { submissionId: subId }, orderBy: { ordinal: "asc" } });
    expect(rows).toHaveLength(3);
    expect(rows[2]!.namaPerseroan).toBe("PT SUMBER TIGA");
    // edit ref → row resets and re-resolves (never stays PENDING silently)
    res = await req(subId, "/sources/2", "PUT", { tanggalRups: "12-03-2026" });
    expect(res.status).toBe(200);
    rows = await db.peleburanSourcePt.findMany({ where: { submissionId: subId }, orderBy: { ordinal: "asc" } });
    expect(rows[2]!.tanggalRups).toBe("12-03-2026");
    expect(rows[2]!.resolutionStatus).not.toBe("PENDING");
    // explicit manual select
    res = await req(subId, "/sources/2/select", "PUT", { manual: true });
    expect(res.status).toBe(200);
    // delete
    res = await req(subId, "/sources/2", "DELETE");
    expect(res.status).toBe(200);
    expect(await db.peleburanSourcePt.count({ where: { submissionId: subId } })).toBe(2);
    // WRONG_STATUS envelope on a COMPLETED submission
    await db.submission.update({ where: { id: subId }, data: { status: "COMPLETED" } });
    res = await req(subId, "/sources", "POST", { namaPerseroan: "PT TERLAMBAT" });
    expect(res.status).toBe(409);
    expect((await json(res)).code).toBe("WRONG_STATUS");
  }, 30000);

  it("source select on the LAST unresolved row un-blocks AWAITING_COMPANY_SELECTION (continueFlow fired)", async () => {
    const { subId } = await seed({ status: "AWAITING_COMPANY_SELECTION", sources: 0 });
    await db.peleburanSourcePt.create({
      data: { submissionId: subId, ordinal: 0, namaPerseroan: "PT SUMBER SATU", resolutionStatus: "MANUAL" },
    });
    await db.peleburanSourcePt.create({
      data: { submissionId: subId, ordinal: 1, namaPerseroan: "PT AMBIGU", resolutionStatus: "AWAITING_SELECTION" },
    });
    const res = await req(subId, "/sources/1/select", "PUT", { manual: true });
    expect(res.status).toBe(200);
    // continueFlow is fire-and-forget; poll briefly for the engine to finish.
    let status = "";
    for (let i = 0; i < 40; i++) {
      status = (await db.submission.findUnique({ where: { id: subId } }))!.status;
      if (status !== "AWAITING_COMPANY_SELECTION" && status !== "EXTRACTING" && status !== "VALIDATING") break;
      await new Promise((r) => setTimeout(r, 250));
    }
    expect(status).toBe("READY");
  }, 30000);

  it("koran PUT lands MANUAL ExtractedFields on the deed (no clipping uploaded)", async () => {
    const { subId, aktaId } = await seed();
    const res = await req(subId, "/koran", "PUT", { namaKoran: "Harian Kompas", tanggalPengumuman: "01-02-2026" });
    expect(res.status).toBe(200);
    const f = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: aktaId, fieldKey: "tanggal_pengumuman" } } });
    expect(f?.value).toBe("01-02-2026");
    expect(f?.status).toBe("MANUAL");
  }, 30000);

  it("section approvals persist explicit rows; unknown keys rejected", async () => {
    const { subId } = await seed();
    expect((await req(subId, "/sections/nope/approve", "POST")).status).toBe(400);
    expect((await req(subId, "/sections/sumber_peleburan/approve", "POST")).status).toBe(200);
    const row = await db.perubahanSectionApproval.findUnique({
      where: { submissionId_sectionKey: { submissionId: subId, sectionKey: "sumber_peleburan" } },
    });
    expect(row?.approved).toBe(true);
    await req(subId, "/sections/sumber_peleburan/unapprove", "POST");
    expect((await db.perubahanSectionApproval.findUnique({
      where: { submissionId_sectionKey: { submissionId: subId, sectionKey: "sumber_peleburan" } },
    }))?.approved).toBe(false);
  });

  it("submit gate: sections → FAIL gate (override) → COMPLETED + VerifikasiPerubahan MENUNGGU_VERIFIKATOR", async () => {
    const { subId } = await seed();
    // 1. sections missing
    let res = await req(subId, "/submit", "POST");
    expect(res.status).toBe(400);
    const missing = (await json(res)) as { code: string; missingSections: string[] };
    expect(missing.code).toBe("SECTIONS_UNAPPROVED");
    expect(missing.missingSections).toContain("rups_pengumuman");
    for (const key of ["data_perseroan", "akta_notaris", "modal_saham", "pengurus", "kbli", "sumber_peleburan", "rups_pengumuman"]) {
      await req(subId, `/sections/${key}/approve`, "POST");
    }
    // 2. canonical FAIL-gate envelope; override unblocks (block-with-override)
    await db.validationResult.create({
      data: { submissionId: subId, ruleCode: "PELEBURAN_KORAN_WINDOW", ruleLabel: "x", status: "FAIL", message: "m" },
    });
    res = await req(subId, "/submit", "POST");
    expect(res.status).toBe(422);
    const blocked = (await json(res)) as { code: string; failing: string[] };
    expect(blocked.code).toBe("VALIDATION_FAILED");
    expect(blocked.failing).toContain("PELEBURAN_KORAN_WINDOW");
    await app.request(`/api/submissions/${subId}/override-validation`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ruleCode: "PELEBURAN_KORAN_WINDOW", reason: "koran date verified manually" }),
    });
    // 3. success — verifikator staging, NOT terminal
    res = await req(subId, "/submit", "POST");
    expect(res.status).toBe(200);
    const sub = await db.submission.findUnique({ where: { id: subId }, include: { verifikasi: true } });
    expect(sub?.status).toBe("COMPLETED");
    expect(sub?.verifikasi?.status).toBe("MENUNGGU_VERIFIKATOR");
    // 4. second submit → 409 (status no longer READY)
    expect((await req(subId, "/submit", "POST")).status).toBe(409);
  }, 30000);
});
  • [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/peleburan-routes.test.ts. Expected: 404s everywhere — route not mounted.
  • [ ] Minimal implementation. Create backend/src/routes/peleburan.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 { peleburanPtFlow } from "../flow-engine/flows/peleburan-pt";
import { resolvePeleburanSources, hasUnresolvedSources, selectSourceRow } from "../services/peleburan-sources";
import { recordAuditEvent } from "../services/audit-event";
import { getRequestContext } from "../middleware/request-context";

/**
 * Route family 'peleburan' — Peleburan PT (Konsolidasi). VERIFIKATOR-STAGED:
 * submit → COMPLETED + VerifikasiPerubahan (MENUNGGU_VERIFIKATOR) in ONE
 * transaction (akuisisi-arm mirror, perubahan.ts:1314-1350). Field edit/confirm/
 * override ride the generic /api/submissions endpoints (queueReMatchAndValidate
 * reaches this flow via the FLOW_REGISTRY). Company selection is PER-ROW here —
 * the perubahan select-company route is single-company on Submission columns
 * and is NOT used by this flow.
 */
const peleburan = new Hono();

const APPROVABLE_KEYS = new Set(peleburanPtFlow.sections.filter((s) => s.approvable).map((s) => s.key));

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 !== "PELEBURAN_PT") return { error: "wrong_type" as const, submission: null };
  return { error: null, submission };
}

/**
 * Status guard (M11 parity — canonical WRONG_STATUS envelope). Source/koran
 * mutations are legal in READY *and* AWAITING_COMPANY_SELECTION (resolving rows
 * is what un-blocks that status); approvals/submit are READY-only.
 */
function guardStatus(submission: { status: string }, allowed: readonly string[]) {
  return allowed.includes(submission.status) ? { blocked: false as const } : { blocked: true as const };
}
const SOURCE_MUTABLE = ["READY", "AWAITING_COMPANY_SELECTION"] as const;
const READY_ONLY = ["READY"] as const;

/** Post-mutation continuation: un-block the paused engine when the LAST row
 *  resolves; otherwise re-validate (serialized — reference_rematch_dispatch). */
async function afterSourcesChange(submissionId: string): Promise<void> {
  const sub = await db.submission.findUnique({ where: { id: submissionId }, select: { status: true } });
  if (!sub) return;
  if (sub.status === "AWAITING_COMPANY_SELECTION") {
    if (!(await hasUnresolvedSources(submissionId))) {
      const [{ continueFlow }, { flowFor }] = await Promise.all([
        import("../flow-engine/processor"),
        import("../flow-engine/registry"),
      ]);
      const entry = flowFor("PELEBURAN_PT");
      if (entry.engine === "v2-generic") {
        void continueFlow(submissionId, entry).catch((err) =>
          console.error(`Continue peleburan flow after source selection failed for ${submissionId}:`, err));
      }
    }
    return;
  }
  await queueReMatchAndValidate(submissionId).catch(console.error);
}

function sourceRowJson(r: {
  ordinal: number; namaPerseroan: string; nomorSk: string | null; tanggalRups: string | null;
  resolutionStatus: string; selectedNomorTransaksi: string | null; selectedTahun: number | null;
  companyLookupResult: unknown; oldData: unknown; blokirState: unknown;
}) {
  const lookup = r.companyLookupResult as { matches?: unknown[] } | null;
  const old = r.oldData as { perseroan?: { nama?: string | null; status_transaksi?: number | null; status_perseroan?: string | null } } | null;
  const blokir = r.blokirState as { blocked?: boolean; reason?: string | null } | null;
  return {
    ordinal: r.ordinal, namaPerseroan: r.namaPerseroan, nomorSk: r.nomorSk, tanggalRups: r.tanggalRups,
    resolutionStatus: r.resolutionStatus,
    selectedNomorTransaksi: r.selectedNomorTransaksi, selectedTahun: r.selectedTahun,
    matches: lookup?.matches ?? [],
    registryNama: old?.perseroan?.nama ?? null,
    statusTransaksi: old?.perseroan?.status_transaksi ?? null,
    statusPerseroan: old?.perseroan?.status_perseroan ?? null,
    blocked: typeof blokir?.blocked === "boolean" ? blokir.blocked : null,
    blokirReason: blokir?.reason ?? null,
  };
}

// GET /submissions/:submissionId/review-data — the composite review payload.
peleburan.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, aktaModal: true, aktaDireksi: true, aktaKomisaris: true,
              aktaPemegangSaham: true, aktaPenghadap: true, kbliEntries: true,
            },
          },
        },
        orderBy: { processingOrder: "asc" },
      },
      validationResults: { orderBy: { createdAt: "asc" } },
      perubahanSectionApprovals: true,
      peleburanSources: { orderBy: { ordinal: "asc" } },
    },
  });
  if (!submission) return c.json({ error: "Submission not found" }, 404);
  if (submission.type !== "PELEBURAN_PT") return c.json({ error: "Not a Peleburan PT submission" }, 400);

  const aktaSubDoc = submission.documents.find((d) => d.classifiedType === "AKTA_PELEBURAN");
  const clipping = submission.documents.find((d) => d.document.documentType === "BUKTI_PENGUMUMAN");
  const koranFieldsOf = (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 = koranFieldsOf(clipping);
  const inline = koranFieldsOf(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 },
    akta: aktaSubDoc
      ? {
          documentId: aktaSubDoc.documentId,
          fields: aktaSubDoc.document.fields.map((f) => ({ fieldKey: f.fieldKey, value: f.value, confidence: f.confidence, status: f.status })),
          modal: aktaSubDoc.document.aktaModal
            ? {
                modalDasar: aktaSubDoc.document.aktaModal.modalDasar, modalDitempatkan: aktaSubDoc.document.aktaModal.modalDitempatkan,
                modalDisetor: aktaSubDoc.document.aktaModal.modalDisetor, jumlahSaham: aktaSubDoc.document.aktaModal.jumlahSaham,
              }
            : null,
          direksi: aktaSubDoc.document.aktaDireksi.map((d) => ({ nama: d.namaLengkap, jabatan: d.jabatan, isAsing: d.isAsing })),
          komisaris: aktaSubDoc.document.aktaKomisaris.map((k) => ({ nama: k.namaLengkap, jabatan: k.jabatan, isAsing: k.isAsing })),
          pemegangSaham: aktaSubDoc.document.aktaPemegangSaham.map((p) => ({
            nama: p.namaAtauBadanHukum, jumlahSaham: p.jumlahSaham, persentase: p.persentase, isAsing: p.isAsing,
          })),
          kbli: aktaSubDoc.document.kbliEntries.map((k) => ({ kode: k.kbliCode, judul: k.description })),
        }
      : null,
    sources: submission.peleburanSources.map(sourceRowJson),
    koran,
    documents: submission.documents.map((d) => ({
      documentId: d.documentId, classifiedType: d.classifiedType,
      extractionStatus: d.extractionStatus, originalName: d.document.originalName,
    })),
    validationResults: submission.validationResults,
    sections: peleburanPtFlow.sections,
    sectionApprovals: Object.fromEntries(submission.perubahanSectionApprovals.map((a) => [a.sectionKey, a.approved])),
  });
});

// GET /submissions/:submissionId/sources — the panel's row list.
peleburan.get("/submissions/:submissionId/sources", async (c) => {
  const submissionId = c.req.param("submissionId");
  const { error } = 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 Peleburan PT submission" }, 400);
  const rows = await db.peleburanSourcePt.findMany({ where: { submissionId }, orderBy: { ordinal: "asc" } });
  return c.json({ sources: rows.map(sourceRowJson) });
});

// POST /submissions/:submissionId/sources — manual add (extraction misses).
peleburan.post("/submissions/:submissionId/sources", 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 Peleburan PT submission" }, 400);
  if (guardStatus(submission!, SOURCE_MUTABLE).blocked)
    return c.json({ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" }, 409);
  const body = await c.req.json<{ namaPerseroan?: string; nomorSk?: string | null; tanggalRups?: string | null }>().catch(() => ({}));
  if (!body.namaPerseroan?.trim()) return c.json({ error: "namaPerseroan wajib diisi" }, 422);
  const max = await db.peleburanSourcePt.aggregate({ where: { submissionId }, _max: { ordinal: true } });
  const row = await db.peleburanSourcePt.create({
    data: {
      submissionId, ordinal: (max._max.ordinal ?? -1) + 1,
      namaPerseroan: body.namaPerseroan.trim(), nomorSk: body.nomorSk ?? null, tanggalRups: body.tanggalRups ?? null,
    },
  });
  await resolvePeleburanSources(submissionId); // resolve the new PENDING row only
  await afterSourcesChange(submissionId);
  const fresh = await db.peleburanSourcePt.findUnique({ where: { id: row.id } });
  return c.json(sourceRowJson(fresh!));
});

// PUT /submissions/:submissionId/sources/:ordinal — edit the extracted ref
// (nama/SK/tanggal RUPS). Ref edits reset the row to PENDING and re-resolve it.
peleburan.put("/submissions/:submissionId/sources/:ordinal", async (c) => {
  const submissionId = c.req.param("submissionId");
  const ordinal = parseInt(c.req.param("ordinal"), 10);
  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 Peleburan PT submission" }, 400);
  if (guardStatus(submission!, SOURCE_MUTABLE).blocked)
    return c.json({ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" }, 409);
  const existing = await db.peleburanSourcePt.findUnique({ where: { submissionId_ordinal: { submissionId, ordinal } } });
  if (!existing) return c.json({ error: "Source row not found" }, 404);
  const body = await c.req.json<{ namaPerseroan?: string; nomorSk?: string | null; tanggalRups?: string | null }>().catch(() => ({}));
  const refChanged = "namaPerseroan" in body || "nomorSk" in body;
  await db.peleburanSourcePt.update({
    where: { id: existing.id },
    data: {
      ...("namaPerseroan" in body && body.namaPerseroan?.trim() ? { namaPerseroan: body.namaPerseroan.trim() } : {}),
      ...("nomorSk" in body ? { nomorSk: body.nomorSk ?? null } : {}),
      ...("tanggalRups" in body ? { tanggalRups: body.tanggalRups ?? null } : {}),
      ...(refChanged
        ? { resolutionStatus: "PENDING", selectedNomorTransaksi: null, selectedTahun: null, oldData: Prisma.JsonNull, blokirState: Prisma.JsonNull, companyLookupResult: Prisma.JsonNull }
        : {}),
    },
  });
  if (refChanged) await resolvePeleburanSources(submissionId);
  await afterSourcesChange(submissionId);
  const fresh = await db.peleburanSourcePt.findUnique({ where: { id: existing.id } });
  return c.json(sourceRowJson(fresh!));
});

// PUT /submissions/:submissionId/sources/:ordinal/select — candidate or manual.
peleburan.put("/submissions/:submissionId/sources/:ordinal/select", async (c) => {
  const submissionId = c.req.param("submissionId");
  const ordinal = parseInt(c.req.param("ordinal"), 10);
  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 Peleburan PT submission" }, 400);
  if (guardStatus(submission!, SOURCE_MUTABLE).blocked)
    return c.json({ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" }, 409);
  const existing = await db.peleburanSourcePt.findUnique({ where: { submissionId_ordinal: { submissionId, ordinal } } });
  if (!existing) return c.json({ error: "Source row not found" }, 404);
  const body = await c.req.json<{ nomorTransaksi?: string; tahun?: number; manual?: boolean }>().catch(() => ({}));
  if (body.manual === true) {
    await selectSourceRow(submissionId, ordinal, { manual: true });
  } else {
    if (!body.nomorTransaksi || !Number.isInteger(body.tahun))
      return c.json({ error: "nomorTransaksi dan tahun wajib untuk pemilihan kandidat" }, 422);
    await selectSourceRow(submissionId, ordinal, { nomorTransaksi: body.nomorTransaksi, tahun: body.tahun! });
  }
  await afterSourcesChange(submissionId);
  const fresh = await db.peleburanSourcePt.findUnique({ where: { id: existing.id } });
  return c.json(sourceRowJson(fresh!));
});

// DELETE /submissions/:submissionId/sources/:ordinal — remove a wrong row.
peleburan.delete("/submissions/:submissionId/sources/:ordinal", async (c) => {
  const submissionId = c.req.param("submissionId");
  const ordinal = parseInt(c.req.param("ordinal"), 10);
  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 Peleburan PT submission" }, 400);
  if (guardStatus(submission!, SOURCE_MUTABLE).blocked)
    return c.json({ error: "submission cannot be edited in its current status", code: "WRONG_STATUS" }, 409);
  const existing = await db.peleburanSourcePt.findUnique({ where: { submissionId_ordinal: { submissionId, ordinal } } });
  if (!existing) return c.json({ error: "Source row not found" }, 404);
  await db.peleburanSourcePt.delete({ where: { id: existing.id } });
  await afterSourcesChange(submissionId);
  return c.json({ deleted: true });
});

// PUT /submissions/:submissionId/koran — operator-editable koran fields (the
// PELEBURAN_KORAN_WINDOW FAIL is correctable by design). Lands on the clipping
// doc when present, else the deed — the SAME canonical ExtractedField keys the
// context reads.
peleburan.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 Peleburan PT submission" }, 400);
  if (guardStatus(submission!, SOURCE_MUTABLE).blocked)
    return c.json({ error: "submission cannot be edited in its current status", code: "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_PELEBURAN" }, select: { documentId: true } });
  const documentId = clipping?.documentId ?? akta?.documentId;
  if (!documentId) return c.json({ error: "Tidak ada dokumen pembawa data koran" }, 404);
  const body = await c.req.json<{ namaKoran?: string | null; tanggalPengumuman?: string | null }>().catch(() => ({}));
  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) {
  peleburan.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 Peleburan PT submission" }, 400);
    if (guardStatus(submission!, READY_ONLY).blocked)
      return c.json({ error: "submission cannot be edited in its current status", code: "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 (spec §4.1).
 * Order: status → 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;
 * peleburan has no diff — aiReport stays null, the verifikator reads the
 * validation list). NO auto-confirm anywhere.
 */
peleburan.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, peleburanSources: { orderBy: { ordinal: "asc" } } },
  });
  if (!submission) return c.json({ error: "Submission not found" }, 404);
  if (submission.type !== "PELEBURAN_PT") return c.json({ error: "Not a Peleburan PT submission" }, 400);
  if (submission.status !== "READY") {
    return c.json({ error: `Cannot submit: status is ${submission.status}, expected READY` }, 409);
  }

  // (1) required section approvals — explicit persisted rows only.
  const requiredKeys = peleburanPtFlow.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, peleburanPtFlow);
  if (blocked) {
    return c.json({ error: "Validasi FAIL harus diselesaikan atau di-override sebelum submit", code: "VALIDATION_FAILED", failing }, 422);
  }

  // (3) verifikator staging — akuisisi arm mirror (perubahan.ts:1314-1350).
  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: "PELEBURAN_PT",
        sectionApprovals: Object.fromEntries(submission.perubahanSectionApprovals.map((s) => [s.sectionKey, s.approved])),
        sources: submission.peleburanSources.map((s) => ({
          ordinal: s.ordinal, namaPerseroan: s.namaPerseroan, resolutionStatus: s.resolutionStatus,
          selectedNomorTransaksi: s.selectedNomorTransaksi,
        })),
      },
    }, getRequestContext(c));
  });

  return c.json({ status: "COMPLETED" });
});

export default peleburan;
  • [ ] Edit backend/src/index.ts: add import peleburan from "./routes/peleburan"; with the other route imports (after laporanRups, line 20) and app.route("/api/peleburan", peleburan); directly after the app.route("/api/laporan-rups", laporanRups); mount.
  • [ ] Run GREEN: cd backend && bun test src/routes/__tests__/peleburan-routes.test.ts. Expected: all pass (the un-block test exercises continueFlow end-to-end with an empty rule context — SABH-adjacent calls no-op as in pt-akta-context.test.ts's environment). (NOTE on recordAuditEvent: its first identity arg accepts { submissionId } — the exact call shape of the akuisisi arm at perubahan.ts:1338; keep that shape.)
  • [ ] Typecheck: cd backend && bunx tsc --noEmit.
  • [ ] Commit: git add backend/src/routes/peleburan.ts backend/src/index.ts backend/src/routes/__tests__/peleburan-routes.test.ts && git commit -m "feat(peleburan): route family — review-data, sources CRUD/select, koran, approvals, submit to verifikator"

Task 10: Verifikator surface — queue admits PELEBURAN_PT + compact verifikator review page

The verifikasi machinery (detail GET /verifikasi, POST /decide, audit trail, voting) is keyed off verifikasiPerubahan.findUnique({ where: { submissionId } }) and is type-agnostic — it works for peleburan rows as-is. Two gaps: (a) the dashboard QUEUE hard-filters submission: { type: "PERUBAHAN_PT" } (verifikator.ts:375-376) so a peleburan row would be invisible; (b) VerifikatorReviewPage renders the perubahan DIFF payload (usePerubahanReviewData → PERUBAHAN_FAMILY-guarded 400 for peleburan). Fix (a) surgically (admit PELEBURAN_PT, tag items with the real type — PERUBAHAN_PT rows byte-identical); fix (b) with a compact VerifikatorPeleburanReviewPage following the per-family precedent (VerifikatorPerbaikanReviewPage exists for exactly this reason), reusing useVerifikasiData/useDecide + the shared DecisionPanel ({status, isPending, onDecide} props) over the peleburan review-data payload.

Files
- backend/src/routes/verifikator.ts — Modify (queue where + tagged type)
- backend/src/routes/__tests__/verifikator-peleburan.test.ts — Create
- frontend/src/pages/VerifikatorPeleburanReviewPage.tsx — Create
- frontend/src/pages/VerifikatorDashboardPage.tsx — Modify (navigate arm)
- frontend/src/routes.tsx — Modify (verifikator route; done here so the FE half is self-contained)

Steps

  • [ ] Write failing backend test. Create backend/src/routes/__tests__/verifikator-peleburan.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";

describe("verifikator queue admits PELEBURAN_PT", () => {
  const ids: string[] = [];
  afterAll(async () => { await db.submission.deleteMany({ where: { id: { in: ids } } }); });

  it("lists a COMPLETED peleburan verifikasi row, tagged with its REAL type", async () => {
    const sub = await db.submission.create({ data: { type: "PELEBURAN_PT", status: "COMPLETED" } });
    ids.push(sub.id);
    await db.verifikasiPerubahan.create({ data: { submissionId: sub.id, status: "MENUNGGU_VERIFIKATOR" } });

    const res = await app.request("/api/verifikator/submissions?status=all&limit=50");
    expect(res.status).toBe(200);
    const body = (await res.json()) as { items: { submissionId: string; type: string }[] };
    const item = body.items.find((i) => i.submissionId === sub.id);
    expect(item).toBeDefined();
    expect(item!.type).toBe("PELEBURAN_PT"); // NOT mis-tagged PERUBAHAN_PT

    // detail + decide machinery is type-agnostic (keyed on submissionId)
    const detail = await app.request(`/api/verifikator/submissions/${sub.id}/verifikasi`);
    expect(detail.status).toBe(200);
  }, 30000);
});
  • [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/verifikator-peleburan.test.ts. Expected: the item is absent from the queue (where-filter excludes the type).
  • [ ] Minimal implementation A — backend/src/routes/verifikator.ts:
    1. Replace the queue filter (lines 375-377):
  const where: any = {
    submission: { type: "PERUBAHAN_PT", status: "COMPLETED" },
  };

with:

  // Perubahan-family queue + the v2 verifikator-staged flows. PERUBAHAN_PT rows
  // are byte-identical; PELEBURAN_PT rows carry no diff/advisory (aiReport null)
  // and surface their validation list on the peleburan verifikator page.
  const where: any = {
    submission: { type: { in: ["PERUBAHAN_PT", "PELEBURAN_PT"] }, status: "COMPLETED" },
  };
  1. Tag items with the REAL type. The item build maps all rows (each includes its submission); the final tagging line (~589) is const tagged = items.map((it) => ({ ...it, type: "PERUBAHAN_PT" as const }));. Locate where items rows are constructed from the all result and ensure each item carries its submissionId; then replace the tagging line with:
  // Tag items with their REAL SubmissionType so the frontend routes them correctly.
  const peleburanIds = new Set(all.filter((v) => v.submission.type === "PELEBURAN_PT").map((v) => v.submissionId));
  const tagged = items.map((it) => ({
    ...it,
    type: peleburanIds.has(it.submissionId) ? ("PELEBURAN_PT" as const) : ("PERUBAHAN_PT" as const),
  }));

(If all rows key the submission id differently — e.g. v.submission.id — use that accessor; the invariant to satisfy is the test's item.type === "PELEBURAN_PT". A peleburan row's jenisPerubahan label may render empty (its changeTypeResult has no checked items) — acceptable; do NOT special-case the label builder.)
- [ ] Run GREEN: cd backend && bun test src/routes/__tests__/verifikator-peleburan.test.ts. Then regression: bun test src/routes/__tests__ (existing verifikator/perubahan suites — PERUBAHAN_PT queue rows byte-identical).
- [ ] Minimal implementation B — Create frontend/src/pages/VerifikatorPeleburanReviewPage.tsx:

import { useParams, useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { Loader2, CheckCircle2, AlertTriangle, XCircle, MinusCircle, Building2, ShieldAlert } 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 SourceRow {
  ordinal: number; namaPerseroan: string; nomorSk: string | null; tanggalRups: string | null;
  resolutionStatus: string; selectedNomorTransaksi: string | null;
  registryNama: string | null; statusTransaksi: number | null; statusPerseroan: string | null;
  blocked: boolean | null; blokirReason: string | null;
}
interface ValidationRow { id: string; ruleCode: string; ruleLabel: string; status: string; message: string; overridden?: boolean; overrideReason?: string | null }
interface ReviewData {
  submission: { id: string; status: string };
  akta: { fields: { fieldKey: string; value: string | null }[]; pemegangSaham: { nama: string | null; persentase: number | null }[] } | null;
  sources: SourceRow[];
  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 PELEBURAN_PT (per-family precedent:
 *  VerifikatorPerbaikanReviewPage). No diff/AI-advisory — the flow has no
 *  old-vs-new diff; the validation list + source snapshots ARE the review. */
export function VerifikatorPeleburanReviewPage() {
  const { submissionId } = useParams<{ submissionId: string }>();
  const navigate = useNavigate();
  const { data: verif } = useVerifikasiData(submissionId);
  const decide = useDecide(submissionId);
  const { data, isLoading } = useQuery<ReviewData>({
    queryKey: ["peleburan-review", submissionId],
    queryFn: () => apiFetch<ReviewData>(`/api/peleburan/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 ?? "—";

  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">Peleburan PT (Konsolidasi)  Tinjauan Verifikator</h1>
          <p className="text-sm text-muted-foreground">PT baru: {f("nama_perseroan")} · Akta No. {f("nomor_akta")} · {f("nama_notaris")}</p>
        </div>
        <Button variant="outline" size="sm" onClick={() => navigate("/verifikator")}>Kembali</Button>
      </div>

      {/* PT Sumber Peleburan */}
      <section className="rounded-lg border bg-card p-4 space-y-2">
        <h2 className="text-sm font-semibold">PT Sumber Peleburan ({data.sources.length})</h2>
        {data.sources.map((s) => (
          <div key={s.ordinal} className="flex items-start gap-2 rounded border p-2 text-sm">
            <Building2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
            <div className="min-w-0 flex-1">
              <div className="font-medium">{s.namaPerseroan}</div>
              <div className="text-xs text-muted-foreground">
                SK: {s.nomorSk ?? "—"} · RUPS: {s.tanggalRups ?? "—"} · Registri: {s.registryNama ?? "—"}
                {s.statusTransaksi != null && ` (status ${s.statusTransaksi})`}
              </div>
            </div>
            <div className="flex shrink-0 gap-1">
              <Badge variant="outline" className="text-[10px]">{s.resolutionStatus}</Badge>
              {s.blocked === true && (
                <Badge className="bg-red-100 text-red-700 text-[10px]"><ShieldAlert className="mr-0.5 h-3 w-3" />BLOKIR{s.blokirReason ? `: ${s.blokirReason}` : ""}</Badge>
              )}
            </div>
          </div>
        ))}
      </section>

      {/* RUPS & Pengumuman */}
      <section className="rounded-lg border bg-card p-4 space-y-1 text-sm">
        <h2 className="text-sm font-semibold">Pengumuman Koran (Ps.127(2))</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 auditEvents={verif.auditEvents as never} />
    </div>
  );
}

Implementation notes for the worker: (a) verifikatorId: "verifikator-dev" mirrors VerifikatorReviewPage.tsx:305; (b) verify AuditTrailPanel's prop name by opening frontend/src/components/verifikator/review/AuditTrailPanel.tsx — if it expects a different prop shape (e.g. events), adapt the ONE prop, not the panel; if it requires verifikasi-specific data unavailable here, drop the panel usage entirely (the decision panel is the load-bearing piece); (c) useVerifikasiData's payload is { verifikasi, decisions, votes, auditEvents } (use-verifikator.ts:108).
- [ ] Minimal implementation C — frontend/src/pages/VerifikatorDashboardPage.tsx: the card onClick navigate (~line 264) becomes a three-way:

                        item.type === "PERBAIKAN_DATA_PT"
                          ? `/verifikator/perbaikan/${item.submissionId}`
                          : item.type === "PELEBURAN_PT"
                            ? `/verifikator/peleburan/${item.submissionId}`
                            : `/verifikator/review/${item.submissionId}`,

and widen the dashboard item type union (where the list item type is declared in this file or use-verifikator.ts — grep "PERBAIKAN_DATA_PT" in both) with "PELEBURAN_PT".
- [ ] Minimal implementation D — frontend/src/routes.tsx: import VerifikatorPeleburanReviewPage and add inside the verifikator RoleGuard children, after the verifikator/perbaikan/:submissionId row (~line 155):

          { path: "verifikator/peleburan/:submissionId", element: <VerifikatorPeleburanReviewPage /> },
  • [ ] Verify: cd frontend && bunx tsc --noEmit && bunx vitest run. Expected: clean (no new FE test in this task — the page is exercised by typecheck; the queue behaviour is covered by the backend test).
  • [ ] Commit: git add backend/src/routes/verifikator.ts backend/src/routes/__tests__/verifikator-peleburan.test.ts frontend/src/pages/VerifikatorPeleburanReviewPage.tsx frontend/src/pages/VerifikatorDashboardPage.tsx frontend/src/routes.tsx && git commit -m "feat(peleburan): verifikator surface — queue admits PELEBURAN_PT + compact review page"

Task 11: Frontend klasifikasi surfacing (detect + labels + suggestion chip + create-route)

The mechanical klasifikasi touchpoints: DetectedFlow += "peleburan", display names (AKTA_PELEBURAN, BUKTI_PENGUMUMAN), the detection ladders with peleburan ABOVE akuisisi, the create-route arm, and the amber suggestion chip that surfaces the backend's suggestion field (applied ONLY through the existing onReclassify re-map — reason dialog included, never auto-adopted).

Files
- frontend/src/components/klasifikasi/detect-banner.tsx — Modify (DetectedFlow L5, FLOW_LABELS L7-13, FLOW_DESCRIPTIONS L15-23)
- frontend/src/lib/classifier-labels.ts — Modify
- frontend/src/store/klasifikasi.ts — Modify (ClassificationResult)
- frontend/src/pages/KlasifikasiPage.tsx — Modify (label type L37, detect chains, ClassifyRes + result mapping, create-route)
- frontend/src/components/klasifikasi/classification-list-view.tsx — Modify (suggestion chip)
- frontend/src/components/klasifikasi/__tests__/detect-banner-peleburan.test.tsx — Create

Steps

  • [ ] Write failing test. Create frontend/src/components/klasifikasi/__tests__/detect-banner-peleburan.test.tsx:
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { DetectBanner } from "../detect-banner";

describe("DetectBanner — peleburan flow", () => {
  it("renders the Peleburan PT label + description", () => {
    render(<DetectBanner state="detected" flow="peleburan" aktaConfidence={0.93} />);
    expect(screen.getByText("Peleburan PT (Konsolidasi)")).toBeInTheDocument();
    expect(screen.getByText(/Akta Peleburan terdeteksi/)).toBeInTheDocument();
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/components/klasifikasi/__tests__/detect-banner-peleburan.test.tsx. Expected: type error — "peleburan" not assignable to DetectedFlow.
  • [ ] Minimal implementation A — frontend/src/components/klasifikasi/detect-banner.tsx:
export type DetectedFlow = "pendirian" | "perubahan" | "perbaikan" | "akuisisi" | "laporan-rups" | "peleburan";

Add to FLOW_LABELS: peleburan: "Peleburan PT (Konsolidasi)", and to FLOW_DESCRIPTIONS: peleburan: "Akta Peleburan terdeteksi. Alur Peleburan PT (konsolidasi — PT baru dari ≥2 PT sumber) akan dijalankan.",
- [ ] Minimal implementation B — frontend/src/lib/classifier-labels.ts, add to CLASSIFIER_LABEL_MAP:

  AKTA_PELEBURAN: "Akta Peleburan",
  BUKTI_PENGUMUMAN: "Bukti Pengumuman Koran",
  • [ ] Minimal implementation C — frontend/src/store/klasifikasi.ts, extend ClassificationResult (after filename: string;):
  /** Keyword pre-suggestion from the backend (never auto-adopted; applied via re-map). */
  suggestion?: { docType: string; displayName: string } | null;
  • [ ] Minimal implementation D — frontend/src/pages/KlasifikasiPage.tsx (exact diffs):
    1. L37: type KlasifikasiLabelType = "pendirian" | "perubahan" | "perbaikan" | "pendirian_pp" | "akuisisi" | "laporan_rups" | "peleburan";
    2. After the hasAktaRupsTahunan block, add:
  const hasAktaPeleburan = results.some(
    (r) => (r.userOverride ?? r.classification) === "AKTA_PELEBURAN"
  );
  1. computedLabelType chain — peleburan rung ABOVE akuisisi (canonical ladder mirror):
  const computedLabelType: KlasifikasiLabelType = hasSuratPerbaikan
    ? "perbaikan"
    : hasAktaPeleburan
      ? "peleburan"
      : hasAktaAkuisisi
        ? "akuisisi"
        : hasAktaPerubahan
          ? "perubahan"
          : hasAktaRupsTahunan
            ? "laporan_rups"
            : "pendirian";
  1. detectedFlow chain — insert : hasAktaPeleburan ? "peleburan" directly after the hasSuratPerbaikan ? "perbaikan" arm (ABOVE the hasAktaAkuisisi arm).
  2. ClassifyRes type (L124-133): add suggestion?: { docType: string; displayName: string } | null; and thread it into setResults (add suggestion: classifyRes.suggestion ?? null, in the result object literal).
  3. createSubmission.onSuccess: add BEFORE the AKUISISI_PT branch:
                          } else if (data.type === "PELEBURAN_PT") {
                            navigate(`/peleburan/${data.submissionId}/extraction`);
  • [ ] Minimal implementation E — frontend/src/components/klasifikasi/classification-list-view.tsx, inside renderRow, insert directly AFTER the confidence <span> block (~line 221):
            {/* Keyword pre-suggestion (spec §4.3.1) — NEVER auto-accepted; the
                Terapkan button routes through the existing re-map + reason dialog. */}
            {r.suggestion?.docType && !r.userOverride && r.suggestion.docType !== effectiveClass && (
              <span className="inline-flex items-center gap-1 rounded-full bg-amber-50 px-2 py-0.5 text-[10px] font-medium text-amber-700">
                Saran: {r.suggestion.displayName}
                <button
                  type="button"
                  className="cursor-pointer underline"
                  onClick={() => onReclassify(r.fileId, r.suggestion!.docType)}
                >
                  Terapkan
                </button>
              </span>
            )}
  • [ ] Run GREEN: cd frontend && bunx vitest run src/components/klasifikasi/__tests__/detect-banner-peleburan.test.tsx then the neighbouring suites: bunx vitest run src/components/klasifikasi (detect-banner + classification-list-view regressions). Typecheck: bunx tsc --noEmit.
  • [ ] Commit: git add frontend/src/components/klasifikasi/detect-banner.tsx frontend/src/lib/classifier-labels.ts frontend/src/store/klasifikasi.ts frontend/src/pages/KlasifikasiPage.tsx frontend/src/components/klasifikasi/classification-list-view.tsx frontend/src/components/klasifikasi/__tests__/detect-banner-peleburan.test.tsx && git commit -m "feat(peleburan): frontend klasifikasi surfacing — detect above akuisisi, labels, suggestion chip, create-route"

Task 12: Frontend flow config — steps (verifikator stage) + destination + gate helpers

PELEBURAN_PT is a 5-step flow exactly like AKUISISI (Unggah → Ekstraksi → Tinjauan → Menunggu Verifikasi → Selesai): pre-COMPLETED stages drive from PENDIRIAN_STAGE_INDEX, post-COMPLETED from VerifikasiPerubahan via PERUBAHAN_VERIFIKASI_STEP. destinationFor treats it exactly like AKUISISI_PT (NOT terminal — never in TERMINAL_TYPES; PERBAIKAN verifikasi → back to review). Plus the pure gate helpers the review page consumes.

Files
- frontend/src/lib/flow-steps.ts — Modify
- frontend/src/lib/destination-for.ts — Modify
- frontend/src/lib/peleburan-review.ts — Create
- frontend/src/lib/peleburan-flow.test.ts, frontend/src/lib/peleburan-review.test.ts — Create

Steps

  • [ ] Write failing tests. Create frontend/src/lib/peleburan-flow.test.ts:
import { describe, it, expect } from "vitest";
import { computeFlowStep } from "./flow-steps";
import { destinationFor } from "./destination-for";

describe("computeFlowStep — PELEBURAN_PT (verifikator-staged, akuisisi twin)", () => {
  it("has 5 steps INCLUDING 'Menunggu Verifikasi'", () => {
    const r = computeFlowStep({ type: "PELEBURAN_PT", status: "READY" }, null);
    expect(r.steps).toHaveLength(5);
    expect(r.steps.map((s) => s.label)).toContain("Menunggu Verifikasi");
    expect(r.currentStep).toBe(2); // READY = Tinjauan
  });
  it("AWAITING_COMPANY_SELECTION sits on Ekstraksi", () => {
    expect(computeFlowStep({ type: "PELEBURAN_PT", status: "AWAITING_COMPANY_SELECTION" }, null).currentStep).toBe(1);
  });
  it("post-COMPLETED stages drive from verifikasi (SELESAI lights all green; DITOLAK errors)", () => {
    expect(computeFlowStep({ type: "PELEBURAN_PT", status: "COMPLETED" }, { status: "MENUNGGU_VERIFIKATOR" }).currentStep).toBe(3);
    const done = computeFlowStep({ type: "PELEBURAN_PT", status: "COMPLETED" }, { status: "SELESAI" });
    expect(done.currentStep).toBe(5);
    expect(done.steps.every((s) => s.completed)).toBe(true);
    const rejected = computeFlowStep({ type: "PELEBURAN_PT", status: "COMPLETED" }, { status: "DITOLAK" });
    expect(rejected.variant).toBe("error");
  });
});

describe("destinationFor — PELEBURAN_PT (NOT terminal)", () => {
  it("pre-COMPLETED → review; COMPLETED → status; PERBAIKAN → back to review", () => {
    expect(destinationFor({ id: "x", type: "PELEBURAN_PT", status: "READY" }, null)).toBe("/peleburan/x/review");
    expect(destinationFor({ id: "x", type: "PELEBURAN_PT", status: "COMPLETED" }, { status: "MENUNGGU_VERIFIKATOR" })).toBe("/peleburan/x/status");
    expect(destinationFor({ id: "x", type: "PELEBURAN_PT", status: "COMPLETED" }, { status: "PERBAIKAN" })).toBe("/peleburan/x/review");
  });
});

Create frontend/src/lib/peleburan-review.test.ts:

import { describe, it, expect } from "vitest";
import { PELEBURAN_REQUIRED_SECTIONS, missingSections, unresolvedSourceOrdinals } from "./peleburan-review";

describe("peleburan review gate helpers", () => {
  it("required sections = the 7 approvable sections (validasi excluded)", () => {
    expect([...PELEBURAN_REQUIRED_SECTIONS]).toEqual([
      "data_perseroan", "akta_notaris", "modal_saham", "pengurus", "kbli", "sumber_peleburan", "rups_pengumuman",
    ]);
  });
  it("missingSections reads EXPLICIT approvals only", () => {
    expect(missingSections({ data_perseroan: true, kbli: false })).toEqual([
      "akta_notaris", "modal_saham", "pengurus", "kbli", "sumber_peleburan", "rups_pengumuman",
    ]);
    expect(missingSections(Object.fromEntries(PELEBURAN_REQUIRED_SECTIONS.map((k) => [k, true])))).toEqual([]);
  });
  it("unresolvedSourceOrdinals flags PENDING/AWAITING_SELECTION rows", () => {
    expect(unresolvedSourceOrdinals([
      { ordinal: 0, resolutionStatus: "AUTO_SELECTED" },
      { ordinal: 1, resolutionStatus: "AWAITING_SELECTION" },
      { ordinal: 2, resolutionStatus: "MANUAL" },
      { ordinal: 3, resolutionStatus: "PENDING" },
    ])).toEqual([1, 3]);
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/lib/peleburan-flow.test.ts src/lib/peleburan-review.test.ts. Expected: type errors + module not found.
  • [ ] Minimal implementation A — frontend/src/lib/flow-steps.ts:
    1. After LAPORAN_RUPS_LABELS (L46-51), add:
// Verifikator-staged (akuisisi twin): submit creates a VerifikasiPerubahan row;
// post-COMPLETED stages drive from its status.
const PELEBURAN_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: "Permohonan disetujui" },
] as const;
  1. SubmissionLite.type union (L66): append | "PELEBURAN_PT".
  2. In computeFlowStep: add const isPeleburan = submission.type === "PELEBURAN_PT"; next to isAkuisisi (L160), extend the labels ternary with : isPeleburan ? PELEBURAN_LABELS (before the isLaporanRups arm), and generalize the akuisisi post-COMPLETED arm (L202-213) from if (isAkuisisi) { to if (isAkuisisi || isPeleburan) { (body unchanged — both drive from PERUBAHAN_VERIFIKASI_STEP).
    - [ ] Minimal implementation B — frontend/src/lib/destination-for.ts:
  3. SubmissionLite.type union += "PELEBURAN_PT". Do NOT touch TERMINAL_TYPES.
  4. base ternary gains an arm before the pendirian default:
        : submission.type === "PELEBURAN_PT"
          ? "peleburan"
  • [ ] Minimal implementation C — Create frontend/src/lib/peleburan-review.ts:
/** Pure gate helpers for PeleburanReviewPage — mirror of the backend submit gate
 *  (routes/peleburan.ts) so the UI pre-flights the exact conditions it will be
 *  blocked on. Approvals are EXPLICIT persisted rows (c0698d8 discipline). */

export const PELEBURAN_REQUIRED_SECTIONS = [
  "data_perseroan", "akta_notaris", "modal_saham", "pengurus", "kbli",
  "sumber_peleburan", "rups_pengumuman",
] as const;

export function missingSections(approvals: Record<string, boolean>): string[] {
  return PELEBURAN_REQUIRED_SECTIONS.filter((k) => !approvals[k]);
}

export interface SourceLite { ordinal: number; resolutionStatus: string }

export function unresolvedSourceOrdinals(sources: SourceLite[]): number[] {
  return sources
    .filter((s) => s.resolutionStatus === "PENDING" || s.resolutionStatus === "AWAITING_SELECTION")
    .map((s) => s.ordinal);
}
  • [ ] Run GREEN: cd frontend && bunx vitest run src/lib/peleburan-flow.test.ts src/lib/peleburan-review.test.ts src/lib then bunx tsc --noEmit (existing flow-steps.test.ts/destination-for.test.ts must stay green).
  • [ ] Commit: git add frontend/src/lib/flow-steps.ts frontend/src/lib/destination-for.ts frontend/src/lib/peleburan-review.ts frontend/src/lib/peleburan-flow.test.ts frontend/src/lib/peleburan-review.test.ts && git commit -m "feat(peleburan): frontend flow steps (verifikator stage) + destination + gate helpers"

Task 13: Frontend extraction page (faithful mirror) + the N-row PeleburanSourcesPanel

Two artifacts: the extraction page is a FAITHFUL MIRROR of AkuisisiExtractionPage.tsx (auto-start, polling, retry, READY handoff all inherited) with the single-company CompanySelectionModal swapped for the genuinely new PeleburanSourcesPanel — an N-row generalization of the modal (CompanySelectionModal.tsx is the visual/UX donor): one row per PeleburanSourcePt (extracted ref → candidates → select/manual), add-row/remove-row, blokir/status badges, blocking progression while any row is unresolved.

Files
- frontend/src/components/peleburan/PeleburanSourcesPanel.tsx — Create
- frontend/src/pages/PeleburanExtractionPage.tsx — Create (copy + diffs)
- frontend/src/components/peleburan/__tests__/PeleburanSourcesPanel.test.tsx — Create

Interfaces
- Consumes: GET/POST /api/peleburan/submissions/:id/sources, PUT .../sources/:ordinal/select, DELETE .../sources/:ordinal (Task 9).
- Produces: PeleburanSourcesPanel({ submissionId, open, onResolved }: { submissionId: string | undefined; open: boolean; onResolved: () => void }).

Steps

  • [ ] Write failing test. Create frontend/src/components/peleburan/__tests__/PeleburanSourcesPanel.test.tsx:
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 { PeleburanSourcesPanel } from "../PeleburanSourcesPanel";

const apiFetch = vi.fn();
vi.mock("@/lib/api-client", () => ({
  apiFetch: (url: string, opts?: RequestInit) => apiFetch(url, opts),
}));

// jsdom doesn't provide IntersectionObserver
class IOStub {
  observe() {} disconnect() {} unobserve() {} takeRecords() { return []; }
  root = null; rootMargin = ""; thresholds: number[] = [];
}
(globalThis as unknown as { IntersectionObserver: typeof IOStub }).IntersectionObserver = IOStub;

const SOURCES = {
  sources: [
    {
      ordinal: 0, namaPerseroan: "PT SUMBER SATU", nomorSk: "AHU-0001", tanggalRups: "10-03-2026",
      resolutionStatus: "AUTO_SELECTED", selectedNomorTransaksi: "TX-1", selectedTahun: 2020,
      matches: [], registryNama: "PT SUMBER SATU", statusTransaksi: 1, statusPerseroan: "AKTIF", blocked: false, blokirReason: null,
    },
    {
      ordinal: 1, namaPerseroan: "PT SUMBER DUA", nomorSk: null, tanggalRups: "11-03-2026",
      resolutionStatus: "AWAITING_SELECTION", selectedNomorTransaksi: null, selectedTahun: null,
      matches: [
        { confidence: "medium", nomor_transaksi: "TX-9", nama_perseroan: "PT SUMBER DUA ABADI", kedudukan: "BANDUNG", nomor_sk: "AHU-0002", npwp_perseroan: null, tahun_transaksi: 2019 },
      ],
      registryNama: null, statusTransaksi: null, statusPerseroan: null, blocked: null, blokirReason: null,
    },
  ],
};

function renderPanel() {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  return render(
    <QueryClientProvider client={qc}>
      <PeleburanSourcesPanel submissionId="sub-1" open onResolved={() => {}} />
    </QueryClientProvider>,
  );
}

describe("PeleburanSourcesPanel", () => {
  beforeEach(() => {
    apiFetch.mockReset();
    apiFetch.mockImplementation((url: string) =>
      String(url).endsWith("/sources") && !String(url).includes("select")
        ? Promise.resolve(SOURCES)
        : Promise.resolve({ ok: true }),
    );
  });

  it("renders one row per source with status badges and candidates for the awaiting row", async () => {
    renderPanel();
    await waitFor(() => expect(screen.getByText("PT SUMBER SATU")).toBeTruthy());
    expect(screen.getByText("PT SUMBER DUA")).toBeTruthy();
    expect(screen.getAllByText(/AUTO_SELECTED|Terpilih otomatis/).length).toBeGreaterThan(0);
    expect(screen.getByText("PT SUMBER DUA ABADI")).toBeTruthy(); // candidate visible
  });

  it("PUTs the per-row select when a candidate is chosen", async () => {
    renderPanel();
    await waitFor(() => expect(screen.getByText("PT SUMBER DUA ABADI")).toBeTruthy());
    fireEvent.click(screen.getByText("PT SUMBER DUA ABADI"));
    fireEvent.click(screen.getByRole("button", { name: /Pilih Kandidat/ }));
    await waitFor(() => {
      const call = apiFetch.mock.calls.find(([u]) => String(u).endsWith("/sources/1/select"));
      expect(call).toBeTruthy();
      expect(JSON.parse(call![1].body)).toEqual({ nomorTransaksi: "TX-9", tahun: 2019 });
    });
  });

  it("POSTs a manual add row", async () => {
    renderPanel();
    await waitFor(() => expect(screen.getByPlaceholderText("PT Contoh Sumber")).toBeTruthy());
    fireEvent.change(screen.getByPlaceholderText("PT Contoh Sumber"), { target: { value: "PT SUMBER TIGA" } });
    fireEvent.click(screen.getByRole("button", { name: /Tambah Sumber/ }));
    await waitFor(() => {
      const call = apiFetch.mock.calls.find(([u, o]) => String(u).endsWith("/sources") && (o as RequestInit)?.method === "POST");
      expect(call).toBeTruthy();
      expect(JSON.parse(call![1].body).namaPerseroan).toBe("PT SUMBER TIGA");
    });
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/components/peleburan/__tests__/PeleburanSourcesPanel.test.tsx. Expected: module not found.
  • [ ] Minimal implementation A — Create frontend/src/components/peleburan/PeleburanSourcesPanel.tsx:
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Building2, Trash2, CheckCircle2, ShieldAlert, Loader2 } from "lucide-react";
import {
  Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription,
} from "@/components/ui/dialog";
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 { cn } from "@/lib/utils";
import { apiFetch } from "@/lib/api-client";

interface CandidateMatch {
  confidence: string; nomor_transaksi: string; nama_perseroan: string;
  kedudukan: string | null; nomor_sk: string; npwp_perseroan: string | null; tahun_transaksi: number;
}
export interface PeleburanSourceRow {
  ordinal: number; namaPerseroan: string; nomorSk: string | null; tanggalRups: string | null;
  resolutionStatus: "PENDING" | "AUTO_SELECTED" | "AWAITING_SELECTION" | "MANUAL";
  selectedNomorTransaksi: string | null; selectedTahun: number | null;
  matches: CandidateMatch[];
  registryNama: string | null; statusTransaksi: number | null; statusPerseroan: string | null;
  blocked: boolean | null; blokirReason: string | null;
}

const STATUS_BADGE: Record<string, { label: string; cls: string }> = {
  AUTO_SELECTED: { label: "Terpilih otomatis", cls: "bg-emerald-100 text-emerald-700" },
  MANUAL: { label: "Manual", cls: "bg-sky-100 text-sky-700" },
  AWAITING_SELECTION: { label: "Perlu dipilih", cls: "bg-amber-100 text-amber-700" },
  PENDING: { label: "Menunggu", cls: "bg-muted text-muted-foreground" },
};

/**
 * N-row generalization of CompanySelectionModal (spec §4.7.2): one row per
 * PeleburanSourcePt — extracted ref → match candidates → select / manual —
 * plus add-row / remove-row. Blocks progression while any row is unresolved
 * (submission status AWAITING_COMPANY_SELECTION; the backend fires continueFlow
 * when the LAST row resolves).
 */
export function PeleburanSourcesPanel({
  submissionId, open, onResolved,
}: { submissionId: string | undefined; open: boolean; onResolved: () => void }) {
  const qc = useQueryClient();
  const [picked, setPicked] = useState<Record<number, string>>({}); // ordinal → nomor_transaksi
  const [addNama, setAddNama] = useState("");
  const [addSk, setAddSk] = useState("");

  const { data, isLoading } = useQuery<{ sources: PeleburanSourceRow[] }>({
    queryKey: ["peleburan-sources", submissionId],
    queryFn: () => apiFetch<{ sources: PeleburanSourceRow[] }>(`/api/peleburan/submissions/${submissionId}/sources`),
    enabled: !!submissionId && open,
    refetchInterval: open ? 5000 : false,
  });

  const invalidate = () => {
    qc.invalidateQueries({ queryKey: ["peleburan-sources", submissionId] });
    onResolved();
  };
  const selectMut = useMutation({
    mutationFn: ({ ordinal, body }: { ordinal: number; body: unknown }) =>
      apiFetch(`/api/peleburan/submissions/${submissionId}/sources/${ordinal}/select`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
      }),
    onSuccess: invalidate,
  });
  const addMut = useMutation({
    mutationFn: (body: { namaPerseroan: string; nomorSk: string | null }) =>
      apiFetch(`/api/peleburan/submissions/${submissionId}/sources`, {
        method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
      }),
    onSuccess: () => { setAddNama(""); setAddSk(""); invalidate(); },
  });
  const deleteMut = useMutation({
    mutationFn: (ordinal: number) =>
      apiFetch(`/api/peleburan/submissions/${submissionId}/sources/${ordinal}`, { method: "DELETE" }),
    onSuccess: invalidate,
  });

  const sources = data?.sources ?? [];
  const pending = selectMut.isPending || addMut.isPending || deleteMut.isPending;

  return (
    <Dialog open={open}>
      <DialogContent className="sm:max-w-2xl" showCloseButton={false}>
        <DialogHeader>
          <DialogTitle>PT Sumber Peleburan</DialogTitle>
          <DialogDescription>
            Identifikasi SETIAP PT yang meleburkan diri (2). Baris berstatus
            "Perlu dipilih" harus diselesaikan sebelum proses berlanjut.
          </DialogDescription>
        </DialogHeader>

        {isLoading && <div className="flex justify-center py-6"><Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /></div>}

        <div className="max-h-[26rem] space-y-3 overflow-y-auto pr-1">
          {sources.map((s) => {
            const badge = STATUS_BADGE[s.resolutionStatus] ?? STATUS_BADGE.PENDING!;
            const needsPick = s.resolutionStatus === "AWAITING_SELECTION";
            return (
              <div key={s.ordinal} className={cn("rounded-lg border p-3", needsPick && "border-amber-400")}>
                <div className="flex items-start gap-2">
                  <Building2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
                  <div className="min-w-0 flex-1">
                    <div className="text-sm font-medium">{s.namaPerseroan}</div>
                    <div className="text-xs text-muted-foreground">
                      SK: {s.nomorSk ?? "—"} · RUPS: {s.tanggalRups ?? "—"}
                      {s.registryNama && <> · Registri: {s.registryNama}</>}
                    </div>
                  </div>
                  <Badge className={cn("text-[10px] border-0", badge.cls)}>{badge.label}</Badge>
                  {s.blocked === true && (
                    <Badge className="border-0 bg-red-100 text-[10px] text-red-700">
                      <ShieldAlert className="mr-0.5 h-3 w-3" />BLOKIR
                    </Badge>
                  )}
                  <button
                    type="button" title="Hapus baris" disabled={pending}
                    className="text-muted-foreground hover:text-red-600"
                    onClick={() => deleteMut.mutate(s.ordinal)}
                  >
                    <Trash2 className="h-4 w-4" />
                  </button>
                </div>

                {s.resolutionStatus === "AUTO_SELECTED" && s.selectedNomorTransaksi && (
                  <div className="mt-1 flex items-center gap-1 pl-6 text-xs text-emerald-700">
                    <CheckCircle2 className="h-3 w-3" /> {s.selectedNomorTransaksi} ({s.selectedTahun})
                  </div>
                )}

                {needsPick && (
                  <div className="mt-2 space-y-1.5 pl-6">
                    {s.matches.map((m) => (
                      <button
                        key={m.nomor_transaksi} type="button" disabled={pending}
                        onClick={() => setPicked((p) => ({ ...p, [s.ordinal]: m.nomor_transaksi }))}
                        className={cn(
                          "w-full rounded border px-2 py-1.5 text-left text-xs transition-colors",
                          picked[s.ordinal] === m.nomor_transaksi ? "border-blue-500 bg-blue-50 dark:bg-blue-950/20" : "hover:bg-muted/50",
                        )}
                      >
                        <span className="font-medium">{m.nama_perseroan}</span>
                        <span className="text-muted-foreground"> · {m.kedudukan ?? "—"} · {m.nomor_sk} · {m.tahun_transaksi}</span>
                      </button>
                    ))}
                    {s.matches.length === 0 && (
                      <div className="text-xs text-muted-foreground">Tidak ada kandidat di SABH.</div>
                    )}
                    <div className="flex gap-2 pt-1">
                      <Button
                        size="sm" disabled={pending || !picked[s.ordinal]}
                        onClick={() => {
                          const m = s.matches.find((x) => x.nomor_transaksi === picked[s.ordinal]);
                          if (m) selectMut.mutate({ ordinal: s.ordinal, body: { nomorTransaksi: m.nomor_transaksi, tahun: m.tahun_transaksi } });
                        }}
                      >
                        Pilih Kandidat
                      </Button>
                      <Button size="sm" variant="outline" disabled={pending}
                        onClick={() => selectMut.mutate({ ordinal: s.ordinal, body: { manual: true } })}>
                        Tandai Manual
                      </Button>
                    </div>
                  </div>
                )}
              </div>
            );
          })}
        </div>

        {/* Add-row: extraction misses are covered manually (spec §4.4.4). */}
        <div className="space-y-2 rounded-lg border border-dashed p-3">
          <Label className="text-xs">Tambah PT sumber yang terlewat</Label>
          <div className="flex gap-2">
            <Input placeholder="PT Contoh Sumber" value={addNama} onChange={(e) => setAddNama(e.target.value)} disabled={pending} />
            <Input placeholder="Nomor SK (opsional)" value={addSk} onChange={(e) => setAddSk(e.target.value)} disabled={pending} />
            <Button
              size="sm" disabled={pending || !addNama.trim()}
              onClick={() => addMut.mutate({ namaPerseroan: addNama.trim(), nomorSk: addSk.trim() || null })}
            >
              Tambah Sumber
            </Button>
          </div>
        </div>
      </DialogContent>
    </Dialog>
  );
}
  • [ ] Minimal implementation B — Create frontend/src/pages/PeleburanExtractionPage.tsx: copy frontend/src/pages/AkuisisiExtractionPage.tsx byte-for-byte, then apply EXACTLY these diffs:
    1. Line 7: import { CompanySelectionModal } from "@/components/perubahan/CompanySelectionModal";import { PeleburanSourcesPanel } from "@/components/peleburan/PeleburanSourcesPanel";
    2. Line 26: delete import { useSelectCompany } from "@/hooks/use-perubahan";
    3. Line 43: export function AkuisisiExtractionPage()export function PeleburanExtractionPage().
    4. Line 53: delete const selectCompanyMutation = useSelectCompany(submissionId);
    5. Line ~254 (computeFlowStep call): { type: "AKUISISI_PT", status: submission.status }{ type: "PELEBURAN_PT", status: submission.status }.
    6. Line ~488 (READY handoff button): navigate(`/akuisisi/${submissionId}/review`)navigate(`/peleburan/${submissionId}/review`).
    7. Lines ~500-517: replace the whole {/* Preserved: Company selection modal */} + <CompanySelectionModal …/> block with:
      {/* Multi-source selection: one row per PT sumber (replaces the single-company modal) */}
      <PeleburanSourcesPanel
        submissionId={submissionId}
        open={isAwaitingSelection}
        onResolved={() => forcePolling()}
      />
  1. No other akuisisi string occurs in the donor (grep-verified: occurrences at lines 7/26/43/53/254/488/501-517 only) — do not change anything else.
    - [ ] Run GREEN: cd frontend && bunx vitest run src/components/peleburan/__tests__/PeleburanSourcesPanel.test.tsx then bunx tsc --noEmit. (Routes for the page land in Task 14; the page compiles standalone.)
    - [ ] Commit: git add frontend/src/components/peleburan frontend/src/pages/PeleburanExtractionPage.tsx && git commit -m "feat(peleburan): extraction page mirror + N-row PeleburanSourcesPanel"

Task 14: Frontend review + status pages + routes

Two pages + the route trio. The review page is genuinely new but flat (NO delta/diff — the payload is pendirian-shaped): pendirian-grade sections (data perseroan / akta notaris / modal & saham / pengurus / KBLI) rendered from Task 9's composite payload with per-field confirm on the deed's key fields, PLUS the two peleburan sections — sumber_peleburan (read-only source snapshots with blokir/status badges) and rups_pengumuman (per-source RUPS dates + editable koran fields — the correctable-FAIL surface) — every section with an EXPLICIT approve button, the validation list with override, and the submit dialog ("Kirim ke Verifikator" — no warning-ack step: a verifier reviews downstream, akuisisi parity). The status page is verifikasi-aware (akuisisi semantics, small dedicated page — SubmissionStatusPage is hardwired to pendirian/perubahan payload hooks; RUPS deviation #6 precedent).

Files
- frontend/src/pages/PeleburanReviewPage.tsx — Create
- frontend/src/pages/PeleburanStatusPage.tsx — Create
- frontend/src/routes.tsx — Modify
- frontend/src/pages/__tests__/PeleburanReviewPage.test.tsx — Create

Interfaces
- Consumes: GET /api/peleburan/submissions/:id/review-data, POST .../sections/:key/approve|unapprove, PUT .../koran, POST .../submit (Task 9); generic POST /api/submissions/:id/documents/:docId/fields/confirm, POST /api/submissions/:id/override-validation; missingSections, unresolvedSourceOrdinals from @/lib/peleburan-review; computeFlowStep; useVerifikasiData (status page).
- Produces: routes peleburan/:submissionId/{extraction,review,status}.

Steps

  • [ ] Write failing test. Create frontend/src/pages/__tests__/PeleburanReviewPage.test.tsx:
/**
 * PeleburanReviewPage — unit tests (LaporanRupsReviewPage.test.tsx pattern:
 * apiFetch mock + IOStub + MemoryRouter + qc pre-seeded cache).
 */
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, waitFor, fireEvent, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { PeleburanReviewPage } from "../PeleburanReviewPage";

const apiFetch = vi.fn();
vi.mock("@/lib/api-client", () => ({
  apiFetch: (url: string, opts?: RequestInit) => apiFetch(url, opts),
}));

// jsdom doesn't provide IntersectionObserver
class IOStub {
  observe() {} disconnect() {} unobserve() {} takeRecords() { return []; }
  root = null; rootMargin = ""; thresholds: number[] = [];
}
(globalThis as unknown as { IntersectionObserver: typeof IOStub }).IntersectionObserver = IOStub;

const SUBMISSION_ID = "peleburan-sub-1";

function makeReviewData(overrides: Record<string, unknown> = {}) {
  return {
    submission: { id: SUBMISSION_ID, status: "READY", type: "PELEBURAN_PT" },
    akta: {
      documentId: "doc-akta-1",
      fields: [
        { fieldKey: "nama_perseroan", value: "PT BARU HASIL LEBUR", confidence: 0.9, status: "CONFIRMED" },
        { fieldKey: "nomor_akta", value: "12", confidence: 0.9, status: "EXTRACTED" },
        { fieldKey: "tanggal_akta", value: "1 April 2026", confidence: 0.9, status: "CONFIRMED" },
        { fieldKey: "nama_notaris", value: "Budi, S.H.", confidence: 0.9, status: "CONFIRMED" },
      ],
      modal: { modalDasar: 1000000000, modalDitempatkan: 500000000, modalDisetor: 500000000, jumlahSaham: 500 },
      direksi: [{ nama: "Andi", jabatan: "Direktur Utama", isAsing: false }],
      komisaris: [{ nama: "Sari", jabatan: "Komisaris", isAsing: false }],
      pemegangSaham: [{ nama: "PT SUMBER SATU", jumlahSaham: 250, persentase: 50, isAsing: false }],
      kbli: [{ kode: "62010", judul: "Pemrograman Komputer" }],
    },
    sources: [
      { ordinal: 0, namaPerseroan: "PT SUMBER SATU", nomorSk: "AHU-0001", tanggalRups: "10-03-2026", resolutionStatus: "AUTO_SELECTED", selectedNomorTransaksi: "TX-1", selectedTahun: 2020, matches: [], registryNama: "PT SUMBER SATU", statusTransaksi: 1, statusPerseroan: "AKTIF", blocked: false, blokirReason: null },
      { ordinal: 1, namaPerseroan: "PT SUMBER DUA", nomorSk: null, tanggalRups: "11-03-2026", resolutionStatus: "MANUAL", selectedNomorTransaksi: null, selectedTahun: null, matches: [], registryNama: null, statusTransaksi: null, statusPerseroan: null, blocked: null, blokirReason: null },
    ],
    koran: { namaKoran: "Harian Kompas", tanggalKoran: "01-02-2026", source: "clipping" },
    documents: [],
    validationResults: [],
    sections: [],
    sectionApprovals: {
      data_perseroan: true, akta_notaris: true, modal_saham: true, pengurus: true,
      kbli: true, sumber_peleburan: true, rups_pengumuman: true,
    },
    ...overrides,
  };
}

function renderPage(data: Record<string, unknown>) {
  const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
  qc.setQueryData(["peleburan-review", SUBMISSION_ID], data);
  return render(
    <QueryClientProvider client={qc}>
      <MemoryRouter initialEntries={[`/peleburan/${SUBMISSION_ID}/review`]}>
        <Routes>
          <Route path="/peleburan/:submissionId/review" element={<PeleburanReviewPage />} />
          <Route path="/peleburan/:submissionId/status" element={<div>Status page</div>} />
        </Routes>
      </MemoryRouter>
    </QueryClientProvider>,
  );
}

describe("PeleburanReviewPage", () => {
  beforeEach(() => {
    apiFetch.mockReset();
    apiFetch.mockImplementation((url: string) =>
      typeof url === "string" && url.includes("/review-data")
        ? Promise.resolve(makeReviewData())
        : Promise.resolve({ ok: true }),
    );
  });

  it("renders the pendirian-grade sections + the two peleburan sections", async () => {
    renderPage(makeReviewData());
    await waitFor(() => expect(screen.getByText("Data Perseroan Baru")).toBeTruthy());
    expect(screen.getAllByText("PT BARU HASIL LEBUR").length).toBeGreaterThan(0);
    expect(screen.getByText("Modal & Saham")).toBeTruthy();
    expect(screen.getByText("Maksud & Tujuan (KBLI)")).toBeTruthy();
    expect(screen.getByText("PT Sumber Peleburan")).toBeTruthy();
    expect(screen.getByText("RUPS & Pengumuman Koran")).toBeTruthy();
    expect(screen.getAllByText(/PT SUMBER SATU/).length).toBeGreaterThan(0);
    expect(screen.getByText(/Harian Kompas/)).toBeTruthy();
  });

  it("POSTs section approve for an unapproved section", async () => {
    renderPage(makeReviewData({ sectionApprovals: { data_perseroan: true } }));
    await waitFor(() => expect(screen.getAllByText("Setujui Bagian").length).toBeGreaterThan(0));
    fireEvent.click(screen.getAllByText("Setujui Bagian")[0]!);
    await waitFor(() => {
      const call = apiFetch.mock.calls.find(([u]) => String(u).includes("/sections/") && String(u).endsWith("/approve"));
      expect(call).toBeTruthy();
    });
  });

  it("PUTs /koran when the koran date is edited and saved", async () => {
    renderPage(makeReviewData());
    await waitFor(() => expect(screen.getByLabelText("Tanggal Pengumuman")).toBeTruthy());
    fireEvent.change(screen.getByLabelText("Tanggal Pengumuman"), { target: { value: "02-02-2026" } });
    fireEvent.click(screen.getByRole("button", { name: /Simpan Koran/ }));
    await waitFor(() => {
      const call = apiFetch.mock.calls.find(([u]) => String(u).endsWith("/koran"));
      expect(call).toBeTruthy();
      expect(JSON.parse(call![1].body).tanggalPengumuman).toBe("02-02-2026");
    });
  });

  it("gates submit: FAIL blocks with Override action; clean data submits and navigates to status", async () => {
    const failing = makeReviewData({
      validationResults: [{ id: "v1", ruleCode: "PELEBURAN_KORAN_WINDOW", ruleLabel: "Koran <30 hari", status: "FAIL", message: "18 hari" }],
    });
    apiFetch.mockImplementation((url: string) =>
      typeof url === "string" && url.includes("/review-data") ? Promise.resolve(failing) : Promise.resolve({ ok: true }),
    );
    const { unmount } = renderPage(failing);
    const blockedBtn = await screen.findByRole("button", { name: /Kirim ke Verifikator/ });
    expect((blockedBtn as HTMLButtonElement).disabled).toBe(true);
    expect(screen.getByRole("button", { name: "Override" })).toBeTruthy();
    unmount();

    const clean = makeReviewData();
    apiFetch.mockImplementation((url: string) =>
      typeof url === "string" && url.includes("/review-data") ? Promise.resolve(clean) : Promise.resolve({ ok: true }),
    );
    renderPage(clean);
    const openBtn = await screen.findByRole("button", { name: /Kirim ke Verifikator/ });
    expect((openBtn as HTMLButtonElement).disabled).toBe(false);
    fireEvent.click(openBtn);
    const dialog = await screen.findByRole("dialog");
    fireEvent.click(within(dialog).getByRole("button", { name: /Kirim Sekarang/ }));
    await waitFor(() => {
      const call = apiFetch.mock.calls.find(([u]) => String(u).endsWith("/submit"));
      expect(call).toBeTruthy();
    });
    await waitFor(() => expect(screen.getByText("Status page")).toBeTruthy());
  });

  it("blocks submit while sections are unapproved or sources unresolved", async () => {
    const data = makeReviewData({
      sectionApprovals: { data_perseroan: true },
      sources: [{ ordinal: 0, namaPerseroan: "PT SUMBER SATU", nomorSk: null, tanggalRups: null, resolutionStatus: "AWAITING_SELECTION", selectedNomorTransaksi: null, selectedTahun: null, matches: [], registryNama: null, statusTransaksi: null, statusPerseroan: null, blocked: null, blokirReason: null }],
    });
    apiFetch.mockImplementation((url: string) =>
      typeof url === "string" && url.includes("/review-data") ? Promise.resolve(data) : Promise.resolve({ ok: true }),
    );
    renderPage(data);
    const btn = await screen.findByRole("button", { name: /Kirim ke Verifikator/ });
    expect((btn as HTMLButtonElement).disabled).toBe(true);
    expect(screen.getByText(/Bagian belum disetujui/)).toBeTruthy();
    expect(screen.getByText(/sumber belum teridentifikasi/i)).toBeTruthy();
  });
});
  • [ ] Run to verify RED: cd frontend && bunx vitest run src/pages/__tests__/PeleburanReviewPage.test.tsx. Expected: module not found.
  • [ ] Minimal implementation A — Create frontend/src/pages/PeleburanReviewPage.tsx:
import { useState } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { CheckCircle2, Loader2, AlertTriangle, XCircle, MinusCircle, Building2, ShieldAlert, ShieldCheck } 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, unresolvedSourceOrdinals } from "@/lib/peleburan-review";
import { cn } from "@/lib/utils";

interface FieldRow { fieldKey: string; value: string | null; confidence: number; status: string }
interface SourceRow {
  ordinal: number; namaPerseroan: string; nomorSk: string | null; tanggalRups: string | null;
  resolutionStatus: string; selectedNomorTransaksi: string | null; selectedTahun: number | null;
  matches: unknown[]; registryNama: string | null; statusTransaksi: number | null; statusPerseroan: string | null;
  blocked: boolean | null; blokirReason: string | null;
}
interface ValidationRow { id: string; ruleCode: string; ruleLabel: string; status: string; message: string; overridden?: boolean }
interface ReviewData {
  submission: { id: string; status: string; type: string };
  akta: {
    documentId: string;
    fields: FieldRow[];
    modal: { modalDasar: number | null; modalDitempatkan: number | null; modalDisetor: number | null; jumlahSaham: number | null } | null;
    direksi: { nama: string | null; jabatan: string | null; isAsing: boolean | null }[];
    komisaris: { nama: string | null; jabatan: string | null; isAsing: boolean | null }[];
    pemegangSaham: { nama: string | null; jumlahSaham: number | null; persentase: number | null; isAsing: boolean | null }[];
    kbli: { kode: string | null; judul: string | null }[];
  } | null;
  sources: SourceRow[];
  koran: { namaKoran: string | null; tanggalKoran: string | null; source: string | null };
  validationResults: ValidationRow[];
  sections: { key: string; label: string; approvable: boolean; required: boolean }[];
  sectionApprovals: Record<string, boolean>;
}

const DATA_PERSEROAN_KEYS = ["nama_perseroan", "jenis_perseroan", "status_perseroan", "tempat_kedudukan", "jangka_waktu"];
const AKTA_NOTARIS_KEYS = ["nomor_akta", "tanggal_akta", "nama_notaris"];
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" },
};
const idr = (n: number | null) => (n == null ? "—" : new Intl.NumberFormat("id-ID").format(n));

export function PeleburanReviewPage() {
  const { submissionId } = useParams<{ submissionId: string }>();
  const navigate = useNavigate();
  const qc = useQueryClient();
  const invalidate = () => qc.invalidateQueries({ queryKey: ["peleburan-review", submissionId] });

  const { data, isLoading } = useQuery<ReviewData>({
    queryKey: ["peleburan-review", submissionId],
    queryFn: () => apiFetch<ReviewData>(`/api/peleburan/submissions/${submissionId}/review-data`),
    enabled: !!submissionId,
  });

  const sectionMut = useMutation({
    mutationFn: ({ key, approve }: { key: string; approve: boolean }) =>
      apiFetch(`/api/peleburan/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 koranMut = useMutation({
    mutationFn: (body: { namaKoran?: string | null; tanggalPengumuman?: string | null }) =>
      apiFetch(`/api/peleburan/submissions/${submissionId}/koran`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
      }),
    onSuccess: invalidate,
  });
  const submitMut = useMutation({
    mutationFn: () => apiFetch(`/api/peleburan/submissions/${submissionId}/submit`, { method: "POST" }),
    onSuccess: () => navigate(`/peleburan/${submissionId}/status`),
  });

  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, akta, sources, koran, validationResults, sectionApprovals } = data;
  if (submission.status === "COMPLETED") { navigate(`/peleburan/${submissionId}/status`, { replace: true }); return null; }

  const flow = computeFlowStep({ type: "PELEBURAN_PT", status: submission.status }, null);
  const fails = validationResults.filter((v) => v.status === "FAIL" && !v.overridden);
  const missing = missingSections(sectionApprovals);
  const unresolved = unresolvedSourceOrdinals(sources);
  const canSubmit = submission.status === "READY" && fails.length === 0 && missing.length === 0 && unresolved.length === 0;
  const f = (key: string) => akta?.fields.find((x) => x.fieldKey === key);

  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 }: { keys: string[] }) => (
    <>
      {keys.map((key) => {
        const field = f(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.status !== "CONFIRMED" && field.status !== "LOCKED" ? (
              <Button size="sm" variant="outline" disabled={confirmMut.isPending}
                onClick={() => akta && confirmMut.mutate({ documentId: akta.documentId, fieldKey: key })}>
                Konfirmasi
              </Button>
            ) : field ? (
              <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 Baru (pendirian-grade — the NEW entity, 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 Baru</h2>
          <ApproveButton sectionKey="data_perseroan" />
        </div>
        {akta ? <FieldList keys={DATA_PERSEROAN_KEYS} /> : <div className="text-sm text-muted-foreground">Akta belum tersedia.</div>}
      </section>

      {/* Akta Notaris */}
      <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 Notaris</h2>
          <ApproveButton sectionKey="akta_notaris" />
        </div>
        {akta && <FieldList keys={AKTA_NOTARIS_KEYS} />}
      </section>

      {/* Modal & Saham */}
      <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">Modal & Saham</h2>
          <ApproveButton sectionKey="modal_saham" />
        </div>
        <div className="grid grid-cols-2 gap-x-6 gap-y-1 text-sm sm:grid-cols-4">
          <div><div className="text-xs text-muted-foreground">Modal Dasar</div>{idr(akta?.modal?.modalDasar ?? null)}</div>
          <div><div className="text-xs text-muted-foreground">Ditempatkan</div>{idr(akta?.modal?.modalDitempatkan ?? null)}</div>
          <div><div className="text-xs text-muted-foreground">Disetor</div>{idr(akta?.modal?.modalDisetor ?? null)}</div>
          <div><div className="text-xs text-muted-foreground">Jumlah Saham</div>{idr(akta?.modal?.jumlahSaham ?? null)}</div>
        </div>
        <div className="pt-1">
          <div className="text-xs font-medium text-muted-foreground">Pemegang Saham PT Baru</div>
          {(akta?.pemegangSaham ?? []).map((p, i) => (
            <div key={i} className="flex items-center justify-between border-b py-1 text-sm last:border-0">
              <span>{p.nama ?? "—"} {p.isAsing && <Badge variant="outline" className="ml-1 text-[10px]">WNA/BHA</Badge>}</span>
              <span className="text-xs text-muted-foreground">{idr(p.jumlahSaham)} lembar · {p.persentase ?? "—"}%</span>
            </div>
          ))}
        </div>
      </section>

      {/* Pengurus & 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">Pengurus & Identitas</h2>
          <ApproveButton sectionKey="pengurus" />
        </div>
        {[...(akta?.direksi ?? []), ...(akta?.komisaris ?? [])].map((p, i) => (
          <div key={i} className="flex items-center justify-between border-b py-1 text-sm last:border-0">
            <span>{p.nama ?? "—"}</span>
            <span className="text-xs text-muted-foreground">{p.jabatan ?? "—"}{p.isAsing ? " · WNA" : ""}</span>
          </div>
        ))}
        <p className="text-xs text-muted-foreground">Kecocokan KTP/NPWP/Paspor divalidasi otomatis (lihat Hasil Validasi).</p>
      </section>

      {/* Maksud & Tujuan (KBLI) */}
      <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">Maksud & Tujuan (KBLI)</h2>
          <ApproveButton sectionKey="kbli" />
        </div>
        {(akta?.kbli ?? []).map((k, i) => (
          <div key={i} className="border-b py-1 text-sm last:border-0">
            <span className="font-mono text-xs">{k.kode ?? "—"}</span> {k.judul ?? "—"}
          </div>
        ))}
      </section>

      {/* PT Sumber Peleburan — read-only snapshot; row management lives on the
          extraction step's panel (AWAITING_COMPANY_SELECTION). */}
      <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">PT Sumber Peleburan ({sources.length})</h2>
          <ApproveButton sectionKey="sumber_peleburan" />
        </div>
        {sources.map((s) => (
          <div key={s.ordinal} className="flex items-start gap-2 rounded border p-2 text-sm">
            <Building2 className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
            <div className="min-w-0 flex-1">
              <div className="font-medium">{s.namaPerseroan}</div>
              <div className="text-xs text-muted-foreground">
                SK: {s.nomorSk ?? "—"} · Registri: {s.registryNama ?? "—"}
                {s.selectedNomorTransaksi && ` · ${s.selectedNomorTransaksi} (${s.selectedTahun})`}
              </div>
            </div>
            <Badge variant="outline" className="shrink-0 text-[10px]">{s.resolutionStatus}</Badge>
            {s.blocked === true && (
              <Badge className="shrink-0 border-0 bg-red-100 text-[10px] text-red-700">
                <ShieldAlert className="mr-0.5 h-3 w-3" />BLOKIR{s.blokirReason ? `: ${s.blokirReason}` : ""}
              </Badge>
            )}
          </div>
        ))}
        {unresolved.length > 0 && (
          <p className="text-xs text-amber-600">PT sumber belum teridentifikasi (baris #{unresolved.map((o) => o + 1).join(", #")})  selesaikan pada langkah Ekstraksi.</p>
        )}
      </section>

      {/* RUPS & Pengumuman Koran — the correctable-FAIL surface (Ps.127(2)) */}
      <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 & Pengumuman Koran</h2>
          <ApproveButton sectionKey="rups_pengumuman" />
        </div>
        {sources.map((s) => (
          <div key={s.ordinal} className="flex items-center justify-between border-b py-1 text-sm last:border-0">
            <span>{s.namaPerseroan}</span>
            <span className="text-xs text-muted-foreground">RUPS: {s.tanggalRups ?? "—"}</span>
          </div>
        ))}
        <div className="grid grid-cols-[160px_1fr] items-center gap-2 pt-2">
          <Label htmlFor="nama-koran" className="text-xs">Nama Koran</Label>
          <Input id="nama-koran" defaultValue={koran.namaKoran ?? ""}
            onChange={(e) => setKoranDraft((d) => ({ namaKoran: koran.namaKoran ?? "", tanggalPengumuman: koran.tanggalKoran ?? "", ...d, 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) => ({ namaKoran: koran.namaKoran ?? "", tanggalPengumuman: koran.tanggalKoran ?? "", ...d, tanggalPengumuman: e.target.value }))} />
        </div>
        <div className="flex items-center justify-between">
          <span className="text-xs text-muted-foreground">Sumber data: {koran.source === "clipping" ? "kliping koran (BUKTI_PENGUMUMAN)" : koran.source === "akta" ? "tercantum di dalam akta" : "—"} · Ps.127(2): 30 hari sebelum RUPS</span>
          <Button size="sm" variant="outline" disabled={!koranDraft || koranMut.isPending}
            onClick={() => koranDraft && koranMut.mutate(koranDraft)}>
            Simpan Koran
          </Button>
        </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 (no warning-ack step: a verifier reviews downstream) */}
      <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>}
        {unresolved.length > 0 && <span className="text-xs text-amber-600">PT sumber belum teridentifikasi</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 Peleburan PT ke Verifikator</DialogTitle>
            <DialogDescription>
              Pengajuan akan masuk antrean verifikator (Menunggu Verifikasi). Anda dapat
              merevisi jika verifikator meminta perbaikan.
            </DialogDescription>
          </DialogHeader>
          <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/PeleburanStatusPage.tsx:
import { useParams, Link, Navigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Loader2, Hourglass } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StepIndicator } from "@/components/process/step-indicator";
import { computeFlowStep } from "@/lib/flow-steps";
import { useVerifikasiData } from "@/hooks/use-verifikator";
import { apiFetch } from "@/lib/api-client";

interface ReviewData {
  submission: { id: string; status: string };
  akta: { fields: { fieldKey: string; value: string | null }[] } | null;
}

const VERIF_LABEL: Record<string, string> = {
  MENUNGGU_VERIFIKATOR: "Menunggu tinjauan verifikator",
  MENUNGGU_VOTING: "Voting pemegang saham berlangsung",
  MENUNGGU_VERIFIKATOR_FINAL: "Menunggu keputusan final verifikator",
  PERBAIKAN: "Verifikator meminta perbaikan",
  SELESAI: "Disetujui — peleburan tercatat",
  DITOLAK: "Ditolak oleh verifikator",
};

/** Verifikasi-aware status page (akuisisi semantics; small dedicated page —
 *  SubmissionStatusPage is hardwired to the pendirian/perubahan payloads). */
export function PeleburanStatusPage() {
  const { submissionId } = useParams<{ submissionId: string }>();
  const { data, isLoading } = useQuery<ReviewData>({
    queryKey: ["peleburan-review", submissionId],
    queryFn: () => apiFetch<ReviewData>(`/api/peleburan/submissions/${submissionId}/review-data`),
    enabled: !!submissionId,
  });
  const { data: verif } = useVerifikasiData(submissionId);

  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>;
  if (data.submission.status !== "COMPLETED") return <Navigate to={`/peleburan/${submissionId}/review`} replace />;
  const vstatus = verif?.verifikasi?.status ?? "MENUNGGU_VERIFIKATOR";
  if (vstatus === "PERBAIKAN") return <Navigate to={`/peleburan/${submissionId}/review`} replace />;

  const flow = computeFlowStep({ type: "PELEBURAN_PT", status: "COMPLETED" }, { status: vstatus });
  const nama = data.akta?.fields.find((x) => x.fieldKey === "nama_perseroan")?.value ?? "PT Baru";
  const done = vstatus === "SELESAI";

  return (
    <div className="space-y-6">
      <div className="bg-card border-b p-6">
        <StepIndicator steps={flow.steps} currentStep={flow.currentStep} variant={flow.variant} />
      </div>
      <div className="mx-auto max-w-xl rounded-lg border bg-card p-8 text-center space-y-3">
        {done
          ? <CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
          : <Hourglass className="mx-auto h-10 w-10 text-amber-500" />}
        <h1 className="text-lg font-semibold">Peleburan PT  {nama}</h1>
        <p className="text-sm text-muted-foreground">{VERIF_LABEL[vstatus] ?? vstatus}</p>
        <Button asChild variant="outline"><Link to="/dashboard">Kembali ke Dashboard</Link></Button>
      </div>
    </div>
  );
}
  • [ ] Minimal implementation C — frontend/src/routes.tsx: import PeleburanExtractionPage, PeleburanReviewPage, PeleburanStatusPage; add directly after the laporan-rups rows (L84-85):
          { path: "peleburan/:submissionId/extraction", element: <PeleburanExtractionPage /> },
          { path: "peleburan/:submissionId/review", element: <PeleburanReviewPage /> },

and after the laporan-rups status row (L96):

          { path: "peleburan/:submissionId/status", element: <PeleburanStatusPage /> },
  • [ ] Run GREEN: cd frontend && bunx vitest run src/pages/__tests__/PeleburanReviewPage.test.tsx then bunx tsc --noEmit && bunx vitest run. Expected: clean; Tasks 11-13 tests still green.
  • [ ] Optional smoke (if backend + DB are up): create a submission via the klasifikasi flow with an AKTA_PELEBURAN-classified PDF and walk extraction (source panel) → review → submit → verifikator queue.
  • [ ] Commit: git add frontend/src/pages/PeleburanReviewPage.tsx frontend/src/pages/PeleburanStatusPage.tsx frontend/src/routes.tsx frontend/src/pages/__tests__/PeleburanReviewPage.test.tsx && git commit -m "feat(peleburan): frontend review + verifikasi-aware status pages + routes"

Task 15: End-to-end integration test (deed passes → multi-source pause/resume → tuned matrix → FAIL-gate → override → verifikator staging)

One DB-backed test proving the whole spine on a REAL seeded AKTA_PELEBURAN submission: runFlow with the registry config runs the 35B/3B deed passes (both mocked via ONE superset global.fetch — each extractor grounds only its own keys), the multi-source resolve loop (environment-robust: handles MANUAL and AWAITING branches), the tuned matrix (PELEBURAN_KORAN_WINDOW FAIL at 18 days), then the route family drives section approvals → canonical FAIL-gate block → override → submit → COMPLETED + VerifikasiPerubahan — asserting AKTA_DATE_WINDOW/OLD_DATA_CONSISTENCY never appear.

Files
- backend/src/flow-engine/__tests__/peleburan-integration.test.ts — Create

Steps

  • [ ] Write the test. Create backend/src/flow-engine/__tests__/peleburan-integration.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";
import { runFlow } from "../processor";
import { peleburanPtFlow } from "../flows/peleburan-pt";

const RAW =
  "AKTA PELEBURAN\nPERSEROAN TERBATAS PT BARU HASIL LEBUR\n" +
  "PT SUMBER SATU, SK AHU-0001.AH.01.01.TAHUN 2020, RUPS tanggal 10 Maret 2026\n" +
  "PT SUMBER DUA, SK AHU-0002.AH.01.01.TAHUN 2019, RUPS tanggal 11 Maret 2026\n" +
  "Pengumuman surat kabar Harian Kompas tanggal 20 Februari 2026"; // 18 hari sebelum RUPS → KORAN_WINDOW FAIL

describe("PELEBURAN_PT end-to-end (deed passes → multi-source → gate → verifikator)", () => {
  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("runs the full spine", async () => {
    // ── seed: post-akta-extraction state (deed DONE with pendirian-grade fields) ──
    const akta = await db.document.create({
      data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "VERIFICATION_READY", rawText: RAW },
    });
    docIds.push(akta.id);
    await db.extractedField.createMany({
      data: [
        { documentId: akta.id, fieldKey: "nama_perseroan", value: "PT BARU HASIL LEBUR" },
        { documentId: akta.id, fieldKey: "nomor_akta", value: "12" },
        { documentId: akta.id, fieldKey: "tanggal_akta", value: "2026-04-01" },
      ],
    });
    const sub = await db.submission.create({ data: { type: "PELEBURAN_PT", status: "CREATED" } });
    ids.push(sub.id);
    await db.submissionDocument.create({
      data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_PELEBURAN", confidence: 1, processingOrder: 0, extractionStatus: "DONE" },
    });

    // ── engine: runFlow with the REAL registry config; 35B + 3B mocked with ONE
    //    superset payload (each extractor picks + grounds its own keys) ──
    const origFetch = globalThis.fetch;
    const origTxnUrl = process.env.AKTA_TXN_CLASSIFIER_URL;
    const origLlmUrl = process.env.CLEANUP_LLM_URL;
    process.env.AKTA_TXN_CLASSIFIER_URL = "http://mock-35b";
    process.env.CLEANUP_LLM_URL = "http://mock-3b";
    globalThis.fetch = (async () =>
      new Response(JSON.stringify({
        choices: [{ message: { content: JSON.stringify({
          sources: [
            { nama_perseroan: "PT SUMBER SATU", nomor_sk: "AHU-0001.AH.01.01.TAHUN 2020", tanggal_rups: "10 Maret 2026" },
            { nama_perseroan: "PT SUMBER DUA", nomor_sk: "AHU-0002.AH.01.01.TAHUN 2019", tanggal_rups: "11 Maret 2026" },
          ],
          tanggal_koran: "20 Februari 2026",
          nama_koran: "Harian Kompas",
        }) } }],
      }), { status: 200 })) as typeof fetch;
    try {
      await runFlow(sub.id, peleburanPtFlow);
    } finally {
      globalThis.fetch = origFetch;
      process.env.AKTA_TXN_CLASSIFIER_URL = origTxnUrl;
      process.env.CLEANUP_LLM_URL = origLlmUrl;
    }

    // ── multi-source: environment-robust resume ──
    // SABH unavailable → rows MANUAL → engine went straight to READY.
    // SABH available → rows may be AWAITING_SELECTION → resolve via the panel routes.
    let after = await db.submission.findUnique({ where: { id: sub.id } });
    if (after?.status === "AWAITING_COMPANY_SELECTION") {
      const rows = await db.peleburanSourcePt.findMany({ where: { submissionId: sub.id }, orderBy: { ordinal: "asc" } });
      for (const r of rows) {
        if (r.resolutionStatus === "PENDING" || r.resolutionStatus === "AWAITING_SELECTION") {
          const res = await app.request(`/api/peleburan/submissions/${sub.id}/sources/${r.ordinal}/select`, {
            method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ manual: true }),
          });
          expect(res.status).toBe(200);
        }
      }
      for (let i = 0; i < 60; i++) {
        after = await db.submission.findUnique({ where: { id: sub.id } });
        if (after?.status === "READY" || after?.status === "ERROR") break;
        await new Promise((r) => setTimeout(r, 250));
      }
    }
    expect(after?.status).toBe("READY");

    // engine assertions: deed passes + tuned matrix
    const rows = await db.peleburanSourcePt.findMany({ where: { submissionId: sub.id }, orderBy: { ordinal: "asc" } });
    expect(rows).toHaveLength(2); // grounded source extraction fired
    const koran = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId: akta.id, fieldKey: "tanggal_pengumuman" } } });
    expect(koran?.value).toBe("20 Februari 2026"); // deed-inline koran persisted
    const validations = await db.validationResult.findMany({ where: { submissionId: sub.id } });
    const byCode = Object.fromEntries(validations.map((v) => [v.ruleCode, v.status]));
    expect(byCode.PELEBURAN_MIN_SOURCES).toBe("PASS");
    expect(byCode.PELEBURAN_KORAN_WINDOW).toBe("FAIL"); // 18 hari < 30 (Ps.127(2))
    expect(validations.map((v) => v.ruleCode)).not.toContain("AKTA_DATE_WINDOW");
    expect(validations.map((v) => v.ruleCode)).not.toContain("OLD_DATA_CONSISTENCY");

    // ── routes: approvals → canonical FAIL-gate → override → verifikator staging ──
    const req = (path: string, method: string, body?: unknown) =>
      app.request(`/api/peleburan/submissions/${sub.id}${path}`, {
        method, headers: { "Content-Type": "application/json" }, body: body !== undefined ? JSON.stringify(body) : undefined,
      });
    for (const key of ["data_perseroan", "akta_notaris", "modal_saham", "pengurus", "kbli", "sumber_peleburan", "rups_pengumuman"]) {
      expect((await req(`/sections/${key}/approve`, "POST")).status).toBe(200);
    }
    let res = await req("/submit", "POST");
    expect(res.status).toBe(422);
    const blocked = (await res.json()) as { code: string; failing: string[] };
    expect(blocked.code).toBe("VALIDATION_FAILED");
    expect(blocked.failing).toContain("PELEBURAN_KORAN_WINDOW");

    await app.request(`/api/submissions/${sub.id}/override-validation`, {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ ruleCode: "PELEBURAN_KORAN_WINDOW", reason: "tanggal koran diverifikasi manual dari bukti fisik" }),
    });
    res = await req("/submit", "POST");
    expect(res.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"); // verifikator-staged, NOT terminal
    expect((await req("/submit", "POST")).status).toBe(409); // no double-submit
  }, 60000);
});
  • [ ] Run: cd backend && bun test src/flow-engine/__tests__/peleburan-integration.test.ts. Expected: 1 pass. (The multi-source branch is environment-robust by construction; validation re-scoring differences are fixed by adjusting the SEED, never the gate.)
  • [ ] Commit: git add backend/src/flow-engine/__tests__/peleburan-integration.test.ts && git commit -m "test(peleburan): end-to-end integration — deed passes, multi-source, FAIL gate, verifikator staging"

Task 16: Full-suite + typecheck gate

The regression net: this flow touched shared surfaces (engine Phase-2 multi-source branch, akta-koran-extract parameterization, document-processor OCR-only case, klasifikasi classify route + refineSupportingLabel surroundings, AKTA_SUBTYPE_MAP, inferSubmissionType, the verifikator queue, flow-steps/destination-for, classification-list-view). Everything that was green before must stay green.

Steps

  • [ ] cd backend && bunx tsc --noEmit — Expected: no errors.
  • [ ] cd backend && bun test — Expected: ALL suites pass (flow-engine parity oracle, perubahan/akuisisi/pendirian/PP/apostille/laporan-rups regressions, plus every new suite from Tasks 1-15). This is against ahu_ocr_test via the bunfig preload — never the dev DB.
  • [ ] cd frontend && bunx tsc --noEmit — Expected: no errors.
  • [ ] cd frontend && bunx vitest run — Expected: all vitest suites pass (incl. the pre-existing flow-steps.test.ts/destination-for.test.ts/detect-banner.test.ts/classification-list-view.test.tsx/LaporanRupsReviewPage.test.tsx).
  • [ ] Fix any regression by adjusting THIS flow's code (never the oracle forks, never the shared rule bodies, never a weakened assertion). Known-acceptable diffs already handled in-plan: registry.test.ts V2_SHIPPED (Task 7), the peleburan-as-unbuilt fixtures → pembubaran (Task 8).
  • [ ] Commit any fixes: git add -A && git commit -m "fix(peleburan): full-suite regression fixes" (skip if clean).

Self-Review

Spec coverage (2026-07-02-peleburan-pt-design.md → tasks):
- §1/§2 scope + second-v2-tenant + enum → Tasks 1, 7 (PELEBURAN_PT, registry entry, doc comments).
- §4.1 data flow → Task 7 (engine phases via config), Task 8 (classify → refine → infer), Task 4 (multi-source pause/resume), Task 9 (review → submit), Task 10 (verifikator), Task 15 (proved end-to-end).
- §4.2 FlowConfig → Task 7 verbatim-with-recorded-deviations (see below).
- §4.3 hybrid extraction → Task 7 (overrideByClassifiedType: pendirian + subtype label row), Task 3 (grounded 35B source pass, akta-koran-extract pattern + rawText gotcha), Task 2 (parameterized extractKoranFields, akuisisi call sites untouched; SHARED clipping extractor with canonical keys, nomor_pengumuman stays null-capable; ExtractedField persistence per §6), Task 9 (PUT /koran editability).
- §4.3.1 classification strategy → the shared keyword pre-suggester already exists (supporting-doc-classifier.ts, autoAcceptable:false); Task 8 surfaces it (classify suggestion field) + Task 11 (amber chip, applied only via the existing re-map/reason mechanism — never auto-accepted); AKTA_PELEBURAN stays a carrier label (mapToDocumentTypeAKTA).
- §4.4 multi-source lookup → Task 1 (PeleburanSourcePt model verbatim incl. @@unique([submissionId, ordinal])), Task 4 (resolve loop over the three existing primitives; AUTO/AWAITING/MANUAL; SABH-down degradation; per-row guards citing the registry-hang lesson), Task 9 (per-row add/edit/select/delete + AWAITING_COMPANY_SELECTION un-block via continueFlow), Task 13 (N-row panel).
- §4.5 context → Task 5 (PeleburanCtx = PtAktaCtx + sources/koran/rupsBaDocs; built once; rules pure).
- §4.6 matrix → Task 6 (all severities + citations in wrappers; exclusion guard tests), re-guarded at the config level (Task 7) and end-to-end (Task 15); required-docs table → Task 8 (PELEBURAN_DOC_TYPES, no bukti-setor) + Task 7 (requiredDocs enum values with DOMISILI).
- §4.7 frontend → Tasks 11-14 (thin mirrors; sources panel generalizing CompanySelectionModal; the two new sections with EXPLICIT PerubahanSectionApproval rows; verifikator reuse via the queue + the per-family review-page precedent).
- §5 wiring checklist 0-12 → slice landed (0); Task 1 (1, minus BUKTI_PENGUMUMAN which already exists — recorded); Task 8 (2, 3, 7); Task 7 (4, 5-already-landed one-liner in Task 1, 6-structural via registry); Task 2 (8 focused pass via engine, akuisisi inline untouched); Task 9 (9); Tasks 11-14 (10); Task 9+10 (11 — "finalize allow-list" realized as the verifikator staging transaction + queue admission; there is no separate finalize allow-list in the landed code); Task 8 (12 launch switch).
- §6 data model → Task 1 (additive only; koran as ExtractedField rows; approvals reuse; no Submission columns; single-company quintet stays null).
- §7 gotchas → rematch dispatch regression (Task 7 test); explicit approvals (Task 9, c0698d8 comment); compile tripwires (Task 1); rawText degradation (Tasks 2/3 warn+skip tests); ladder precedence incl. beats-AKTA_PENDIRIAN (Task 8 test); FAIL ≠ upload gate (rules surface at review; AWAITING_COMPANY_SELECTION enforces SOURCE_RESOLVED operationally — Task 9); edit races via queueReMatchAndValidate + sequential per-row loop (Tasks 4/9); v1 forks untouched (Task 2 delegate keeps akuisisi call sites byte-identical; Task 16 full regression).
- §8 testing strategy → every listed class has a home: routing/gating + stale-fixture flip (Task 8), registry dispatch + rematch (Task 7), extraction grounding/fabrication/empty-rawText (Tasks 2/3), multi-source loop incl. manual add/remove/SABH-down (Tasks 4/9), rule pack tiers incl. 29d/30d boundary + severityFloor (Task 6), section approvals + gate (Task 9), regression (Task 16). Wrapped-rule parity + async wall-clock live in the engine's existing wrapper-fidelity/validation-runner suites (shared bodies — not re-proven per flow).

Resolved ambiguities / deviations (engine-facts-over-spec or code-reality, each recorded in-task):
1. Verifikator staging semantics: spec §4.1/§4.7 prescribe the verifikasi state machine; the landed code has no generic "finalize allow-list" — realized as the AKUISISI submit-arm mirror (COMPLETED + VerifikasiPerubahan in one transaction + audit event, Task 9) plus queue admission (Task 10). VerifikatorReviewPage's "type guards" don't exist in code (it's diff-payload-bound); the per-family page precedent (VerifikatorPerbaikanReviewPage) is followed instead.
2. rules: [...PT_PENDIRIAN_RULE_SET, ...PELEBURAN_RULES] (spec §4.2) → PELEBURAN_RULE_SET = 7 flow rules + pickRules(PT_AKTA_RULE_SET, 16 codes) — the engine's canonical mechanism (C11: values are the contract, not names).
3. AKTA_DATE_WINDOW resolved per spec §4.6's "verify during build": body is isPerubahan-keyed (30d under the shared ctx; a peleburan filing is pendirian-grade/60d) → EXCLUDED with rationale + guard tests (Tasks 6/7/15).
4. matchRupsAttendance preValidationStep (spec §4.2 sketch) dropped: the binding §4.6 matrix has no RUPS_ATTENDANCE_QUORUM row; attendance data would feed nothing (Task 7 comment).
5. PELEBURAN_SOURCE_BLOKIR/_STATUS async:true (spec) → sync: the SABH round-trips are pre-resolved in the resolve loop and persisted on rows (same class as the RUPS plan's deviation #5). The async partition survives on PP29_MODAL/KBLI_VALID (Task 6 test).
6. PELEBURAN_KORAN_WINDOW missing-koran tier: §4.6 row 19 said WARNING-when-absent; §10.7 (the later, user-stamped resolution, echoed verbatim in the task brief) says SKIPPED-when-either-unreadable → SKIPPED implemented (Task 6, cited in the rule doc).
7. PELEBURAN_SOURCE_STATUS FAIL scope: "unambiguous terminal" = status_transaksi === 6 or status_perseroan ~ /bubar|berakhir/i; status_transaksi === 5 (already-in-peleburan) → WARNING (noisy legacy data, verifier judgment) — mirrors validatePerseroanState's own semantics (cross-validator.ts:1432-1449).
8. Submit gate reads section approvals for ALL sections (not per-field collectUnconfirmed): the RUPS-template gate pattern; field-level confirm stays available via the generic endpoints and is surfaced per-field on the review page, but the persisted gate condition is the explicit approval rows (c0698d8 discipline; spec's "confirmations for pendirian-grade sections" realized as the operator approving each section after confirming its fields).
9. No runAdvisory on submit (akuisisi calls it): the advisory engine builds a perubahan-shaped diff report; peleburan has no diff — aiReport stays null and the verifikator page renders the validation list as the review substance (Tasks 9/10 comments).
10. 35B env reuse: the source-refs pass rides AKTA_TXN_CLASSIFIER_URL/_MODEL/_TIMEOUT_MS (the spec's "AKTA_TXN_CLASSIFIER_URL-style config" read literally — one endpoint, no new env vars).
11. Suggestion surfacing: refineSupportingLabel already runs on every classify and correctly refuses autoAcceptable:false; what ships is the response-level suggestion + the board chip whose "Terapkan" routes through the EXISTING onReclassify re-map (reason dialog) — never silent adoption (Tasks 8/11).
12. Row-edit semantics: editing a row's ref (nama/SK) resets it to PENDING and re-resolves immediately (never a silently stale snapshot); editing only tanggalRups keeps the resolution (Task 9).

Placeholder scan: no TBD/TODO/"similar to" anywhere. The two copy-style artifacts are sanctioned faithful mirrors with exhaustive diff lists (extraction page: 8 grep-verified points, Task 13; verifikator queue tagging: anchor-based with the invariant test, Task 10). All other code (extractors, resolve loop, context, 7 rules, flow config, both route files' full bodies, panel, review/status/verifikator pages, every test) is given in full. Verify-notes delegate only prop/name confirmation with fallbacks spelled out (AuditTrailPanel prop, all row accessor in verifikator.ts).

Type consistency: PeleburanCtx extends PtAktaCtx used identically in Tasks 5/6/7 (method-position bivariance for the pickRules spread — the landed RUPS pattern). PeleburanSourceState field names match the sourceRowJson route payload and the FE SourceRow/PeleburanSourceRow interfaces field-for-field (ordinal/namaPerseroan/nomorSk/tanggalRups/resolutionStatus/selectedNomorTransaksi/selectedTahun/matches/registryNama/statusTransaksi/statusPerseroan/blocked/blokirReason). Canonical ExtractedField keys nama_koran/tanggal_pengumuman/nomor_pengumuman uniform across Task 2 (writer), Task 3 (deed-inline writer), Task 5 (reader), Task 9 (PUT /koran writer + review-data reader). Resolution-status literals PENDING|AUTO_SELECTED|AWAITING_SELECTION|MANUAL uniform across schema default, service, context, rules, routes, FE. Envelope literals (VALIDATION_FAILED+422, WRONG_STATUS+409, SECTIONS_UNAPPROVED+400) uniform across Task 9 impl, Task 9 tests, Task 15. Enum spellings (PASS/WARNING/FAIL/SKIPPED, PELEBURAN_PT, AKTA_PELEBURAN, MENUNGGU_VERIFIKATOR) uniform across all tasks. All test seeds creating submissionDocument rows carry confidence: 1 (Tasks 5, 7, 9, 15 — checked line-by-line).

Known risks flagged to the executor: (1) Tasks 7/9/15 exercise buildPtAktaContext/continueFlow (SABH-adjacent) — same environment expectations as the landed pt-akta-context.test.ts/laporan-rups-integration.test.ts; Task 15's multi-source branch is deliberately environment-robust. (2) Task 10's verifikator item-tagging edit is anchor-based (the items build spans ~200 unread lines) — the backend test pins the invariant (item.type === "PELEBURAN_PT"), so any accessor drift fails loud. (3) The BUKTI_PENGUMUMAN OCR-only handler is exercised indirectly (typecheck + the focused pass consumes seeded rawText) — same accepted gap as the RUPS plan's LAPORAN_KEUANGAN handler. (4) DecisionPanel/AuditTrailPanel prop shapes verified-at-build with spelled-out fallbacks (Task 10 notes).