Pelaporan RUPS Tahunan (LAPORAN_RUPS_TAHUNAN) 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 LAPORAN_RUPS_TAHUNAN OCR flow (upload → classify → extract → validate → review → terminal self-service submit, no verifier) as the FIRST engine:'v2-generic' tenant of the flow-engine FLOW_REGISTRY — one defineFlow config + wrapped/flow-specific rules + the engine's first focusedPasses consumer. 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 already landed on feat/pt-flows-registry). The deed rides the perubahan extractor via extraction.overrideByClassifiedType; the laporan keuangan gets an OCR-only document handler plus a NEW grounded 3B focused pass invoked by the engine's NEW (built here, generic) focusedPasses mechanism. Validation is a tuned matrix (RUPS_TAHUNAN_RULE_SET): 8 flow-specific rules + 5 wrapped shared rules + the shared RUPS-quorum wrapper — with AKTA_DATE_WINDOW and the whole identity/kontak group deliberately EXCLUDED. Submit is a new thin route family (routes/laporan-rups.ts, routeFamily 'laporan-rups'): section approvals + attestation completeness + checkFailGate (block-with-override) + WARNING declarations → COMPLETED directly. No VerifikasiPerubahan row is ever created. Frontend = thin mirrors (extraction page copied from akuisisi; flat review page; small terminal status page). Everything stays dark until the BUILT_AKTA_TXN_TYPES allowlist flip (Task 7) — the launch switch.
Tech Stack: Bun + Hono backend; Prisma 7 (client generated to src/generated/prisma/); PostgreSQL 16; backend tests via bun test against the isolated ahu_ocr_test DB (bunfig preload; bun run db:push:test after schema changes); frontend React 19 + Vite 6 + TanStack Query v5, tests via bun run test (vitest); typecheck via bunx tsc --noEmit in each package.
Global Constraints
Binding values — copy verbatim; do not paraphrase:
- Enum spellings (exact). SubmissionType
LAPORAN_RUPS_TAHUNAN(mechanical uppercase of classifier taxonomylaporan_rups_tahunan; NOT SABH's controller nounPELAPORAN_). New Prisma enumRupsJenisAudit { WAJIB_AUDIT NON_AUDIT }.ValidationStatusis"PASS" | "WARNING" | "FAIL" | "SKIPPED"(NO-ED). Carrier akta labelAKTA_RUPS_TAHUNANalready exists inAKTA_TXN_TYPE_TO_LABEL(akta-txn-classifier.ts:24). DocumentTypesLAPORAN_KEUANGAN+LAMPIRAN_LAPORAN_TAHUNANalready exist in Prisma (registry-slice batch) — do NOT re-add. - Terminal self-service — no verifier. Status path:
CREATED → EXTRACTING → (AWAITING_COMPANY_SELECTION?) → VALIDATING → READY → COMPLETED.IN_VERIFICATIONis never entered; noVerifikasiPerubahanrow is ever created (the verifikator dashboard keys off verifikasi rows and must never see this type). Submit →COMPLETEDdirectly, stampingrupsSubmittedAt. - FAIL gate is a NAMED step.
POST /api/laporan-rups/submissions/:id/submitMUST callcheckFailGate(submissionId, laporanRupsTahunanFlow)(backend/src/flow-engine/fail-gate.ts— block-with-override: blocks on anyValidationResultrow withstatus === "FAIL" && overridden !== true; SKIPPED/WARNING never block). Overrides ride the existingPOST /api/submissions/:id/override-validationendpoint — no new table. - The tuned validation matrix (severities binding):
- FAIL-capable:
RUPS_AKTA_FIELDS_PRESENT,TAHUN_LAPORAN_VALID,TAHUN_BUKU_ORDERING,AKUNTAN_PUBLIK_REQUIRED(conditional —SKIPPEDunlessjenisAudit=WAJIB_AUDIT), wrappedPERSEROAN_STATE. - WARNING — never FAIL (legal citations live in the wrappers' doc comments):
RUPS_ANNUAL_WINDOW(UUPT Ps.78(2) — lateness does not invalidate; SABH accepts 10-year backlog),RUPS_ATTENDANCE_QUORUMvia the sharedrupsQuorumRule()at its default >½ threshold (UUPT Ps.86(1)/(4)),AKUNTAN_IZIN_FORMAT(no on-prem P2PK registry — full on-premise forbids the cloud call),LAPORAN_KEUANGAN_TAHUN_MATCH,RUPS_LAMPIRAN_COMPLETENESS(Ps.66(2) letters c/d/g conditional). - Wrapped shared (bit-for-bit from
PT_AKTA_RULE_SETviapickRules):NAMA_PT_CONSISTENCY,OLD_DATA_CONSISTENCY,NOTARIS_TERAKHIR,NPWP_PERSEROAN_FUZZY,PERSEROAN_STATE. - EXCLUDED — deliberately, guard with tests:
AKTA_DATE_WINDOW(its 30/60-day window would false-FAIL every legitimate backlog filing — replaced by rules 2/3/6), ALL identity/kontak/modal/saham/KBLI/jenis rules (NIK_KTP_AKTA,NAMA_NPWP_KTP,KTP_COMPLETENESS,NPWP_COMPLETENESS,PASSPORT_AKTA,CONTACT_INFO_COMPLETE,PEMEGANG_SAHAM_CONTACT,DOMISILI_NAMA,MODAL_*,SHARES_*,PS_TOTAL_100,NOMINAL_RECONCILE,PP29_MODAL,KBLI_VALID,PERUBAHAN_KBLI,DATA_ACTUALLY_CHANGED,JENIS_*,CONTRADICTORY_JENIS,REQUIRED_SUPPORTING_DOCS,UPLOADED_DOC_UNUSED) — this filing registers no person/address/kontak/modal; carrying 20 permanent SKIPPED rows is noise. - Wajib-audit conditionality:
LAPORAN_KEUANGANdoc + nama/izin/tanggal-izin akuntan publik are mandatory ONLY whenjenisAudit = WAJIB_AUDIT(enforced byAKUNTAN_PUBLIK_REQUIRED, NOT by the doc checklist — the board shows the docrequired: falsewith description "Wajib jika perseroan wajib diaudit (UUPT Ps.68)"). Sectionakuntan-publikbecomes required AT GATE TIME whenjenisAudit=WAJIB_AUDIT. - Backlog
tahunLaporan: integer windowcurrentYear−10 … currentYear−1(SABH dropdown parity). Typed columnSubmission.tahunLaporan Int?(rules branch on it — never JSON). - Ladder position (canonical):
Perbaikan > Berakhirnya(doc-set predicate) > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT. Only the built rungs exist ininferSubmissionTypetoday; this flow adds its OWN rung afterAKTA_PERUBAHAN, beforeSURAT_PERNYATAAN_PENDIRIAN_PP— a perubahan submission that also carries the annual BA must stayPERUBAHAN_PT. - Rematch discipline: field edits reach this flow ONLY via
queueReMatchAndValidate(serialized) → the registry check inreMatchAndValidate(submission-processor.ts:~717) →reMatchAndValidateFlow— already wired for any v2 entry; regression-test it (a field edit must NOT wipe validations). - 3B brittleness: the laporan-keuangan pass is a SINGLE-PURPOSE grounded call (CLEANUP_LLM 3B @
CLEANUP_LLM_URL),temperature: 0, guided-JSON, every value verbatim-substring-grounded on the doc's rawText (ground()as inakta-koran-extract.ts). Never broaden a shared prompt. Empty results → manual entry in the akuntan-publik section, never an extraction ERROR. Empty rawText →console.warn+ skip (visible, not silent nulls). - Classifier gating = the launch switch: everything stays dark ("belum didukung" on the board) until
BUILT_AKTA_TXN_TYPES += "laporan_rups_tahunan"(Task 7). Tasks 1–6 must not make the flow reachable. - Test-DB isolation: all backend DB tests target
ahu_ocr_testvia the bunfig preload. NEVER runbun testagainst the dev DB. Runcd backend && bun run db:push:testafter Task 1's schema change. - Frontend = thin mirrors. NO ReviewEngine / FrontendReviewDescriptor / ReviewData reshape. Extraction page = copy of
AkuisisiExtractionPage.tsx+ exact diffs; review page = flat composition (no delta/diff — nothing changes on the record). Frontend tests viacd frontend && bun run test(vitest), backend viacd backend && bun test. - No auto-confirm: the gate reads confirmations/approvals, it never writes them. Section approvals are EXPLICIT persisted
PerubahanSectionApprovalrows — never derived from child confirmed state (regression c0698d8).
File Structure
| File | Create/Modify | Responsibility |
|---|---|---|
backend/prisma/schema.prisma |
Modify | SubmissionType += LAPORAN_RUPS_TAHUNAN; enum RupsJenisAudit; Submission += tahunLaporan/jenisAudit/rupsSubmittedAt/rupsDeclarations; new LaporanKeuanganExtraction model + Document relation |
backend/src/flow-engine/registry.ts |
Modify (twice) | Task 1: temporary v1-fork stub (compile-gate); Task 6: real laporanRupsTahunanFlow entry |
backend/src/services/submission-dispatch.ts |
Modify | processorKindForType case LAPORAN_RUPS_TAHUNAN → "v2-generic" (satisfies the never tripwire) |
backend/src/flow-engine/context/rups-tahunan-context.ts |
Create | RupsTahunanCtx (extends PtAktaCtx) + buildRupsTahunanContext + LAMPIRAN_FINE_LABELS |
backend/src/flow-engine/rules/laporan-rups/index.ts |
Create | 8 flow rules + parseFlexibleDate + assembled RUPS_TAHUNAN_RULE_SET |
backend/src/services/laporan-keuangan-extract.ts |
Create | extractLaporanKeuanganAkuntan (grounded 3B) + detectTahunBuku + runLaporanKeuanganPass (persist satellite + ExtractedFields) |
backend/src/services/document-processor.ts |
Modify | OCR-only handler case for documentType === "LAPORAN_KEUANGAN" |
backend/src/flow-engine/focused-passes.ts |
Create | Generic named-pass registry FOCUSED_PASS_HANDLERS + runFocusedPassesForDocument (the ExtractionPlan.focusedPasses invocation mechanism — first built here) |
backend/src/flow-engine/processor.ts |
Modify | continueFlow Phase 3 invokes focused passes per doc (log-and-continue) |
backend/src/flow-engine/flows/laporan-rups-tahunan.ts |
Create | The real defineFlow<RupsTahunanCtx> config — FIRST v2-generic registry entry |
backend/src/ocr/classifier.ts |
Modify | AKTA_SUBTYPE_MAP gains raw-subtype identity rows (pendirian/perubahan) so engine-passed subtype overrides resolve |
backend/src/services/akta-txn-classifier.ts |
Modify | BUILT_AKTA_TXN_TYPES += "laporan_rups_tahunan" (launch switch) |
backend/src/routes/submissions.ts |
Modify | inferSubmissionType rung + return union; mapToDocumentType/getProcessingOrder for LAPORAN_KEUANGAN/LAMPIRAN_* |
backend/src/routes/klasifikasi.ts |
Modify | LAPORAN_RUPS_DOC_TYPES + ?type=laporan_rups; refineAktaLabel BERITA_ACARA_RUPS conservative extension; refineSupportingLabel (supporting-doc-classifier wiring); memoized OCR closure |
backend/src/routes/laporan-rups.ts |
Create | Thin route family: review-data, tahun-laporan, jenis-audit, akuntan-publik, section approve/unapprove, terminal submit |
backend/src/index.ts |
Modify | Mount /api/laporan-rups |
backend/src/flow-engine/__tests__/{laporan-rups-schema,rups-tahunan-context,rules-laporan-rups,focused-passes,laporan-rups-flow,laporan-rups-integration}.test.ts |
Create | Per-task TDD |
backend/src/services/__tests__/laporan-keuangan-extract.test.ts |
Create | Extractor grounding + pass persistence |
backend/src/routes/__tests__/{laporan-rups-routing,laporan-rups-routes}.test.ts |
Create | Routing/gating + route-family/gate |
frontend/src/lib/classifier-labels.ts |
Modify | New label display names |
frontend/src/components/klasifikasi/detect-banner.tsx |
Modify | DetectedFlow += "laporan-rups" + labels |
frontend/src/pages/KlasifikasiPage.tsx |
Modify | Detect + label set + create-route |
frontend/src/lib/flow-steps.ts |
Modify | LAPORAN_RUPS_LABELS (4 steps, terminal — NO verifikasi stage) + type union |
frontend/src/lib/destination-for.ts |
Modify | Base laporan-rups + terminal routing |
frontend/src/lib/laporan-rups-review.ts |
Create | Pure submit-gate helpers (outstanding warnings, required sections) |
frontend/src/pages/LaporanRupsExtractionPage.tsx |
Create | Copy of AkuisisiExtractionPage.tsx + exact diffs |
frontend/src/pages/LaporanRupsReviewPage.tsx |
Create | Flat review page (fields, tahun picker, jenis-audit radio, akuntan card, lampiran slots, validations, approvals, terminal submit + declarations) |
frontend/src/pages/LaporanRupsStatusPage.tsx |
Create | Small terminal status page |
frontend/src/routes.tsx |
Modify | laporan-rups/:submissionId/{extraction,review,status} |
frontend/src/lib/laporan-rups-flow.test.ts, frontend/src/lib/laporan-rups-review.test.ts |
Create | vitest units |
Task 1: Prisma schema + compile-gate (enum, columns, satellite, registry stub, ProcessorKind case)
Adds LAPORAN_RUPS_TAHUNAN to the Prisma SubmissionType enum — which FORCES two compile-time additions: a FLOW_REGISTRY key (temporary v1-fork stub, replaced by Task 6) and a processorKindForType case (returns "v2-generic"; the dispatch switch's "v2-generic" case is an unreachable-throw, correct because real v2 dispatch resolves the registry BEFORE the switch — submission-dispatch.ts:64-69). Also adds RupsJenisAudit, the four Submission columns, and the LaporanKeuanganExtraction satellite. LAPORAN_KEUANGAN/LAMPIRAN_LAPORAN_TAHUNAN DocumentTypes already exist — do not touch DocumentType.
Files
- backend/prisma/schema.prisma — Modify (enum SubmissionType ends at AKUISISI_PT ~line 89; Submission model ~line 683, insert after akuisisiAttestation Json? ~line 721; new enum + new model)
- backend/src/flow-engine/registry.ts — Modify (add stub key)
- backend/src/services/submission-dispatch.ts — Modify (processorKindForType, lines 24-52)
- backend/src/flow-engine/__tests__/laporan-rups-schema.test.ts — Create
Interfaces
- Produces: generated enums SubmissionType.LAPORAN_RUPS_TAHUNAN, RupsJenisAudit.{WAJIB_AUDIT,NON_AUDIT}; columns Submission.tahunLaporan: number | null, Submission.jenisAudit: RupsJenisAudit | null, Submission.rupsSubmittedAt: Date | null, Submission.rupsDeclarations: Prisma.JsonValue | null; model client db.laporanKeuanganExtraction; processorKindForType("LAPORAN_RUPS_TAHUNAN") === "v2-generic".
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/laporan-rups-schema.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { SubmissionType, RupsJenisAudit } from "../../generated/prisma/enums";
import { processorKindForType } from "../../services/submission-dispatch";
import { FLOW_REGISTRY } from "../registry";
/**
* Task 1 — LAPORAN_RUPS_TAHUNAN schema slice + the two compile-forced wiring
* points (registry key + ProcessorKind case). The registry entry is a TEMPORARY
* v1-fork stub until Task 6 registers the real defineFlow config.
*/
describe("LAPORAN_RUPS_TAHUNAN schema + compile-gate", () => {
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("exposes the new enums", () => {
expect(SubmissionType.LAPORAN_RUPS_TAHUNAN).toBe("LAPORAN_RUPS_TAHUNAN");
expect(RupsJenisAudit.WAJIB_AUDIT).toBe("WAJIB_AUDIT");
expect(RupsJenisAudit.NON_AUDIT).toBe("NON_AUDIT");
});
it("persists tahunLaporan / jenisAudit / rupsSubmittedAt / rupsDeclarations", async () => {
const sub = await db.submission.create({
data: {
type: "LAPORAN_RUPS_TAHUNAN",
status: "CREATED",
tahunLaporan: 2023,
jenisAudit: "WAJIB_AUDIT",
rupsDeclarations: { declaration: true, warningAcks: [] },
},
});
ids.push(sub.id);
expect(sub.tahunLaporan).toBe(2023);
expect(sub.jenisAudit).toBe("WAJIB_AUDIT");
expect(sub.rupsSubmittedAt).toBeNull();
});
it("persists a LaporanKeuanganExtraction satellite keyed by documentId", async () => {
const doc = await db.document.create({
data: { filename: "lk.pdf", originalName: "lk.pdf", fileSize: 0, filePath: "/tmp/lk.pdf", documentType: "LAPORAN_KEUANGAN", status: "UPLOADED", rawText: "" },
});
docIds.push(doc.id);
const lk = await db.laporanKeuanganExtraction.create({
data: { documentId: doc.id, namaAkuntanPublik: "Drs. Mulyoto, Ak., CPA", nomorIzinAkuntanPublik: "AP.1234", tahunBuku: "2023" },
});
expect(lk.confidence).toBe(0);
expect(lk.tanggalIzinAkuntanPublik).toBeNull();
// @unique on documentId
await expect(
db.laporanKeuanganExtraction.create({ data: { documentId: doc.id } }),
).rejects.toThrow();
});
it("routes the new type to the v2-generic ProcessorKind and a total registry", () => {
expect(processorKindForType("LAPORAN_RUPS_TAHUNAN")).toBe("v2-generic");
expect(FLOW_REGISTRY.LAPORAN_RUPS_TAHUNAN).toBeDefined();
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/laporan-rups-schema.test.ts. Expected: fails —LAPORAN_RUPS_TAHUNANnot on the enum (and typecheck of the test file itself errors). - [ ] Minimal implementation A —
backend/prisma/schema.prisma:
1. Insideenum SubmissionType { ... }, append afterAKUISISI_PT:
/// Pelaporan RUPS Tahunan (UUPT Ps.66-68): the statutory annual filing. TERMINAL
/// self-service — submit → COMPLETED, no verifier queue, no VerifikasiPerubahan row.
/// FIRST engine:'v2-generic' FLOW_REGISTRY tenant. Spelling = mechanical uppercase
/// of the classifier taxonomy `laporan_rups_tahunan` (NOT SABH's PELAPORAN_ noun).
LAPORAN_RUPS_TAHUNAN
- Directly after the
enum SubmissionTypeblock, add:
/// Jenis-audit attestation for LAPORAN_RUPS_TAHUNAN (UUPT Ps.68 wajib-audit gate).
enum RupsJenisAudit {
WAJIB_AUDIT
NON_AUDIT
}
- In
model Submission, after the lineakuisisiAttestation Json?(~line 721), add:
// ── Pelaporan RUPS Tahunan (null for all other submission types) ──
/// Backlog-capable reporting year; SABH window currentYear-10 … currentYear-1.
tahunLaporan Int?
jenisAudit RupsJenisAudit?
/// Terminal self-service submit timestamp (this flow has NO verifikator stage).
rupsSubmittedAt DateTime?
/// Submit declarations {declaration:true, warningAcks:string[], declaredAt}. JSON on
/// Submission (akuisisiAttestation precedent) because recordAuditEvent is keyed on
/// VerifikasiPerubahan and this flow NEVER creates a verifikasi row.
rupsDeclarations Json?
- Add the satellite model next to
model BeritaAcaraRupsExtraction(~line 605):
/// Per-document satellite for the LAPORAN_KEUANGAN focused grounded pass (akuntan-
/// publik block, flow-spec §4.4/§6). Mirrors BeritaAcaraRupsExtraction's shape.
model LaporanKeuanganExtraction {
id String @id @default(uuid())
documentId String @unique
document Document @relation(fields: [documentId], references: [id], onDelete: Cascade)
namaAkuntanPublik String?
nomorIzinAkuntanPublik String?
tanggalIzinAkuntanPublik String?
tahunBuku String?
confidence Float @default(0)
rawOcrText String? @db.Text
rawExtractionJson Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
- In
model Document, add the back-relation alongside the existingberitaAcaraRupsExtraction BeritaAcaraRupsExtraction?line:
laporanKeuanganExtraction LaporanKeuanganExtraction?
- [ ] Minimal implementation B —
backend/src/flow-engine/registry.ts, append toFLOW_REGISTRYafter theAPOSTILLEline:
// TEMPORARY v1-fork stub — satisfies Record totality until Task 6 registers the
// real defineFlow(...) entry. processorKindForType already returns "v2-generic",
// so a premature legacy dispatch throws LOUD (unreachable while the classifier
// gate keeps the flow dark — BUILT_AKTA_TXN_TYPES flips in Task 7).
LAPORAN_RUPS_TAHUNAN: { engine: "v1-fork", type: "LAPORAN_RUPS_TAHUNAN" },
- [ ] Minimal implementation C —
backend/src/services/submission-dispatch.ts, inprocessorKindForType, add before thedefault:arm:
case "LAPORAN_RUPS_TAHUNAN":
// 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__/laporan-rups-schema.test.ts. Expected: 4 pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. Expected: no errors (registry totality +nevertripwire both satisfied). Also run the existing engine suite to confirm the stub breaks nothing:bun test src/flow-engine/__tests__/registry.test.ts src/flow-engine/__tests__/dispatch-routing.test.ts. - [ ] 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__/laporan-rups-schema.test.ts && git commit -m "feat(laporan-rups): LAPORAN_RUPS_TAHUNAN enum + RupsJenisAudit + satellite + compile-gate wiring"
Task 2: Context builder — buildRupsTahunanContext
The flow's buildContext. Extends the shared PtAktaCtx (so wrapped PT_AKTA_RULE_SET rules run unchanged on ctx.input) with the RUPS-Tahunan surface: tahunLaporan/jenisAudit typed columns, the akuntan-publik values from the LaporanKeuanganExtraction satellite, hasLaporanKeuanganDoc, and the six lampiran slot fills (fine labels live on SubmissionDocument.classifiedType).
Files
- backend/src/flow-engine/context/rups-tahunan-context.ts — Create
- backend/src/flow-engine/__tests__/rups-tahunan-context.test.ts — Create
Interfaces
- Consumes: db from ../../lib/db; buildPtAktaContext, type PtAktaCtx from ./pt-akta-context (buildPtAktaContext(submissionId, { aktaClassifiedType: string; tanggalKoranFieldKey?: string }): Promise<PtAktaCtx>); db.laporanKeuanganExtraction (Task 1).
- Produces:
- const LAMPIRAN_FINE_LABELS: readonly ["LAMPIRAN_KEGIATAN_PERSEROAN","LAMPIRAN_TJSL","LAMPIRAN_RINCIAN_MASALAH","LAMPIRAN_PENGAWASAN_KOMISARIS","LAMPIRAN_SUSUNAN_PENGURUS","LAMPIRAN_GAJI_PENGURUS"]
- interface RupsTahunanCtx extends PtAktaCtx { tahunLaporan: number | null; jenisAudit: "WAJIB_AUDIT" | "NON_AUDIT" | null; hasLaporanKeuanganDoc: boolean; akuntan: { nama: string | null; nomorIzin: string | null; tanggalIzin: string | null; tahunBuku: string | null }; lampiranSlots: { key: string; filled: boolean }[] }
- async function buildRupsTahunanContext(submissionId: string): Promise<RupsTahunanCtx>
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/rups-tahunan-context.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { buildRupsTahunanContext, LAMPIRAN_FINE_LABELS } from "../context/rups-tahunan-context";
describe("buildRupsTahunanContext", () => {
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: { withLk: boolean; withLampiran: boolean }) {
const akta = await db.document.create({
data: { filename: "a.pdf", originalName: "a.pdf", fileSize: 0, filePath: "/tmp/a.pdf", documentType: "AKTA", status: "AI_EXTRACTION", rawText: "" },
});
docIds.push(akta.id);
await db.extractedField.createMany({
data: [
{ documentId: akta.id, fieldKey: "nama_perseroan", value: "PT RUPS UJI" },
{ documentId: akta.id, fieldKey: "tanggal_rups", value: "2024-04-10" },
],
});
const sub = await db.submission.create({
data: { type: "LAPORAN_RUPS_TAHUNAN", status: "VALIDATING", tahunLaporan: 2023, jenisAudit: "WAJIB_AUDIT" },
});
ids.push(sub.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_RUPS_TAHUNAN", processingOrder: 0, extractionStatus: "DONE" },
});
if (opts.withLk) {
const lk = await db.document.create({
data: { filename: "lk.pdf", originalName: "lk.pdf", fileSize: 0, filePath: "/tmp/lk.pdf", documentType: "LAPORAN_KEUANGAN", status: "VERIFICATION_READY", rawText: "LAPORAN KEUANGAN" },
});
docIds.push(lk.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: lk.id, classifiedType: "LAPORAN_KEUANGAN", processingOrder: 6, extractionStatus: "DONE" },
});
await db.laporanKeuanganExtraction.create({
data: { documentId: lk.id, namaAkuntanPublik: "KAP Mulyoto & Rekan", nomorIzinAkuntanPublik: "AP.1234", tanggalIzinAkuntanPublik: "12 Januari 2020", tahunBuku: "2023" },
});
}
if (opts.withLampiran) {
const lamp = await db.document.create({
data: { filename: "tjsl.pdf", originalName: "tjsl.pdf", fileSize: 0, filePath: "/tmp/tjsl.pdf", documentType: "LAMPIRAN_LAPORAN_TAHUNAN", status: "UPLOADED", rawText: "" },
});
docIds.push(lamp.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: lamp.id, classifiedType: "LAMPIRAN_TJSL", processingOrder: 7, extractionStatus: "DONE" },
});
}
return sub.id;
}
it("assembles base PtAktaCtx + typed columns + akuntan from the satellite + lampiran slots", async () => {
const id = await seed({ withLk: true, withLampiran: true });
const ctx = await buildRupsTahunanContext(id);
expect(ctx.input.aktaNamaPerseroan).toBe("PT RUPS UJI"); // base builder ran with AKTA_RUPS_TAHUNAN
expect(ctx.tahunLaporan).toBe(2023);
expect(ctx.jenisAudit).toBe("WAJIB_AUDIT");
expect(ctx.hasLaporanKeuanganDoc).toBe(true);
expect(ctx.akuntan).toEqual({ nama: "KAP Mulyoto & Rekan", nomorIzin: "AP.1234", tanggalIzin: "12 Januari 2020", tahunBuku: "2023" });
expect(ctx.lampiranSlots).toHaveLength(LAMPIRAN_FINE_LABELS.length);
expect(ctx.lampiranSlots.find((s) => s.key === "LAMPIRAN_TJSL")?.filled).toBe(true);
expect(ctx.lampiranSlots.find((s) => s.key === "LAMPIRAN_GAJI_PENGURUS")?.filled).toBe(false);
});
it("null-safe when no laporan keuangan doc / satellite / attestation", async () => {
const id = await seed({ withLk: false, withLampiran: false });
await db.submission.update({ where: { id }, data: { tahunLaporan: null, jenisAudit: null } });
const ctx = await buildRupsTahunanContext(id);
expect(ctx.tahunLaporan).toBeNull();
expect(ctx.jenisAudit).toBeNull();
expect(ctx.hasLaporanKeuanganDoc).toBe(false);
expect(ctx.akuntan).toEqual({ nama: null, nomorIzin: null, tanggalIzin: null, tahunBuku: null });
expect(ctx.lampiranSlots.every((s) => !s.filled)).toBe(true);
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/rups-tahunan-context.test.ts. Expected: fails — cannot resolve../context/rups-tahunan-context. - [ ] Minimal implementation. Create
backend/src/flow-engine/context/rups-tahunan-context.ts:
import { db } from "../../lib/db";
import { buildPtAktaContext, type PtAktaCtx } from "./pt-akta-context";
/** The six UUPT Ps.66(2) letters b-g upload slots. Fine labels live on
* SubmissionDocument.classifiedType; they ALL map to the ONE Prisma carrier
* DocumentType LAMPIRAN_LAPORAN_TAHUNAN (store-only, manual board assignment). */
export const LAMPIRAN_FINE_LABELS = [
"LAMPIRAN_KEGIATAN_PERSEROAN",
"LAMPIRAN_TJSL",
"LAMPIRAN_RINCIAN_MASALAH",
"LAMPIRAN_PENGAWASAN_KOMISARIS",
"LAMPIRAN_SUSUNAN_PENGURUS",
"LAMPIRAN_GAJI_PENGURUS",
] as const;
export interface RupsTahunanCtx extends PtAktaCtx {
tahunLaporan: number | null;
jenisAudit: "WAJIB_AUDIT" | "NON_AUDIT" | null;
hasLaporanKeuanganDoc: boolean;
/** Akuntan-publik block from the LaporanKeuanganExtraction satellite (focused
* pass OR manual entry via PUT /akuntan-publik — same row either way). */
akuntan: { nama: string | null; nomorIzin: string | null; tanggalIzin: string | null; tahunBuku: string | null };
lampiranSlots: { key: string; filled: boolean }[];
}
export async function buildRupsTahunanContext(submissionId: string): Promise<RupsTahunanCtx> {
const base = await buildPtAktaContext(submissionId, { aktaClassifiedType: "AKTA_RUPS_TAHUNAN" });
const submission = await db.submission.findUnique({
where: { id: submissionId },
select: {
tahunLaporan: true,
jenisAudit: true,
documents: { select: { classifiedType: true, documentId: true, document: { select: { documentType: true } } } },
},
});
if (!submission) throw new Error(`buildRupsTahunanContext: submission ${submissionId} not found`);
const lkDoc = submission.documents.find((d) => d.document.documentType === "LAPORAN_KEUANGAN");
const lk = lkDoc
? await db.laporanKeuanganExtraction.findUnique({ where: { documentId: lkDoc.documentId } })
: null;
return {
...base,
tahunLaporan: submission.tahunLaporan,
jenisAudit: submission.jenisAudit,
hasLaporanKeuanganDoc: !!lkDoc,
akuntan: {
nama: lk?.namaAkuntanPublik ?? null,
nomorIzin: lk?.nomorIzinAkuntanPublik ?? null,
tanggalIzin: lk?.tanggalIzinAkuntanPublik ?? null,
tahunBuku: lk?.tahunBuku ?? null,
},
lampiranSlots: LAMPIRAN_FINE_LABELS.map((key) => ({
key,
filled: submission.documents.some((d) => d.classifiedType === key),
})),
};
}
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/rups-tahunan-context.test.ts. Expected: 2 pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/flow-engine/context/rups-tahunan-context.ts backend/src/flow-engine/__tests__/rups-tahunan-context.test.ts && git commit -m "feat(laporan-rups): RupsTahunanCtx context builder over buildPtAktaContext"
Task 3: Flow rules — RUPS_TAHUNAN_RULE_SET (the tuned matrix)
The 8 flow-specific rules (4 FAIL-capable + 4 advisory) + the 5 wrapped shared rules via pickRules + the shared rupsQuorumRule() at the default >½ threshold. Severities are binding (Global Constraints). Two fidelity notes baked into code comments: (a) the spec's "RUPS_KUORUM" rides the shared wrapper, which emits RUPS_ATTENDANCE_QUORUM — keep the shared code (bit-for-bit); (b) the spec marks PERSEROAN_STATE/NOTARIS_TERAKHIR/NPWP_PERSEROAN_FUZZY async:true, but the SABH round-trips actually happen inside buildPtAktaContext (blokir state + notaris id are pre-resolved into ctx.input) — the shared wrappers are sync and stay sync (wrapper fidelity trumps).
Files
- backend/src/flow-engine/rules/laporan-rups/index.ts — Create
- backend/src/flow-engine/__tests__/rules-laporan-rups.test.ts — Create
Interfaces
- Consumes: Rule, RuleResult from ../../types; RupsTahunanCtx from ../../context/rups-tahunan-context; PT_AKTA_RULE_SET, pickRules from ../pt-akta/index; rupsQuorumRule from ../pt-akta/rups-quorum.
- Produces:
- function parseFlexibleDate(s: string | null | undefined): Date | null (ISO YYYY-MM-DD, DD-MM-YYYY, DD/MM/YYYY, Indonesian word dates 12 Januari 2024)
- 8 exported Rule<RupsTahunanCtx> consts: rupsAktaFieldsPresentRule, tahunLaporanValidRule, tahunBukuOrderingRule, akuntanPublikRequiredRule, rupsAnnualWindowRule, akuntanIzinFormatRule, laporanKeuanganTahunMatchRule, rupsLampiranCompletenessRule
- const RUPS_TAHUNAN_SHARED_RULE_CODES: readonly string[]
- const RUPS_TAHUNAN_RULE_SET: Rule<RupsTahunanCtx>[] (14 rules total)
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/rules-laporan-rups.test.ts:
import { describe, it, expect } from "bun:test";
import {
parseFlexibleDate,
rupsAktaFieldsPresentRule, tahunLaporanValidRule, tahunBukuOrderingRule,
akuntanPublikRequiredRule, rupsAnnualWindowRule, akuntanIzinFormatRule,
laporanKeuanganTahunMatchRule, rupsLampiranCompletenessRule,
RUPS_TAHUNAN_RULE_SET,
} from "../rules/laporan-rups/index";
import type { RupsTahunanCtx } from "../context/rups-tahunan-context";
import type { RuleResult } from "../types";
const YEAR = new Date().getFullYear();
function ctx(over: Partial<RupsTahunanCtx> = {}): RupsTahunanCtx {
return {
input: {} as never,
rupsQuorum: { pemegangSaham: [], hasBaRupsDoc: false },
aktaFields: [
{ fieldKey: "nomor_akta", value: "12" },
{ fieldKey: "tanggal_akta", value: `10-04-${YEAR}` },
{ fieldKey: "tanggal_rups", value: `10-04-${YEAR}` },
],
tahunLaporan: YEAR - 1,
jenisAudit: "NON_AUDIT",
hasLaporanKeuanganDoc: false,
akuntan: { nama: null, nomorIzin: null, tanggalIzin: null, tahunBuku: null },
lampiranSlots: [{ key: "LAMPIRAN_TJSL", filled: false }],
...over,
} as RupsTahunanCtx;
}
const run = (r: { run: (c: RupsTahunanCtx) => unknown }, c: RupsTahunanCtx) => r.run(c) as RuleResult;
describe("parseFlexibleDate", () => {
it("parses ISO / DD-MM-YYYY / DD/MM/YYYY / Indonesian word dates", () => {
expect(parseFlexibleDate("2024-04-10")?.toISOString().slice(0, 10)).toBe("2024-04-10");
expect(parseFlexibleDate("10-04-2024")?.toISOString().slice(0, 10)).toBe("2024-04-10");
expect(parseFlexibleDate("10/04/2024")?.toISOString().slice(0, 10)).toBe("2024-04-10");
expect(parseFlexibleDate("10 April 2024")?.toISOString().slice(0, 10)).toBe("2024-04-10");
expect(parseFlexibleDate("31 Desember 2023")?.toISOString().slice(0, 10)).toBe("2023-12-31");
expect(parseFlexibleDate("32 Januari 2024")).toBeNull();
expect(parseFlexibleDate("kapan-kapan")).toBeNull();
expect(parseFlexibleDate(null)).toBeNull();
});
});
describe("RUPS_AKTA_FIELDS_PRESENT (FAIL)", () => {
it("PASSes when nomor_akta + tanggal_akta present", () => {
expect(run(rupsAktaFieldsPresentRule, ctx()).status).toBe("PASS");
});
it("FAILs listing the missing SABH-mandatory fields", () => {
const c = ctx({ aktaFields: [{ fieldKey: "nomor_akta", value: " " }] });
const r = run(rupsAktaFieldsPresentRule, c);
expect(r.status).toBe("FAIL");
expect(r.message).toContain("nomor_akta");
expect(r.message).toContain("tanggal_akta");
});
});
describe("TAHUN_LAPORAN_VALID (FAIL, backlog window)", () => {
it("FAILs when unset (nudges the picker; the gate also blocks)", () => {
expect(run(tahunLaporanValidRule, ctx({ tahunLaporan: null })).status).toBe("FAIL");
});
it("accepts the -10 and -1 boundaries", () => {
expect(run(tahunLaporanValidRule, ctx({ tahunLaporan: YEAR - 10 })).status).toBe("PASS");
expect(run(tahunLaporanValidRule, ctx({ tahunLaporan: YEAR - 1 })).status).toBe("PASS");
});
it("FAILs at -11 and at the current year", () => {
expect(run(tahunLaporanValidRule, ctx({ tahunLaporan: YEAR - 11 })).status).toBe("FAIL");
expect(run(tahunLaporanValidRule, ctx({ tahunLaporan: YEAR })).status).toBe("FAIL");
});
});
describe("TAHUN_BUKU_ORDERING (FAIL) — RUPS must be AFTER 31 Dec of tahunLaporan", () => {
const base = { tahunLaporan: 2023 };
it("FAILs on RUPS dated 31 Dec of the tahun buku (boundary)", () => {
const c = ctx({ ...base, aktaFields: [{ fieldKey: "tanggal_rups", value: "31-12-2023" }] });
expect(run(tahunBukuOrderingRule, c).status).toBe("FAIL");
});
it("PASSes on 1 Jan of tahunLaporan+1 (boundary)", () => {
const c = ctx({ ...base, aktaFields: [{ fieldKey: "tanggal_rups", value: "01-01-2024" }] });
expect(run(tahunBukuOrderingRule, c).status).toBe("PASS");
});
it("SKIPs when tahun unset or tanggal_rups unparseable", () => {
expect(run(tahunBukuOrderingRule, ctx({ tahunLaporan: null })).status).toBe("SKIPPED");
expect(run(tahunBukuOrderingRule, ctx({ ...base, aktaFields: [] })).status).toBe("SKIPPED");
});
});
describe("AKUNTAN_PUBLIK_REQUIRED (conditional FAIL, UUPT Ps.68)", () => {
it("SKIPs when NON_AUDIT or unattested", () => {
expect(run(akuntanPublikRequiredRule, ctx({ jenisAudit: "NON_AUDIT" })).status).toBe("SKIPPED");
expect(run(akuntanPublikRequiredRule, ctx({ jenisAudit: null })).status).toBe("SKIPPED");
});
it("FAILs under WAJIB_AUDIT listing every missing element (doc + 3 AP fields)", () => {
const r = run(akuntanPublikRequiredRule, ctx({ jenisAudit: "WAJIB_AUDIT" }));
expect(r.status).toBe("FAIL");
expect(r.message).toContain("LAPORAN_KEUANGAN");
expect(r.message).toContain("nama_akuntan_publik");
});
it("PASSes under WAJIB_AUDIT when doc + all three AP values exist", () => {
const c = ctx({
jenisAudit: "WAJIB_AUDIT", hasLaporanKeuanganDoc: true,
akuntan: { nama: "KAP X", nomorIzin: "AP.1234", tanggalIzin: "2020-01-12", tahunBuku: "2023" },
});
expect(run(akuntanPublikRequiredRule, c).status).toBe("PASS");
});
});
describe("RUPS_ANNUAL_WINDOW (WARNING — NEVER FAIL, Ps.78(2) + 10y backlog)", () => {
it("WARNs when RUPS is after 30 Jun of tahunLaporan+1 — never FAIL even years late", () => {
const c = ctx({ tahunLaporan: 2015, aktaFields: [{ fieldKey: "tanggal_rups", value: "10-04-2024" }] });
const r = run(rupsAnnualWindowRule, c);
expect(r.status).toBe("WARNING");
});
it("PASSes on/before 30 Jun of tahunLaporan+1", () => {
const c = ctx({ tahunLaporan: 2023, aktaFields: [{ fieldKey: "tanggal_rups", value: "30-06-2024" }] });
expect(run(rupsAnnualWindowRule, c).status).toBe("PASS");
});
it("SKIPs on missing inputs", () => {
expect(run(rupsAnnualWindowRule, ctx({ tahunLaporan: null })).status).toBe("SKIPPED");
});
});
describe("AKUNTAN_IZIN_FORMAT (WARNING — NEVER FAIL, no on-prem P2PK registry)", () => {
it("SKIPs without a nomor izin; PASSes AP.1234; WARNs on odd shapes", () => {
expect(run(akuntanIzinFormatRule, ctx()).status).toBe("SKIPPED");
const ok = ctx({ akuntan: { nama: "X", nomorIzin: "AP.1234", tanggalIzin: null, tahunBuku: null } });
expect(run(akuntanIzinFormatRule, ok).status).toBe("PASS");
const odd = ctx({ akuntan: { nama: "X", nomorIzin: "SK-99/WEIRD", tanggalIzin: null, tahunBuku: null } });
expect(run(akuntanIzinFormatRule, odd).status).toBe("WARNING");
});
});
describe("LAPORAN_KEUANGAN_TAHUN_MATCH (WARNING)", () => {
it("PASS on match, WARNING on mismatch, SKIPPED when either side missing", () => {
const match = ctx({ tahunLaporan: 2023, akuntan: { nama: null, nomorIzin: null, tanggalIzin: null, tahunBuku: "per 31 Desember 2023" } });
expect(run(laporanKeuanganTahunMatchRule, match).status).toBe("PASS");
const mis = ctx({ tahunLaporan: 2023, akuntan: { nama: null, nomorIzin: null, tanggalIzin: null, tahunBuku: "2022" } });
expect(run(laporanKeuanganTahunMatchRule, mis).status).toBe("WARNING");
expect(run(laporanKeuanganTahunMatchRule, ctx()).status).toBe("SKIPPED");
});
});
describe("RUPS_LAMPIRAN_COMPLETENESS (WARNING — NEVER FAIL, Ps.66(2) conditionals)", () => {
it("WARNs listing empty slots; PASSes when all filled", () => {
const r = run(rupsLampiranCompletenessRule, ctx());
expect(r.status).toBe("WARNING");
expect(r.message).toContain("LAMPIRAN_TJSL");
const full = ctx({ lampiranSlots: [{ key: "LAMPIRAN_TJSL", filled: true }] });
expect(run(rupsLampiranCompletenessRule, full).status).toBe("PASS");
});
});
describe("RUPS_TAHUNAN_RULE_SET composition (the tuned matrix)", () => {
const codes = RUPS_TAHUNAN_RULE_SET.map((r) => r.code);
it("contains exactly the 14 tuned rules", () => {
expect([...codes].sort()).toEqual([
"AKUNTAN_IZIN_FORMAT", "AKUNTAN_PUBLIK_REQUIRED", "LAPORAN_KEUANGAN_TAHUN_MATCH",
"NAMA_PT_CONSISTENCY", "NOTARIS_TERAKHIR", "NPWP_PERSEROAN_FUZZY",
"OLD_DATA_CONSISTENCY", "PERSEROAN_STATE", "RUPS_AKTA_FIELDS_PRESENT",
"RUPS_ANNUAL_WINDOW", "RUPS_ATTENDANCE_QUORUM", "RUPS_LAMPIRAN_COMPLETENESS",
"TAHUN_BUKU_ORDERING", "TAHUN_LAPORAN_VALID",
]);
});
it("EXCLUDES AKTA_DATE_WINDOW — the 30-day window false-FAILs every backlog filing (guard: a runAllValidations refactor must trip this)", () => {
expect(codes).not.toContain("AKTA_DATE_WINDOW");
});
it("EXCLUDES the identity/kontak/modal/KBLI/jenis groups (filing registers no person)", () => {
for (const off of ["NIK_KTP_AKTA", "NAMA_NPWP_KTP", "KTP_COMPLETENESS", "NPWP_COMPLETENESS", "PASSPORT_AKTA", "CONTACT_INFO_COMPLETE", "PEMEGANG_SAHAM_CONTACT", "DOMISILI_NAMA", "MODAL_BUKTI_SETOR", "PP29_MODAL", "KBLI_VALID", "PERUBAHAN_KBLI", "JENIS_SELECTION_NONEMPTY", "CONTRADICTORY_JENIS", "REQUIRED_SUPPORTING_DOCS", "DATA_ACTUALLY_CHANGED"]) {
expect(codes).not.toContain(off);
}
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/rules-laporan-rups.test.ts. Expected: fails — cannot resolve../rules/laporan-rups/index. - [ ] Minimal implementation. Create
backend/src/flow-engine/rules/laporan-rups/index.ts:
import type { Rule, RuleResult } from "../../types";
import type { RupsTahunanCtx } from "../../context/rups-tahunan-context";
import { PT_AKTA_RULE_SET, pickRules } from "../pt-akta/index";
import { rupsQuorumRule } from "../pt-akta/rups-quorum";
// ── date helper ───────────────────────────────────────────────────────────────
const MONTHS: Record<string, number> = {
januari: 1, februari: 2, maret: 3, april: 4, mei: 5, juni: 6,
juli: 7, agustus: 8, september: 9, oktober: 10, november: 11, desember: 12,
};
function utc(y: number, mo: number, d: number): Date | null {
const dt = new Date(Date.UTC(y, mo - 1, d));
return dt.getUTCFullYear() === y && dt.getUTCMonth() === mo - 1 && dt.getUTCDate() === d ? dt : null;
}
/** "2024-04-10" | "10-04-2024" | "10/04/2024" | "10 April 2024" → UTC Date | null. */
export function parseFlexibleDate(s: string | null | undefined): Date | null {
if (!s) return null;
const t = s.trim();
let m = t.match(/^(\d{4})-(\d{2})-(\d{2})/);
if (m) return utc(+m[1]!, +m[2]!, +m[3]!);
m = t.match(/^(\d{1,2})[-/](\d{1,2})[-/](\d{4})/);
if (m) return utc(+m[3]!, +m[2]!, +m[1]!);
m = t.match(/^(\d{1,2})\s+([A-Za-z]+)\s+(\d{4})/);
if (m) {
const month = MONTHS[m[2]!.toLowerCase()];
if (month) return utc(+m[3]!, month, +m[1]!);
}
return null;
}
const field = (ctx: RupsTahunanCtx, key: string): string | null =>
ctx.aktaFields.find((f) => f.fieldKey === key)?.value ?? null;
const rr = (ruleCode: string, ruleLabel: string, status: RuleResult["status"], message: string, details?: unknown): RuleResult =>
({ ruleCode, ruleLabel, status, message, ...(details !== undefined ? { details: details as never } : {}) });
// ── the 8 flow rules ──────────────────────────────────────────────────────────
/** FAIL — SABH's two mandatory typed fields (no_akta_notaris + tanggal_akta_notaris).
* A terminal submit with them missing is an unfixable filing (no verifier catches it). */
export const rupsAktaFieldsPresentRule: Rule<RupsTahunanCtx> = {
code: "RUPS_AKTA_FIELDS_PRESENT",
label: "Nomor & Tanggal Akta RUPS Tahunan Terisi",
run: (ctx) => {
const missing: string[] = [];
if (!field(ctx, "nomor_akta")?.trim()) missing.push("nomor_akta");
if (!field(ctx, "tanggal_akta")?.trim()) missing.push("tanggal_akta");
if (missing.length > 0)
return rr("RUPS_AKTA_FIELDS_PRESENT", "Nomor & Tanggal Akta RUPS Tahunan Terisi", "FAIL",
`Field akta wajib SABH belum terisi: ${missing.join(", ")}`, { missing });
return rr("RUPS_AKTA_FIELDS_PRESENT", "Nomor & Tanggal Akta RUPS Tahunan Terisi", "PASS",
"Nomor dan tanggal akta terisi");
},
};
/** FAIL — SABH accepts backlog filings only within currentYear-10 … currentYear-1
* (the pelaporan dropdown window); outside it SABH hard-rejects. Unset → FAIL
* (nudges the picker; the submit gate independently blocks on unset). */
export const tahunLaporanValidRule: Rule<RupsTahunanCtx> = {
code: "TAHUN_LAPORAN_VALID",
label: "Tahun Laporan dalam Jendela Backlog SABH",
run: (ctx) => {
const now = new Date().getFullYear();
const min = now - 10, max = now - 1;
if (ctx.tahunLaporan == null)
return rr("TAHUN_LAPORAN_VALID", "Tahun Laporan dalam Jendela Backlog SABH", "FAIL",
`Tahun laporan belum dipilih (jendela ${min}-${max})`, { min, max });
if (ctx.tahunLaporan < min || ctx.tahunLaporan > max)
return rr("TAHUN_LAPORAN_VALID", "Tahun Laporan dalam Jendela Backlog SABH", "FAIL",
`Tahun laporan ${ctx.tahunLaporan} di luar jendela backlog SABH (${min}-${max})`, { min, max });
return rr("TAHUN_LAPORAN_VALID", "Tahun Laporan dalam Jendela Backlog SABH", "PASS",
`Tahun laporan ${ctx.tahunLaporan} valid`);
},
};
/** FAIL — a RUPS cannot ratify a fiscal year that has not ended: tanggal_rups must
* be strictly AFTER 31 Dec of tahunLaporan. Violation ⇒ OCR misread or wrong tahun. */
export const tahunBukuOrderingRule: Rule<RupsTahunanCtx> = {
code: "TAHUN_BUKU_ORDERING",
label: "Tanggal RUPS Setelah Akhir Tahun Buku",
run: (ctx) => {
const L = "Tanggal RUPS Setelah Akhir Tahun Buku";
if (ctx.tahunLaporan == null)
return rr("TAHUN_BUKU_ORDERING", L, "SKIPPED", "Tahun laporan belum dipilih");
const rups = parseFlexibleDate(field(ctx, "tanggal_rups"));
if (!rups)
return rr("TAHUN_BUKU_ORDERING", L, "SKIPPED", "Tanggal RUPS tidak terbaca dari akta");
const yearEnd = Date.UTC(ctx.tahunLaporan, 11, 31);
if (rups.getTime() <= yearEnd)
return rr("TAHUN_BUKU_ORDERING", L, "FAIL",
`Tanggal RUPS (${field(ctx, "tanggal_rups")}) tidak setelah akhir tahun buku ${ctx.tahunLaporan} — periksa hasil OCR atau pilihan tahun`);
return rr("TAHUN_BUKU_ORDERING", L, "PASS", "Tanggal RUPS setelah akhir tahun buku");
},
};
/** Conditional FAIL — UUPT Ps.68(1)+(3): an audited PT files the audited laporan
* keuangan; under jenisAudit=WAJIB_AUDIT the doc + nama/izin/tanggal-izin AP are
* mandatory. SKIPPED when NON_AUDIT / unattested. */
export const akuntanPublikRequiredRule: Rule<RupsTahunanCtx> = {
code: "AKUNTAN_PUBLIK_REQUIRED",
label: "Kelengkapan Akuntan Publik (Wajib Audit)",
run: (ctx) => {
const L = "Kelengkapan Akuntan Publik (Wajib Audit)";
if (ctx.jenisAudit !== "WAJIB_AUDIT")
return rr("AKUNTAN_PUBLIK_REQUIRED", L, "SKIPPED",
"Perseroan tidak wajib diaudit (NON_AUDIT / belum diattestasi)");
const missing: string[] = [];
if (!ctx.hasLaporanKeuanganDoc) missing.push("dokumen LAPORAN_KEUANGAN");
if (!ctx.akuntan.nama?.trim()) missing.push("nama_akuntan_publik");
if (!ctx.akuntan.nomorIzin?.trim()) missing.push("nomor_izin_akuntan_publik");
if (!ctx.akuntan.tanggalIzin?.trim()) missing.push("tanggal_izin_akuntan_publik");
if (missing.length > 0)
return rr("AKUNTAN_PUBLIK_REQUIRED", L, "FAIL",
`Wajib audit (UUPT Ps.68) — belum lengkap: ${missing.join(", ")}`, { missing });
return rr("AKUNTAN_PUBLIK_REQUIRED", L, "PASS", "Data akuntan publik lengkap");
},
};
/** WARNING — NEVER FAIL. UUPT Ps.78(2) wants the annual RUPS ≤6 months after the
* tahun buku ends, but lateness does NOT invalidate the RUPS or the filing, and
* SABH itself accepts a 10-year backlog — a hard FAIL would block the exact
* backlog filings this flow exists to accept. */
export const rupsAnnualWindowRule: Rule<RupsTahunanCtx> = {
code: "RUPS_ANNUAL_WINDOW",
label: "RUPS Tahunan ≤6 Bulan Setelah Tahun Buku (Ps.78(2))",
run: (ctx) => {
const L = "RUPS Tahunan ≤6 Bulan Setelah Tahun Buku (Ps.78(2))";
if (ctx.tahunLaporan == null)
return rr("RUPS_ANNUAL_WINDOW", L, "SKIPPED", "Tahun laporan belum dipilih");
const rups = parseFlexibleDate(field(ctx, "tanggal_rups"));
if (!rups) return rr("RUPS_ANNUAL_WINDOW", L, "SKIPPED", "Tanggal RUPS tidak terbaca");
const deadline = Date.UTC(ctx.tahunLaporan + 1, 5, 30); // 30 Jun tahun+1
if (rups.getTime() > deadline)
return rr("RUPS_ANNUAL_WINDOW", L, "WARNING",
`RUPS tahunan melewati 6 bulan setelah tahun buku ${ctx.tahunLaporan} (Ps.78(2)) — sah untuk backlog, mohon dikonfirmasi`);
return rr("RUPS_ANNUAL_WINDOW", L, "PASS", "RUPS dalam jendela 6 bulan Ps.78(2)");
},
};
/** WARNING — NEVER FAIL. No authoritative on-prem AP-license registry exists
* (P2PK Kemenkeu is a cloud service; the full on-premise constraint forbids the
* call) — we can flag an odd-looking izin, never assert invalidity. */
export const akuntanIzinFormatRule: Rule<RupsTahunanCtx> = {
code: "AKUNTAN_IZIN_FORMAT",
label: "Format Nomor Izin Akuntan Publik",
run: (ctx) => {
const L = "Format Nomor Izin Akuntan Publik";
const izin = ctx.akuntan.nomorIzin?.trim();
if (!izin) return rr("AKUNTAN_IZIN_FORMAT", L, "SKIPPED", "Nomor izin akuntan publik belum terisi");
if (/^AP[.\s-]?\d{3,5}$/i.test(izin))
return rr("AKUNTAN_IZIN_FORMAT", L, "PASS", `Nomor izin ${izin} berbentuk wajar (AP.xxxx)`);
return rr("AKUNTAN_IZIN_FORMAT", L, "WARNING",
`Nomor izin "${izin}" tidak berbentuk AP.xxxx — mohon periksa (verifikasi registri P2PK tidak tersedia on-premise)`);
},
};
/** WARNING — the cover's tahun buku vs the picked tahunLaporan; grounded
* extraction may legitimately miss → advisory only. */
export const laporanKeuanganTahunMatchRule: Rule<RupsTahunanCtx> = {
code: "LAPORAN_KEUANGAN_TAHUN_MATCH",
label: "Tahun Buku Laporan Keuangan vs Tahun Laporan",
run: (ctx) => {
const L = "Tahun Buku Laporan Keuangan vs Tahun Laporan";
const m = ctx.akuntan.tahunBuku?.match(/(\d{4})/);
if (ctx.tahunLaporan == null || !m)
return rr("LAPORAN_KEUANGAN_TAHUN_MATCH", L, "SKIPPED", "Tahun buku atau tahun laporan belum tersedia");
if (+m[1]! !== ctx.tahunLaporan)
return rr("LAPORAN_KEUANGAN_TAHUN_MATCH", L, "WARNING",
`Tahun buku laporan keuangan (${m[1]}) ≠ tahun laporan dipilih (${ctx.tahunLaporan})`);
return rr("LAPORAN_KEUANGAN_TAHUN_MATCH", L, "PASS", `Tahun buku ${m[1]} sesuai`);
},
};
/** WARNING — NEVER FAIL. Ps.66(2) letters c (TJSL — Ps.74 sectoral), d (rincian
* masalah — may be nil) and g are conditional; hard-requiring all six slots is
* legally wrong. Advisory nudge listing the empty slots. */
export const rupsLampiranCompletenessRule: Rule<RupsTahunanCtx> = {
code: "RUPS_LAMPIRAN_COMPLETENESS",
label: "Kelengkapan Lampiran Laporan Tahunan (Ps.66(2))",
run: (ctx) => {
const L = "Kelengkapan Lampiran Laporan Tahunan (Ps.66(2))";
const empty = ctx.lampiranSlots.filter((s) => !s.filled).map((s) => s.key);
if (empty.length === 0)
return rr("RUPS_LAMPIRAN_COMPLETENESS", L, "PASS", "Semua slot lampiran terisi");
return rr("RUPS_LAMPIRAN_COMPLETENESS", L, "WARNING",
`Slot lampiran kosong (sebagian bersifat kondisional): ${empty.join(", ")}`, { empty });
},
};
// ── the assembled tuned matrix ────────────────────────────────────────────────
/** Wrapped shared rules (bodies bit-for-bit in cross-validator.ts). The SABH
* round-trips for PERSEROAN_STATE / NOTARIS_TERAKHIR happen inside
* buildPtAktaContext (blokir state + notaris id pre-resolved into ctx.input),
* so these wrappers are sync — the spec's async:true note predates the wrappers. */
export const RUPS_TAHUNAN_SHARED_RULE_CODES = [
"NAMA_PT_CONSISTENCY", "OLD_DATA_CONSISTENCY", "NOTARIS_TERAKHIR",
"NPWP_PERSEROAN_FUZZY", "PERSEROAN_STATE",
] as const;
/**
* THE tuned matrix (flow-spec §4.5). DELIBERATE EXCLUSIONS (do not "fix"):
* AKTA_DATE_WINDOW (false-FAILs every 10-year backlog filing — replaced by
* TAHUN_LAPORAN_VALID/TAHUN_BUKU_ORDERING/RUPS_ANNUAL_WINDOW) and the whole
* identity/kontak/modal/saham/KBLI/jenis group (this filing registers nobody).
* RUPS quorum rides the SHARED wrapper (default >1/2, Ps.86(1)) and emits the
* shared code RUPS_ATTENDANCE_QUORUM.
*/
export const RUPS_TAHUNAN_RULE_SET: Rule<RupsTahunanCtx>[] = [
rupsAktaFieldsPresentRule,
tahunLaporanValidRule,
tahunBukuOrderingRule,
akuntanPublikRequiredRule,
rupsAnnualWindowRule,
akuntanIzinFormatRule,
laporanKeuanganTahunMatchRule,
rupsLampiranCompletenessRule,
...pickRules(PT_AKTA_RULE_SET, RUPS_TAHUNAN_SHARED_RULE_CODES),
rupsQuorumRule(),
];
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/rules-laporan-rups.test.ts. Expected: all pass. - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/flow-engine/rules/laporan-rups backend/src/flow-engine/__tests__/rules-laporan-rups.test.ts && git commit -m "feat(laporan-rups): tuned RUPS_TAHUNAN_RULE_SET (8 flow rules + wrapped shared + quorum)"
Task 4: Laporan-keuangan grounded extractor + OCR-only document handler
The NEW focused extractor (flow-spec §4.4): a single-purpose grounded 3B call mirroring akta-koran-extract.ts shape-for-shape, plus runLaporanKeuanganPass (persists the satellite + ExtractedField rows) and the document-processor.ts OCR-only case — without it a LAPORAN_KEUANGAN doc falls through to the generic akta handler and burns a full akta-VLM extraction on a 100-page report.
Files
- backend/src/services/laporan-keuangan-extract.ts — Create
- backend/src/services/document-processor.ts — Modify (insert case in the dispatch chain, after the AKTA_PERALIHAN_PP_PT case ~line 2471, before "// 7. Akta handling")
- backend/src/services/__tests__/laporan-keuangan-extract.test.ts — Create
Interfaces
- Consumes: db; CLEANUP_LLM_URL/CLEANUP_LLM_MODEL/CLEANUP_LLM_TIMEOUT_MS env (3B @8003 — apostille/koran precedent); getLayoutProvider from ../ocr/provider (already imported in document-processor.ts).
- Produces:
- interface LaporanKeuanganAkuntan { nama_akuntan_publik: string | null; nomor_izin_akuntan_publik: string | null; tanggal_izin_akuntan_publik: string | null; tahun_buku: string | null }
- async function extractLaporanKeuanganAkuntan(rawText: string): Promise<LaporanKeuanganAkuntan> — every value verbatim-substring-grounded; hallucinations → null
- function detectTahunBuku(rawText: string): string | null — deterministic year fallback
- async function runLaporanKeuanganPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> — idempotent-once: if a LaporanKeuanganExtraction row already exists it returns immediately (NEVER clobbers a manual akuntan entry on engine resume)
Steps
- [ ] Write failing test. Create
backend/src/services/__tests__/laporan-keuangan-extract.test.ts:
import { describe, it, expect, afterAll, afterEach } from "bun:test";
import { db } from "../../lib/db";
import { extractLaporanKeuanganAkuntan, detectTahunBuku, runLaporanKeuanganPass } from "../laporan-keuangan-extract";
const RAW =
"LAPORAN KEUANGAN\nPT RUPS UJI\nuntuk tahun yang berakhir pada tanggal 31 Desember 2023\n" +
"LAPORAN AUDITOR INDEPENDEN\nKAP Mulyoto & Rekan\nIzin Akuntan Publik AP.1234 tanggal 12 Januari 2020\nOPINI WAJAR TANPA PENGECUALIAN";
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("detectTahunBuku", () => {
it("reads the fiscal year off the cover", () => {
expect(detectTahunBuku(RAW)).toBe("2023");
expect(detectTahunBuku("tahun buku 2019 ...")).toBe("2019");
expect(detectTahunBuku("no year here")).toBeNull();
});
});
describe("extractLaporanKeuanganAkuntan (grounded 3B)", () => {
const origEnv = process.env.CLEANUP_LLM_URL;
afterEach(() => { process.env.CLEANUP_LLM_URL = origEnv; });
it("grounds every value on rawText — a hallucinated AP name becomes null", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm({
nama_akuntan_publik: "KAP Halusinasi Sejahtera", // NOT in rawText → null
nomor_izin_akuntan_publik: "AP.1234",
tanggal_izin_akuntan_publik: "12 Januari 2020",
tahun_buku: "2023",
});
try {
const r = await extractLaporanKeuanganAkuntan(RAW);
expect(r.nama_akuntan_publik).toBeNull();
expect(r.nomor_izin_akuntan_publik).toBe("AP.1234");
expect(r.tanggal_izin_akuntan_publik).toBe("12 Januari 2020");
expect(r.tahun_buku).toBe("2023");
} finally { restore(); }
});
it("falls back to detectTahunBuku when the LLM misses tahun_buku", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm({ nama_akuntan_publik: null, nomor_izin_akuntan_publik: null, tanggal_izin_akuntan_publik: null, tahun_buku: null });
try {
const r = await extractLaporanKeuanganAkuntan(RAW);
expect(r.tahun_buku).toBe("2023");
} finally { restore(); }
});
it("returns all-null on empty rawText (visible warn path) and never throws on LLM outage", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm(null, 500);
try {
expect(await extractLaporanKeuanganAkuntan("")).toEqual({
nama_akuntan_publik: null, nomor_izin_akuntan_publik: null, tanggal_izin_akuntan_publik: null, tahun_buku: null,
});
const r = await extractLaporanKeuanganAkuntan(RAW); // outage → deterministic tahun only
expect(r.nama_akuntan_publik).toBeNull();
expect(r.tahun_buku).toBe("2023");
} finally { restore(); }
});
});
describe("runLaporanKeuanganPass persistence", () => {
const docIds: string[] = [];
afterAll(async () => { await db.document.deleteMany({ where: { id: { in: docIds } } }); });
async function seedDoc() {
const doc = await db.document.create({
data: { filename: "lk.pdf", originalName: "lk.pdf", fileSize: 0, filePath: "/tmp/lk.pdf", documentType: "LAPORAN_KEUANGAN", status: "VERIFICATION_READY", rawText: RAW },
});
docIds.push(doc.id);
return doc.id;
}
it("persists the satellite + ExtractedField rows (grounded-tier 0.75 confidence)", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm({ nama_akuntan_publik: "KAP Mulyoto & Rekan", nomor_izin_akuntan_publik: "AP.1234", tanggal_izin_akuntan_publik: "12 Januari 2020", tahun_buku: "2023" });
try {
const documentId = await seedDoc();
await runLaporanKeuanganPass({ submissionId: "s", documentId, rawText: RAW });
const lk = await db.laporanKeuanganExtraction.findUnique({ where: { documentId } });
expect(lk?.namaAkuntanPublik).toBe("KAP Mulyoto & Rekan");
expect(lk?.confidence).toBe(0.75);
const f = await db.extractedField.findUnique({ where: { documentId_fieldKey: { documentId, fieldKey: "nomor_izin_akuntan_publik" } } });
expect(f?.value).toBe("AP.1234");
expect(f?.status).toBe("EDITABLE");
} finally { restore(); }
});
it("is idempotent-once: NEVER clobbers an existing satellite (manual entry survives resume)", async () => {
process.env.CLEANUP_LLM_URL = "http://mock";
const restore = mockLlm({ nama_akuntan_publik: "KAP Mulyoto & Rekan", nomor_izin_akuntan_publik: null, tanggal_izin_akuntan_publik: null, tahun_buku: null });
try {
const documentId = await seedDoc();
await db.laporanKeuanganExtraction.create({ data: { documentId, namaAkuntanPublik: "ENTRI MANUAL OPERATOR" } });
await runLaporanKeuanganPass({ submissionId: "s", documentId, rawText: RAW });
const lk = await db.laporanKeuanganExtraction.findUnique({ where: { documentId } });
expect(lk?.namaAkuntanPublik).toBe("ENTRI MANUAL OPERATOR");
} finally { restore(); }
});
it("skips loudly (no satellite) on empty rawText", async () => {
const documentId = await seedDoc();
await runLaporanKeuanganPass({ submissionId: "s", documentId, rawText: "" });
expect(await db.laporanKeuanganExtraction.findUnique({ where: { documentId } })).toBeNull();
});
});
- [ ] Run to verify RED:
cd backend && bun test src/services/__tests__/laporan-keuangan-extract.test.ts. Expected: fails — module not found. - [ ] Minimal implementation A. Create
backend/src/services/laporan-keuangan-extract.ts:
/**
* Focused, grounded akuntan-publik extraction for LAPORAN_KEUANGAN docs.
* Mirrors akta-koran-extract.ts shape-for-shape: CLEANUP_LLM 3B, temperature 0,
* guided-JSON, verbatim-substring grounding. SINGLE-PURPOSE call — never broaden
* a shared prompt (the 3B is brittle; memory reference_apostille_extraction_llm).
* Absent values → manual entry in the akuntan-publik section, never an ERROR.
*/
import { db } from "../lib/db";
import { Prisma } from "../generated/prisma/client";
export interface LaporanKeuanganAkuntan {
nama_akuntan_publik: string | null;
nomor_izin_akuntan_publik: string | null;
tanggal_izin_akuntan_publik: string | null;
tahun_buku: string | null;
}
const NONE: LaporanKeuanganAkuntan = {
nama_akuntan_publik: null, nomor_izin_akuntan_publik: null,
tanggal_izin_akuntan_publik: null, tahun_buku: null,
};
/** Deterministic tahun-buku fallback off the cover line. */
export function detectTahunBuku(rawText: string): string | null {
const m =
rawText.match(/berakhir(?:\s+pada)?(?:\s+tanggal)?\s+31\s+desember\s+(\d{4})/i) ??
rawText.match(/tahun\s+buku\s+(\d{4})/i);
return m ? m[1]! : null;
}
export async function extractLaporanKeuanganAkuntan(rawText: string): Promise<LaporanKeuanganAkuntan> {
const url = process.env.CLEANUP_LLM_URL || "";
const model = process.env.CLEANUP_LLM_MODEL || "cleanup-3b";
if (!rawText.trim()) {
// GPU-OCR path returns no rawText — surface the gap, don't silently null.
console.warn("[laporan-keuangan] rawText empty — akuntan extraction skipped (GPU-OCR path?)");
return { ...NONE };
}
const out: LaporanKeuanganAkuntan = { ...NONE };
if (url) {
const system =
"From an Indonesian laporan keuangan / laporan auditor independen, extract the public-accountant block: " +
"nama_akuntan_publik (the signing akuntan publik or KAP name), nomor_izin_akuntan_publik (license number, e.g. 'AP.1234'), " +
"tanggal_izin_akuntan_publik (the license date), tahun_buku (the fiscal year the report covers, e.g. '2023'). " +
"Copy VERBATIM from the text; if absent, return null. Never invent. " +
"Return ONLY JSON {nama_akuntan_publik, nomor_izin_akuntan_publik, tanggal_izin_akuntan_publik, tahun_buku}.";
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, 15000) },
],
guided_json: {
type: "object",
properties: {
nama_akuntan_publik: { type: ["string", "null"] },
nomor_izin_akuntan_publik: { type: ["string", "null"] },
tanggal_izin_akuntan_publik: { type: ["string", "null"] },
tahun_buku: { type: ["string", "null"] },
},
required: ["nama_akuntan_publik", "nomor_izin_akuntan_publik", "tanggal_izin_akuntan_publik", "tahun_buku"],
additionalProperties: false,
},
}),
signal: AbortSignal.timeout(parseInt(process.env.CLEANUP_LLM_TIMEOUT_MS || "30000", 10)),
});
if (res.ok) {
const json = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const m = (json.choices?.[0]?.message?.content ?? "").match(/\{[\s\S]*\}/);
if (m) {
const parsed = JSON.parse(m[0]) as Partial<Record<keyof LaporanKeuanganAkuntan, string | null>>;
// Grounding: every value must substring-match the normalized rawText.
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;
};
out.nama_akuntan_publik = ground(parsed.nama_akuntan_publik);
out.nomor_izin_akuntan_publik = ground(parsed.nomor_izin_akuntan_publik);
out.tanggal_izin_akuntan_publik = ground(parsed.tanggal_izin_akuntan_publik);
out.tahun_buku = ground(parsed.tahun_buku);
}
}
} catch (err) {
console.warn("[laporan-keuangan] 3B extraction failed — falling back to deterministic tahun:", err);
}
}
out.tahun_buku ??= detectTahunBuku(rawText);
return out;
}
/**
* The 'laporan-keuangan' focused pass body (invoked by the flow-engine
* focusedPasses mechanism). IDEMPOTENT-ONCE: if the satellite already exists
* (earlier run OR manual entry via PUT /akuntan-publik) it returns untouched —
* an engine resume must never clobber operator input.
*/
export async function runLaporanKeuanganPass(args: { submissionId: string; documentId: string; rawText: string }): Promise<void> {
const { documentId, rawText } = args;
if (!rawText.trim()) {
console.warn(`[laporan-keuangan] no rawText for doc ${documentId} — focused pass skipped`);
return;
}
const existing = await db.laporanKeuanganExtraction.findUnique({ where: { documentId } });
if (existing) return;
const r = await extractLaporanKeuanganAkuntan(rawText);
const any = !!(r.nama_akuntan_publik || r.nomor_izin_akuntan_publik || r.tanggal_izin_akuntan_publik || r.tahun_buku);
await db.laporanKeuanganExtraction.create({
data: {
documentId,
namaAkuntanPublik: r.nama_akuntan_publik,
nomorIzinAkuntanPublik: r.nomor_izin_akuntan_publik,
tanggalIzinAkuntanPublik: r.tanggal_izin_akuntan_publik,
tahunBuku: r.tahun_buku,
confidence: any ? 0.75 : 0, // grounded-3B "please review" tier — never auto-LOCK territory
rawOcrText: rawText.slice(0, 20000),
rawExtractionJson: r as unknown as Prisma.InputJsonValue,
},
});
// ExtractedField rows so the standard field edit/confirm machinery sees them.
for (const [fieldKey, value] of Object.entries(r) as [string, string | null][]) {
await db.extractedField.upsert({
where: { documentId_fieldKey: { documentId, fieldKey } },
update: { value, confidence: value ? 0.75 : 0, status: value ? "EDITABLE" : "FLAGGED" },
create: { documentId, fieldKey, value, confidence: value ? 0.75 : 0, status: value ? "EDITABLE" : "FLAGGED" },
});
}
}
- [ ] Minimal implementation B. Edit
backend/src/services/document-processor.ts: inside thedispatchchain, insert after theAKTA_PERALIHAN_PP_PTcase's closing brace (~line 2471) and before the// 7. Akta handlingcomment:
// 6i. LAPORAN_KEUANGAN (annual financial statements) — OCR-ONLY. The grounded
// akuntan-publik extraction runs as the flow-engine 'laporan-keuangan'
// focusedPasses pass, NOT here. Without this case the doc falls through to
// the generic Akta handler and burns a full akta-VLM extraction on a
// potentially 100-page report.
if (doc.documentType === "LAPORAN_KEUANGAN") {
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;
}
(getLayoutProvider is already imported in this file — verify; if the import lacks the provider-name overload used here, mirror the klasifikasi.ts:229-231 call shape.)
- [ ] Run GREEN: cd backend && bun test src/services/__tests__/laporan-keuangan-extract.test.ts. Expected: all pass.
- [ ] Typecheck: cd backend && bunx tsc --noEmit.
- [ ] Commit: git add backend/src/services/laporan-keuangan-extract.ts backend/src/services/document-processor.ts backend/src/services/__tests__/laporan-keuangan-extract.test.ts && git commit -m "feat(laporan-rups): grounded laporan-keuangan akuntan extractor + OCR-only doc handler"
Task 5: Engine focusedPasses invocation mechanism (generic — first built here)
ExtractionPlan.focusedPasses is typed but INERT (flow-engine/types.ts:49-58 says so explicitly) — this flow is its first consumer and MUST implement the invocation. Build it GENERIC: a named-handler registry + a per-document runner wired into continueFlow Phase 3. Handlers are log-and-continue (a focused pass must never brick the run — afterAktaExtraction precedent, processor.ts:47-53). Runs for fresh AND already-DONE docs (resume-safe; the pass bodies own their idempotence — Task 4's satellite-exists guard).
Files
- backend/src/flow-engine/focused-passes.ts — Create
- backend/src/flow-engine/processor.ts — Modify (continueFlow Phase 3 loop, lines 96-105)
- backend/src/flow-engine/__tests__/focused-passes.test.ts — Create
Interfaces
- Consumes: db; DocumentType from ../generated/prisma/enums; runLaporanKeuanganPass (Task 4, dynamic import).
- Produces:
- type FocusedPassArgs = { submissionId: string; documentId: string; rawText: string }
- type FocusedPassHandler = (args: FocusedPassArgs) => Promise<void>
- const FOCUSED_PASS_HANDLERS: Record<string, FocusedPassHandler> (mutable — tests may register)
- async function runFocusedPassesForDocument(focusedPasses: Partial<Record<DocumentType, string>> | undefined, submissionId: string, documentId: string): Promise<void>
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/focused-passes.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { FOCUSED_PASS_HANDLERS, type FocusedPassArgs } from "../focused-passes";
import { continueFlow } from "../processor";
import { defineFlow, type FlowConfig } from "../types";
/** Minimal v2 config exercising ONLY the focused-pass seam. */
function cfg(passName: string): FlowConfig<Record<string, never>> {
return defineFlow<Record<string, never>>({
type: "LAPORAN_RUPS_TAHUNAN", engine: "v2-generic",
primaryAkta: null, skipExtractionTypes: ["OTHER"], companyLookup: false, applyContactInfo: false,
extraction: { focusedPasses: { LAPORAN_KEUANGAN: passName } },
buildContext: async () => ({}),
rules: [], requiredDocs: [], sections: [],
rematch: { checklistResetDocTypes: [], standardPtMatchers: false },
routeFamily: "laporan-rups", labels: { akta: "x", flow: "x" },
});
}
describe("focusedPasses invocation (continueFlow Phase 3)", () => {
const ids: string[] = [];
const docIds: string[] = [];
afterAll(async () => {
await db.submission.deleteMany({ where: { id: { in: ids } } });
await db.document.deleteMany({ where: { id: { in: docIds } } });
delete FOCUSED_PASS_HANDLERS["__test-pass"];
delete FOCUSED_PASS_HANDLERS["__test-throws"];
});
async function seed(opts?: { alsoSkipType?: boolean }) {
const sub = await db.submission.create({ data: { type: "LAPORAN_RUPS_TAHUNAN", status: "EXTRACTING" } });
ids.push(sub.id);
const lk = await db.document.create({
data: { filename: "lk.pdf", originalName: "lk.pdf", fileSize: 0, filePath: "/tmp/lk.pdf", documentType: "LAPORAN_KEUANGAN", status: "VERIFICATION_READY", rawText: "LAPORAN KEUANGAN 2023" },
});
docIds.push(lk.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: lk.id, classifiedType: "LAPORAN_KEUANGAN", processingOrder: 6, extractionStatus: "DONE" },
});
if (opts?.alsoSkipType) {
const other = await db.document.create({
data: { filename: "o.pdf", originalName: "o.pdf", fileSize: 0, filePath: "/tmp/o.pdf", documentType: "LAPORAN_KEUANGAN", status: "UPLOADED", rawText: "should not fire" },
});
docIds.push(other.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: other.id, classifiedType: "OTHER", processingOrder: 9, extractionStatus: "PENDING" },
});
}
return { subId: sub.id, lkId: lk.id };
}
it("invokes the named handler with {submissionId, documentId, rawText} — incl. already-DONE docs (resume-safe)", async () => {
const calls: FocusedPassArgs[] = [];
FOCUSED_PASS_HANDLERS["__test-pass"] = async (a) => { calls.push(a); };
const { subId, lkId } = await seed({ alsoSkipType: true });
await continueFlow(subId, cfg("__test-pass"));
expect(calls).toHaveLength(1); // NOT for the skipExtractionTypes doc
expect(calls[0]).toEqual({ submissionId: subId, documentId: lkId, rawText: "LAPORAN KEUANGAN 2023" });
const sub = await db.submission.findUnique({ where: { id: subId } });
expect(sub?.status).toBe("READY");
});
it("a throwing handler logs-and-continues — the run still reaches READY", async () => {
FOCUSED_PASS_HANDLERS["__test-throws"] = async () => { throw new Error("boom"); };
const { subId } = await seed();
await continueFlow(subId, cfg("__test-throws"));
expect((await db.submission.findUnique({ where: { id: subId } }))?.status).toBe("READY");
});
it("an unregistered pass name warns and continues — READY, no throw", async () => {
const { subId } = await seed();
await continueFlow(subId, cfg("__no-such-pass"));
expect((await db.submission.findUnique({ where: { id: subId } }))?.status).toBe("READY");
});
it("ships the real 'laporan-keuangan' handler entry", () => {
expect(typeof FOCUSED_PASS_HANDLERS["laporan-keuangan"]).toBe("function");
});
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/focused-passes.test.ts. Expected: fails — cannot resolve../focused-passes. - [ ] Minimal implementation A. Create
backend/src/flow-engine/focused-passes.ts:
import { db } from "../lib/db";
import type { DocumentType } from "../generated/prisma/enums";
export type FocusedPassArgs = { submissionId: string; documentId: string; rawText: string };
export type FocusedPassHandler = (args: FocusedPassArgs) => Promise<void>;
/**
* Named focused-pass registry — the INVOCATION half of ExtractionPlan.focusedPasses
* (the config seam shipped inert with the registry slice; this flow is its first
* consumer). GENERIC by design: a flow declares { [carrier DocumentType]: passName },
* the engine invokes the named handler after that doc's extraction (Phase 3).
* Handlers must be idempotent-safe (they may re-run on engine resume) and are
* ALWAYS log-and-continue — a focused pass must never brick the run.
* Later flows only add entries (surat-likuidator, bukti-pengumuman, ...).
*/
export const FOCUSED_PASS_HANDLERS: Record<string, FocusedPassHandler> = {
"laporan-keuangan": async (args) => {
const { runLaporanKeuanganPass } = await import("../services/laporan-keuangan-extract");
await runLaporanKeuanganPass(args);
},
};
/** Resolve the doc's CARRIER DocumentType → pass name → handler; run guarded. */
export async function runFocusedPassesForDocument(
focusedPasses: Partial<Record<DocumentType, string>> | undefined,
submissionId: string,
documentId: string,
): Promise<void> {
if (!focusedPasses) return;
const doc = await db.document.findUnique({
where: { id: documentId },
select: { documentType: true, rawText: true, status: true },
});
if (!doc || doc.status === "ERROR") return;
const passName = focusedPasses[doc.documentType];
if (!passName) return;
const handler = FOCUSED_PASS_HANDLERS[passName];
if (!handler) {
console.warn(`[flow-engine] focused pass "${passName}" has no registered handler — skipped`);
return;
}
try {
await handler({ submissionId, documentId, rawText: doc.rawText ?? "" });
} catch (err) {
console.error(`[flow-engine] focused pass "${passName}" failed for doc ${documentId}:`, err);
}
}
- [ ] Minimal implementation B. Edit
backend/src/flow-engine/processor.ts. Add the import at the top:import { runFocusedPassesForDocument } from "./focused-passes";. Then replace the Phase-3 loop (lines 96-105):
// Phase 3: remaining docs
for (const subDoc of subDocs.filter((d) => d.id !== aktaDoc?.id)) {
if (subDoc.extractionStatus === "DONE") continue;
if (cfg.skipExtractionTypes.includes(subDoc.classifiedType)) {
await db.submissionDocument.update({ where: { id: subDoc.id }, data: { extractionStatus: "DONE" } });
} else {
const override = cfg.extraction.overrideByClassifiedType?.[subDoc.classifiedType] ?? subDoc.classifiedType;
await processOneDocument(submissionId, subDoc.id, subDoc.documentId, override);
}
}
with:
// Phase 3: remaining docs (+ per-docType focused passes)
for (const subDoc of subDocs.filter((d) => d.id !== aktaDoc?.id)) {
if (cfg.skipExtractionTypes.includes(subDoc.classifiedType)) {
// store-only: mark DONE without extraction; focused passes never fire here.
if (subDoc.extractionStatus !== "DONE") {
await db.submissionDocument.update({ where: { id: subDoc.id }, data: { extractionStatus: "DONE" } });
}
continue;
}
if (subDoc.extractionStatus !== "DONE") {
const override = cfg.extraction.overrideByClassifiedType?.[subDoc.classifiedType] ?? subDoc.classifiedType;
await processOneDocument(submissionId, subDoc.id, subDoc.documentId, override);
}
// Focused pass — runs for freshly-extracted AND already-DONE docs so a crash
// between extraction and the pass cannot permanently skip it (handlers are
// idempotent-safe; failures log-and-continue inside the runner).
await runFocusedPassesForDocument(cfg.extraction.focusedPasses, submissionId, subDoc.documentId);
}
- [ ] Run GREEN:
cd backend && bun test src/flow-engine/__tests__/focused-passes.test.ts. Expected: 4 pass. Then 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(the Phase-3 restructure must be behaviour-identical for configs withoutfocusedPasses). - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/flow-engine/focused-passes.ts backend/src/flow-engine/processor.ts backend/src/flow-engine/__tests__/focused-passes.test.ts && git commit -m "feat(flow-engine): generic focusedPasses invocation mechanism (first consumer: laporan-keuangan)"
Task 6: The real FlowConfig — registry entry + subtype-map identity rows + dispatch/rematch proof
Replaces Task 1's stub with the FIRST engine:'v2-generic' registry entry. Two engine-fidelity notes vs the spec's §4.2 sketch (record, don't drift): (a) skipExtractionTypes matches SubmissionDocument.classifiedType, which for lampiran is the FINE label (spec §4.3: "the fine label survives on classifiedType") — so the list carries the six LAMPIRAN_* fine labels + the carrier + OTHER, not the spec's shorthand ['LAMPIRAN_LAPORAN_TAHUNAN','OTHER']; (b) the quorum matcher rides the engine's rematch.rupsAttendance: true opt-in (shipped after the spec's preValidationSteps sketch). Also: the engine passes overrideByClassifiedType values straight into processDocument({classificationOverride}) → resolveAktaSubtype → getAktaSubtype, whose AKTA_SUBTYPE_MAP throws on the raw subtype "perubahan" — add identity rows (pendirian/perubahan) so engine-passed raw subtypes resolve (the ExtractionPlan doc comment already promises exactly these values for the four new deeds).
Files
- backend/src/flow-engine/flows/laporan-rups-tahunan.ts — Create
- backend/src/flow-engine/registry.ts — Modify (stub → real entry)
- backend/src/ocr/classifier.ts — Modify (AKTA_SUBTYPE_MAP, lines 52-57)
- backend/src/flow-engine/__tests__/registry.test.ts — Modify (the "no v2 entries yet" assertion is now stale by design)
- backend/src/flow-engine/__tests__/laporan-rups-flow.test.ts — Create
Interfaces
- Consumes: defineFlow from ../types; buildRupsTahunanContext, RupsTahunanCtx, LAMPIRAN_FINE_LABELS (Task 2); RUPS_TAHUNAN_RULE_SET (Task 3); resolveAktaSubtype from ../../services/document-processor (test only).
- Produces: const laporanRupsTahunanFlow: FlowConfig<RupsTahunanCtx>; FLOW_REGISTRY.LAPORAN_RUPS_TAHUNAN === laporanRupsTahunanFlow; typesForRouteFamily("laporan-rups") === ["LAPORAN_RUPS_TAHUNAN"]; resolveAktaSubtype("perubahan", …) === "perubahan".
Steps
- [ ] Write failing test. Create
backend/src/flow-engine/__tests__/laporan-rups-flow.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { FLOW_REGISTRY, flowFor, typesForRouteFamily } from "../registry";
import { laporanRupsTahunanFlow } from "../flows/laporan-rups-tahunan";
import { LAMPIRAN_FINE_LABELS } from "../context/rups-tahunan-context";
import { resolveAktaSubtype } from "../../services/document-processor";
import { reMatchAndValidate } from "../../services/submission-processor";
describe("laporanRupsTahunanFlow registry entry (FIRST v2-generic tenant)", () => {
it("is registered as the real v2 config", () => {
const entry = flowFor("LAPORAN_RUPS_TAHUNAN");
expect(entry).toBe(laporanRupsTahunanFlow as never);
expect(entry.engine).toBe("v2-generic");
expect(typesForRouteFamily("laporan-rups")).toEqual(["LAPORAN_RUPS_TAHUNAN"]);
// NOT in the perubahan family — the shared perubahan guards must never accept it.
expect(typesForRouteFamily("perubahan")).not.toContain("LAPORAN_RUPS_TAHUNAN");
});
it("carries the canonical config values", () => {
const f = laporanRupsTahunanFlow;
expect(f.primaryAkta).toEqual({
classifiedType: "AKTA_RUPS_TAHUNAN",
missingError: "Akta/Risalah RUPS Tahunan tidak ditemukan",
extractError: "Gagal memproses Akta RUPS Tahunan",
});
expect(f.extraction.overrideByClassifiedType).toEqual({ AKTA_RUPS_TAHUNAN: "perubahan" });
expect(f.extraction.focusedPasses).toEqual({ LAPORAN_KEUANGAN: "laporan-keuangan" });
expect(f.companyLookup).toBe(true);
expect(f.applyContactInfo).toBe(false);
expect(f.requiredDocs).toEqual(["AKTA"]);
expect(f.failGate).not.toBe("off"); // block-with-override stays ON
for (const fine of LAMPIRAN_FINE_LABELS) expect(f.skipExtractionTypes).toContain(fine);
expect(f.skipExtractionTypes).toContain("OTHER");
expect(f.rematch).toEqual({ checklistResetDocTypes: [], standardPtMatchers: false, rupsAttendance: true });
expect(f.routeFamily).toBe("laporan-rups");
expect(f.sections.map((s) => s.key)).toEqual([
"identitas-perseroan", "akta-rups", "laporan-tahunan", "akuntan-publik", "lampiran", "validasi",
]);
// AKTA_DATE_WINDOW exclusion guard at the CONFIG level too (gotcha 4).
expect(f.rules.map((r) => r.code)).not.toContain("AKTA_DATE_WINDOW");
});
it("engine-passed raw subtype overrides resolve (getAktaSubtype identity rows)", () => {
expect(resolveAktaSubtype("perubahan", { classification: "AKTA_PENDIRIAN", confidence: 1 } as never)).toBe("perubahan");
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: "AI_EXTRACTION", rawText: "" },
});
docIds.push(akta.id);
await db.extractedField.createMany({
data: [
{ documentId: akta.id, fieldKey: "nama_perseroan", value: "PT REMATCH UJI" },
{ documentId: akta.id, fieldKey: "nomor_akta", value: "7" },
{ documentId: akta.id, fieldKey: "tanggal_akta", value: "10-04-2024" },
],
});
const sub = await db.submission.create({ data: { type: "LAPORAN_RUPS_TAHUNAN", status: "READY" } });
ids.push(sub.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_RUPS_TAHUNAN", processingOrder: 0, extractionStatus: "DONE" },
});
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 — the flow's tuned matrix persisted
expect(rows.map((r) => r.ruleCode)).toContain("TAHUN_LAPORAN_VALID"); // tahun unset → FAIL row exists
expect(rows.map((r) => r.ruleCode)).not.toContain("AKTA_DATE_WINDOW");
}, 30000);
});
- [ ] Run to verify RED:
cd backend && bun test src/flow-engine/__tests__/laporan-rups-flow.test.ts. Expected: fails — cannot resolve../flows/laporan-rups-tahunan. - [ ] Minimal implementation A. Create
backend/src/flow-engine/flows/laporan-rups-tahunan.ts:
import { defineFlow } from "../types";
import { buildRupsTahunanContext, LAMPIRAN_FINE_LABELS, type RupsTahunanCtx } from "../context/rups-tahunan-context";
import { RUPS_TAHUNAN_RULE_SET } from "../rules/laporan-rups/index";
/**
* Pelaporan RUPS Tahunan (SABH PelaporanRupsTahunanController; UUPT Ps.66-68) —
* the FIRST engine:'v2-generic' FLOW_REGISTRY tenant (program ship order:
* LAPORAN_RUPS_TAHUNAN → PELEBURAN_PT → PEMBUBARAN_PT → BERAKHIRNYA_STATUS_PT).
*
* TERMINAL SELF-SERVICE: submit → COMPLETED. No VerifikasiPerubahan row is ever
* created; IN_VERIFICATION is never entered. The submit gate
* (routes/laporan-rups.ts) is the safety net: section approvals + tahun/jenis
* attestation + checkFailGate (block-with-override) + WARNING declarations.
*
* No diff: companyLookup runs for oldData/consistency-rules/registry display
* only — the filing changes nothing on the record.
*/
export const laporanRupsTahunanFlow = defineFlow<RupsTahunanCtx>({
type: "LAPORAN_RUPS_TAHUNAN",
engine: "v2-generic",
primaryAkta: {
classifiedType: "AKTA_RUPS_TAHUNAN",
missingError: "Akta/Risalah RUPS Tahunan tidak ditemukan",
extractError: "Gagal memproses Akta RUPS Tahunan",
},
// Store-only docs, matched on SubmissionDocument.classifiedType — for lampiran
// that is the FINE label (Ps.66(2) letters b-g); the carrier is kept defensively
// for operator re-maps that use the coarse label directly.
skipExtractionTypes: [...LAMPIRAN_FINE_LABELS, "LAMPIRAN_LAPORAN_TAHUNAN", "OTHER"],
companyLookup: true,
applyContactInfo: false, // no kontak surface in this filing
extraction: {
// Deed rides the perubahan extractor (AKTA_AKUISISI precedent): the perubahan
// schema already yields nomor_akta/tanggal_akta/nama_notaris/nama_perseroan/
// ref_pendirian_nomor_sk/tanggal_rups. Raw-subtype value resolves via the
// AKTA_SUBTYPE_MAP identity rows (ocr/classifier.ts).
overrideByClassifiedType: { AKTA_RUPS_TAHUNAN: "perubahan" },
focusedPasses: { LAPORAN_KEUANGAN: "laporan-keuangan" },
},
buildContext: buildRupsTahunanContext,
rules: RUPS_TAHUNAN_RULE_SET,
// LAPORAN_KEUANGAN conditionality is enforced by AKUNTAN_PUBLIK_REQUIRED, not
// the checklist (board shows it required:false, "Wajib jika perseroan wajib diaudit").
requiredDocs: ["AKTA"],
sections: [
{ key: "identitas-perseroan", label: "Identitas Perseroan", approvable: true, required: true },
{ key: "akta-rups", label: "Akta / Risalah RUPS Tahunan", approvable: true, required: true },
{ key: "laporan-tahunan", label: "Tahun Laporan & Jenis Audit", approvable: true, required: true },
// required flips at GATE time when jenisAudit=WAJIB_AUDIT (routes/laporan-rups.ts).
{ key: "akuntan-publik", label: "Akuntan Publik", approvable: true, required: false },
{ key: "lampiran", label: "Lampiran Laporan Tahunan", approvable: true, required: false },
{ key: "validasi", label: "Hasil Validasi", approvable: false, required: false },
],
rematch: {
checklistResetDocTypes: [], // no card checklist — this filing registers no person
standardPtMatchers: false,
rupsAttendance: true, // quorum advisory input when a BA RUPS rides along
},
routeFamily: "laporan-rups", // NOT 'perubahan': no diff, no verifikator
labels: { akta: "Akta/Risalah RUPS Tahunan", flow: "Pelaporan RUPS Tahunan" },
});
- [ ] Minimal implementation B. Edit
backend/src/flow-engine/registry.ts: addimport { laporanRupsTahunanFlow } from "./flows/laporan-rups-tahunan";and replace the Task-1 stub line (drop its TEMPORARY comment block) with:
LAPORAN_RUPS_TAHUNAN: laporanRupsTahunanFlow, // FIRST v2-generic tenant
- [ ] Minimal implementation C. Edit
backend/src/ocr/classifier.tsAKTA_SUBTYPE_MAP(lines 52-57), append inside the object:
// Engine-passed RAW subtype overrides: flow-engine ExtractionPlan.
// overrideByClassifiedType values are akta SUBTYPES (types.ts doc: AKTA_PELEBURAN
// → 'pendirian'; AKTA_PEMBUBARAN/AKTA_BERAKHIRNYA/AKTA_RUPS_TAHUNAN → 'perubahan').
// Identity rows let resolveAktaSubtype accept them without a label round-trip.
pendirian: "pendirian",
perubahan: "perubahan",
- [ ] Minimal implementation D. Edit
backend/src/flow-engine/__tests__/registry.test.ts: the test"ships every existing type as a v1-fork stub (no v2 entries yet)"is stale BY DESIGN now. Replace its body with:
it("every type is v1-fork EXCEPT the shipped v2 tenants", () => {
const V2_SHIPPED = new Set(["LAPORAN_RUPS_TAHUNAN"]);
for (const t of Object.values(SubmissionType)) {
expect(FLOW_REGISTRY[t].engine).toBe(V2_SHIPPED.has(t) ? "v2-generic" : "v1-fork");
}
});
(Also update the typesForRouteFamily("perubahan") expectation in that file if it asserts [] — it still holds, laporan-rups is a different family; leave as-is unless it fails.)
- [ ] Run GREEN: cd backend && bun test src/flow-engine/__tests__/laporan-rups-flow.test.ts src/flow-engine/__tests__/registry.test.ts. Expected: all pass. NOTE: the rematch test exercises buildPtAktaContext (SABH lookups happen inside it); it runs in the same env the existing pt-akta-context.test.ts passes in — if SABH is unreachable the engine's rematch try/catch logs and persists nothing; in that case assert-and-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. The subtype identity rows must not alter any existing label path (AKTA_PENDIRIAN/AKTA_PERUBAHAN/AKTA_AKUISISI keys unchanged).
- [ ] Commit: git add backend/src/flow-engine/flows/laporan-rups-tahunan.ts backend/src/flow-engine/registry.ts backend/src/ocr/classifier.ts backend/src/flow-engine/__tests__/laporan-rups-flow.test.ts backend/src/flow-engine/__tests__/registry.test.ts && git commit -m "feat(laporan-rups): register the first v2-generic FlowConfig + raw-subtype identity rows"
Task 7: Routing + gating — BUILT set flip, ladder rung, doc-type maps, labels endpoint, refine extensions (THE LAUNCH SWITCH)
Everything that makes the flow REACHABLE: BUILT_AKTA_TXN_TYPES += "laporan_rups_tahunan" (flips supported, removes it from detectUnsupportedAktaType), the inferSubmissionType rung at the canonical ladder position, mapToDocumentType/getProcessingOrder for the new labels, the klasifikasi label set + ?type=laporan_rups, the conservative refineAktaLabel extension for BERITA_ACARA_RUPS (gotcha 2 — the flow's most common deed shape is GPU-labelled BERITA_ACARA_RUPS, which never reached the refine pass), and the supporting-doc classifier wiring (refineSupportingLabel, keyword-first, LAPORAN_KEUANGAN auto-adopts only on a strong keyword hit).
Files
- backend/src/services/akta-txn-classifier.ts — Modify (BUILT_AKTA_TXN_TYPES, lines 32-36)
- backend/src/routes/submissions.ts — Modify (mapToDocumentType L27-46, getProcessingOrder L49-63, inferSubmissionType L84-100)
- backend/src/routes/klasifikasi.ts — Modify (COARSE_AKTA_LABELS L13, refineAktaLabel L20-38, new refineSupportingLabel, LAPORAN_RUPS_DOC_TYPES, LABEL_MAP L130-132, classify route L228-233, labels route L274-282)
- backend/src/routes/__tests__/laporan-rups-routing.test.ts — Create
Interfaces
- Consumes: classifySupportingDoc, type SupportingDocSuggestion from ../services/supporting-doc-classifier; config.classifierMinConfidence.
- Produces: inferSubmissionType return union gains "LAPORAN_RUPS_TAHUNAN"; mapToDocumentType("LAPORAN_KEUANGAN") === "LAPORAN_KEUANGAN", mapToDocumentType("LAMPIRAN_*") === "LAMPIRAN_LAPORAN_TAHUNAN"; GET /api/klasifikasi/labels?type=laporan_rups; export async function refineSupportingLabel(label, confidence, ocrText, classify?).
Steps
- [ ] Write failing test. Create
backend/src/routes/__tests__/laporan-rups-routing.test.ts:
import { describe, it, expect } from "bun:test";
import { inferSubmissionType, detectUnsupportedAktaType, mapToDocumentType, getProcessingOrder } from "../submissions";
import { refineAktaLabel, refineSupportingLabel } from "../klasifikasi";
import { BUILT_AKTA_TXN_TYPES } from "../../services/akta-txn-classifier";
import type { AktaTxnClassification } 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("laporan_rups_tahunan is BUILT — no longer 'belum didukung'", () => {
expect(BUILT_AKTA_TXN_TYPES.has("laporan_rups_tahunan")).toBe(true);
expect(detectUnsupportedAktaType([f("AKTA_RUPS_TAHUNAN")])).toBeNull();
expect(detectUnsupportedAktaType([f("AKTA_PELEBURAN")])).toBe("peleburan"); // others stay gated
});
});
describe("inferSubmissionType ladder (Perbaikan > Akuisisi > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT)", () => {
it("routes a lone AKTA_RUPS_TAHUNAN to LAPORAN_RUPS_TAHUNAN", () => {
expect(inferSubmissionType([f("AKTA_RUPS_TAHUNAN"), f("LAPORAN_KEUANGAN")])).toBe("LAPORAN_RUPS_TAHUNAN");
});
it("a perubahan submission also carrying the annual deed stays PERUBAHAN_PT (rung is BELOW Perubahan)", () => {
expect(inferSubmissionType([f("AKTA_PERUBAHAN"), f("AKTA_RUPS_TAHUNAN")])).toBe("PERUBAHAN_PT");
expect(inferSubmissionType([f("AKTA_AKUISISI"), f("AKTA_RUPS_TAHUNAN")])).toBe("AKUISISI_PT");
});
it("beats the pendirian fallbacks", () => {
expect(inferSubmissionType([f("AKTA_RUPS_TAHUNAN"), f("KTP")])).toBe("LAPORAN_RUPS_TAHUNAN");
});
});
describe("doc-type maps", () => {
it("LAPORAN_KEUANGAN + six LAMPIRAN_* fine labels → their carriers; akta stays AKTA", () => {
expect(mapToDocumentType("LAPORAN_KEUANGAN")).toBe("LAPORAN_KEUANGAN");
for (const fine of ["LAMPIRAN_KEGIATAN_PERSEROAN","LAMPIRAN_TJSL","LAMPIRAN_RINCIAN_MASALAH","LAMPIRAN_PENGAWASAN_KOMISARIS","LAMPIRAN_SUSUNAN_PENGURUS","LAMPIRAN_GAJI_PENGURUS","LAMPIRAN_LAPORAN_TAHUNAN"]) {
expect(mapToDocumentType(fine)).toBe("LAMPIRAN_LAPORAN_TAHUNAN");
}
expect(mapToDocumentType("AKTA_RUPS_TAHUNAN")).toBe("AKTA");
});
it("processing order: akta first, laporan keuangan mid, lampiran last", () => {
expect(getProcessingOrder("AKTA_RUPS_TAHUNAN")).toBe(0);
expect(getProcessingOrder("LAPORAN_KEUANGAN")).toBe(6);
expect(getProcessingOrder("LAMPIRAN_TJSL")).toBe(7);
});
});
describe("refineAktaLabel BERITA_ACARA_RUPS extension (gotcha 2 — conservative)", () => {
const mk = (txnType: string, label: string): AktaTxnClassification =>
({ txnType: txnType as never, label, confidence: 0.9, evidence: null, supported: true, source: "llm" });
const env = process.env.AKTA_TXN_CLASSIFIER_URL;
it("promotes BERITA_ACARA_RUPS → AKTA_RUPS_TAHUNAN ONLY on a laporan_rups_tahunan read", async () => {
process.env.AKTA_TXN_CLASSIFIER_URL = "http://mock";
try {
const promoted = await refineAktaLabel("BERITA_ACARA_RUPS", async () => "RUPS TAHUNAN ... pengesahan laporan tahunan",
async () => mk("laporan_rups_tahunan", "AKTA_RUPS_TAHUNAN"));
expect(promoted).toBe("AKTA_RUPS_TAHUNAN");
const kept = await refineAktaLabel("BERITA_ACARA_RUPS", async () => "RUPS ... perubahan anggaran dasar",
async () => mk("perubahan", "AKTA_PERUBAHAN"));
expect(kept).toBe("BERITA_ACARA_RUPS"); // a BA RUPS is normally a SUPPORTING doc — never promoted to perubahan/akuisisi primary
const keptAkuisisi = await refineAktaLabel("BERITA_ACARA_RUPS", async () => "RUPS ... pengambilalihan",
async () => mk("akuisisi", "AKTA_AKUISISI"));
expect(keptAkuisisi).toBe("BERITA_ACARA_RUPS");
} finally { process.env.AKTA_TXN_CLASSIFIER_URL = env; }
});
it("AKTA_PERUBAHAN coarse label still refines to AKTA_RUPS_TAHUNAN (existing path unchanged)", async () => {
process.env.AKTA_TXN_CLASSIFIER_URL = "http://mock";
try {
const r = await refineAktaLabel("AKTA_PERUBAHAN", async () => "x", async () => mk("laporan_rups_tahunan", "AKTA_RUPS_TAHUNAN"));
expect(r).toBe("AKTA_RUPS_TAHUNAN");
} finally { process.env.AKTA_TXN_CLASSIFIER_URL = env; }
});
});
describe("refineSupportingLabel (supporting-doc classifier wiring)", () => {
it("adopts LAPORAN_KEUANGAN on a strong keyword hit when the GPU is unconfident", async () => {
const out = await refineSupportingLabel("KTP", 0.2, async () => "LAPORAN KEUANGAN\nOPINI AUDITOR ... AKUNTAN PUBLIK");
expect(out).toBe("LAPORAN_KEUANGAN");
});
it("keeps the GPU label when confident, for akta labels, and on weak/none suggestions", async () => {
expect(await refineSupportingLabel("KTP", 0.99, async () => "LAPORAN KEUANGAN OPINI")).toBe("KTP");
expect(await refineSupportingLabel("AKTA_PERUBAHAN", 0.2, async () => "LAPORAN KEUANGAN OPINI")).toBe("AKTA_PERUBAHAN");
expect(await refineSupportingLabel("OTHER", 0.2, async () => "surat biasa tanpa sinyal")).toBe("OTHER");
});
});
describe("labels endpoint", () => {
it("?type=laporan_rups serves the RUPS-Tahunan label set (akta wajib; LK conditional-desc; six lampiran; NPWP optional; OTHER)", async () => {
const res = await app.request("/api/klasifikasi/labels?type=laporan_rups");
expect(res.status).toBe(200);
const labels = (await res.json()) as { key: string; required: boolean; description?: string }[];
const keys = labels.map((l) => l.key);
expect(keys[0]).toBe("AKTA_RUPS_TAHUNAN");
expect(labels.find((l) => l.key === "AKTA_RUPS_TAHUNAN")?.required).toBe(true);
const lk = labels.find((l) => l.key === "LAPORAN_KEUANGAN");
expect(lk?.required).toBe(false);
expect(lk?.description).toContain("Ps.68");
for (const fine of ["LAMPIRAN_KEGIATAN_PERSEROAN","LAMPIRAN_TJSL","LAMPIRAN_RINCIAN_MASALAH","LAMPIRAN_PENGAWASAN_KOMISARIS","LAMPIRAN_SUSUNAN_PENGURUS","LAMPIRAN_GAJI_PENGURUS"]) {
expect(keys).toContain(fine);
}
expect(keys).toContain("NPWP");
expect(keys).toContain("OTHER");
expect(keys).not.toContain("KTP"); // deliberately EXCLUDED — registers no person
expect(keys).not.toContain("DATA_KONTAK"); // deliberately EXCLUDED
});
});
- [ ] Run to verify RED:
cd backend && bun test src/routes/__tests__/laporan-rups-routing.test.ts. Expected: fails (BUILT set, ladder, maps, refine, 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
]);
- [ ] Minimal implementation B —
backend/src/routes/submissions.ts:
1.mapToDocumentType: insert BEFORE the finalreturn "AKTA"; // fallback:
if (classification === "LAPORAN_KEUANGAN") return "LAPORAN_KEUANGAN";
// Six Ps.66(2) fine labels + the coarse key all map onto the ONE carrier
// (many-fine-labels→one-DocumentType, same pattern as AKTA_* → AKTA).
if (classification.startsWith("LAMPIRAN_")) return "LAMPIRAN_LAPORAN_TAHUNAN";
getProcessingOrder: insert before the finalreturn 4;:
if (classification === "LAPORAN_KEUANGAN") return 6; // after the deed + cards
if (classification.startsWith("LAMPIRAN_")) return 7; // store-only, last
inferSubmissionType: widen the return union to"PENDIRIAN_PT" | "PENDIRIAN_PP" | "PERUBAHAN_PT" | "PERBAIKAN_DATA_PT" | "AKUISISI_PT" | "LAPORAN_RUPS_TAHUNAN", update the doc comment's precedence line toPerbaikan > 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 theAKTA_PERUBAHANcheck (line 97), BEFORE theSURAT_PERNYATAAN_PENDIRIAN_PPcheck:
// Below Akuisisi/Perubahan: a perubahan submission that also carries the annual
// BA must not be stolen by the RUPS-Tahunan rung.
if (files.some((f) => eff(f) === "AKTA_RUPS_TAHUNAN")) return "LAPORAN_RUPS_TAHUNAN";
- [ ] Minimal implementation C —
backend/src/routes/klasifikasi.ts:
1. Line 13:const COARSE_AKTA_LABELS = new Set(["AKTA_PENDIRIAN", "AKTA_PERUBAHAN", "BERITA_ACARA_RUPS"]);
2. InrefineAktaLabel, replace the two lines afterconst r = await classify(text, { gpuPrior: prior });(lines 31-33) with:
if (coarseLabel === "BERITA_ACARA_RUPS") {
// Conservative (flow-spec §7 gotcha 2): a BA RUPS is normally a SUPPORTING
// doc of perubahan/akuisisi and must never be promoted to their primary
// deed. Promote ONLY on a laporan_rups_tahunan read; anything else — incl.
// the LLM-outage gpu-prior fallback — keeps the GPU label.
return r.txnType === "laporan_rups_tahunan" ? r.label : coarseLabel;
}
// Override only to a genuinely new type; never flip pendirian<->perubahan.
if (r.txnType !== "pendirian" && r.txnType !== "perubahan") return r.label;
return coarseLabel;
- Add the supporting refine (below
refineAktaLabel), with importsimport { classifySupportingDoc, type SupportingDocSuggestion } from "../services/supporting-doc-classifier";:
/**
* Second refine tier: suggest a supporting DocumentType for files the GPU
* classifier is unconfident about (it has no laporan-keuangan/lampiran labels and
* will be confidently wrong). Keyword-first via supporting-doc-classifier; adopts
* the suggestion ONLY when autoAcceptable (strong lexical hit — LAPORAN_KEUANGAN
* tier). Weak/none/BUKTI_PENGUMUMAN suggestions leave the label for manual board
* assignment, which always overrides.
*/
export async function refineSupportingLabel(
label: string,
confidence: number,
ocrText: () => Promise<string>,
classify: (t: string) => Promise<SupportingDocSuggestion> = (t) => classifySupportingDoc(t),
): Promise<string> {
if (label.startsWith("AKTA_") || label === "BERITA_ACARA_RUPS") return label;
if (confidence >= config.classifierMinConfidence) return label;
try {
const s = await classify(await ocrText());
if (s.docType && s.autoAcceptable) return s.docType;
return label;
} catch (err) {
console.warn("[klasifikasi] supporting-doc refine failed, keeping label:", err);
return label;
}
}
- In the classify route (lines 228-233), memoize the OCR closure and chain both refines:
const ocrText = (() => {
let p: Promise<string> | null = null;
return () => (p ??= (async () => {
const layout = await getLayoutProvider("paddleocr");
const ocr = await layout.extractTextWithWords(fileBuffer, { unwarp: false, orient: false });
return ocr.rawText ?? "";
})());
})();
let refinedClassification = await refineAktaLabel(rawClassification, ocrText);
refinedClassification = await refineSupportingLabel(refinedClassification, confidence, ocrText);
- Add the label set (after
AKUISISI_DOC_TYPES):
const LAPORAN_RUPS_DOC_TYPES: DocTypeLabel[] = [
{ key: "AKTA_RUPS_TAHUNAN", displayName: "Akta / Risalah RUPS Tahunan", required: true, maxCount: 1 },
{ key: "LAPORAN_KEUANGAN", displayName: "Laporan Keuangan", required: false, maxCount: 1, description: "Wajib jika perseroan wajib diaudit (UUPT Ps.68)" },
{ key: "LAMPIRAN_KEGIATAN_PERSEROAN", displayName: "Laporan Kegiatan Perseroan", required: false, maxCount: 1 },
{ key: "LAMPIRAN_TJSL", displayName: "Laporan Pelaksanaan TJSL", required: false, maxCount: 1, description: "Untuk perseroan sektor sumber daya alam (UUPT Ps.74)" },
{ key: "LAMPIRAN_RINCIAN_MASALAH", displayName: "Rincian Masalah Selama Tahun Buku", required: false, maxCount: 1 },
{ key: "LAMPIRAN_PENGAWASAN_KOMISARIS", displayName: "Laporan Pengawasan Dewan Komisaris", required: false, maxCount: 1 },
{ key: "LAMPIRAN_SUSUNAN_PENGURUS", displayName: "Susunan Direksi dan Dewan Komisaris", required: false, maxCount: 1 },
{ key: "LAMPIRAN_GAJI_PENGURUS", displayName: "Gaji & Tunjangan Direksi/Komisaris", required: false, maxCount: 1 },
{ key: "NPWP", displayName: "NPWP Perseroan", required: false, maxCount: 1, description: "Opsional — cross-check NPWP perseroan vs registri SABH" },
{ key: "OTHER", displayName: "Dokumen lain bukan persyaratan", required: false, description: "Dokumen pendukung tambahan" },
];
- Include it in
LABEL_MAP(extend the spread array at line 131 with...LAPORAN_RUPS_DOC_TYPES) and in the labels route addif (type === "laporan_rups") return c.json(LAPORAN_RUPS_DOC_TYPES);(+ document it in the route comment line 273).
- [ ] Run GREEN:cd backend && bun test src/routes/__tests__/laporan-rups-routing.test.ts. Then regression:bun test src/routes/__tests__/akuisisi-routing.test.ts src/services/__tests__/akta-txn-classifier.test.ts(PERUBAHAN/AKUISISI inference + refine untouched).
- [ ] 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__/laporan-rups-routing.test.ts && git commit -m "feat(laporan-rups): routing + gating — BUILT flip, ladder rung, doc maps, labels, BA-RUPS refine, supporting-doc wiring"
Task 8: Route family routes/laporan-rups.ts — review-data, attestations, approvals, TERMINAL submit
The flow's own thin route file (routeFamily 'laporan-rups' — NOT perubahan: no diff, no verifikator; the dormant v2 501 arm in perubahan.ts:1249-1270 stays for the perubahan-family v2 flows). Company selection needs NO new route: POST /api/perubahan/select-company/:submissionId has no family guard and already resumes v2 flows via continueFlow (perubahan.ts:125-209) — the frontend's shared useSelectCompany hook works unchanged. Field edit/confirm/override ride the existing generic /api/submissions/:id/... endpoints (their revalidation reaches this flow via queueReMatchAndValidate → the registry). The submit gate is the safety net: status READY + tahun/jenis attested + required sections approved (akuntan-publik flips required at gate time under WAJIB_AUDIT) + checkFailGate (NAMED step, block-with-override) + WARNING declarations → ONE transaction → COMPLETED + rupsSubmittedAt + rupsDeclarations. No VerifikasiPerubahan row. Declarations persist as Submission.rupsDeclarations JSON (akuisisiAttestation precedent) because recordAuditEvent is VerifikasiPerubahan-keyed and no verifikasi row ever exists here.
Files
- backend/src/routes/laporan-rups.ts — Create
- backend/src/index.ts — Modify (import + app.route("/api/laporan-rups", laporanRups); after the /api/perubahan mount)
- backend/src/routes/__tests__/laporan-rups-routes.test.ts — Create
Interfaces
- Consumes: db; queueReMatchAndValidate from ../services/submission-processor; checkFailGate from ../flow-engine/fail-gate ((submissionId, cfg) → { blocked, failing }); laporanRupsTahunanFlow (sections vocabulary); LAMPIRAN_FINE_LABELS; Prisma.
- Produces routes (all under /api/laporan-rups):
- GET /submissions/:submissionId/review-data
- PUT /submissions/:submissionId/tahun-laporan body { tahunLaporan: number }
- PUT /submissions/:submissionId/jenis-audit body { jenisAudit: "WAJIB_AUDIT" | "NON_AUDIT" }
- PUT /submissions/:submissionId/akuntan-publik body { namaAkuntanPublik?, nomorIzinAkuntanPublik?, tanggalIzinAkuntanPublik? } (all string | null)
- POST /submissions/:submissionId/sections/:sectionKey/approve and /unapprove
- POST /submissions/:submissionId/submit body { declaration: boolean; warningAcks?: string[] }
Steps
- [ ] Write failing test. Create
backend/src/routes/__tests__/laporan-rups-routes.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";
const YEAR = new Date().getFullYear();
describe("laporan-rups 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?: { withLk?: 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 GATE UJI" },
{ documentId: akta.id, fieldKey: "nomor_akta", value: "9" },
{ documentId: akta.id, fieldKey: "tanggal_akta", value: `10-04-${YEAR}` },
{ documentId: akta.id, fieldKey: "tanggal_rups", value: `10-04-${YEAR}` },
],
});
const sub = await db.submission.create({ data: { type: "LAPORAN_RUPS_TAHUNAN", status: "READY" } });
ids.push(sub.id);
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_RUPS_TAHUNAN", processingOrder: 0, extractionStatus: "DONE" },
});
let lkId: string | null = null;
if (opts?.withLk) {
const lk = await db.document.create({
data: { filename: "lk.pdf", originalName: "lk.pdf", fileSize: 0, filePath: "/tmp/lk.pdf", documentType: "LAPORAN_KEUANGAN", status: "VERIFICATION_READY", rawText: "LAPORAN KEUANGAN" },
});
docIds.push(lk.id);
lkId = lk.id;
await db.submissionDocument.create({
data: { submissionId: sub.id, documentId: lk.id, classifiedType: "LAPORAN_KEUANGAN", processingOrder: 6, extractionStatus: "DONE" },
});
}
return { subId: sub.id, aktaId: akta.id, lkId };
}
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/laporan-rups/submissions/${other.id}/review-data`)).status).toBe(400);
const { subId } = await seed({ withLk: true });
const res = await app.request(`/api/laporan-rups/submissions/${subId}/review-data`);
expect(res.status).toBe(200);
const body = await json(res) as never as {
submission: { tahunLaporan: number | null; jenisAudit: string | null };
akta: { documentId: string; fields: unknown[] } | null;
laporanKeuangan: { documentId: string } | null;
lampiranSlots: { key: string; documentId: string | null }[];
sections: { key: string }[];
sectionApprovals: Record<string, boolean>;
validationResults: unknown[];
};
expect(body.akta).not.toBeNull();
expect(body.laporanKeuangan).not.toBeNull();
expect(body.lampiranSlots).toHaveLength(6);
expect(body.sections.map((s) => s.key)).toContain("akuntan-publik");
});
it("tahun-laporan enforces the backlog window; jenis-audit enforces the enum", async () => {
const { subId } = await seed();
expect((await app.request(`/api/laporan-rups/submissions/${subId}/tahun-laporan`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tahunLaporan: YEAR - 11 }),
})).status).toBe(422);
expect((await app.request(`/api/laporan-rups/submissions/${subId}/tahun-laporan`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ tahunLaporan: YEAR - 10 }),
})).status).toBe(200);
expect((await db.submission.findUnique({ where: { id: subId } }))?.tahunLaporan).toBe(YEAR - 10);
expect((await app.request(`/api/laporan-rups/submissions/${subId}/jenis-audit`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jenisAudit: "MAYBE" }),
})).status).toBe(422);
expect((await app.request(`/api/laporan-rups/submissions/${subId}/jenis-audit`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ jenisAudit: "NON_AUDIT" }),
})).status).toBe(200);
}, 30000);
it("akuntan-publik upserts the satellite; 404 without a laporan keuangan doc", async () => {
const bare = await seed();
expect((await app.request(`/api/laporan-rups/submissions/${bare.subId}/akuntan-publik`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ namaAkuntanPublik: "KAP X" }),
})).status).toBe(404);
const { subId, lkId } = await seed({ withLk: true });
const res = await app.request(`/api/laporan-rups/submissions/${subId}/akuntan-publik`, {
method: "PUT", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ namaAkuntanPublik: "KAP Manual", nomorIzinAkuntanPublik: "AP.9999" }),
});
expect(res.status).toBe(200);
const lk = await db.laporanKeuanganExtraction.findUnique({ where: { documentId: lkId! } });
expect(lk?.namaAkuntanPublik).toBe("KAP Manual");
expect(lk?.nomorIzinAkuntanPublik).toBe("AP.9999");
}, 30000);
it("section approvals persist explicit rows; unknown keys rejected", async () => {
const { subId } = await seed();
expect((await app.request(`/api/laporan-rups/submissions/${subId}/sections/nope/approve`, { method: "POST" })).status).toBe(400);
expect((await app.request(`/api/laporan-rups/submissions/${subId}/sections/akta-rups/approve`, { method: "POST" })).status).toBe(200);
const row = await db.perubahanSectionApproval.findUnique({
where: { submissionId_sectionKey: { submissionId: subId, sectionKey: "akta-rups" } },
});
expect(row?.approved).toBe(true);
await app.request(`/api/laporan-rups/submissions/${subId}/sections/akta-rups/unapprove`, { method: "POST" });
expect((await db.perubahanSectionApproval.findUnique({
where: { submissionId_sectionKey: { submissionId: subId, sectionKey: "akta-rups" } },
}))?.approved).toBe(false);
});
it("TERMINAL submit gate: attestation → sections → FAIL gate (override) → WARNING acks → COMPLETED, NO verifikasi row", async () => {
const { subId } = await seed();
const post = (path: string, body?: unknown) => app.request(`/api/laporan-rups/submissions/${subId}${path}`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: body ? JSON.stringify(body) : "{}",
});
// 1. attestation incomplete
let res = await post("/submit", { declaration: true });
expect(res.status).toBe(400);
expect((await json(res)).code).toBe("ATTESTATION_INCOMPLETE");
await db.submission.update({ where: { id: subId }, data: { tahunLaporan: YEAR - 2, jenisAudit: "WAJIB_AUDIT" } });
// 2. required sections missing — under WAJIB_AUDIT, akuntan-publik is required at gate time
res = await post("/submit", { declaration: true });
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("akuntan-publik");
for (const key of ["identitas-perseroan", "akta-rups", "laporan-tahunan", "akuntan-publik"]) {
await post(`/sections/${key}/approve`);
}
// 3. FAIL gate blocks; override unblocks (block-with-override)
await db.validationResult.create({
data: { submissionId: subId, ruleCode: "AKUNTAN_PUBLIK_REQUIRED", ruleLabel: "x", status: "FAIL", message: "m" },
});
res = await post("/submit", { declaration: true });
expect(res.status).toBe(422);
expect(((await json(res)) as { failing: string[] }).failing).toContain("AKUNTAN_PUBLIK_REQUIRED");
await app.request(`/api/submissions/${subId}/override-validation`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ruleCode: "AKUNTAN_PUBLIK_REQUIRED", reason: "manual check ok" }),
});
// 4. WARNINGs need declaration + per-rule acks
await db.validationResult.create({
data: { submissionId: subId, ruleCode: "RUPS_ANNUAL_WINDOW", ruleLabel: "x", status: "WARNING", message: "m" },
});
res = await post("/submit", { declaration: true, warningAcks: [] });
expect(res.status).toBe(400);
expect(((await json(res)) as { code: string; outstanding: string[] }).outstanding).toContain("RUPS_ANNUAL_WINDOW");
res = await post("/submit", { declaration: false, warningAcks: ["RUPS_ANNUAL_WINDOW"] });
expect(res.status).toBe(400);
// 5. success — transactional + terminal
res = await post("/submit", { declaration: true, warningAcks: ["RUPS_ANNUAL_WINDOW"] });
expect(res.status).toBe(200);
const sub = await db.submission.findUnique({ where: { id: subId }, include: { verifikasi: true } });
expect(sub?.status).toBe("COMPLETED");
expect(sub?.rupsSubmittedAt).not.toBeNull();
expect((sub?.rupsDeclarations as { warningAcks: string[] }).warningAcks).toContain("RUPS_ANNUAL_WINDOW");
expect(sub?.verifikasi).toBeNull(); // NO VerifikasiPerubahan — the verifikator surfaces never see it
// 6. terminal — a second submit is rejected
expect((await post("/submit", { declaration: true })).status).toBe(409);
}, 30000);
});
(NOTE on the Submission.verifikasi include: the relation name is whatever submissions.ts:177 uses — verifikasi: { select: { status: true } } — keep that name.)
- [ ] Run to verify RED: cd backend && bun test src/routes/__tests__/laporan-rups-routes.test.ts. Expected: 404s everywhere — route not mounted.
- [ ] Minimal implementation. Create backend/src/routes/laporan-rups.ts:
import { Hono } from "hono";
import { db } from "../lib/db";
import { queueReMatchAndValidate } from "../services/submission-processor";
import { checkFailGate } from "../flow-engine/fail-gate";
import { laporanRupsTahunanFlow } from "../flow-engine/flows/laporan-rups-tahunan";
import { LAMPIRAN_FINE_LABELS } from "../flow-engine/context/rups-tahunan-context";
/**
* Route family 'laporan-rups' — Pelaporan RUPS Tahunan. TERMINAL SELF-SERVICE:
* the submit gate below is the safety net (no verifier downstream); it creates
* NO VerifikasiPerubahan row. Company selection reuses the shared v2-aware
* POST /api/perubahan/select-company/:id (no family guard there); field edit/
* confirm/override ride the generic /api/submissions endpoints, whose
* queueReMatchAndValidate reaches this flow via the FLOW_REGISTRY.
*/
const laporanRups = new Hono();
const APPROVABLE_KEYS = new Set(laporanRupsTahunanFlow.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 !== "LAPORAN_RUPS_TAHUNAN") return { error: "wrong_type" as const, submission: null };
return { error: null, submission };
}
// GET /submissions/:submissionId/review-data
laporanRups.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 } } }, orderBy: { processingOrder: "asc" } },
validationResults: { orderBy: { createdAt: "asc" } },
perubahanSectionApprovals: true,
},
});
if (!submission) return c.json({ error: "Submission not found" }, 404);
if (submission.type !== "LAPORAN_RUPS_TAHUNAN") return c.json({ error: "Not a Pelaporan RUPS Tahunan submission" }, 400);
const aktaSubDoc = submission.documents.find((d) => d.classifiedType === "AKTA_RUPS_TAHUNAN");
const lkSubDoc = submission.documents.find((d) => d.document.documentType === "LAPORAN_KEUANGAN");
const lk = lkSubDoc ? await db.laporanKeuanganExtraction.findUnique({ where: { documentId: lkSubDoc.documentId } }) : null;
return c.json({
submission: {
id: submission.id, status: submission.status, type: submission.type,
tahunLaporan: submission.tahunLaporan, jenisAudit: submission.jenisAudit,
rupsSubmittedAt: submission.rupsSubmittedAt?.toISOString() ?? null,
selectedNomorTransaksi: submission.selectedNomorTransaksi, selectedTahun: submission.selectedTahun,
},
oldData: submission.oldData ?? null,
akta: aktaSubDoc
? { documentId: aktaSubDoc.documentId, fields: aktaSubDoc.document.fields.map((f) => ({ fieldKey: f.fieldKey, value: f.value, confidence: f.confidence, status: f.status })) }
: null,
laporanKeuangan: lkSubDoc ? { documentId: lkSubDoc.documentId, extraction: lk } : null,
lampiranSlots: LAMPIRAN_FINE_LABELS.map((key) => {
const d = submission.documents.find((x) => x.classifiedType === key);
return { key, documentId: d?.documentId ?? null, originalName: d?.document.originalName ?? null };
}),
documents: submission.documents.map((d) => ({
documentId: d.documentId, classifiedType: d.classifiedType,
extractionStatus: d.extractionStatus, originalName: d.document.originalName,
})),
validationResults: submission.validationResults,
sections: laporanRupsTahunanFlow.sections,
sectionApprovals: Object.fromEntries(submission.perubahanSectionApprovals.map((a) => [a.sectionKey, a.approved])),
});
});
// PUT /submissions/:submissionId/tahun-laporan — backlog window currentYear-10..-1
laporanRups.put("/submissions/:submissionId/tahun-laporan", 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 Pelaporan RUPS Tahunan submission" }, 400);
const body = await c.req.json<{ tahunLaporan?: number }>().catch(() => ({}) as { tahunLaporan?: number });
const now = new Date().getFullYear();
const t = body.tahunLaporan;
if (!Number.isInteger(t) || t! < now - 10 || t! > now - 1) {
return c.json({ error: `tahunLaporan harus ${now - 10}-${now - 1}`, min: now - 10, max: now - 1 }, 422);
}
await db.submission.update({ where: { id: submissionId }, data: { tahunLaporan: t } });
// Rules 2/3/6/9 branch on tahunLaporan — revalidate (serialized; failures non-fatal).
await queueReMatchAndValidate(submissionId).catch(console.error);
return c.json({ tahunLaporan: t });
});
// PUT /submissions/:submissionId/jenis-audit
laporanRups.put("/submissions/:submissionId/jenis-audit", 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 Pelaporan RUPS Tahunan submission" }, 400);
const body = await c.req.json<{ jenisAudit?: string }>().catch(() => ({}) as { jenisAudit?: string });
if (body.jenisAudit !== "WAJIB_AUDIT" && body.jenisAudit !== "NON_AUDIT") {
return c.json({ error: "jenisAudit harus WAJIB_AUDIT | NON_AUDIT" }, 422);
}
await db.submission.update({ where: { id: submissionId }, data: { jenisAudit: body.jenisAudit } });
await queueReMatchAndValidate(submissionId).catch(console.error);
return c.json({ jenisAudit: body.jenisAudit });
});
// PUT /submissions/:submissionId/akuntan-publik — manual entry lands on the SAME
// satellite the focused pass writes (rules read one place either way).
laporanRups.put("/submissions/:submissionId/akuntan-publik", 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 Pelaporan RUPS Tahunan submission" }, 400);
const lkSubDoc = await db.submissionDocument.findFirst({
where: { submissionId, document: { documentType: "LAPORAN_KEUANGAN" } },
select: { documentId: true },
});
if (!lkSubDoc) return c.json({ error: "Belum ada dokumen Laporan Keuangan pada pengajuan ini" }, 404);
const body = await c.req.json<{ namaAkuntanPublik?: string | null; nomorIzinAkuntanPublik?: string | null; tanggalIzinAkuntanPublik?: string | null }>().catch(() => ({}));
const data: Record<string, string | null> = {};
if ("namaAkuntanPublik" in body) data.namaAkuntanPublik = body.namaAkuntanPublik ?? null;
if ("nomorIzinAkuntanPublik" in body) data.nomorIzinAkuntanPublik = body.nomorIzinAkuntanPublik ?? null;
if ("tanggalIzinAkuntanPublik" in body) data.tanggalIzinAkuntanPublik = body.tanggalIzinAkuntanPublik ?? null;
const lk = await db.laporanKeuanganExtraction.upsert({
where: { documentId: lkSubDoc.documentId },
update: data,
create: { documentId: lkSubDoc.documentId, ...data },
});
await queueReMatchAndValidate(submissionId).catch(console.error);
return c.json(lk);
});
// 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) {
laporanRups.post(`/submissions/:submissionId/sections/:sectionKey/${suffix}`, async (c) => {
const submissionId = c.req.param("submissionId");
const sectionKey = c.req.param("sectionKey");
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 Pelaporan RUPS Tahunan submission" }, 400);
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 — THE TERMINAL GATE (flow-spec §4.6).
* Order: status → attestation → required sections (akuntan-publik flips required
* under WAJIB_AUDIT) → checkFailGate (block-with-override, NAMED engine step) →
* WARNING declarations → ONE transaction to COMPLETED. No VerifikasiPerubahan,
* no re-open (parity with SABH; corrections are a Perbaikan-family concern).
*/
laporanRups.post("/submissions/:submissionId/submit", 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 Pelaporan RUPS Tahunan submission" }, 400);
if (submission!.status !== "READY") {
return c.json({ error: `Cannot submit: status is ${submission!.status}, expected READY` }, 409);
}
const body = await c.req.json<{ declaration?: boolean; warningAcks?: string[] }>().catch(() => ({}) as { declaration?: boolean; warningAcks?: string[] });
// (2) attestation
if (submission!.tahunLaporan == null || submission!.jenisAudit == null) {
return c.json({ error: "Tahun laporan dan jenis audit wajib diisi sebelum submit", code: "ATTESTATION_INCOMPLETE" }, 400);
}
// (1) required section approvals — explicit persisted rows only
const requiredKeys = laporanRupsTahunanFlow.sections.filter((s) => s.approvable && s.required).map((s) => s.key);
if (submission!.jenisAudit === "WAJIB_AUDIT") requiredKeys.push("akuntan-publik");
const approvals = await db.perubahanSectionApproval.findMany({ where: { submissionId } });
const approvedSet = new Set(approvals.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);
}
// (3) FAIL gate — the engine's block-with-override step, by name.
const { blocked, failing } = await checkFailGate(submissionId, laporanRupsTahunanFlow);
if (blocked) {
return c.json({ error: "Validasi FAIL harus diselesaikan atau di-override sebelum submit", code: "VALIDATION_FAILED", failing }, 422);
}
// (4) WARNING declarations — every outstanding WARNING must be acknowledged.
const warnings = await db.validationResult.findMany({ where: { submissionId, status: "WARNING" }, select: { ruleCode: true } });
const acks = new Set(body.warningAcks ?? []);
const outstanding = warnings.map((w) => w.ruleCode).filter((code) => !acks.has(code));
if (body.declaration !== true || outstanding.length > 0) {
return c.json({
error: "Pernyataan kebenaran data dan konfirmasi setiap peringatan wajib sebelum submit",
code: "DECLARATION_REQUIRED",
outstanding,
}, 400);
}
// (5) terminal transition — single transaction, audit trail on the submission.
const now = new Date();
await db.submission.update({
where: { id: submissionId },
data: {
status: "COMPLETED",
rupsSubmittedAt: now,
rupsDeclarations: { declaration: true, warningAcks: [...acks], declaredAt: now.toISOString() },
},
});
return c.json({ submitted: true, status: "COMPLETED", rupsSubmittedAt: now.toISOString() });
});
export default laporanRups;
- [ ] Edit
backend/src/index.ts: addimport laporanRups from "./routes/laporan-rups";with the other route imports andapp.route("/api/laporan-rups", laporanRups);directly after theapp.route("/api/perubahan", perubahan);line (62). - [ ] Run GREEN:
cd backend && bun test src/routes/__tests__/laporan-rups-routes.test.ts. Expected: all pass (the PUT tests tolerate SABH-dependent revalidation becausereMatchAndValidateFlowcatches internally). - [ ] Typecheck:
cd backend && bunx tsc --noEmit. - [ ] Commit:
git add backend/src/routes/laporan-rups.ts backend/src/index.ts backend/src/routes/__tests__/laporan-rups-routes.test.ts && git commit -m "feat(laporan-rups): route family — review-data, attestations, approvals, terminal submit gate"
Task 9: Frontend klasifikasi surfacing (detect + label set + create-route)
The same 6 mechanical touchpoints the akuisisi flow used, minus routes/pages (Tasks 10-11). Detection order mirrors the backend ladder: perbaikan > akuisisi > perubahan > laporan-rups > pendirian.
Files
- frontend/src/lib/classifier-labels.ts — Modify (CLASSIFIER_LABEL_MAP)
- frontend/src/components/klasifikasi/detect-banner.tsx — Modify (DetectedFlow L5, FLOW_LABELS L7-12, FLOW_DESCRIPTIONS L14-20)
- frontend/src/pages/KlasifikasiPage.tsx — Modify (KlasifikasiLabelType L37, detection L62-81, detectedFlow L265-275, createSubmission.onSuccess L476-488)
- frontend/src/components/klasifikasi/__tests__/detect-banner-laporan-rups.test.tsx — Create
Steps
- [ ] Write failing test. Create
frontend/src/components/klasifikasi/__tests__/detect-banner-laporan-rups.test.tsx:
import { describe, it, expect } from "vitest";
import { render, screen } from "@testing-library/react";
import { DetectBanner } from "../detect-banner";
describe("DetectBanner — laporan-rups flow", () => {
it("renders the Pelaporan RUPS Tahunan label + description", () => {
render(<DetectBanner state="detected" flow="laporan-rups" aktaConfidence={0.91} />);
expect(screen.getByText("Pelaporan RUPS Tahunan")).toBeInTheDocument();
expect(screen.getByText(/Akta\/Risalah RUPS Tahunan terdeteksi/)).toBeInTheDocument();
});
});
- [ ] Run to verify RED:
cd frontend && bun run test -- detect-banner-laporan-rups. Expected: type error —"laporan-rups"not assignable toDetectedFlow. - [ ] Minimal implementation A —
frontend/src/components/klasifikasi/detect-banner.tsx:
export type DetectedFlow = "pendirian" | "perubahan" | "perbaikan" | "akuisisi" | "laporan-rups";
Add to FLOW_LABELS: "laporan-rups": "Pelaporan RUPS Tahunan", and to FLOW_DESCRIPTIONS: "laporan-rups": "Akta/Risalah RUPS Tahunan terdeteksi. Alur Pelaporan RUPS Tahunan (tanpa verifikator) akan dijalankan.",
- [ ] Minimal implementation B — frontend/src/lib/classifier-labels.ts, add to CLASSIFIER_LABEL_MAP:
AKTA_RUPS_TAHUNAN: "Akta / Risalah RUPS Tahunan",
LAPORAN_KEUANGAN: "Laporan Keuangan",
LAMPIRAN_LAPORAN_TAHUNAN: "Lampiran Laporan Tahunan",
LAMPIRAN_KEGIATAN_PERSEROAN: "Laporan Kegiatan Perseroan",
LAMPIRAN_TJSL: "Laporan Pelaksanaan TJSL",
LAMPIRAN_RINCIAN_MASALAH: "Rincian Masalah Selama Tahun Buku",
LAMPIRAN_PENGAWASAN_KOMISARIS: "Laporan Pengawasan Dewan Komisaris",
LAMPIRAN_SUSUNAN_PENGURUS: "Susunan Direksi dan Dewan Komisaris",
LAMPIRAN_GAJI_PENGURUS: "Gaji & Tunjangan Direksi/Komisaris",
- [ ] Minimal implementation C —
frontend/src/pages/KlasifikasiPage.tsx(exact diffs):
1. L37:type KlasifikasiLabelType = "pendirian" | "perubahan" | "perbaikan" | "pendirian_pp" | "akuisisi" | "laporan_rups";
2. After thehasAktaPerubahanblock (L68-70), add:
const hasAktaRupsTahunan = results.some(
(r) => (r.userOverride ?? r.classification) === "AKTA_RUPS_TAHUNAN"
);
computedLabelTypechain (L71-77) — insert below perubahan (ladder mirror):
const computedLabelType: KlasifikasiLabelType = hasSuratPerbaikan
? "perbaikan"
: hasAktaAkuisisi
? "akuisisi"
: hasAktaPerubahan
? "perubahan"
: hasAktaRupsTahunan
? "laporan_rups"
: "pendirian";
detectedFlowchain (L265-275) — insert: hasAktaRupsTahunan ? "laporan-rups"between thehasAktaPerubahan ? "perubahan"arm and thehasAnyAkta ? "pendirian"arm.createSubmission.onSuccess(L476-488) — add before thePERUBAHAN_PTbranch:
} else if (data.type === "LAPORAN_RUPS_TAHUNAN") {
navigate(`/laporan-rups/${data.submissionId}/extraction`);
- [ ] Run GREEN:
cd frontend && bun run test -- detect-banner-laporan-rupsthenbunx tsc --noEmit. - [ ] Commit:
git add frontend/src/lib/classifier-labels.ts frontend/src/components/klasifikasi/detect-banner.tsx frontend/src/pages/KlasifikasiPage.tsx frontend/src/components/klasifikasi/__tests__/detect-banner-laporan-rups.test.tsx && git commit -m "feat(laporan-rups): frontend klasifikasi surfacing — detect + labels + create-route"
Task 10: Frontend flow config — steps (terminal, NO verifikasi stage) + destination + gate helpers
LAPORAN_RUPS_TAHUNAN is a 4-step flow like PENDIRIAN (Unggah → Ekstraksi → Tinjauan → Selesai) — there is NO "Menunggu Verifikasi" stage. computeFlowStep's existing non-perubahan fallthrough (flow-steps.ts:203-207 + PENDIRIAN_STAGE_INDEX with COMPLETED: 4) already yields the right indices; we only add the label set + type. destinationFor treats it exactly like PENDIRIAN_PT post-COMPLETED (terminal, never verifikasi-driven). Also ships the pure review-gate helpers Task 11's page consumes.
Files
- frontend/src/lib/flow-steps.ts — Modify (SubmissionLite L57, labels + selection L36-58 & L149-158)
- frontend/src/lib/destination-for.ts — Modify (type union + base + terminal branch)
- frontend/src/lib/laporan-rups-review.ts — Create
- frontend/src/lib/laporan-rups-flow.test.ts, frontend/src/lib/laporan-rups-review.test.ts — Create
Steps
- [ ] Write failing tests. Create
frontend/src/lib/laporan-rups-flow.test.ts:
import { describe, it, expect } from "vitest";
import { computeFlowStep } from "./flow-steps";
import { destinationFor } from "./destination-for";
describe("computeFlowStep — LAPORAN_RUPS_TAHUNAN (terminal, no verifikasi stage)", () => {
it("has 4 steps with NO 'Menunggu Verifikasi'", () => {
const r = computeFlowStep({ type: "LAPORAN_RUPS_TAHUNAN", status: "READY" }, null);
expect(r.steps).toHaveLength(4);
expect(r.steps.map((s) => s.label)).not.toContain("Menunggu Verifikasi");
expect(r.currentStep).toBe(2); // READY = Tinjauan
});
it("COMPLETED lights every step green (past-end convention)", () => {
const r = computeFlowStep({ type: "LAPORAN_RUPS_TAHUNAN", status: "COMPLETED" }, null);
expect(r.currentStep).toBe(4);
expect(r.steps.every((s) => s.completed)).toBe(true);
});
it("ERROR lights the extraction step red", () => {
const r = computeFlowStep({ type: "LAPORAN_RUPS_TAHUNAN", status: "ERROR" }, null);
expect(r.variant).toBe("error");
expect(r.currentStep).toBe(1);
});
});
describe("destinationFor — LAPORAN_RUPS_TAHUNAN", () => {
it("pre-COMPLETED → review; COMPLETED → status (terminal — verifikasi NEVER consulted)", () => {
expect(destinationFor({ id: "x", type: "LAPORAN_RUPS_TAHUNAN", status: "READY" }, null))
.toBe("/laporan-rups/x/review");
expect(destinationFor({ id: "x", type: "LAPORAN_RUPS_TAHUNAN", status: "COMPLETED" }, { status: "PERBAIKAN" }))
.toBe("/laporan-rups/x/status");
});
});
Create frontend/src/lib/laporan-rups-review.test.ts:
import { describe, it, expect } from "vitest";
import { outstandingWarnings, requiredSectionKeys, missingSections } from "./laporan-rups-review";
const v = (ruleCode: string, status: string) => ({ ruleCode, status });
describe("laporan-rups review gate helpers", () => {
it("outstandingWarnings = WARNING rows not yet acknowledged", () => {
const rows = [v("A", "WARNING"), v("B", "WARNING"), v("C", "FAIL"), v("D", "PASS")];
expect(outstandingWarnings(rows, ["A"])).toEqual(["B"]);
expect(outstandingWarnings(rows, ["A", "B"])).toEqual([]);
});
it("requiredSectionKeys flips akuntan-publik under WAJIB_AUDIT", () => {
expect(requiredSectionKeys("NON_AUDIT")).toEqual(["identitas-perseroan", "akta-rups", "laporan-tahunan"]);
expect(requiredSectionKeys("WAJIB_AUDIT")).toEqual(["identitas-perseroan", "akta-rups", "laporan-tahunan", "akuntan-publik"]);
});
it("missingSections reads EXPLICIT approvals only", () => {
expect(missingSections("WAJIB_AUDIT", { "akta-rups": true, "laporan-tahunan": false }))
.toEqual(["identitas-perseroan", "laporan-tahunan", "akuntan-publik"]);
});
});
- [ ] Run to verify RED:
cd frontend && bun run test -- laporan-rups. Expected: type errors + module not found. - [ ] Minimal implementation A —
frontend/src/lib/flow-steps.ts:
1. AfterAKUISISI_LABELS(L36-42), add:
// Terminal self-service filing: submit → Selesai. NO verifikator stage exists
// for LAPORAN_RUPS_TAHUNAN (no VerifikasiPerubahan row is ever created).
const LAPORAN_RUPS_LABELS = [
{ label: "Unggah", sub: "AI mengklasifikasi otomatis" },
{ label: "Ekstraksi", sub: "AI membaca dokumen" },
{ label: "Tinjauan", sub: "Verifikasi & kirim" },
{ label: "Selesai", sub: "Laporan tahunan tercatat" },
] as const;
SubmissionLite(L57):type: "PENDIRIAN_PT" | "PERUBAHAN_PT" | "PERBAIKAN_DATA_PT" | "AKUISISI_PT" | "LAPORAN_RUPS_TAHUNAN";- In
computeFlowStep(L149-158), addconst isLaporanRups = submission.type === "LAPORAN_RUPS_TAHUNAN";and extend the labels ternary:... : isAkuisisi ? AKUISISI_LABELS : isLaporanRups ? LAPORAN_RUPS_LABELS : PENDIRIAN_LABELS;. No stage-logic change: the existingif (!isPerubahan || submission.status !== "COMPLETED")fallthrough drives it fromPENDIRIAN_STAGE_INDEX(incl.COMPLETED: 4), which is exactly the terminal behaviour.
- [ ] Minimal implementation B —frontend/src/lib/destination-for.ts: SubmissionLite.typeunion +="LAPORAN_RUPS_TAHUNAN".baseternary gains:submission.type === "LAPORAN_RUPS_TAHUNAN" ? "laporan-rups" : ...(before the pendirian default).- Terminal branch: change
if (submission.type === "PENDIRIAN_PT") {toif (submission.type === "PENDIRIAN_PT" || submission.type === "LAPORAN_RUPS_TAHUNAN") {with a comment// terminal flows: COMPLETED → status; verifikasi is never consulted.
- [ ] Minimal implementation C — Createfrontend/src/lib/laporan-rups-review.ts:
/** Pure gate helpers for the LaporanRupsReviewPage — mirror of the backend
* submit gate (routes/laporan-rups.ts) so the UI can pre-flight the exact
* same conditions it will be blocked on. */
export interface ValidationRow { ruleCode: string; status: string }
export function outstandingWarnings(rows: ValidationRow[], acks: string[]): string[] {
const acked = new Set(acks);
return rows.filter((r) => r.status === "WARNING" && !acked.has(r.ruleCode)).map((r) => r.ruleCode);
}
const BASE_REQUIRED = ["identitas-perseroan", "akta-rups", "laporan-tahunan"] as const;
export function requiredSectionKeys(jenisAudit: string | null): string[] {
// akuntan-publik flips required at gate time under WAJIB_AUDIT (UUPT Ps.68).
return jenisAudit === "WAJIB_AUDIT" ? [...BASE_REQUIRED, "akuntan-publik"] : [...BASE_REQUIRED];
}
export function missingSections(jenisAudit: string | null, approvals: Record<string, boolean>): string[] {
return requiredSectionKeys(jenisAudit).filter((k) => !approvals[k]);
}
- [ ] Run GREEN:
cd frontend && bun run test -- laporan-rupsthenbunx tsc --noEmit. - [ ] Commit:
git add frontend/src/lib/flow-steps.ts frontend/src/lib/destination-for.ts frontend/src/lib/laporan-rups-review.ts frontend/src/lib/laporan-rups-flow.test.ts frontend/src/lib/laporan-rups-review.test.ts && git commit -m "feat(laporan-rups): frontend flow steps (terminal) + destination + gate helpers"
Task 11: Frontend pages — extraction (faithful mirror), flat review, terminal status + routes
Three pages + the route trio. The extraction page is a FAITHFUL MIRROR (copy AkuisisiExtractionPage.tsx + the exact diffs below — it already handles auto-start, polling, company-selection modal, retry, and READY handoff). The review page is genuinely new (flat — no delta/diff): registry-entity header from oldData, akta field card with confirm, tahun picker + jenis-audit radio, akuntan-publik card, lampiran slots, validation list with override, section approvals, and the terminal submit dialog with the declaration + per-WARNING acks. The status page is a small dedicated terminal page — deliberately NOT SubmissionStatusPage (that 997-line page is hardwired to the pendirian/perubahan review payloads; threading a third payload through it violates thin-mirror discipline — deviation from spec §4.7's parenthetical, recorded in Self-Review).
Files
- frontend/src/pages/LaporanRupsExtractionPage.tsx — Create (copy + diffs)
- frontend/src/pages/LaporanRupsReviewPage.tsx — Create
- frontend/src/pages/LaporanRupsStatusPage.tsx — Create
- frontend/src/routes.tsx — Modify (after the akuisisi trio, L79-80 and L90)
Interfaces
- Consumes: GET/PUT/POST /api/laporan-rups/submissions/:id/* (Task 8); shared POST /api/perubahan/select-company/:id via useSelectCompany; generic POST /api/submissions/:id/documents/:docId/fields/confirm, POST /api/submissions/:id/override-validation, POST /api/submissions/:id/revalidate; helpers from @/lib/laporan-rups-review (Task 10).
- Produces: routes laporan-rups/:submissionId/{extraction,review,status}.
Steps
- [ ] Create
frontend/src/pages/LaporanRupsExtractionPage.tsx— copyfrontend/src/pages/AkuisisiExtractionPage.tsxbyte-for-byte, then apply EXACTLY these diffs:
1. Rename the component:export function AkuisisiExtractionPage()→export function LaporanRupsExtractionPage().
2. Line ~254 (computeFlowStepcall):{ type: "AKUISISI_PT", status: submission.status }→{ type: "LAPORAN_RUPS_TAHUNAN", status: submission.status }.
3. Line ~488 (READY handoff button):navigate(\/akuisisi/${submissionId}/review`)→navigate(`/laporan-rups/${submissionId}/review`). 4. No otherakuisisistring occurs in the donor (verified by grep) — do not change anything else (the shareduseSelectCompanyhook posts to the type-agnostic/api/perubahan/select-company/:id, which resumes v2 flows viacontinueFlow`). - [ ] Create
frontend/src/pages/LaporanRupsStatusPage.tsx:
import { useParams, Link, Navigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { CheckCircle2, Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { StepIndicator } from "@/components/process/step-indicator";
import { computeFlowStep } from "@/lib/flow-steps";
import { apiFetch } from "@/lib/api-client";
interface ReviewData {
submission: { id: string; status: string; tahunLaporan: number | null; jenisAudit: string | null; rupsSubmittedAt: string | null };
oldData: { perseroan?: { nama?: string } } | null;
}
/** Terminal status page — this flow has NO verifikator stage, so there is no
* verifikasi timeline to render (deliberately not SubmissionStatusPage). */
export function LaporanRupsStatusPage() {
const { submissionId } = useParams<{ submissionId: string }>();
const { data, isLoading } = useQuery<ReviewData>({
queryKey: ["laporan-rups-review", submissionId],
queryFn: () => apiFetch<ReviewData>(`/api/laporan-rups/submissions/${submissionId}/review-data`),
enabled: !!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={`/laporan-rups/${submissionId}/review`} replace />;
const flow = computeFlowStep({ type: "LAPORAN_RUPS_TAHUNAN", status: "COMPLETED" }, null);
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">
<CheckCircle2 className="mx-auto h-10 w-10 text-emerald-500" />
<h1 className="text-lg font-semibold">Laporan Tahunan Tercatat</h1>
<p className="text-sm text-muted-foreground">
{data.oldData?.perseroan?.nama ?? "Perseroan"} — tahun laporan {data.submission.tahunLaporan ?? "-"}
{data.submission.jenisAudit ? ` (${data.submission.jenisAudit === "WAJIB_AUDIT" ? "Wajib Audit" : "Non Audit"})` : ""}
</p>
{data.submission.rupsSubmittedAt && (
<p className="text-xs text-muted-foreground">Dikirim {new Date(data.submission.rupsSubmittedAt).toLocaleString("id-ID")}</p>
)}
<p className="text-xs text-muted-foreground">Pengajuan ini bersifat final — tidak ada tahap verifikator.</p>
<Button asChild variant="outline"><Link to="/dashboard">Kembali ke Dashboard</Link></Button>
</div>
</div>
);
}
- [ ] Create
frontend/src/pages/LaporanRupsReviewPage.tsx(complete — a flat review, no diff):
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, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Checkbox } from "@/components/ui/checkbox";
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 { outstandingWarnings, missingSections } from "@/lib/laporan-rups-review";
import { cn } from "@/lib/utils";
interface FieldRow { fieldKey: string; value: string | null; confidence: number; status: string }
interface ValidationRow { id: string; ruleCode: string; ruleLabel: string; status: string; message: string; overridden?: boolean }
interface ReviewData {
submission: { id: string; status: string; tahunLaporan: number | null; jenisAudit: "WAJIB_AUDIT" | "NON_AUDIT" | null; rupsSubmittedAt: string | null; selectedNomorTransaksi: string | null };
oldData: { perseroan?: { nama?: string; nomor_sk?: string; npwp?: string } } | null;
akta: { documentId: string; fields: FieldRow[] } | null;
laporanKeuangan: { documentId: string; extraction: { namaAkuntanPublik: string | null; nomorIzinAkuntanPublik: string | null; tanggalIzinAkuntanPublik: string | null; tahunBuku: string | null } | null } | null;
lampiranSlots: { key: string; documentId: string | null; originalName: string | null }[];
validationResults: ValidationRow[];
sections: { key: string; label: string; approvable: boolean; required: boolean }[];
sectionApprovals: Record<string, boolean>;
}
const AKTA_REVIEW_KEYS = ["nomor_akta", "tanggal_akta", "nama_notaris", "nama_perseroan", "tanggal_rups"];
const LAMPIRAN_LABELS: Record<string, string> = {
LAMPIRAN_KEGIATAN_PERSEROAN: "Laporan Kegiatan Perseroan",
LAMPIRAN_TJSL: "Laporan Pelaksanaan TJSL",
LAMPIRAN_RINCIAN_MASALAH: "Rincian Masalah Selama Tahun Buku",
LAMPIRAN_PENGAWASAN_KOMISARIS: "Laporan Pengawasan Dewan Komisaris",
LAMPIRAN_SUSUNAN_PENGURUS: "Susunan Direksi dan Dewan Komisaris",
LAMPIRAN_GAJI_PENGURUS: "Gaji & Tunjangan Direksi/Komisaris",
};
const STATUS_STYLE: Record<string, { icon: typeof CheckCircle2; cls: string }> = {
PASS: { icon: CheckCircle2, cls: "text-emerald-600 border-l-emerald-500" },
WARNING: { icon: AlertTriangle, cls: "text-amber-600 border-l-amber-500" },
FAIL: { icon: XCircle, cls: "text-red-600 border-l-red-500" },
SKIPPED: { icon: MinusCircle, cls: "text-muted-foreground border-l-gray-300" },
};
export function LaporanRupsReviewPage() {
const { submissionId } = useParams<{ submissionId: string }>();
const navigate = useNavigate();
const qc = useQueryClient();
const invalidate = () => qc.invalidateQueries({ queryKey: ["laporan-rups-review", submissionId] });
const { data, isLoading } = useQuery<ReviewData>({
queryKey: ["laporan-rups-review", submissionId],
queryFn: () => apiFetch<ReviewData>(`/api/laporan-rups/submissions/${submissionId}/review-data`),
enabled: !!submissionId,
});
const put = (path: string) =>
useMutation({
mutationFn: (body: unknown) =>
apiFetch(`/api/laporan-rups/submissions/${submissionId}${path}`, {
method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
}),
onSuccess: invalidate,
});
const tahunMut = put("/tahun-laporan");
const jenisMut = put("/jenis-audit");
const akuntanMut = put("/akuntan-publik");
const sectionMut = useMutation({
mutationFn: ({ key, approve }: { key: string; approve: boolean }) =>
apiFetch(`/api/laporan-rups/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 submitMut = useMutation({
mutationFn: (body: { declaration: boolean; warningAcks: string[] }) =>
apiFetch(`/api/laporan-rups/submissions/${submissionId}/submit`, {
method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
}),
onSuccess: () => navigate(`/laporan-rups/${submissionId}/status`),
});
const [akuntanDraft, setAkuntanDraft] = useState<{ namaAkuntanPublik: string; nomorIzinAkuntanPublik: string; tanggalIzinAkuntanPublik: string } | null>(null);
const [submitOpen, setSubmitOpen] = useState(false);
const [declaration, setDeclaration] = useState(false);
const [acks, setAcks] = useState<string[]>([]);
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, oldData, akta, laporanKeuangan, lampiranSlots, validationResults, sections, sectionApprovals } = data;
if (submission.status === "COMPLETED") { navigate(`/laporan-rups/${submissionId}/status`, { replace: true }); return null; }
const flow = computeFlowStep({ type: "LAPORAN_RUPS_TAHUNAN", status: submission.status }, null);
const nowYear = new Date().getFullYear();
const tahunOptions = Array.from({ length: 10 }, (_, i) => nowYear - 1 - i);
const isWajib = submission.jenisAudit === "WAJIB_AUDIT";
const fails = validationResults.filter((v) => v.status === "FAIL" && !v.overridden);
const warnings = validationResults.filter((v) => v.status === "WARNING");
const missing = missingSections(submission.jenisAudit, sectionApprovals);
const outstanding = outstandingWarnings(validationResults, acks);
const canOpenSubmit = submission.status === "READY" && submission.tahunLaporan != null && submission.jenisAudit != null && fails.length === 0 && missing.length === 0;
const akuntan = laporanKeuangan?.extraction ?? null;
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>
);
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>
{/* Identitas Perseroan (registry entity — display only, 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">Identitas Perseroan</h2>
<ApproveButton sectionKey="identitas-perseroan" />
</div>
<div className="text-sm">{oldData?.perseroan?.nama ?? submission.selectedNomorTransaksi ?? "—"}</div>
<div className="text-xs text-muted-foreground">SK: {oldData?.perseroan?.nomor_sk ?? "—"} · NPWP: {oldData?.perseroan?.npwp ?? "—"}</div>
</section>
{/* Akta / Risalah RUPS Tahunan */}
<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 / Risalah RUPS Tahunan</h2>
<ApproveButton sectionKey="akta-rups" />
</div>
{akta ? AKTA_REVIEW_KEYS.map((key) => {
const f = akta.fields.find((x) => x.fieldKey === 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">{f?.value ?? <span className="text-red-500">kosong</span>}</div>
</div>
{f && f.status !== "CONFIRMED" && f.status !== "LOCKED" ? (
<Button size="sm" variant="outline" disabled={confirmMut.isPending}
onClick={() => confirmMut.mutate({ documentId: akta.documentId, fieldKey: key })}>
Konfirmasi
</Button>
) : (
<CheckCircle2 className="h-4 w-4 shrink-0 text-emerald-500" />
)}
</div>
);
}) : <div className="text-sm text-muted-foreground">Akta belum tersedia.</div>}
</section>
{/* Tahun Laporan & Jenis Audit */}
<section className="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Tahun Laporan & Jenis Audit</h2>
<ApproveButton sectionKey="laporan-tahunan" />
</div>
<div className="flex items-center gap-3">
<Label htmlFor="tahun" className="text-xs">Tahun Laporan</Label>
<select
id="tahun"
className="h-9 rounded-md border bg-background px-2 text-sm"
value={submission.tahunLaporan ?? ""}
onChange={(e) => tahunMut.mutate({ tahunLaporan: Number(e.target.value) })}
>
<option value="" disabled>Pilih tahun (backlog s.d. 10 tahun)</option>
{tahunOptions.map((y) => <option key={y} value={y}>{y}</option>)}
</select>
</div>
<div className="flex items-center gap-4 text-sm">
{(["WAJIB_AUDIT", "NON_AUDIT"] as const).map((v) => (
<label key={v} className="flex items-center gap-1.5">
<input type="radio" name="jenisAudit" checked={submission.jenisAudit === v}
onChange={() => jenisMut.mutate({ jenisAudit: v })} />
{v === "WAJIB_AUDIT" ? "Wajib Audit (UUPT Ps.68)" : "Non Audit"}
</label>
))}
</div>
</section>
{/* Akuntan Publik — conditional-required styling under WAJIB_AUDIT */}
<section className={cn("rounded-lg border bg-card p-4 space-y-2", isWajib && "border-amber-300")}>
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold">Akuntan Publik {isWajib && <span className="text-xs text-amber-600">(wajib — perseroan wajib diaudit)</span>}</h2>
<ApproveButton sectionKey="akuntan-publik" />
</div>
{laporanKeuangan ? (
<div className="space-y-2">
{([
["namaAkuntanPublik", "Nama Akuntan Publik / KAP", akuntan?.namaAkuntanPublik],
["nomorIzinAkuntanPublik", "Nomor Izin (AP.xxxx)", akuntan?.nomorIzinAkuntanPublik],
["tanggalIzinAkuntanPublik", "Tanggal Izin", akuntan?.tanggalIzinAkuntanPublik],
] as const).map(([key, label, value]) => (
<div key={key} className="grid grid-cols-[200px_1fr] items-center gap-2">
<Label className="text-xs">{label}</Label>
<Input
defaultValue={value ?? ""}
onChange={(e) => setAkuntanDraft((d) => ({
namaAkuntanPublik: akuntan?.namaAkuntanPublik ?? "",
nomorIzinAkuntanPublik: akuntan?.nomorIzinAkuntanPublik ?? "",
tanggalIzinAkuntanPublik: akuntan?.tanggalIzinAkuntanPublik ?? "",
...d,
[key]: e.target.value,
}))}
/>
</div>
))}
<div className="text-xs text-muted-foreground">Tahun buku terbaca: {akuntan?.tahunBuku ?? "—"}</div>
<Button size="sm" disabled={!akuntanDraft || akuntanMut.isPending} onClick={() => akuntanDraft && akuntanMut.mutate(akuntanDraft)}>
Simpan Akuntan Publik
</Button>
</div>
) : (
<div className="text-sm text-muted-foreground">
Belum ada dokumen Laporan Keuangan.{isWajib && " Unggah pada langkah Ekstraksi — wajib untuk perseroan wajib audit."}
</div>
)}
</section>
{/* Lampiran Laporan Tahunan (store-only slots) */}
<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">Lampiran Laporan Tahunan (UUPT Ps.66(2))</h2>
<ApproveButton sectionKey="lampiran" />
</div>
{lampiranSlots.map((s) => (
<div key={s.key} className="flex items-center justify-between border-b py-1.5 text-sm last:border-0">
<span>{LAMPIRAN_LABELS[s.key] ?? s.key}</span>
{s.documentId
? <span className="text-xs text-emerald-600">{s.originalName}</span>
: <span className="text-xs text-muted-foreground">kosong (opsional/kondisional)</span>}
</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>
{/* Terminal submit */}
<div className="flex items-center justify-end gap-3">
{missing.length > 0 && <span className="text-xs text-amber-600">Bagian belum disetujui: {missing.join(", ")}</span>}
{fails.length > 0 && <span className="text-xs text-red-600">{fails.length} validasi FAIL menghalangi submit</span>}
<Button disabled={!canOpenSubmit} onClick={() => setSubmitOpen(true)}>
<ShieldCheck className="mr-1.5 h-4 w-4" /> Kirim Laporan (Final)
</Button>
</div>
<Dialog open={submitOpen} onOpenChange={setSubmitOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Kirim Pelaporan RUPS Tahunan</DialogTitle>
<DialogDescription>
Pengiriman bersifat FINAL — tidak ada verifikator yang meninjau setelah ini.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 text-sm">
<label className="flex items-start gap-2">
<Checkbox checked={declaration} onCheckedChange={(v) => setDeclaration(v === true)} />
<span>Saya menyatakan data laporan tahunan ini benar dan menjadi tanggung jawab saya.</span>
</label>
{warnings.map((w) => (
<label key={w.ruleCode} className="flex items-start gap-2">
<Checkbox
checked={acks.includes(w.ruleCode)}
onCheckedChange={(v) => setAcks((prev) => v === true ? [...prev, w.ruleCode] : prev.filter((x) => x !== w.ruleCode))}
/>
<span><span className="font-medium">{w.ruleLabel}</span>: {w.message}</span>
</label>
))}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setSubmitOpen(false)}>Batal</Button>
<Button disabled={!declaration || outstanding.length > 0 || submitMut.isPending}
onClick={() => submitMut.mutate({ declaration, warningAcks: acks })}>
{submitMut.isPending && <Loader2 className="mr-1.5 h-4 w-4 animate-spin" />} Kirim Final
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
Implementation notes for the worker: (a) const put = (path) => useMutation(...) calls hooks conditionally-looking but is called unconditionally exactly 3 times at the top level in fixed order — if the project's lint rejects it, inline the three useMutation blocks verbatim instead; (b) verify Checkbox's change prop name in @/components/ui/checkbox (base-nova/base-ui — if it is onCheckedChange keep as written, else adapt to the component's actual prop); (c) do NOT add auto-confirm anywhere — every field is confirmed manually.
- [ ] Modify frontend/src/routes.tsx: add the imports (LaporanRupsExtractionPage, LaporanRupsReviewPage, LaporanRupsStatusPage) and, directly after the akuisisi rows (L79-80):
{ path: "laporan-rups/:submissionId/extraction", element: <LaporanRupsExtractionPage /> },
{ path: "laporan-rups/:submissionId/review", element: <LaporanRupsReviewPage /> },
and after the akuisisi status row (L90):
{ path: "laporan-rups/:submissionId/status", element: <LaporanRupsStatusPage /> },
- [ ] Verify:
cd frontend && bunx tsc --noEmit && bun run test. Expected: clean; Tasks 9-10 tests still green. - [ ] Optional smoke (if backend + DB are up): create a submission via the klasifikasi flow with an AKTA_RUPS_TAHUNAN-classified PDF and walk extraction → review → submit.
- [ ] Commit:
git add frontend/src/pages/LaporanRupsExtractionPage.tsx frontend/src/pages/LaporanRupsReviewPage.tsx frontend/src/pages/LaporanRupsStatusPage.tsx frontend/src/routes.tsx && git commit -m "feat(laporan-rups): frontend pages — extraction mirror, flat review, terminal status + routes"
Task 12: End-to-end integration test (engine run → attest → FAIL-gate block → override → terminal COMPLETED)
One DB-backed test proving the whole spine on a REAL seeded AKTA_RUPS_TAHUNAN submission: continueFlow runs the tuned matrix + skip-types + the focused pass (3B mocked via global.fetch — app.request does not use network fetch, so the mock is safe around route calls too, but restore it before route steps anyway), then the route flow drives backlog-year attestation, section approvals, FAIL-gate block + override, WARNING acks, and the terminal transition — asserting AKTA_DATE_WINDOW never appears and no verifikasi row ever exists.
Files
- backend/src/flow-engine/__tests__/laporan-rups-integration.test.ts — Create
Steps
- [ ] Write the test. Create
backend/src/flow-engine/__tests__/laporan-rups-integration.test.ts:
import { describe, it, expect, afterAll } from "bun:test";
import { db } from "../../lib/db";
import { app } from "../../index";
import { continueFlow } from "../processor";
import { laporanRupsTahunanFlow } from "../flows/laporan-rups-tahunan";
const YEAR = new Date().getFullYear();
const LK_RAW =
"LAPORAN KEUANGAN\nPT INTEGRASI RUPS\nuntuk tahun yang berakhir pada tanggal 31 Desember " + (YEAR - 2) +
"\nLAPORAN AUDITOR INDEPENDEN\nKAP Integrasi & Rekan\nIzin Akuntan Publik AP.4321 tanggal 5 Mei 2019\nOPINI WAJAR";
describe("LAPORAN_RUPS_TAHUNAN end-to-end (engine → gate → terminal)", () => {
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; tanggal_akta MISSING on purpose
// so RUPS_AKTA_FIELDS_PRESENT FAILs and exercises the gate+override) ──
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 INTEGRASI RUPS" },
{ documentId: akta.id, fieldKey: "nomor_akta", value: "77" },
{ documentId: akta.id, fieldKey: "tanggal_rups", value: `15-05-${YEAR - 1}` },
],
});
const lk = await db.document.create({
data: { filename: "lk.pdf", originalName: "lk.pdf", fileSize: 0, filePath: "/tmp/lk.pdf", documentType: "LAPORAN_KEUANGAN", status: "VERIFICATION_READY", rawText: LK_RAW },
});
docIds.push(lk.id);
const lampiran = await db.document.create({
data: { filename: "tjsl.pdf", originalName: "tjsl.pdf", fileSize: 0, filePath: "/tmp/t.pdf", documentType: "LAMPIRAN_LAPORAN_TAHUNAN", status: "UPLOADED", rawText: "" },
});
docIds.push(lampiran.id);
const sub = await db.submission.create({ data: { type: "LAPORAN_RUPS_TAHUNAN", status: "EXTRACTING" } });
ids.push(sub.id);
await db.submissionDocument.createMany({
data: [
{ submissionId: sub.id, documentId: akta.id, classifiedType: "AKTA_RUPS_TAHUNAN", processingOrder: 0, extractionStatus: "DONE" },
{ submissionId: sub.id, documentId: lk.id, classifiedType: "LAPORAN_KEUANGAN", processingOrder: 6, extractionStatus: "DONE" },
{ submissionId: sub.id, documentId: lampiran.id, classifiedType: "LAMPIRAN_TJSL", processingOrder: 7, extractionStatus: "PENDING" },
],
});
// ── engine: continueFlow with the REAL config; 3B mocked ──
const origFetch = globalThis.fetch;
const origLlmUrl = process.env.CLEANUP_LLM_URL;
process.env.CLEANUP_LLM_URL = "http://mock-3b";
globalThis.fetch = (async () =>
new Response(JSON.stringify({
choices: [{ message: { content: JSON.stringify({
nama_akuntan_publik: "KAP Integrasi & Rekan",
nomor_izin_akuntan_publik: "AP.4321",
tanggal_izin_akuntan_publik: "5 Mei 2019",
tahun_buku: String(YEAR - 2),
}) } }],
}), { status: 200 })) as typeof fetch;
try {
await continueFlow(sub.id, laporanRupsTahunanFlow);
} finally {
globalThis.fetch = origFetch;
process.env.CLEANUP_LLM_URL = origLlmUrl;
}
// engine assertions
const after = await db.submission.findUnique({ where: { id: sub.id }, include: { documents: true, validationResults: true, verifikasi: true } });
expect(after?.status).toBe("READY");
expect(after?.documents.find((d) => d.classifiedType === "LAMPIRAN_TJSL")?.extractionStatus).toBe("DONE"); // skip-type, no extraction
const sat = await db.laporanKeuanganExtraction.findUnique({ where: { documentId: lk.id } });
expect(sat?.namaAkuntanPublik).toBe("KAP Integrasi & Rekan"); // focused pass fired + grounded
const codes = after!.validationResults.map((v) => v.ruleCode);
expect(codes).toContain("RUPS_AKTA_FIELDS_PRESENT");
expect(codes).toContain("TAHUN_LAPORAN_VALID");
expect(codes).not.toContain("AKTA_DATE_WINDOW"); // THE exclusion guard, end-to-end
expect(after!.validationResults.find((v) => v.ruleCode === "RUPS_AKTA_FIELDS_PRESENT")?.status).toBe("FAIL");
// ── routes: attest (backlog boundary year), approve, gate, override, terminal ──
const req = (path: string, method: string, body?: unknown) =>
app.request(`/api/laporan-rups/submissions/${sub.id}${path}`, {
method, headers: { "Content-Type": "application/json" }, body: body !== undefined ? JSON.stringify(body) : undefined,
});
expect((await req("/tahun-laporan", "PUT", { tahunLaporan: YEAR - 10 })).status).toBe(200); // backlog boundary accepted
expect((await req("/tahun-laporan", "PUT", { tahunLaporan: YEAR - 2 })).status).toBe(200); // real year for ordering rules
expect((await req("/jenis-audit", "PUT", { jenisAudit: "WAJIB_AUDIT" })).status).toBe(200);
for (const key of ["identitas-perseroan", "akta-rups", "laporan-tahunan", "akuntan-publik"]) {
expect((await req(`/sections/${key}/approve`, "POST")).status).toBe(200);
}
// FAIL gate blocks (tanggal_akta still missing)
let res = await req("/submit", "POST", { declaration: true, warningAcks: [] });
expect(res.status).toBe(422);
const blocked = (await res.json()) as { failing: string[] };
expect(blocked.failing).toContain("RUPS_AKTA_FIELDS_PRESENT");
// block-with-override
await app.request(`/api/submissions/${sub.id}/override-validation`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ruleCode: "RUPS_AKTA_FIELDS_PRESENT", reason: "deed date verified manually" }),
});
// WARNING acknowledgment required, then terminal success
const review = (await (await req("/review-data", "GET")).json()) as { validationResults: { ruleCode: string; status: string }[] };
const warnCodes = review.validationResults.filter((v) => v.status === "WARNING").map((v) => v.ruleCode);
res = await req("/submit", "POST", { declaration: true, warningAcks: [] });
if (warnCodes.length > 0) {
expect(res.status).toBe(400);
res = await req("/submit", "POST", { declaration: true, warningAcks: warnCodes });
}
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?.rupsSubmittedAt).not.toBeNull();
expect(done?.verifikasi).toBeNull(); // terminal — the verifikator surface never sees it
}, 60000);
});
- [ ] Run:
cd backend && bun test src/flow-engine/__tests__/laporan-rups-integration.test.ts. Expected: 1 pass. (The PUT steps trigger real revalidation throughbuildPtAktaContext— same environment expectation aspt-akta-context.test.ts. If revalidation legitimately re-scores a seeded expectation, adjust the SEED, never the gate.) - [ ] Commit:
git add backend/src/flow-engine/__tests__/laporan-rups-integration.test.ts && git commit -m "test(laporan-rups): end-to-end integration — engine run, gate block, override, terminal COMPLETED"
Task 13: Full-suite + typecheck gate
The regression net: this flow touched shared surfaces (processor Phase 3, klasifikasi classify route, AKTA_SUBTYPE_MAP, inferSubmissionType, registry test). 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 regressions, plus every new suite from Tasks 1-12). This is againstahu_ocr_testvia the bunfig preload — never the dev DB. - [ ]
cd frontend && bunx tsc --noEmit— Expected: no errors. - [ ]
cd frontend && bun run test— Expected: all vitest suites pass (incl. the pre-existingflow-steps.test.ts/destination-for.test.ts/SubmissionStatusPage.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(Task 6 rewrote the stale "no v2 entries" assertion). - [ ] Commit any fixes:
git add -A && git commit -m "fix(laporan-rups): full-suite regression fixes"(skip if clean).
Self-Review
Spec coverage (2026-07-02-pelaporan-rups-tahunan-design.md → tasks):
- §1/§2 scope + first-v2-tenant + enum spelling → Tasks 1, 6 (registry entry, LAPORAN_RUPS_TAHUNAN).
- §4.1 data flow → Task 6 (engine phases via config), Task 7 (classify → refine → infer), Task 8 (review → terminal submit).
- §4.2 FlowConfig → Task 6 verbatim-with-recorded-deviations (skip-types fine labels; rematch.rupsAttendance:true replacing the preValidationSteps sketch — engine fact postdates spec).
- §4.3 DocumentTypes + classification strategy → Task 7 (mapToDocumentType many-fine→one-carrier, keyword-first refineSupportingLabel for LAPORAN_KEUANGAN, lampiran manual-only — no LLM path is ever invoked for them since suggestByKeyword's lampiran hit is autoAcceptable:false).
- §4.4 extraction plan → Task 4 (grounded extractor + OCR-only handler + word-date left verbatim/deterministic-year fallback), Task 5 (the focusedPasses invocation mechanism — generic, engine-side, first consumer), Task 6 (overrideByClassifiedType + subtype identity rows).
- §4.5 validation matrix incl. EXCLUSIONS → Task 3 (all 14 rules, citations in wrappers, exclusion guard tests), re-guarded at config level (Task 6) and end-to-end (Task 12).
- §4.6 terminal submit → Task 8 (gate order: attestation → sections w/ runtime akuntan-publik requirement → checkFailGate → WARNING declarations → single-update COMPLETED; no verifikasi row; no re-open — second submit 409), Task 12 (proved end-to-end).
- §4.7 frontend → Tasks 9-11 (6 klasifikasi touchpoints; flat review; terminal UX with declaration checklist).
- §5 wiring checklist items 1-10 → Tasks 1 (schema), 7 (classifier/labels/infer/maps), 6 (flow+registry+dispatch case), 5+4 (focused pass instead of document-processor generalization — see deviations), 8 (routes+mount), 9-11 (frontend).
- §6 data model → Task 1 (typed columns, satellite mirroring BeritaAcaraRupsExtraction, PerubahanSectionApproval reuse, fine label on classifiedType — no new column).
- §7 gotchas 1-10 → rematch regression (Task 6 test), BERITA_ACARA_RUPS refine both directions (Task 7), ladder test (Task 7), AKTA_DATE_WINDOW triple guard (Tasks 3/6/12), explicit approvals (Task 8), no auto-confirm (Task 11 note), never-switch (Task 1), 3B single-purpose + grounding + empty-rawText warn (Task 4), gating dark until Task 7.
- §8 testing strategy → every listed test class has a home; ahu_ocr_test isolation stated in Global Constraints and per-task commands.
Resolved ambiguities / deviations (all engine-facts-over-spec or code-reality, recorded in-task):
1. overrideByClassifiedType: { AKTA_RUPS_TAHUNAN: "perubahan" } kept per spec, but getAktaSubtype's AKTA_SUBTYPE_MAP throws on raw subtypes — added pendirian/perubahan identity rows (Task 6), matching the ExtractionPlan doc comment's promised values for all four future deeds.
2. skipExtractionTypes carries the six LAMPIRAN_* FINE labels (+ carrier + OTHER), not the spec's shorthand — the engine matches SubmissionDocument.classifiedType, which §4.3 itself says holds the fine label.
3. Declarations persist as Submission.rupsDeclarations Json (akuisisiAttestation precedent) — recordAuditEvent requires a VerifikasiPerubahan row, which this flow never creates; the spec's "checklist/attestation tables" phrasing had no concrete verifikasi-free home.
4. Spec's "RUPS_KUORUM" emits the shared wrapper code RUPS_ATTENDANCE_QUORUM (bit-for-bit reuse beats renaming).
5. Spec's async:true on rules 5/13/14 dropped: the SABH round-trips live in buildPtAktaContext, so the shared wrappers are pure/sync — marking them async would change nothing and diverge from PT_AKTA_RULE_SET.
6. SubmissionStatusPage type="LAPORAN_RUPS_TAHUNAN" (spec §4.7 parenthetical) replaced by a dedicated ~90-line LaporanRupsStatusPage — the shared page is hardwired to pendirian/perubahan payload hooks and a verifikasi timeline this flow doesn't have.
7. TAHUN_LAPORAN_VALID FAILs (not SKIPs) when unset — drives the reviewer to the picker; the submit gate independently blocks (ATTESTATION_INCOMPLETE), so no double-jeopardy at submit.
8. Company selection reuses the shared POST /api/perubahan/select-company/:id — it is guard-free and already v2-aware (continueFlow arm, perubahan.ts:188-194); no new route, frontend hook unchanged.
9. Focused pass is idempotent-once (skips when the satellite exists) so engine resume can never clobber a manual akuntan entry; consequence: a re-extraction wanting fresh LLM values requires deleting the satellite row (acceptable, documented in code).
10. rematch.preValidationSteps: [matchRupsAttendance] (spec §4.2) → rematch.rupsAttendance: true — the engine shipped a dedicated opt-in after the spec was written.
Placeholder scan: no TBD/TODO/"similar to" anywhere. The single copy-style task (extraction page) is a sanctioned faithful-mirror with an exhaustive, grep-verified 4-point diff list. All other code (rules, extractor, focused passes, flow config, routes, review/status pages, every test) is given in full. Two verify-notes delegate only prop/import-name confirmation to the worker (Checkbox change-prop; getLayoutProvider overload) with the fallback spelled out.
Type consistency: RupsTahunanCtx extends PtAktaCtx is used identically in Tasks 2/3/6 (method-position run(ctx) bivariance makes Rule<PtAktaCtx> spreadable into Rule<RupsTahunanCtx>[] — same pattern the shadow configs rely on). LaporanKeuanganAkuntan keys = the four ExtractedField fieldKeys = the satellite columns (camelCase mapping shown at both write sites). Route payload shapes in Task 8's implementation match Task 8's tests and Task 11's ReviewData interface field-for-field. checkFailGate(submissionId, cfg) → {blocked, failing} matches fail-gate.ts:11-21. Enum spellings (PASS/WARNING/FAIL/SKIPPED, WAJIB_AUDIT/NON_AUDIT, LAPORAN_RUPS_TAHUNAN) uniform across all tasks.
Known risks flagged to the executor: Tasks 6/12's rematch/integration tests exercise buildPtAktaContext (SABH-adjacent) — they run in the same environment the existing pt-akta-context.test.ts passes in; failures there are environment, not spec. The document-processor OCR-only handler is exercised only indirectly (typecheck + integration seeds post-OCR state) — flagged as the one untested glue line.