think
16px
820px

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 taxonomy laporan_rups_tahunan; NOT SABH's controller noun PELAPORAN_). New Prisma enum RupsJenisAudit { WAJIB_AUDIT NON_AUDIT }. ValidationStatus is "PASS" | "WARNING" | "FAIL" | "SKIPPED" (NO -ED). Carrier akta label AKTA_RUPS_TAHUNAN already exists in AKTA_TXN_TYPE_TO_LABEL (akta-txn-classifier.ts:24). DocumentTypes LAPORAN_KEUANGAN + LAMPIRAN_LAPORAN_TAHUNAN already 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_VERIFICATION is never entered; no VerifikasiPerubahan row is ever created (the verifikator dashboard keys off verifikasi rows and must never see this type). Submit → COMPLETED directly, stamping rupsSubmittedAt.
  • FAIL gate is a NAMED step. POST /api/laporan-rups/submissions/:id/submit MUST call checkFailGate(submissionId, laporanRupsTahunanFlow) (backend/src/flow-engine/fail-gate.ts — block-with-override: blocks on any ValidationResult row with status === "FAIL" && overridden !== true; SKIPPED/WARNING never block). Overrides ride the existing POST /api/submissions/:id/override-validation endpoint — 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 — SKIPPED unless jenisAudit=WAJIB_AUDIT), wrapped PERSEROAN_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_QUORUM via the shared rupsQuorumRule() 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_SET via pickRules): 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_KEUANGAN doc + nama/izin/tanggal-izin akuntan publik are mandatory ONLY when jenisAudit = WAJIB_AUDIT (enforced by AKUNTAN_PUBLIK_REQUIRED, NOT by the doc checklist — the board shows the doc required: false with description "Wajib jika perseroan wajib diaudit (UUPT Ps.68)"). Section akuntan-publik becomes required AT GATE TIME when jenisAudit=WAJIB_AUDIT.
  • Backlog tahunLaporan: integer window currentYear−10 … currentYear−1 (SABH dropdown parity). Typed column Submission.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 in inferSubmissionType today; this flow adds its OWN rung after AKTA_PERUBAHAN, before SURAT_PERNYATAAN_PENDIRIAN_PP — a perubahan submission that also carries the annual BA must stay PERUBAHAN_PT.
  • Rematch discipline: field edits reach this flow ONLY via queueReMatchAndValidate (serialized) → the registry check in reMatchAndValidate (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 in akta-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_test via the bunfig preload. NEVER run bun test against the dev DB. Run cd backend && bun run db:push:test after Task 1's schema change.
  • 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 via cd frontend && bun run test (vitest), backend via cd backend && bun test.
  • No auto-confirm: the gate reads confirmations/approvals, it never writes them. Section approvals are EXPLICIT persisted PerubahanSectionApproval rows — 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_TAHUNAN not on the enum (and typecheck of the test file itself errors).
  • [ ] Minimal implementation A — backend/prisma/schema.prisma:
    1. Inside enum SubmissionType { ... }, append after AKUISISI_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
  1. Directly after the enum SubmissionType block, add:
/// Jenis-audit attestation for LAPORAN_RUPS_TAHUNAN (UUPT Ps.68 wajib-audit gate).
enum RupsJenisAudit {
  WAJIB_AUDIT
  NON_AUDIT
}
  1. In model Submission, after the line akuisisiAttestation 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?
  1. 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
}
  1. In model Document, add the back-relation alongside the existing beritaAcaraRupsExtraction BeritaAcaraRupsExtraction? line:
  laporanKeuanganExtraction LaporanKeuanganExtraction?
  • [ ] Minimal implementation B — backend/src/flow-engine/registry.ts, append to FLOW_REGISTRY after the APOSTILLE line:
  // 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, in processorKindForType, add before the default: 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 + never tripwire 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 the dispatch chain, insert after the AKTA_PERALIHAN_PP_PT case's closing brace (~line 2471) and before the // 7. Akta handling comment:
    // 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 without focusedPasses).
  • [ ] 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})resolveAktaSubtypegetAktaSubtype, 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: add import { 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.ts AKTA_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 final return "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";
  1. getProcessingOrder: insert before the final return 4;:
  if (classification === "LAPORAN_KEUANGAN") return 6; // after the deed + cards
  if (classification.startsWith("LAMPIRAN_")) return 7; // store-only, last
  1. 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 to Perbaikan > Akuisisi > Perubahan > LaporanRupsTahunan > Pendirian PP > Pendirian PT (built rungs of the canonical ladder: Perbaikan > Berakhirnya > Peleburan > Akuisisi > Pembubaran > Perubahan > LaporanRupsTahunan > PendirianPP > PendirianPT), and insert AFTER the AKTA_PERUBAHAN check (line 97), BEFORE the SURAT_PERNYATAAN_PENDIRIAN_PP check:
  // 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. In refineAktaLabel, replace the two lines after const 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;
  1. Add the supporting refine (below refineAktaLabel), with imports import { 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;
  }
}
  1. 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);
  1. 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" },
];
  1. Include it in LABEL_MAP (extend the spread array at line 131 with ...LAPORAN_RUPS_DOC_TYPES) and in the labels route add if (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: add import laporanRups from "./routes/laporan-rups"; with the other route imports and app.route("/api/laporan-rups", laporanRups); directly after the app.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 because reMatchAndValidateFlow catches 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 to DetectedFlow.
  • [ ] 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 the hasAktaPerubahan block (L68-70), add:
  const hasAktaRupsTahunan = results.some(
    (r) => (r.userOverride ?? r.classification) === "AKTA_RUPS_TAHUNAN"
  );
  1. computedLabelType chain (L71-77) — insert below perubahan (ladder mirror):
  const computedLabelType: KlasifikasiLabelType = hasSuratPerbaikan
    ? "perbaikan"
    : hasAktaAkuisisi
      ? "akuisisi"
      : hasAktaPerubahan
        ? "perubahan"
        : hasAktaRupsTahunan
          ? "laporan_rups"
          : "pendirian";
  1. detectedFlow chain (L265-275) — insert : hasAktaRupsTahunan ? "laporan-rups" between the hasAktaPerubahan ? "perubahan" arm and the hasAnyAkta ? "pendirian" arm.
  2. createSubmission.onSuccess (L476-488) — add before the PERUBAHAN_PT branch:
                          } else if (data.type === "LAPORAN_RUPS_TAHUNAN") {
                            navigate(`/laporan-rups/${data.submissionId}/extraction`);
  • [ ] Run GREEN: cd frontend && bun run test -- detect-banner-laporan-rups then bunx 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. After AKUISISI_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;
  1. SubmissionLite (L57): type: "PENDIRIAN_PT" | "PERUBAHAN_PT" | "PERBAIKAN_DATA_PT" | "AKUISISI_PT" | "LAPORAN_RUPS_TAHUNAN";
  2. In computeFlowStep (L149-158), add const 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 existing if (!isPerubahan || submission.status !== "COMPLETED") fallthrough drives it from PENDIRIAN_STAGE_INDEX (incl. COMPLETED: 4), which is exactly the terminal behaviour.
    - [ ] Minimal implementation B — frontend/src/lib/destination-for.ts:
  3. SubmissionLite.type union += "LAPORAN_RUPS_TAHUNAN".
  4. base ternary gains: submission.type === "LAPORAN_RUPS_TAHUNAN" ? "laporan-rups" : ... (before the pendirian default).
  5. Terminal branch: change if (submission.type === "PENDIRIAN_PT") { to if (submission.type === "PENDIRIAN_PT" || submission.type === "LAPORAN_RUPS_TAHUNAN") { with a comment // terminal flows: COMPLETED → status; verifikasi is never consulted.
    - [ ] Minimal implementation C — Create frontend/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-rups then bunx 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 — copy frontend/src/pages/AkuisisiExtractionPage.tsx byte-for-byte, then apply EXACTLY these diffs:
    1. Rename the component: export function AkuisisiExtractionPage()export function LaporanRupsExtractionPage().
    2. Line ~254 (computeFlowStep call): { 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.fetchapp.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 through buildPtAktaContext — same environment expectation as pt-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 against ahu_ocr_test via 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-existing flow-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.