// ==UserScript== // @name SiRUP Sanding DIPA ↔ RUP & Revisi Massal // @namespace https://github.com/Fakhry-Glob // @version 1.4.5 // @description Sanding PDF DIPA SAKTI (RKK / FA Detail 16 Segmen) dengan PKKR dan RUP terumumkan di SiRUP pasca-putus integrasi SAKTI (31 Juli 2026): tambah dan sesuaikan PKKR Manual, klasifikasi pengadaan/non-pengadaan, kartu keputusan per kelompok, daftar pengajuan revisi dengan pratinjau isian, revisi satu-ke-satu/satu-ke-banyak lewat antrean, pembatalan yang bisa diaktifkan kembali, umumkan, dan samakan Struktur Anggaran. // @author Fakhry-Glob // @homepageURL https://github.com/Fakhry-Glob/sirup-sanding-dipa // @supportURL https://github.com/Fakhry-Glob/sirup-sanding-dipa/issues // @updateURL https://raw.githubusercontent.com/Fakhry-Glob/sirup-sanding-dipa/main/dist/sirup_sanding_dipa.meta.js // @downloadURL https://raw.githubusercontent.com/Fakhry-Glob/sirup-sanding-dipa/main/dist/sirup_sanding_dipa.user.js // @match https://sirup.inaproc.id/sirup/* // @require https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js // @require https://cdnjs.cloudflare.com/ajax/libs/exceljs/4.3.0/exceljs.min.js // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const APP_VERSION = '1.4.5'; const PDFJS_WORKER = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.worker.min.js'; // ─────────────────────────────────────────────────────────────────── STYLE ── const CSS = ` .sdr, .sdr *, .sdr *::before, .sdr *::after { box-sizing: border-box; } .sdr { --b:#1F497D; --b2:#2E6DA4; --b-soft:#eaf1fa; --ink:#0f172a; --ink2:#334155; --mut:#64748b; --line:#e2e8f0; --bg:#f4f6fa; --ok:#15803d; --ok-soft:#ecfdf3; --warn:#b45309; --warn-soft:#fffbeb; --bad:#b91c1c; --bad-soft:#fef2f2; --np:#7c3aed; --teal:#0f766e; font: 14px/1.55 "Segoe UI", system-ui, -apple-system, sans-serif; color: var(--ink); } .sdr-fab { position: fixed; right: 24px; bottom: 84px; z-index: 99990; display: inline-flex; align-items: center; gap: 8px; padding: 12px 20px; border: 0; border-radius: 999px; cursor: pointer; font: 600 14px/1 "Segoe UI", system-ui, sans-serif; color: #fff; background: linear-gradient(135deg, #0f766e 0%, #0e9f8e 100%); box-shadow: 0 6px 18px rgba(15,118,110,.35); } .sdr-fab:hover { transform: translateY(-2px); } /* jendela */ .sdr-ov { position: fixed; inset: 0; z-index: 99991; background: rgba(15,23,42,.55); display: flex; justify-content: center; padding: 20px; } .sdr-win { background: var(--bg); border-radius: 14px; width: min(1320px, 100%); display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 24px 60px rgba(0,0,0,.3); } .sdr-hd { display: flex; align-items: center; gap: 12px; padding: 14px 22px; background: var(--b); color: #fff; } .sdr-hd h2 { font-size: 17px; margin: 0; font-weight: 650; } .sdr-hd .sdr-ctx { flex: 1; font-size: 12.5px; opacity: .92; display: flex; gap: 6px; flex-wrap: wrap; } .sdr-hd .sdr-ctx span { background: rgba(255,255,255,.14); padding: 3px 10px; border-radius: 999px; } .sdr-hd .btn { background: rgba(255,255,255,.12); color: #fff; border-color: rgba(255,255,255,.3); } .sdr-x { background: transparent; border: 0; color: #fff; font-size: 24px; cursor: pointer; line-height: 1; padding: 0 4px; } /* stepper */ .sdr-steps { display: flex; gap: 4px; padding: 10px 18px; background: #fff; border-bottom: 1px solid var(--line); overflow-x: auto; } .sdr-step { display: flex; align-items: center; gap: 8px; padding: 8px 14px; cursor: pointer; border: 0; background: transparent; font: inherit; color: var(--mut); border-radius: 10px; white-space: nowrap; } .sdr-step .no { width: 24px; height: 24px; border-radius: 50%; display: inline-grid; place-items: center; font-size: 12px; font-weight: 700; background: #e2e8f0; color: var(--ink2); } .sdr-step.on { background: var(--b-soft); color: var(--b); font-weight: 650; } .sdr-step.on .no { background: var(--b); color: #fff; } .sdr-step.done .no { background: var(--ok); color: #fff; } .sdr-body { flex: 1; overflow: auto; padding: 22px 26px 0; } /* laci log */ .sdr-log { border-top: 1px solid #1e293b; background: #0b1220; color: #cbd5e1; font: 12.5px/1.55 Consolas, monospace; } .sdr-log-bar { display: flex; align-items: center; gap: 10px; padding: 7px 18px; cursor: pointer; user-select: none; } .sdr-log-bar b { color: #e2e8f0; font-family: "Segoe UI", system-ui, sans-serif; font-weight: 600; } .sdr-log-bar .last { flex: 1; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; opacity: .85; } .sdr-log-body { max-height: 0; overflow: auto; padding: 0 18px; transition: max-height .2s; } .sdr-log.open .sdr-log-body { max-height: 240px; padding-bottom: 10px; } .sdr-log .e { color: #fca5a5; } .sdr-log .o { color: #86efac; } .sdr-log .w { color: #fcd34d; } /* tipografi & kartu */ .sdr h3 { font-size: 16px; margin: 0 0 6px; font-weight: 650; } .sdr h4 { font-size: 13px; margin: 0 0 10px; font-weight: 700; color: var(--ink2); text-transform: uppercase; letter-spacing: .04em; } .sdr p.note { color: var(--mut); margin: 0 0 14px; max-width: 900px; } .sdr .card { border: 1px solid var(--line); border-radius: 12px; padding: 20px 22px; margin-bottom: 18px; background: #fff; box-shadow: 0 1px 2px rgba(15,23,42,.04); } .sdr .kpis { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-bottom: 18px; } .sdr .kpi { border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; background: #fff; } .sdr .kpi b { display: block; font-size: 20px; font-variant-numeric: tabular-nums; margin-bottom: 2px; } .sdr .kpi span { color: var(--mut); font-size: 12.5px; } .sdr .kpi.link { cursor: pointer; } .sdr .kpi.link:hover { border-color: var(--b2); box-shadow: 0 2px 8px rgba(46,109,164,.12); } /* tombol & input */ .sdr .btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 16px; border-radius: 9px; border: 1px solid #cbd5e1; background: #fff; cursor: pointer; font: inherit; color: var(--ink); white-space: nowrap; } .sdr .btn:hover { border-color: var(--b2); } .sdr .btn[disabled] { opacity: .5; cursor: not-allowed; } .sdr .btn.pri { background: var(--b); color: #fff; border-color: var(--b); } .sdr .btn.go { background: var(--ok); color: #fff; border-color: var(--ok); font-weight: 600; } .sdr .btn.danger { background: #fff; color: var(--bad); border-color: #fca5a5; } .sdr .btn.sm { padding: 5px 11px; font-size: 13px; border-radius: 8px; } .sdr .btn.ghost { border-color: transparent; background: transparent; color: var(--b2); } .sdr .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } .sdr input[type=text], .sdr input[type=number], .sdr input[type=month], .sdr select, .sdr textarea { font: inherit; padding: 7px 10px; border: 1px solid #cbd5e1; border-radius: 8px; background: #fff; color: var(--ink); max-width: 100%; min-height: 38px; } .sdr input:focus, .sdr select:focus, .sdr textarea:focus { outline: 2px solid #bfdbfe; border-color: var(--b2); } .sdr textarea { width: 100%; min-height: 96px; resize: vertical; font-size: 13px; line-height: 1.5; } .sdr input[type=checkbox] { width: 17px; height: 17px; accent-color: var(--b); cursor: pointer; } .sdr .drop { border: 2px dashed #94a3b8; border-radius: 14px; padding: 32px; text-align: center; color: var(--mut); cursor: pointer; background: #fff; } .sdr .drop.hover { border-color: var(--b2); background: #eff6ff; } /* tabel */ .sdr table.t { border-collapse: collapse; width: 100%; font-size: 13.5px; } .sdr table.t th, .sdr table.t td { border-bottom: 1px solid var(--line); padding: 9px 12px; text-align: left; vertical-align: top; } .sdr table.t th { position: sticky; top: 0; background: #f1f5f9; z-index: 1; font-weight: 650; white-space: nowrap; color: var(--ink2); } .sdr table.t tbody tr:hover td { background: #f8fafc; } .sdr table.t td.n, .sdr table.t th.n { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } .sdr table.t tr.sub td { background: #fafbfd; } .sdr .tbl { max-height: 62vh; overflow: auto; border: 1px solid var(--line); border-radius: 10px; background: #fff; } .sdr .pill { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; white-space: nowrap; } .sdr .p-ok { background: #dcfce7; color: var(--ok); } .sdr .p-warn { background: #fef3c7; color: var(--warn); } .sdr .p-bad { background: #fee2e2; color: var(--bad); } .sdr .p-np { background: #ede9fe; color: var(--np); } .sdr .p-mut { background: #f1f5f9; color: var(--mut); } .sdr .p-info { background: #dbeafe; color: #1d4ed8; } .sdr .muted { color: var(--mut); } .sdr .mono { font-family: Consolas, monospace; font-size: 13px; } .sdr .grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } .sdr details > summary { cursor: pointer; padding: 6px 0; font-weight: 600; color: var(--ink2); } .sdr .warnbox, .sdr .errbox, .sdr .okbox, .sdr .infobox { padding: 12px 16px; border-radius: 10px; margin: 0 0 14px; } .sdr .warnbox { border-left: 4px solid var(--warn); background: var(--warn-soft); } .sdr .errbox { border-left: 4px solid var(--bad); background: var(--bad-soft); } .sdr .okbox { border-left: 4px solid var(--ok); background: var(--ok-soft); } .sdr .infobox { border-left: 4px solid var(--b2); background: var(--b-soft); } .sdr .prog { height: 6px; background: #e2e8f0; border-radius: 99px; overflow: hidden; margin: 8px 0; } .sdr .prog > i { display: block; height: 100%; background: var(--b2); width: 0; transition: width .2s; } /* langkah 4: tab segmen, daftar baris, editor */ .sdr .seg { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 18px; padding: 6px; background: #fff; border: 1px solid var(--line); border-radius: 12px; } .sdr .seg button { border: 0; background: transparent; padding: 9px 14px; border-radius: 9px; font: inherit; color: var(--ink2); cursor: pointer; display: inline-flex; gap: 8px; align-items: center; } .sdr .seg button .cnt { background: #e2e8f0; color: var(--ink2); border-radius: 999px; padding: 1px 8px; font-size: 12px; font-weight: 700; } .sdr .seg button.on { background: var(--b); color: #fff; } .sdr .seg button.on .cnt { background: rgba(255,255,255,.25); color: #fff; } .sdr .flow { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; margin: 6px 0 4px; } .sdr .flow > div { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; } .sdr .flow .no { display: inline-grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; background: var(--b-soft); color: var(--b); font-weight: 700; margin-bottom: 6px; } .sdr .list { display: flex; flex-direction: column; gap: 10px; } .sdr .item { background: #fff; border: 1px solid var(--line); border-radius: 12px; } .sdr .item.off { opacity: .72; border-style: dashed; } .sdr .item.warn { border-color: #fcd34d; } .sdr .item-hd { display: grid; grid-template-columns: auto 1fr auto; gap: 14px; align-items: start; padding: 14px 16px; } .sdr .item-title { font-weight: 600; line-height: 1.4; } .sdr .item-sub { color: var(--mut); font-size: 12.5px; margin-top: 3px; display: flex; gap: 8px; flex-wrap: wrap; align-items: center; } .sdr .item-side { text-align: right; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; } .sdr .item-side b { font-variant-numeric: tabular-nums; font-size: 15px; } .sdr .notes { margin: 0 16px 12px 47px; padding: 10px 14px; background: var(--warn-soft); border-radius: 8px; font-size: 13px; color: #78350f; } .sdr .notes div + div { margin-top: 3px; } .sdr .sub-items { border-top: 1px solid var(--line); padding: 8px 16px 12px 47px; display: flex; flex-direction: column; gap: 8px; } .sdr .sub-item { border: 1px solid var(--line); border-radius: 10px; background: #fcfdff; } .sdr .sub-item .item-hd { padding: 10px 12px; } .sdr .editor { border-top: 1px solid var(--line); padding: 18px 20px; background: #fbfcfe; border-radius: 0 0 12px 12px; display: flex; flex-direction: column; gap: 18px; } .sdr .fs { background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; } .sdr .formgrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 14px 18px; } .sdr .field label { display: block; font-size: 12.5px; color: var(--ink2); font-weight: 600; margin-bottom: 5px; } .sdr .field .hint { font-size: 12px; color: var(--mut); margin-top: 4px; } .sdr .range { display: flex; align-items: center; gap: 8px; } .sdr .range span { color: var(--mut); } .sdr .range input { flex: 1; min-width: 140px; } .sdr .rangegrid { display: grid; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); gap: 14px 18px; margin-bottom: 12px; } .sdr .checks { display: flex; flex-wrap: wrap; gap: 8px 18px; } .sdr .checks label { display: inline-flex; align-items: center; gap: 7px; font-size: 13.5px; cursor: pointer; } .sdr .mak-t { width: 100%; border-collapse: collapse; } .sdr .mak-t th { font-size: 12px; color: var(--mut); text-align: left; padding: 0 8px 6px 0; font-weight: 600; } .sdr .mak-t td { padding: 4px 8px 4px 0; vertical-align: middle; } .sdr .lok { display: grid; grid-template-columns: 1fr 1fr 1.5fr auto; gap: 8px; margin-bottom: 8px; } .sdr .bulk { background: #ecfeff; border: 1px solid #a5f3fc; border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; } .sdr .bulk .formgrid { margin: 10px 0 12px; } .sdr-body::after { content: ""; display: block; height: 22px; } .sdr .actionbar { padding: 14px 26px; background: #fff; border-top: 1px solid var(--line); box-shadow: 0 -4px 14px rgba(15,23,42,.06); display: flex; gap: 12px; align-items: center; flex-wrap: wrap; } .sdr .actionbar .sum { flex: 1; color: var(--ink2); font-size: 13.5px; } .sdr .empty { text-align: center; color: var(--mut); padding: 34px; background: #fff; border: 1px dashed var(--line); border-radius: 12px; } /* langkah 4 v1.3: proyeksi, kartu keputusan, perubahan, antrean */ .sdr .proj .kpi b { font-size: 21px; } .sdr .proj .kpi .ket { display: block; margin-top: 3px; font-size: 12px; color: var(--mut); } .sdr .proj .kpi.ok { border-color: #86efac; background: var(--ok-soft); } .sdr .proj .kpi.ok b { color: var(--ok); } .sdr .proj .kpi.warn { border-color: #fcd34d; background: var(--warn-soft); } .sdr .proj .kpi.warn b { color: var(--warn); } .sdr .kartu { background: #fff; border: 1px solid var(--line); border-radius: 12px; padding: 16px 18px; margin-bottom: 12px; } .sdr .kartu.belum { border-color: #fcd34d; box-shadow: inset 4px 0 0 #f59e0b; } .sdr .kartu-hd { display: flex; justify-content: space-between; gap: 14px; align-items: flex-start; margin-bottom: 8px; } .sdr .opsi { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 8px; margin-top: 10px; } .sdr .op { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--line); border-radius: 10px; cursor: pointer; background: #fff; } .sdr .op:hover { border-color: var(--b2); } .sdr .op.on { border-color: var(--b2); background: var(--b-soft); } .sdr .op input { margin-top: 4px; accent-color: var(--b); } .sdr .op b { font-weight: 600; } .sdr .op small { display: block; color: var(--mut); font-size: 12.5px; margin-top: 2px; line-height: 1.45; } .sdr .op small.dampak { color: var(--ink2); font-weight: 600; } .sdr .sublabel { margin-top: 12px; font-size: 12.5px; font-weight: 700; color: var(--ink2); text-transform: uppercase; letter-spacing: .04em; } .sdr .grp-hd { display: flex; gap: 10px; align-items: baseline; margin: 18px 0 8px; color: var(--ink2); font-weight: 600; } .sdr .delta { font-size: 12.5px; font-variant-numeric: tabular-nums; } .sdr .delta.up { color: var(--ok); } .sdr .delta.down { color: var(--bad); } .sdr .sumber { margin-top: 6px; font-size: 12.5px; color: var(--mut); } .sdr .cek div { padding: 3px 0; } .sdr .cek .ok { color: var(--ok); } .sdr .cek .warn { color: var(--warn); } .sdr .cek .bad { color: var(--bad); font-weight: 600; } .sdr details.bulk > summary { font-weight: 650; color: var(--ink); } .sdr .cb2 { display: flex; flex-direction: column; gap: 6px; } .sdr .cb2 label { display: flex; align-items: center; gap: 5px; font-size: 11.5px; color: var(--mut); cursor: pointer; } .sdr-modal { position: fixed; inset: 0; z-index: 99995; background: rgba(15,23,42,.5); display: flex; align-items: center; justify-content: center; padding: 20px; } .sdr-modal > div { background: #fff; border-radius: 14px; max-width: 940px; width: 100%; max-height: 86vh; overflow: auto; padding: 22px 24px; } /* v1.4: daftar pengajuan, popup isian pengajuan, penyesuaian PKKR */ .sdr .chips { display: flex; gap: 6px; flex-wrap: wrap; margin: 4px 0 12px; } .sdr .chip { border: 1px solid var(--line); background: #fff; border-radius: 999px; padding: 5px 12px; font: inherit; font-size: 13px; color: var(--ink2); cursor: pointer; display: inline-flex; gap: 6px; align-items: center; } .sdr .chip .cnt { background: #e2e8f0; border-radius: 999px; padding: 0 7px; font-size: 11.5px; font-weight: 700; } .sdr .chip.on { background: var(--b); border-color: var(--b); color: #fff; } .sdr .chip.on .cnt { background: rgba(255,255,255,.25); color: #fff; } .sdr .pj-list { border: 1px solid var(--line); border-radius: 12px; background: #fff; overflow: hidden; } .sdr .pj-grp { display: flex; justify-content: space-between; gap: 10px; padding: 8px 14px; background: #f8fafc; border-bottom: 1px solid var(--line); font-size: 12px; font-weight: 700; color: var(--ink2); text-transform: uppercase; letter-spacing: .04em; } .sdr .pj-grp .delta { text-transform: none; letter-spacing: 0; } .sdr .pj-row { display: grid; grid-template-columns: auto 132px minmax(0, 1fr) auto 16px; gap: 12px; align-items: center; padding: 11px 14px; border-bottom: 1px solid var(--line); cursor: pointer; } .sdr .pj-row:last-child { border-bottom: 0; } .sdr .pj-row:hover, .sdr .pj-row:focus-visible { background: #f5f9ff; outline: none; } .sdr .pj-row.off { opacity: .55; } .sdr .pj-row.warn { box-shadow: inset 3px 0 0 #f59e0b; } .sdr .pj-row > .pill { justify-self: start; } .sdr .pj-cb { display: flex; padding: 4px; cursor: pointer; } .sdr .pj-title { font-weight: 600; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .sdr .pj-sub { color: var(--mut); font-size: 12.5px; margin-top: 3px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; } .sdr .pj-side { text-align: right; display: flex; flex-direction: column; gap: 2px; align-items: flex-end; } .sdr .pj-side b { font-variant-numeric: tabular-nums; } .sdr .pj-go { color: #94a3b8; font-size: 22px; line-height: 1; } .sdr-modal > div.pop { max-width: 1100px; padding: 0; display: flex; flex-direction: column; max-height: 92vh; overflow: hidden; } .sdr .pop-hd { display: flex; justify-content: space-between; gap: 16px; align-items: flex-start; padding: 18px 24px 14px; border-bottom: 1px solid var(--line); background: #fff; } .sdr .pop-title { font-size: 17px; font-weight: 650; margin: 6px 0 2px; line-height: 1.35; } .sdr .pop-body { flex: 1; overflow: auto; padding: 16px 24px; display: flex; flex-direction: column; gap: 12px; background: var(--bg); } .sdr .pop-ft { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; padding: 12px 24px; border-top: 1px solid var(--line); background: #fff; } .sdr .pop-ft .sp { flex: 1; } .sdr .sdr-x.dark { color: var(--mut); } .sdr .draft { border: 1px solid var(--line); border-radius: 12px; background: #fff; } .sdr .draft.off { opacity: .6; border-style: dashed; } .sdr .draft-hd { display: flex; gap: 10px 14px; align-items: center; flex-wrap: wrap; padding: 12px 16px; border-bottom: 1px solid var(--line); } .sdr .draft > div:last-child { padding: 12px; } .sdr .ubah-list { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; font-size: 12.5px; } .sdr .frm { display: flex; flex-direction: column; gap: 10px; } .sdr .frm-s { background: #fff; border: 1px solid var(--line); border-radius: 10px; padding: 12px 16px; } .sdr .frm-s.ch { border-color: #fcd34d; box-shadow: inset 3px 0 0 #f59e0b; } .sdr .frm-s.tetap { opacity: .78; } .sdr .frm-t { font-size: 12px; font-weight: 700; color: var(--ink2); text-transform: uppercase; letter-spacing: .04em; margin-bottom: 8px; display: flex; gap: 8px; align-items: center; } .sdr .frm-t .no { display: inline-grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; background: var(--b-soft); color: var(--b); font-size: 11px; letter-spacing: 0; } .sdr .frm-r { display: grid; grid-template-columns: 200px minmax(0, 1fr); gap: 12px; padding: 4px 0; font-size: 13.5px; } .sdr .frm-l { color: var(--mut); } .sdr .frm table.t { margin: 4px 0; } .sdr .frm table.t th, .sdr .frm table.t td { padding: 6px 10px; font-size: 13px; } .sdr .frm tr.add td { background: var(--ok-soft); } .sdr .frm tr.del td { background: var(--bad-soft); color: var(--bad); text-decoration: line-through; } .sdr del { color: var(--bad); background: var(--bad-soft); text-decoration: line-through; padding: 0 3px; border-radius: 3px; } .sdr ins { color: var(--ok); background: var(--ok-soft); text-decoration: none; padding: 0 3px; border-radius: 3px; font-weight: 600; } .sdr .txt { white-space: pre-wrap; max-height: 150px; overflow: auto; font-size: 13px; background: #f8fafc; border-radius: 6px; padding: 6px 9px; } .sdr .txt.del { background: var(--bad-soft); color: #7f1d1d; text-decoration: line-through; } .sdr .txt.ins { background: var(--ok-soft); color: #14532d; } .sdr .infobox.row { display: flex; } `; // ─────────────────────────────────────────────────────────────── PARSER DIPA ── // Membaca PDF SAKTI "Rincian Kertas Kerja Satker" (RKK) atau "Laporan FA Detail // 16 Segmen" (FA) menjadi pohon Program › Kegiatan › KRO › RO › Komponen › // SubKomponen › Akun › Item. Kolom dikenali dari posisi x teks (pdf.js), bukan // dari urutan kata, karena uraian panjang sering terbungkus ke baris berikutnya. const DipaParser = (() => { const NUM = /^-?\d{1,3}(,\d{3})*(\.\d+)?$/; const toNum = s => Math.round(parseFloat(String(s).replace(/,/g, '')) || 0); const LEVELS = ['prog', 'keg', 'kro', 'ro', 'komp', 'sub', 'akun']; async function pageLines(pdfjsLib, page) { const vp = page.getViewport({ scale: 1 }); const tc = await page.getTextContent(); const items = []; for (const it of tc.items) { const s = it.str.replace(/\s+/g, ' ').trim(); if (!s) continue; const t = pdfjsLib.Util.transform(vp.transform, it.transform); items.push({ x0: t[4], x1: t[4] + it.width, y: t[5], s }); } items.sort((a, b) => a.y - b.y || a.x0 - b.x0); const lines = []; for (const it of items) { const ln = lines[lines.length - 1]; if (ln && Math.abs(ln.y - it.y) <= 1.5) ln.items.push(it); else lines.push({ y: it.y, items: [it] }); } for (const ln of lines) { ln.items.sort((a, b) => a.x0 - b.x0); ln.text = ln.items.map(i => i.s).join(' '); } return { width: vp.width, height: vp.height, lines }; } function keyOf(ctx, upto) { const parts = []; for (const l of LEVELS) { if (!ctx[l]) break; parts.push(ctx[l]); if (l === upto) break; } return parts.join('.'); } function setLevel(ctx, level, code) { const i = LEVELS.indexOf(level); ctx[level] = code; for (const l of LEVELS.slice(i + 1)) ctx[l] = ''; } // ── FA Detail 16 Segmen (landscape) ──────────────────────────────────── function parseFA(pages) { const meta = { jenis: 'FA', satker: '', namaSatker: '', periode: '', total: 0 }; const nodes = new Map(), items = []; const ctx = {}; let last = null; const rapikan = s => s.replace(/\s*\[[^\]]*\]\s*/g, ' ').replace(/\s+/g, ' ').trim(); // buang catatan [..] SAKTI for (const pg of pages) { const sc = pg.width / 842; // kolom dinormalisasi ke lebar 842pt // Nama node yang terbungkus: SAKTI menaruh potongan nama DI ATAS dan DI BAWAH baris kodenya, sedangkan baris // kodenya sendiri tanpa nama (mis. RBJ.725 "Gedung, … Ditingkatkan" / "Kapasitasnya"). Potongan di kolom // uraian node (x 55-95) ditampung, lalu dipasangkan ke baris node terdekat (sebelum atau sesudahnya). // Tiap level punya kolom nama sendiri, jadi potongan hanya dipasangkan ke node yang kolomnya cocok // (Sub Komponen 622035 301.GA: potongan pertamanya lebih dekat ke Komponen 301 di atasnya). let nodeAkhir = null; const yatim = []; const KOLOM = { keg: [53, 62], komp: [53, 62], ro: [62, 75], sub: [75, 88], akun: [88, 95] }; const cocok = (f, lvl) => !!KOLOM[lvl] && f.x >= KOLOM[lvl][0] && f.x < KOLOM[lvl][1]; const lepas = () => { for (const f of yatim.splice(0)) if (nodeAkhir && cocok(f, nodeAkhir.node.level) && f.y - nodeAkhir.y <= 16) nodeAkhir.node.uraian = rapikan(nodeAkhir.node.uraian + ' ' + f.teks); }; for (const ln of pg.lines) { const t = ln.text; if (!meta.satker) { const m = t.match(/Satuan Kerja\s*:\s*(\d{6})\s+(.+?)(\s+Hal\s+\d+.*)?$/); if (m) { meta.satker = m[1]; meta.namaSatker = m[2].trim(); } } if (!meta.periode) { const m = t.match(/^Periode\s+(\w+\s+\d{4})/); if (m) meta.periode = m[1]; } const X = it => it.x0 / sc, X1 = it => it.x1 / sc; const left = ln.items.filter(it => X1(it) < 345); const nums = ln.items.filter(it => X(it) >= 340 && NUM.test(it.s.replace(/\s*%$/, ''))); const col = (a, b) => { const w = nums.find(it => X1(it) >= a && X1(it) <= b); return w ? toNum(w.s) : null; }; const pagu = col(395, 420), realisasi = col(700, 716), sisa = col(800, 830); if (/^JUMLAH SELURUHNYA/.test(t) || (!left.length && !meta.total && pagu && ln.y < 200)) { if (pagu) meta.total = pagu; continue; } if (!left.length) { if (last && last.pagu == null && pagu != null) Object.assign(last, { pagu, realisasi: realisasi || 0 }); continue; } let first = left[0].s, x = X(left[0]); let rest = left.slice(1).map(i => i.s).join(' '); const sp = first.indexOf(' '); if (sp > 0) { rest = (first.slice(sp + 1) + ' ' + rest).trim(); first = first.slice(0, sp); } let lvl = null, code = first; if (x < 26 && /^[A-Z]{2}$/.test(first)) lvl = 'prog'; else if (x < 26 && /^[A-Z]{2}[A-Z]/.test(first) && !first.includes('.')) { lvl = 'prog'; rest = first.slice(2) + ' ' + rest; code = first.slice(0, 2); } else if (x < 26 && /^[A-Z]{2}\.\d{4}$/.test(first)) { lvl = 'keg'; code = first.split('.')[1]; } else if (x >= 26 && x < 34 && /^[A-Z0-9]{3}\.[A-Z0-9]{3}$/.test(first)) { lvl = 'ro'; code = first.split('.')[1]; } else if (x >= 26 && x < 34 && /^[A-Z0-9]{3}/.test(first)) { lvl = 'kro'; code = first.slice(0, 3); if (first.length > 3) rest = first.slice(3) + ' ' + rest; } else if (x >= 34 && x < 42 && /^\d{3}$/.test(first)) lvl = 'komp'; else if (x >= 42 && x < 48 && /^\d{3}\.[A-Z0-9]{1,2}$/.test(first)) { lvl = 'sub'; code = first.split('.')[1]; } else if (x >= 48 && x < 62 && /^\d{6}$/.test(first)) lvl = 'akun'; else if (x >= 90 && /^\d{6}\.$/.test(first)) lvl = 'item'; if (!lvl) { if (x >= 53 && x < 95) { yatim.push({ y: ln.y, x, teks: left.map(i => i.s).join(' ') }); continue; } // sambungan uraian item hanya dari kolom item (x 95-110); kepala tabel "Uraian" (x ±165) di awal // halaman berikutnya dulu ikut tertempel ke item terakhir if (last && x >= 95 && x < 110) { last.uraian += ' ' + left.map(i => i.s).join(' '); if (last.pagu == null && pagu != null) Object.assign(last, { pagu, realisasi: realisasi || 0 }); } continue; } rest = rest.trim(); if (lvl === 'item') { lepas(); nodeAkhir = null; last = { key: keyOf(ctx, 'akun'), no: first.replace('.', ''), uraian: rest, pagu, realisasi: realisasi || 0, sisa }; items.push(last); continue; } setLevel(ctx, lvl, code); const key = keyOf(ctx, lvl); // potongan di antara node sebelumnya dan node ini masuk ke yang lebih dekat const depan = []; for (const f of yatim.splice(0)) { const keSini = ln.y - f.y, keSana = nodeAkhir ? f.y - nodeAkhir.y : Infinity; const okSini = cocok(f, lvl) && keSini <= 16, okSana = !!nodeAkhir && cocok(f, nodeAkhir.node.level) && keSana <= 16; if (okSini && (!okSana || keSini <= keSana)) depan.push(f.teks); else if (okSana) nodeAkhir.node.uraian = rapikan(nodeAkhir.node.uraian + ' ' + f.teks); } const node = { key, level: lvl, kode: code, uraian: rapikan([...depan, rest].join(' ')), pagu }; nodes.set(key, node); nodeAkhir = { node, y: ln.y }; last = null; } lepas(); } return { meta, nodes, items }; } // ── Rincian Kertas Kerja Satker (portrait) ───────────────────────────── // Baris RKK tidak rapi: uraian yang terbungkus diletakkan di tengah secara // vertikal (teks bisa di atas tanda "-" dan angkanya). Karena itu setiap kode // atau tanda "-" dijadikan jangkar, lalu teks/volume/jumlah ditempelkan ke // jangkar terdekat di halaman yang sama. function parseRKK(pages) { const meta = { jenis: 'RKK', satker: '', namaSatker: '', periode: '', total: 0 }; const nodes = new Map(), items = []; const ctx = {}; let curKro = null; const CODE = [ ['prog', /^\d{3}\.\d{2}\.([A-Z]{2})$/], ['keg', /^(\d{4})$/], ['kro', /^\d{4}\.([A-Z0-9]{3})$/], ['ro', /^\d{4}\.[A-Z0-9]{3}\.([A-Z0-9]{3})$/], ['akun', /^(\d{6})$/], ['komp', /^(\d{3})$/], ['sub', /^([A-Z0-9]{1,2})$/], ]; for (const pg of pages) { const sc = pg.width / 595, sy = pg.height / 842; const all = pg.lines.flatMap(l => l.items); for (const ln of pg.lines) { const t = ln.text; let m; if (!meta.satker && (m = t.match(/^UNIT KERJA\s*\((\d{6})\)\s*(.*)$/))) { meta.satker = m[1]; meta.namaSatker = m[2].trim(); } if (!meta.total && (m = t.match(/^ALOKASI\s+Rp\.?\s*([\d,]+)/))) meta.total = toNum(m[1]); } const X = it => it.x0 / sc, X1 = it => it.x1 / sc; const body = all.filter(it => it.y > 145 * sy); const anchors = []; for (const it of body) { if (X(it) < 80) { const tok = it.s.split(' ')[0]; for (const [lvl, re] of CODE) { const mm = tok.match(re); if (mm) { anchors.push({ kind: 'code', lvl, code: mm[1], y: it.y, it, text: [], rest: it.s.slice(tok.length).trim() }); break; } } } else if (X(it) >= 84 && X(it) < 96 && /^>+/.test(it.s)) { // baris kelompok detil (">" / ">>") membawa subtotal; bukan item const depth = it.s.match(/^>+/)[0].length; const rest = it.s.replace(/^>+\s*/, ''); anchors.push({ kind: 'group', depth, y: it.y, it, text: rest ? [{ y: it.y, x: X(it), s: rest }] : [] }); } else if (X(it) >= 86 && X(it) < 96 && /^-/.test(it.s)) { anchors.push({ kind: 'item', y: it.y, it, text: it.s.length > 1 ? [{ y: it.y, x: X(it), s: it.s.replace(/^-\s*/, '') }] : [] }); } } anchors.sort((a, b) => a.y - b.y); if (!anchors.length) continue; const nearest = y => { let best = null, bd = 1e9; for (const a of anchors) { const d = Math.abs(a.y - y); if (d < bd) { bd = d; best = a; } } return { a: best, d: bd }; }; const lokasi = []; for (const it of body) { if (anchors.some(a => a.it === it)) continue; const x = X(it), x1 = X1(it); if (x >= 78 && x1 < 288) { if (/^Lokasi\s*:/.test(it.s)) { lokasi.push(it); continue; } if (/^\(KPPN/.test(it.s)) continue; const { a, d } = nearest(it.y); if (a && d < 14) a.text.push({ y: it.y, x, s: it.s }); } else if (x >= 280 && x1 <= 342) { const { a, d } = nearest(it.y); if (a && a.kind === 'item' && d < 14) a.volume = ((a.volume || '') + ' ' + it.s).trim(); } else if (x1 >= 405 && x1 <= 420 && NUM.test(it.s)) { const { a, d } = nearest(it.y); if (a && a.kind === 'item' && d < 14 && a.harga == null) a.harga = toNum(it.s); } else if (x1 >= 500 && x1 <= 530 && NUM.test(it.s)) { const { a, d } = nearest(it.y); if (a && d < 14 && a.jumlah == null) a.jumlah = toNum(it.s); } else if (x >= 545 && /^[A-Z]{1,4}$/.test(it.s)) { const { a, d } = nearest(it.y); if (a && a.kind === 'code' && a.lvl === 'akun' && d < 4) a.sd = it.s; } } for (const lk of lokasi) { const prev = anchors.filter(a => a.kind === 'code' && a.lvl === 'kro' && a.y <= lk.y).pop(); if (prev) prev.lokasi = lk.s.replace(/^Lokasi\s*:\s*/, ''); else if (curKro) curKro.lokasi = lk.s.replace(/^Lokasi\s*:\s*/, ''); } for (const a of anchors) { const txt = [a.rest, ...a.text.sort((p, q) => p.y - q.y || p.x - q.x).map(t => t.s)].filter(Boolean).join(' ') .replace(/\[[^\]]*\]/g, ' ').replace(/\s+/g, ' ').trim(); if (a.kind === 'group') { ctx._grp = (ctx._grp || []).slice(0, a.depth - 1); ctx._grp[a.depth - 1] = txt; continue; } if (a.kind === 'code') { ctx._grp = []; setLevel(ctx, a.lvl, a.code); const key = keyOf(ctx, a.lvl); ctx._sd = a.sd || ''; const dup = nodes.get(key); if (dup && a.lvl === 'akun') { // akun yang sama dipecah per sumber dana (RM/PNP/BLU...) dup.pagu = (dup.pagu || 0) + (a.jumlah || 0); if (a.sd && !dup.sd.includes(a.sd)) dup.sd.push(a.sd); continue; } const node = { key, level: a.lvl, kode: a.code, uraian: txt, pagu: a.jumlah ?? null }; if (a.lvl === 'akun') node.sd = a.sd ? [a.sd] : []; if (a.lvl === 'kro') { node.lokasi = a.lokasi || ''; curKro = node; } nodes.set(key, node); } else if (ctx.akun) { items.push({ key: keyOf(ctx, 'akun'), no: String(items.length + 1), uraian: txt, volume: a.volume || '', harga: a.harga ?? null, pagu: a.jumlah ?? null, realisasi: 0, sd: ctx._sd || '', grup: (ctx._grp || []).filter(Boolean).join(' › ') }); } } } return { meta, nodes, items }; } async function parse(pdfjsLib, data, onProgress) { const doc = await pdfjsLib.getDocument({ data, verbosity: 0 }).promise; const pages = []; for (let i = 1; i <= doc.numPages; i++) { const page = await doc.getPage(i); pages.push(await pageLines(pdfjsLib, page)); if (onProgress) onProgress(i, doc.numPages); } const head = pages[0].lines.slice(0, 25).map(l => l.text).join('\n'); let res; if (/KETERSEDIAAN DANA DETAIL/i.test(head)) res = parseFA(pages); else if (/UNIT KERJA\s*\(\d{6}\)/.test(head) && /SUBKOMP/.test(head)) res = parseRKK(pages); else throw new Error('Format PDF tidak dikenali (bukan FA Detail 16 Segmen / Rincian Kertas Kerja Satker).'); res.meta.halaman = doc.numPages; // Pagu tiap node dihitung ulang dari item: angka header bisa hilang // saat uraiannya terbungkus, sementara jumlah item selalu utuh. const sum = new Map(); for (const it of res.items) { const parts = it.key.split('.'); for (let i = 1; i <= parts.length; i++) { const k = parts.slice(0, i).join('.'); sum.set(k, (sum.get(k) || 0) + (it.pagu || 0)); } } res.check = { itemTotal: sum.size ? [...res.nodes.values()].filter(n => n.level === 'prog').reduce((a, n) => a + (sum.get(n.key) || 0), 0) : 0, mismatches: [] }; for (const n of res.nodes.values()) { const s = sum.get(n.key) || 0; if (n.pagu != null && n.pagu !== s) res.check.mismatches.push({ key: n.key, header: n.pagu, items: s }); n.paguHeader = n.pagu; n.pagu = s; } res.check.ok = res.meta.total ? res.check.itemTotal === res.meta.total : res.check.mismatches.length === 0; return res; } return { parse }; })(); // ──────────────────────────────────────────────────────── KLASIFIKASI BELANJA ── // Memutuskan tiap baris detail DIPA termasuk pengadaan (P), non-pengadaan (NP) // atau perlu dicek (CEK). Aturan disusun dari akun + kata kunci uraian item. // Keputusan pengguna: PJLP = pengadaan; honor PPNPN beserta iuran BPJS = non. const Classify = (() => { const has = (re, ...s) => s.some(x => re.test(x || '')); const RE = { pjlp: /\bpjlp\b|jasa lainnya perorangan|penyedia jasa (lainnya )?perorangan|cleaning ?service|tenaga (kebersihan|keamanan|satpam|pramubakti|pengemudi|teknisi)|\bsatpam\b|satuan pengaman|pramubakti|outsourc/i, bpjs: /\bbpjs\b|iuran jaminan|jaminan (kesehatan|kecelakaan|kematian|hari tua|pensiun|sosial)|\bjkk\b|\bjkm\b|\bjht\b/i, ppnpn: /ppnpn|pppn|pegawai pemerintah non pegawai negeri|pegawai non asn|tenaga honorer|paruh waktu|tenaga kontrak|\bthr\b|tunjangan hari raya/i, honor: /honor|insentif|narasumber|narsum|pembahas|moderator|rohaniawan|\btunjangan\b|uang lembur|\blembur\b/i, uang: /uang (saku|harian|representasi|transport|makan (pns|pppk|lembur))|transport(asi)? lokal|lumpsum|lump sum|biaya transport(asi)? (peserta|narasumber)/i, pungutan: /\bpajak\b|\bpbb\b|\bstnk\b|retribusi|bea (materai|meterai)|biaya tol|\btol\b|e-?toll|\bparkir\b|biaya administrasi bank/i, natura: /makan (taruna|siswa|peserta didik|mahasiswa|kadet)|konsumsi (taruna|siswa|peserta didik)|ransum|(seragam|pakaian( dinas)?|perlengkapan) (taruna|siswa|peserta didik)/i, bantuan: /beasiswa|biaya pendidikan|\bspp\b|uang kuliah|tugas belajar|izin belajar|bantuan (pemerintah|biaya|uang|dana|sosial|langsung)|\bbanpem\b|hadiah (uang|lomba)/i, // paket meeting luar/dalam kota: penginapan hotel & ruang rapat direalisasikan sebagai pengadaan (metode Dikecualikan) meeting: /paket meeting|full ?board|full ?day|half ?day|fullboard|fullday|halfday|sewa (ruang|gedung|hall|aula)|ruang (rapat|pertemuan)|akomodasi|penginapan|\bhotel\b|\bkamar\b|paket (kegiatan|pertemuan)/i, // komponen khas EO: bila ada di RAB, paket diperlakukan sebagai jasa EO (bukan dikecualikan) eo: /hiburan|\bmc\b|master of ceremony|pembawa acara|dekorasi|sound ?system|dokumentasi|event organi[sz]er|\beo\b|panggung|lighting|backdrop/i, konsultan: /konsultan|pengawas(an)?\b|perencana(an)?\b|manajemen konstruksi|\bded\b|desain|kajian teknis|supervisi/i, pengelolaan: /pengelolaan kegiatan|manajemen proyek|biaya umum/i, }; // akun → kelompok jenis belanja pada Struktur Anggaran SiRUP function jenisBelanja(akun) { const p2 = akun.slice(0, 2); if (p2 === '52') return 'barjas'; if (p2 === '53') return 'modal'; if (p2 === '57') return 'sosial'; if (p2 === '56') return 'hibah'; if (['54', '55', '58'].includes(p2)) return 'lainnya'; return 'pegawai'; } function saranJenis(akun, uraian) { if (/^53[34]/.test(akun)) return has(RE.konsultan, uraian) ? 'Jasa Konsultansi' : 'Pekerjaan Konstruksi'; if (/^53/.test(akun)) return 'Barang'; if (akun === '522131' || has(RE.konsultan, uraian) && /^52/.test(akun) && !/^5211/.test(akun)) return 'Jasa Konsultansi'; if (akun === '523111' || akun === '523112') return 'Pekerjaan Konstruksi'; if (/^52(2|3)/.test(akun) || /^5241/.test(akun) || has(RE.pjlp, uraian)) return 'Jasa Lainnya'; return 'Barang'; } // Satu baris detail → {kelas, alasan} function item(akun, akunNama, uraian, grup) { const u = `${grup || ''} ${uraian || ''}`; const A = akun || ''; if (/^51/.test(A)) return { kelas: 'NP', alasan: 'Belanja pegawai (gaji/tunjangan)' }; if (has(RE.bpjs, u)) return { kelas: 'NP', alasan: 'Iuran BPJS / jaminan sosial' }; if (has(RE.ppnpn, u)) return { kelas: 'NP', alasan: 'Honor PPNPN' }; if (has(RE.pjlp, u)) return { kelas: 'P', alasan: 'PJLP / jasa lainnya perorangan (pengadaan)' }; if (/^5241/.test(A)) { if (/^52411[49]$/.test(A) && has(RE.meeting, u)) return { kelas: 'P', alasan: 'Paket meeting / penginapan hotel (pengadaan, metode Dikecualikan)' }; if (/^52411[49]$/.test(A) && has(RE.eo, u)) return { kelas: 'P', alasan: 'Komponen EO pada paket meeting' }; return { kelas: 'NP', alasan: 'Perjalanan dinas' }; } if (['521115', '521213', '521214'].includes(A)) return { kelas: 'NP', alasan: 'Honorarium' }; if (has(RE.honor, u)) return { kelas: 'NP', alasan: 'Honor / insentif' }; if (has(RE.uang, u)) return { kelas: 'NP', alasan: 'Uang saku / uang harian / transport' }; if (has(RE.pungutan, u)) return { kelas: 'NP', alasan: 'Pajak / retribusi / tol' }; // "Bantuan uang makan/seragam taruna" di poltek sering direalisasikan lewat kontrak katering/seragam if (has(RE.natura, u)) return { kelas: 'CEK', alasan: 'Bantuan natura taruna/peserta (makan/seragam) — pengadaan bila lewat kontrak katering/penyedia' }; if (has(RE.bantuan, u)) return { kelas: 'NP', alasan: 'Bantuan / beasiswa / biaya pendidikan' }; if (A === '522151') return { kelas: 'CEK', alasan: 'Jasa profesi selain honor — cek apakah dikontrakkan' }; if (/^526/.test(A)) return { kelas: 'CEK', alasan: 'Belanja barang untuk diserahkan — pengadaan bila berupa barang' }; if (/^5[678]/.test(A) || /^5[45]/.test(A)) return { kelas: 'CEK', alasan: 'Bansos/hibah/lainnya — pengadaan hanya bila berupa barang/jasa' }; if (/^53/.test(A) && has(RE.pengelolaan, u)) return { kelas: 'CEK', alasan: 'Biaya pengelolaan kegiatan pada belanja modal (umumnya honor/perjalanan)' }; if (/^5[23]/.test(A)) return { kelas: 'P', alasan: /^53/.test(A) ? 'Belanja modal' : 'Belanja barang/jasa' }; return { kelas: 'CEK', alasan: 'Akun tidak dikenali' }; } // Saran metode pemilihan (ambang Perpres 46/2025; bisa diubah di Pengaturan) function saranMetode(jenis, pagu, akun, uraian, cfg) { const t = cfg || { plBarjas: 200e6, plKonstruksi: 400e6, plKonsultansi: 100e6 }; // Paket meeting: hanya kamar/ruang rapat → Dikecualikan. Ada hiburan/MC/dekorasi → EO // (jasa lainnya: PL s.d. batas PL, di atasnya metode EO dari pengaturan; default Tender). if (/^52411[49]$/.test(akun || '')) return has(RE.eo, uraian) ? (pagu <= t.plBarjas ? 'Pengadaan Langsung' : (t.metodeEO || 'Tender')) : 'Dikecualikan'; if (jenis === 'Jasa Konsultansi') return pagu <= t.plKonsultansi ? 'Pengadaan Langsung' : 'Seleksi'; if (jenis === 'Pekerjaan Konstruksi') return pagu <= t.plKonstruksi ? 'Pengadaan Langsung' : 'Tender'; return pagu <= t.plBarjas ? 'Pengadaan Langsung' : 'Tender'; } return { item, jenisBelanja, saranJenis, saranMetode, RE }; })(); // ─────────────────────────────────────────────────────────────────── SANDING ── // Menyandingkan DIPA (hasil parser) dengan PKKR dan paket RUP SiRUP per akun // (MAK 7 segmen). Rencana perbaikannya disusun di src/rencana.js. const Analysis = (() => { const LV = ['prog', 'keg', 'kro', 'ro', 'komp', 'sub', 'akun']; const LV_SIRUP = { prog: 'program', keg: 'kegiatan', kro: 'kro', ro: 'ro', komp: 'komponen', sub: 'subkomponen' }; const ST = { '11': 'Draft', '12': 'Draft', '2': 'Final Draft', '3': 'Terumumkan', '51': 'Dibatalkan' }; const levelOfKey = k => LV[k.split('.').length - 1]; const parentKey = k => k.split('.').slice(0, -1).join('.'); // Kode sub-komponen PKKR SiRUP kadang ditulis tanpa nol di depan ("A" untuk "0A" di DIPA, satker 352595) atau // dengan huruf O untuk angka 0 ("OA", 239188). Bentuk baku untuk mencocokkan: O→0 di depan, lalu nol di depan dibuang. const kodeSubBaku = k => String(k || '').trim().toUpperCase().replace(/^O(?=[A-Z0-9])/, '0').replace(/^0+(?=[A-Z0-9])/, ''); const kunciBaku = key => { const p = String(key || '').split('.'); if (p.length >= 6) p[5] = kodeSubBaku(p[5]); return p.join('.'); }; // PKKR dibaca dengan kunci DIPA: node yang kodenya hanya beda tulisan dipindah ke kunci DIPA-nya, supaya pembanding // PKKR tidak menawarkan cabang "baru" yang sebenarnya sudah ada (dulu 21 sub-komponen di 352595) function selaraskanKodePkkr(pkkr, dipaNodes) { const dipaB = new Map(); for (const k of dipaNodes.keys()) dipaB.set(kunciBaku(k), k); const out = new Map(), diubah = []; for (const [k, n] of pkkr) { const t = dipaNodes.has(k) ? k : (dipaB.get(kunciBaku(k)) || k); if (t !== k) { n.key = t; if (n.manualTwin) n.manualTwin.key = t; diubah.push([k, t]); } const ada = out.get(t); if (!ada) { out.set(t, n); continue; } // "A" (Integrasi) dan "0A" (Manual) di komponen yang sama → satu kunci, node Manual menjadi salinannya const integ = ada.manual ? n : ada, man = ada.manual ? ada : n; if (!integ.manual && man.manual && !integ.manualTwin) { integ.manualTwin = man; out.set(t, integ); } } out.diubah = diubah; return out; } const TOL = 1000; // selisih ≤ Rp1.000 dianggap sama (pembulatan) // kode SD di RKK → id_dana_apbn SiRUP (dipetakan dari paket nyata satker BPPSDMKP, Sep 2026) const SD_CODE = { RM: 'A', PLN: 'B', PNP: 'D', PNBP: 'D', BLU: 'F', SBSN: 'T' }; // ── 1. DIPA per akun, dengan klasifikasi per item ──────────────────── function buildDipa(dipa, overrides, opts) { const cekSebagaiP = opts && opts.cekSebagai === 'P'; const cekKep = (opts && opts.cekKeputusan) || {}; // alasan → 'P' | 'NP' (kartu "perlu dicek") const nodes = dipa.nodes instanceof Map ? dipa.nodes : new Map(dipa.nodes.map(n => [n.key, n])); const akun = new Map(); dipa.items.forEach((it, idx) => { const code = it.key.split('.').pop(); const node = nodes.get(it.key) || {}; const iid = `${it.key}#${it.no || idx}`; let c = Classify.item(code, node.uraian, it.uraian, it.grup); const ov = overrides && overrides[iid]; if (ov) c = { kelas: ov, alasan: 'Diubah manual', manual: true }; else if (c.kelas === 'CEK' && cekKep[c.alasan]) c = { kelas: cekKep[c.alasan], alasan: c.alasan, cekAsal: c.alasan }; else if (cekSebagaiP && c.kelas === 'CEK') c = { kelas: 'P', alasan: '(perlu cek → dihitung pengadaan) ' + c.alasan, cekAsal: c.alasan }; let a = akun.get(it.key); if (!a) { a = { key: it.key, akun: code, nama: node.uraian || '', pagu: 0, P: 0, NP: 0, CEK: 0, items: [], rup: [], sd: node.sd || [] }; akun.set(it.key, a); } const row = { iid, no: it.no, uraian: it.uraian, grup: it.grup || '', volume: it.volume || '', harga: it.harga ?? null, pagu: it.pagu || 0, realisasi: it.realisasi || 0, kelas: c.kelas, alasan: c.alasan, manual: !!c.manual, cekAsal: c.cekAsal || null }; a.items.push(row); a.pagu += row.pagu; a[row.kelas] += row.pagu; if (row.kelas === 'P') { const sd = it.sd || (a.sd.length === 1 ? a.sd[0] : ''); a.PbySd = a.PbySd || {}; a.PbySd[sd] = (a.PbySd[sd] || 0) + row.pagu; } }); return { nodes, akun }; } // FA Detail (pagu revisi + realisasi) dilengkapi RKK pendamping: nama node, lokasi KRO, // sumber dana, serta volume & harga satuan item (dicocokkan per MAK + uraian). function gabungDipa(utama, pendamping) { const norm = s => String(s || '').toLowerCase().replace(/\[[^\]]*\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim(); let node = 0, item = 0; for (const [k, n] of utama.nodes) { const o = pendamping.nodes.get(k); if (!o) continue; if (!n.uraian && o.uraian) { n.uraian = o.uraian; node++; } if (!n.lokasi && o.lokasi) n.lokasi = o.lokasi; if ((!n.sd || !n.sd.length) && o.sd && o.sd.length) n.sd = o.sd; } const antre = new Map(); for (const it of pendamping.items) { const k = it.key + '|' + norm(it.uraian); if (!antre.has(k)) antre.set(k, []); antre.get(k).push(it); } for (const it of utama.items) { const q = antre.get(it.key + '|' + norm(it.uraian)); if (!q || !q.length) continue; const o = q.shift(); if (!it.volume && o.volume) it.volume = o.volume; if (it.harga == null && o.harga != null) it.harga = o.harga; if (!it.sd && o.sd) it.sd = o.sd; item++; } return { node, item }; } // ── 2. PKKR SiRUP vs DIPA ─────────────────────────────────────────── // pkkr: Map key → {id, level, kode, nama, pagu, manual, ppk} function diffPkkr(dipaNodes, akunMap, pkkr) { // node DIPA yang di bawahnya ada pagu pengadaan (P/CEK) const pengadaanUnder = new Map(); for (const a of akunMap.values()) { const v = a.P + a.CEK; if (!v) continue; const parts = a.key.split('.'); for (let i = 1; i < parts.length; i++) { const k = parts.slice(0, i).join('.'); pengadaanUnder.set(k, (pengadaanUnder.get(k) || 0) + v); } } const baru = [], hilang = [], beda = []; for (const n of dipaNodes.values()) { if (n.level === 'akun') continue; const s = pkkr.get(n.key); if (!s) { baru.push({ key: n.key, level: n.level, kode: n.kode, nama: n.uraian, pagu: n.pagu, pengadaan: pengadaanUnder.get(n.key) || 0, parentKey: parentKey(n.key), pilih: (pengadaanUnder.get(n.key) || 0) > 0 }); } else if (Math.abs((s.pagu || 0) - (n.pagu || 0)) > TOL) { beda.push({ key: n.key, level: n.level, nama: n.uraian, paguDipa: n.pagu, paguSirup: s.pagu, manual: s.manual, id: s.id }); } } for (const [k, s] of pkkr) if (!dipaNodes.has(k)) hilang.push({ key: k, level: s.level, nama: s.nama, pagu: s.pagu, manual: s.manual, id: s.id }); const byDepth = (a, b) => a.key.split('.').length - b.key.split('.').length || a.key.localeCompare(b.key); baru.sort(byDepth); return { baru, hilang, beda }; } // ── 3. RUP vs DIPA per akun ───────────────────────────────────────── function sanding(akunMap, pakets) { const orphans = []; for (const p of pakets) { p.verdicts = []; if (p.status === '51' || p.aktif === 'false' && p.status !== '3') continue; for (const sd of p.sumberDana || []) { const key = sd.mak; const a = akunMap.get(key); const ref = { paketId: p.id, nama: p.nama, status: p.status, pagu: sd.pagu, mak: key }; if (a) a.rup.push(ref); else orphans.push(ref); } } for (const a of akunMap.values()) { const sum = st => a.rup.filter(r => st.includes(r.status)).reduce((s, r) => s + r.pagu, 0); a.rupU = sum(['3']); a.rupFD = sum(['2']); a.rupDraft = sum(['11', '12']); a.target = a.P; // pagu pengadaan yang semestinya terumumkan a.selisih = a.target - a.rupU; // + kurang, − lebih if (a.rupU === 0 && a.target === 0) a.status = a.CEK ? 'CEK' : 'NP'; else if (a.target === 0 && a.rupU > 0) a.status = a.CEK ? 'CEK' : 'NP_TERUMUMKAN'; else if (Math.abs(a.selisih) <= TOL) a.status = 'SESUAI'; else a.status = a.selisih > 0 ? 'KURANG' : 'LEBIH'; } return { orphans }; } // ── 4. Verdict per paket ──────────────────────────────────────────── function verdictPaket(p, akunMap) { const rows = (p.sumberDana || []).map(sd => { const base = { mak: sd.mak, pagu: sd.pagu, danaApbn: sd.danaApbn, idLama: sd.id }; const a = akunMap.get(sd.mak); if (!a) return { ...base, v: 'MAK_HILANG' }; if (a.target === 0 && !a.CEK) return { ...base, v: 'NON_PENGADAAN', alasan: [...new Set(a.items.filter(i => i.kelas === 'NP').map(i => i.alasan))].join('; ') }; // sumber dana paket harus salah satu SD akun di DIPA (hanya bisa dicek bila RKK diunggah) const danaDipa = (a.sd || []).map(x => SD_CODE[x]).filter(Boolean); const danaBeda = sd.danaApbn && danaDipa.length && !danaDipa.includes(sd.danaApbn) ? danaDipa[0] : null; if (a.status === 'LEBIH') return { ...base, v: 'LEBIH', selisih: a.selisih, danaBeda }; return { ...base, v: danaBeda ? 'DANA_BEDA' : 'OK', danaBeda }; }); const all = v => rows.length && rows.every(r => r.v === v); const any = v => rows.some(r => r.v === v); let verdict = 'OK'; if (p.status === '51') verdict = 'DIBATALKAN'; else if (all('NON_PENGADAAN')) verdict = 'BATAL'; else if (any('MAK_HILANG')) verdict = 'REVISI_MAK'; else if (any('NON_PENGADAAN')) verdict = 'REVISI_KELUARKAN_NP'; else if (any('LEBIH')) verdict = 'REVISI_LEBIH'; else if (any('DANA_BEDA')) verdict = 'REVISI_DANA'; if (p.status === '2' && verdict === 'OK') verdict = 'UMUMKAN'; return { rows, verdict }; } const BULAN = ['januari', 'februari', 'maret', 'april', 'mei', 'juni', 'juli', 'agustus', 'september', 'oktober', 'november', 'desember']; function ym(s) { const m = String(s || '').trim().toLowerCase().match(/([a-z]+)\s+(\d{4})/); if (!m) return ''; const i = BULAN.indexOf(m[1]); return i < 0 ? '' : `${m[2]}-${String(i + 1).padStart(2, '0')}`; } // ── 7. Struktur anggaran yang direkomendasikan ─────────────────────── // = total pagu paket terumumkan (setelah rencana dijalankan) per jenis belanja function strukturAnggaran(akunMap, extra) { const out = { barjas: 0, modal: 0, sosial: 0, hibah: 0, lainnya: 0 }; for (const a of akunMap.values()) { const g = Classify.jenisBelanja(a.akun); if (g in out) out[g] += a.rupU; } for (const e of extra || []) { const g = Classify.jenisBelanja(e.mak.split('.').pop()); if (g in out) out[g] += e.delta; } return out; } return { SD_CODE, buildDipa, gabungDipa, diffPkkr, sanding, verdictPaket, strukturAnggaran, ym, ST, LV_SIRUP, levelOfKey, parentKey, kodeSubBaku, kunciBaku, selaraskanKodePkkr, TOL }; })(); // ─────────────────────────────────────────────────────────────── RENCANA ── // Menyusun rencana perbaikan RUP dari hasil sanding (Analysis): // • kartu keputusan per kelompok (MAK lama, kelebihan besar, non-pengadaan, // item "perlu dicek", final draft bermasalah) — bukan per paket; // • perubahan per paket existing: satu paket = satu revisi (tambah pagu, pindah // MAK, keluarkan baris non-pengadaan, potong kelebihan, batalkan); // • paket baru hanya untuk MAK yang belum punya paket, dititipkan lewat revisi // satu ke banyak ke paket di komponen yang sama; // • proyeksi RUP terumumkan. // Fungsi murni tanpa akses SiRUP, supaya bisa diuji di Node untuk banyak satker. // Semua isian satker-spesifik (lokasi satker, kode BA/eselon) datang dari ctx. const Rencana = (() => { const TOL = 1000; const SD_CODE = { RM: 'A', PLN: 'B', PNP: 'D', PNBP: 'D', BLU: 'F', SBSN: 'T' }; const SD_BELUM_DIUJI = new Set(['B', 'T']); // PLN / SBSN: butuh isian register yang belum pernah direkam const seg = (mak, n) => mak.split('.').slice(0, n).join('.'); const akunOf = mak => mak.split('.').pop(); const fmt = n => Math.round(n).toLocaleString('id-ID'); const sum = (xs, f) => xs.reduce((s, x) => s + (f ? f(x) : x), 0); const aktif = p => p.aktif !== 'false' && p.aktif !== false; const penyedia = p => (p.jenisPaket || 'penyedia') === 'penyedia'; const isU = p => p.status === '3' && aktif(p) && penyedia(p); const isFD = p => p.status === '2' && penyedia(p); const rowsOf = p => (p.sumberDana || []).filter(s => +s.pagu > 0); const MEETING = /^52411[49]$/; const LABEL_JENIS = { 'Barang': 'Pengadaan barang', 'Jasa Lainnya': 'Pengadaan jasa lainnya', 'Pekerjaan Konstruksi': 'Pekerjaan konstruksi', 'Jasa Konsultansi': 'Jasa konsultansi' }; // ── kemiripan nama ────────────────────────────────────────────────── const STOP = new Set(['belanja', 'pengadaan', 'paket', 'dan', 'untuk', 'pada', 'di', 'ke', 'dari', 'yang', 'dengan', 'atau', 'tahun', 'anggaran', 'serta', 'dalam', 'lainnya', 'lain', 'the', 'kab', 'kota']); const tokens = s => new Set(String(s || '').toLowerCase().replace(/\[[^\]]*\]/g, ' ').replace(/[^a-z0-9]+/g, ' ').split(' ') .filter(t => t.length > 2 && !STOP.has(t) && !/^\d+$/.test(t))); const jaccard = (a, b) => { if (!a.size || !b.size) return 0; let i = 0; for (const t of a) if (b.has(t)) i++; return i / (a.size + b.size - i); }; const contain = (a, b) => { if (!a.size) return 0; let i = 0; for (const t of a) if (b.has(t)) i++; return i / a.size; }; const bersih = s => { const t = String(s || '').replace(/\[[^\]]*\]/g, ' ').replace(/^\s*belanja\s+/i, '').replace(/\s+/g, ' ').trim(); return t.charAt(0).toUpperCase() + t.slice(1); }; // untuk nama paket: buang juga rincian volume dalam kurung, mis. "(60 orang x 2 kegiatan)" const bersihNama = s => bersih(s).replace(/\s*\([^)]*\d[^)]*(\)|$)/g, ' ').replace(/\s+/g, ' ').trim(); const potong = (s, n) => s.length <= n ? s : s.slice(0, n - 3).replace(/\s+\S*$/, '') + '...'; // Paket "umum" untuk sebuah akun: namanya nama akun/komponen (mis. "Belanja Bahan", // "Langganan Daya dan Jasa", "Pelatihan Berbasis Kompetensi"), bukan pekerjaan spesifik // (mis. "Pengadaan Konsumsi Kegiatan Konsolidasi"). Kekurangan pagu ditambahkan ke paket umum. function isUmum(p, mak, akunMap, nodes) { const nm = String(p.nama || '').trim(); // "Belanja …" saja tidak cukup: "Belanja Modal Peralatan Laboratorium …" itu paket spesifik. const tp = tokens(nm); if (!tp.size) return true; const namaAkun = [(akunMap.get(mak) || {}).nama, ...rowsOf(p).map(r => (akunMap.get(r.mak) || {}).nama)]; for (const c of namaAkun) { const tc = tokens(c); if (tc.size && (jaccard(tp, tc) >= 0.5 || contain(tp, tc) >= 0.75)) return true; } for (const k of [seg(mak, 5), seg(mak, 6)]) { const n = nodes.get(k); if (n && n.uraian && jaccard(tp, tokens(n.uraian)) >= 0.6) return true; } return false; } // bagi total ke baris sebanding bobot; dibulatkan ke Rp1.000, sisa pembulatan ke baris terbesar function bagi(total, bobot) { const tb = sum(bobot); if (!tb || total <= 0) return bobot.map(() => 0); const out = bobot.map(w => Math.floor(total * w / tb / 1000) * 1000); const imax = bobot.indexOf(Math.max(...bobot)); out[imax] += Math.round(total - sum(out)); return out; } // ── bulan (YYYY-MM) ───────────────────────────────────────────────── const BULAN = ['januari', 'februari', 'maret', 'april', 'mei', 'juni', 'juli', 'agustus', 'september', 'oktober', 'november', 'desember']; const BULAN_EN = ['january', 'february', 'march', 'april', 'may', 'june', 'july', 'august', 'september', 'october', 'november', 'december']; function ym(s) { const t = String(s || '').trim().toLowerCase(); let m = t.match(/^(\d{4})-(\d{2})/); if (m) return `${m[1]}-${m[2]}`; m = t.match(/([a-z]+)\s+(\d{4})/); if (!m) return ''; let i = BULAN.indexOf(m[1]); if (i < 0) i = BULAN_EN.indexOf(m[1]); return i < 0 ? '' : `${m[2]}-${String(i + 1).padStart(2, '0')}`; } const ymAdd = (y, d) => { const [a, b] = y.split('-').map(Number); const t = new Date(a, b - 1 + d, 1); return `${t.getFullYear()}-${String(t.getMonth() + 1).padStart(2, '0')}`; }; const maxYm = (a, b) => (a > b ? a : b); // jadwal SiRUP: akhir pemilihan ≥ awal; awal kontrak ≥ akhir pemilihan; akhir kontrak ≥ awal kontrak; // akhir pemanfaatan ≥ awal pemanfaatan (aturan validasi form paket SiRUP) function rapikanJadwal(j) { const o = { ...j }; o.akhirPengadaan = maxYm(o.akhirPengadaan || o.awalPengadaan, o.awalPengadaan); o.awalPekerjaan = maxYm(o.awalPekerjaan || o.akhirPengadaan, o.akhirPengadaan); o.akhirPekerjaan = maxYm(o.akhirPekerjaan || o.awalPekerjaan, o.awalPekerjaan); o.awalKebutuhan = o.awalKebutuhan || o.awalPekerjaan; o.kebutuhan = maxYm(o.kebutuhan || o.akhirPekerjaan, o.awalKebutuhan); return o; } function jadwalPaket(p) { const t = p.tanggal || {}; const j = { awalPengadaan: ym(t.awalPengadaan) || ym(p.pemilihan && p.pemilihan.mulai), akhirPengadaan: ym(t.akhirPengadaan) || ym(p.pemilihan && p.pemilihan.akhir), awalPekerjaan: ym(t.awalPekerjaan) || ym(p.pelaksanaan && p.pelaksanaan.mulai), akhirPekerjaan: ym(t.akhirPekerjaan) || ym(p.pelaksanaan && p.pelaksanaan.akhir), awalKebutuhan: ym(p.pemanfaatan && p.pemanfaatan.mulai), kebutuhan: ym(p.pemanfaatan && p.pemanfaatan.akhir), }; if (!j.awalPengadaan) return null; j.awalKebutuhan = j.awalKebutuhan || j.awalPekerjaan; j.kebutuhan = j.kebutuhan || j.akhirPekerjaan; return rapikanJadwal(j); } // ── objek paket siap kirim (dipakai payload revisi) ───────────────── const JENIS_ID = { 'Barang': 1, 'Pekerjaan Konstruksi': 2, 'Jasa Konsultansi': 3, 'Jasa Lainnya': 4 }; const JENIS_NAMA = Object.fromEntries(Object.entries(JENIS_ID).map(([k, v]) => [v, k])); function jenisPaket(p) { const r = (p.jenisRaw || [])[0]; if (r && JENIS_NAMA[r.jenisid]) return JENIS_NAMA[r.jenisid]; return ((p.jenisPengadaan || [])[0] || {}).jenis || 'Barang'; } function pkDari(p, rows, cfg) { const total = sum(rows, r => r.pagu); const jenisRaw = (p.jenisRaw || []).map(j => ({ ...j })); return { baru: false, sumberId: p.id, nama: p.nama, anggaran: rows.map(r => ({ ...r })), jenis: jenisPaket(p), jenisList: jenisRaw.length > 1 && Math.abs(sum(jenisRaw, j => +j.pagu) - total) <= 1 ? jenisRaw : null, jenisIdLama: (jenisRaw[0] || {}).id || '', metode: p.metode || Classify.saranMetode(jenisPaket(p), total, '', '', cfg), uraian: p.uraianRaw != null ? p.uraianRaw : (p.uraian || ''), spesifikasi: p.spesifikasiRaw != null ? p.spesifikasiRaw : (p.spesifikasi || ''), volume: p.volume || '1 Paket', praDipa: /^ya/i.test(p.pradipa || ''), pdn: !/tidak/i.test(p.pdn || ''), umkm: /^ya/i.test(p.ukm || '') || (p.ukm == null && total <= 15e9), lokasiRaw: (p.lokasiRaw || []).map(l => ({ ...l })), jadwal: jadwalPaket(p) || null, spp: p.spp ? { ...p.spp } : { ekonomi: true, sosial: true, lingkungan: false }, }; } // ── penyusun utama ────────────────────────────────────────────────── // ctx: { akunMap, pakets, dipaNodes, cfg, tahun, hariIni, keputusan, atur, lokasiSatker, lokasiRkk, komponenId } function susun(ctx) { const { akunMap, pakets, cfg } = ctx; const nodes = ctx.dipaNodes || new Map(); const kep = ctx.keputusan || {}; const atur = ctx.atur || {}; const AMBANG = cfg.ambangSelisih != null ? +cfg.ambangSelisih : 1e6; const BESAR = cfg.ambangKeputusan != null ? +cfg.ambangKeputusan : 100e6; const TA = ctx.tahun || (ctx.hariIni || new Date()).getFullYear(); const byId = new Map(pakets.map(p => [p.id, p])); const U = pakets.filter(isU), FD = pakets.filter(isFD); const kartu = [], peringatan = []; const kelasRow = r => { const a = akunMap.get(r.mak); if (!a) return 'HILANG'; if (a.P > 0) return 'P'; if (a.CEK > 0) return 'CEK'; return 'NP'; }; // perubahan per paket (salinan baris yang bisa diubah) const ubah = new Map(); const getUbah = p => { let u = ubah.get(p.id); if (!u) { u = { paket: p, rows: rowsOf(p).map(r => ({ idLama: r.id || '', mak: r.mak, pagu: +r.pagu, danaApbn: r.danaApbn || 'A', sumber: r.sumber, ta: r.ta, idKomponen: r.idKomponen, asal: r.asal, asalSatker: r.asalSatker, kodeInstansi: r.kodeInstansi, kodeEselon: r.kodeEselon, kodeSatker: r.kodeSatker })), sumber: new Set(), catatan: [], batal: false, umumkanDulu: p.status === '2', override: {} }; ubah.set(p.id, u); } return u; }; const rowOf = (u, r) => u.rows.find(x => x.idLama && x.idLama === r.id) || u.rows.find(x => x.mak === r.mak && x.pagu === +r.pagu) || u.rows.find(x => x.mak === r.mak); // RUP terumumkan per akun setelah rencana const rup = new Map([...akunMap.values()].map(a => [a.key, a.rupU])); const addRup = (mak, v) => { if (rup.has(mak)) rup.set(mak, rup.get(mak) + v); }; const kartuBaru = (k, pilihanDefault) => { const d = kep[k.id] || {}; k.pilihan = d.opsi !== undefined ? d.opsi : pilihanDefault; k.param = d; k.diputuskan = k.pilihan != null; k.otomatis = d.opsi === undefined && pilihanDefault != null; kartu.push(k); return k; }; const blok = new Set(); // akun tujuan yang keputusannya belum/ditunda → jangan dibuatkan paket const paketTertahan = new Set(); // paket di kelompok yang belum diputuskan → jangan dijadikan titipan const batalFD = []; // {paketId, nama, pagu, alasan, sumber} // ── 1. Non-pengadaan yang terumumkan ───────────────────────────── const npList = []; for (const p of U) { const rows = rowsOf(p); const np = rows.filter(r => kelasRow(r) === 'NP'); if (!np.length) continue; const alasan = [...new Set(np.flatMap(r => (akunMap.get(r.mak).items || []).filter(i => i.kelas === 'NP').map(i => i.alasan)))].slice(0, 2).join('; '); npList.push({ paketId: p.id, nama: p.nama, pagu: +p.pagu, np: sum(np, r => +r.pagu), semua: np.length === rows.length, alasan, rows: np }); } if (npList.length) { const k = kartuBaru({ id: 'np', jenis: 'NP', judul: 'Non-pengadaan yang terumumkan', ringkas: `${npList.length} paket memuat akun non-pengadaan senilai Rp${fmt(sum(npList, x => x.np))}`, daftar: npList, opsi: [ { id: 'keluarkan', label: 'Keluarkan', ket: 'Paket yang seluruhnya non-pengadaan dibatalkan; paket campuran direvisi tanpa baris non-pengadaan', dampak: -sum(npList, x => x.np) }, { id: 'biarkan', label: 'Biarkan', ket: 'Tidak diubah', dampak: 0 }] }, 'keluarkan'); if (k.pilihan === 'keluarkan') for (const x of npList) { const u = getUbah(byId.get(x.paketId)); u.sumber.add('np'); for (const r of x.rows) { const row = rowOf(u, { id: r.id, mak: r.mak, pagu: r.pagu }); if (row) { addRup(row.mak, -row.pagu); row.pagu = 0; } } u.catatan.push(`Baris non-pengadaan dikeluarkan (Rp${fmt(x.np)}): ${x.alasan}`); } } // ── 2. MAK lama (tidak ada di DIPA) → kelompok per akun tujuan ───── // Tujuan = akun berkode sama di kegiatan yang sama, diutamakan yang paling dekat strukturnya // (komponen > RO > KRO > kode sub-komponen). Padanan lemah (hanya sama kegiatan, kandidat >1) // selalu jadi kartu keputusan, sekecil apa pun nilainya. const kodeSub = mak => String(mak.split('.')[5] || '').toUpperCase().replace(/O/g, '0'); const skorTujuan = (lama, a) => (seg(lama, 5) === seg(a.key, 5) ? 8 : 0) + (seg(lama, 4) === seg(a.key, 4) ? 4 : 0) + (seg(lama, 3) === seg(a.key, 3) ? 2 : 0) + (kodeSub(lama) === kodeSub(a.key) ? 3 : 0); const kandidatTujuan = mak => [...akunMap.values()].filter(a => a.akun === akunOf(mak) && a.key.startsWith(seg(mak, 2) + '.') && a.P > 0) .map(a => ({ a, skor: skorTujuan(mak, a), sisa: Math.max(0, a.P - a.rupU) })).sort((x, y) => y.skor - x.skor || y.sisa - x.sisa); const salahKetikO = (lama, tujuan) => /O/i.test(lama.split('.')[5] || '') && lama.split('.')[5] !== tujuan.split('.')[5] && kodeSub(lama) === kodeSub(tujuan); const grup = new Map(); for (const p of [...U, ...FD]) for (const r of rowsOf(p)) { if (kelasRow(r) !== 'HILANG') continue; const kand = kandidatTujuan(r.mak); const key = `${seg(r.mak, 2)}|${akunOf(r.mak)}|${seg(r.mak, 6)}`; const pilihT = (kep['pindah:' + key] || {}).target; const best = (pilihT && kand.find(k => k.a.key === pilihT)) || kand[0]; if (!grup.has(key)) grup.set(key, { key, rows: [], kand, best, lemah: !!best && best.skor < 4 && kand.length > 1 && !pilihT }); grup.get(key).rows.push({ p, r, fd: p.status === '2' }); } const itemTanpaPaket = new Map(); // akun → item yang belum punya paket (untuk nama/uraian paket baru) for (const g of [...grup.values()].sort((a, b) => a.key.localeCompare(b.key))) { const [keg, code] = g.key.split('|'); const targets = g.best ? [g.best.a] : []; const rowsU = g.rows.filter(x => !x.fd), rowsFD = g.rows.filter(x => x.fd); const gU = sum(rowsU, x => +x.r.pagu), gFD = sum(rowsFD, x => +x.r.pagu); const makLama = [...new Set(g.rows.map(x => x.r.mak))]; const namaAkun = (targets[0] && targets[0].nama) || code; const id = 'pindah:' + g.key; if (!targets.length) { const k = kartuBaru({ id, jenis: 'TANPA_PADANAN', judul: `MAK lama tanpa padanan · ${code}`, akun: code, makLama, ringkas: `${new Set(g.rows.map(x => x.p.id)).size} paket (Rp${fmt(gU)} terumumkan${gFD ? `, Rp${fmt(gFD)} final draft` : ''}) memakai ${makLama.join(', ')} yang tidak ada di DIPA, dan kegiatan ${keg} tidak punya akun ${code}`, paket: paketGrup(g.rows), opsi: [ { id: 'keluarkan', label: 'Keluarkan dari RUP', ket: 'Baris MAK ini dihapus; paket yang tidak punya baris lain dibatalkan', dampak: -gU }, { id: 'biarkan', label: 'Biarkan', ket: 'Tidak diubah', dampak: 0 }] }, gU + gFD < BESAR ? 'keluarkan' : null); if (k.pilihan === 'keluarkan') { for (const x of rowsU) { const u = getUbah(x.p); const row = rowOf(u, x.r); if (row) row.pagu = 0; u.sumber.add('pindah'); u.catatan.push(`Baris ${x.r.mak} (Rp${fmt(x.r.pagu)}) dikeluarkan: MAK tidak ada di DIPA`); } for (const p of new Set(rowsFD.map(x => x.p))) if (!batalFD.some(b => b.paketId === p.id)) batalFD.push({ paketId: p.id, nama: p.nama, pagu: +p.pagu, alasan: `MAK ${makLama.join(', ')} tidak ada di DIPA revisi terakhir`, sumber: id }); } if (k.pilihan == null) for (const x of g.rows) paketTertahan.add(x.p.id); continue; } const buckets = bucketize(g.rows, targets, key => rup.get(key)); const sisaTarget = sum(targets, a => Math.max(0, a.P - rup.get(a.key))); const lebih = gU + gFD - sisaTarget; const fdOpt = (kep[id] || {}).fd || 'biarkan'; const saran = lebih <= TOL ? 'tetap' : 'sesuaikan'; const def = g.lemah || lebih >= BESAR ? null : saran; const bucketInfo = buckets.map(b => ({ id: b.id, label: b.label, jenis: b.jenis, target: b.target.key, dipa: b.dipa, sisa: b.sisa, rup: sum(b.rows.filter(x => !x.fd), x => +x.r.pagu), fd: sum(b.rows.filter(x => x.fd), x => +x.r.pagu), paket: paketGrup(b.rows), items: b.items.map(i => i.uraian) })); const k = kartuBaru({ id, jenis: 'PINDAH', judul: `${bersih(namaAkun)} · ${code}`, akun: code, makLama, target: targets.map(t => t.key), saran, lemah: g.lemah, kandidatTujuan: g.kand.slice(0, 8).map(x => ({ mak: x.a.key, nama: x.a.nama, sisa: x.sisa, skor: x.skor })), ringkas: `${new Set(rowsU.map(x => x.p.id)).size} paket terumumkan Rp${fmt(gU)}${gFD ? ` + ${new Set(rowsFD.map(x => x.p.id)).size} final draft Rp${fmt(gFD)}` : ''} masih di MAK lama ${makLama.join(', ')}; usulan tujuan ${targets.map(t => t.key).join(', ')} (sisa pagu pengadaan Rp${fmt(sisaTarget)})${g.lemah ? '. Tujuan hanya sama kegiatan — pilih tujuan yang benar' : ''}${targets[0] && makLama.some(m => salahKetikO(m, targets[0].key)) ? '. Kode sub-komponen di RUP memakai huruf O, di DIPA angka 0' : ''}`, lebih, bucket: bucketInfo, adaFD: rowsFD.length > 0, opsi: [ { id: 'sesuaikan', label: 'Pindah ke MAK baru, pagu disesuaikan ke DIPA', ket: 'Pagu tiap paket dikali faktor per bagian (bisa diubah per paket)', dampak: -Math.max(0, gU - Math.min(gU, sisaTarget)) }, { id: 'tetap', label: 'Pindah ke MAK baru, pagu tetap', ket: gU - sisaTarget > AMBANG ? `RUP tetap lebih Rp${fmt(gU - sisaTarget)} dari DIPA (mis. tahun jamak)` : 'Nilai paket terumumkan muat di pagu DIPA', dampak: 0 }, { id: 'susun', label: 'Susun ulang', ket: 'Pilih paket yang dipertahankan per bagian (pagu dan metode diubah), sisanya dibatalkan', dampak: null }, { id: 'tunda', label: 'Tunda', ket: 'Paket tidak disentuh; MAK tujuan tidak dibuatkan paket baru agar tidak dobel', dampak: 0 }], opsiFD: rowsFD.length ? [ { id: 'biarkan', label: 'Biarkan final draft', ket: 'Tidak diumumkan, tidak dikembalikan' }, { id: 'kembalikan', label: 'Kembalikan ke PPK', ket: 'Batal final draft; PPK memperbaiki atau menghapus' }, { id: 'ikut', label: 'Umumkan lalu ikut disesuaikan', ket: 'Final draft diumumkan, lalu direvisi bersama paket lain di kelompok ini' }] : null, pilihanFD: fdOpt }, def); const pil = k.pilihan; if (pil == null || pil === 'tunda') { for (const t of targets) blok.add(t.key); for (const x of g.rows) paketTertahan.add(x.p.id); for (const b of buckets) if (!b.rows.length) continue; continue; } if (rowsFD.length && fdOpt === 'kembalikan' && pil !== 'susun') for (const p of new Set(rowsFD.map(x => x.p))) batalFD.push({ paketId: p.id, nama: p.nama, pagu: +p.pagu, alasan: 'MAK paket tidak ada di DIPA revisi terakhir', sumber: id }); const ikutFD = x => !x.fd || fdOpt === 'ikut'; const pindah = (x, mak, pagu, ket) => { const u = getUbah(x.p); const row = rowOf(u, x.r); if (!row) return; row.dari = row.dari || row.mak; row.mak = mak; row.pagu = pagu; row.idKomponen = null; u.sumber.add('pindah'); if (ket) u.catatan.push(ket); addRup(mak, pagu); }; if (pil === 'tetap') { for (const b of buckets) for (const x of b.rows.filter(ikutFD)) pindah(x, b.target.key, +x.r.pagu, `MAK ${x.r.mak} → ${b.target.key}`); } else if (pil === 'sesuaikan') { for (const b of buckets) { const rows = b.rows.filter(ikutFD); const tot = sum(rows, x => +x.r.pagu); const baru = Math.min(tot, Math.max(0, b.sisa)); const alok = baru >= tot - TOL ? rows.map(x => +x.r.pagu) : bagi(baru, rows.map(x => +x.r.pagu)); rows.forEach((x, i) => pindah(x, b.target.key, alok[i], `MAK ${x.r.mak} → ${b.target.key}${alok[i] !== +x.r.pagu ? `; pagu Rp${fmt(x.r.pagu)} → Rp${fmt(alok[i])} (${b.label}: DIPA Rp${fmt(b.dipa)})` : ''}`)); } } else if (pil === 'susun') { const prm = (k.param && k.param.susun) || {}; k.susun = {}; for (const b of buckets) { const cfgB = prm[b.id] || {}; const ids = [...new Set(b.rows.map(x => x.p.id))]; const besar = ids.slice().sort((a, c) => sum(b.rows.filter(x => x.p.id === c), x => +x.r.pagu) - sum(b.rows.filter(x => x.p.id === a), x => +x.r.pagu)); const simpan = (cfgB.simpan || (b.rows.length ? [besar.find(i => !byId.get(i) || byId.get(i).status === '3') || besar[0]] : [])).filter(i => ids.includes(i)); const target = Math.max(0, b.sisa); const paguSimpan = simpan.length ? (cfgB.pagu && simpan.every(i => cfgB.pagu[i] != null) ? simpan.map(i => +cfgB.pagu[i]) : bagi(target, simpan.map(i => sum(b.rows.filter(x => x.p.id === i), x => +x.r.pagu)))) : []; k.susun[b.id] = { simpan, pagu: Object.fromEntries(simpan.map((i, j) => [i, paguSimpan[j]])), metode: cfgB.metode || '', nama: cfgB.nama || {} }; for (const pid of ids) { const rows = b.rows.filter(x => x.p.id === pid); const j = simpan.indexOf(pid); if (j >= 0) { const alok = bagi(paguSimpan[j], rows.map(x => +x.r.pagu)); rows.forEach((x, i) => pindah(x, b.target.key, alok[i], `MAK ${x.r.mak} → ${b.target.key}; pagu disusun ulang Rp${fmt(alok[i])}`)); const u = getUbah(byId.get(pid)); u.sumber.add('susun'); if (cfgB.metode) u.override.metode = cfgB.metode; if (cfgB.nama && cfgB.nama[pid]) u.override.nama = cfgB.nama[pid]; // lokasi paket yang digabung ikut ke paket yang dipertahankan const lain = ids.filter(i => !simpan.includes(i)).map(i => byId.get(i)); const lok = [...(byId.get(pid).lokasiRaw || []), ...lain.flatMap(q => q.lokasiRaw || [])]; const uniq = []; const seen = new Set(); for (const l of lok) { const kk = `${l.id_provinsi}|${l.id_kabupaten}`; if (!seen.has(kk)) { seen.add(kk); uniq.push({ ...l, id: '' }); } } if (uniq.length > (byId.get(pid).lokasiRaw || []).length) u.override.lokasiRaw = uniq; } else { const p = byId.get(pid); if (p.status === '2') { if (fdOpt !== 'biarkan') batalFD.push({ paketId: p.id, nama: p.nama, pagu: +p.pagu, alasan: 'Disusun ulang sesuai DIPA revisi terakhir', sumber: id }); continue; } const u = getUbah(p); u.sumber.add('susun'); for (const x of rows) { const row = rowOf(u, x.r); if (row) row.pagu = 0; } u.catatan.push(`Digabung ke paket yang dipertahankan (${simpan.join(', ')})`); } } } } // item target yang belum punya paket (mis. "Perencanaan") → dipakai menamai paket baru for (const b of buckets) if (!b.rows.length) for (const i of b.items) { if (!itemTanpaPaket.has(b.target.key)) itemTanpaPaket.set(b.target.key, new Set()); itemTanpaPaket.get(b.target.key).add(i.iid); } } // ── 3. Kelebihan pada MAK yang sama ────────────────────────────── const targetPindah = new Set(kartu.filter(k => k.jenis === 'PINDAH').flatMap(k => k.target)); const dipotong = new Set(); // paket yang barisnya dipotong di sini: calon utama penambahan akun lain di paket yang sama for (const a of [...akunMap.values()].sort((x, y) => x.key.localeCompare(y.key))) { if (a.P <= 0) continue; // tujuan pindah MAK tetap dicek: kelebihan yang sudah ada sebelum pemindahan const lebih = a.rupU - a.P; // kelebihan sekecil apa pun dipotong: RUP di atas pagu DIPA membuat IKU > 100% (dikurangkan dari capaian) if (lebih <= TOL) continue; const refs = a.rup.filter(r => r.status === '3'); const ids = [...new Set(refs.map(r => r.paketId))].filter(i => byId.has(i) && isU(byId.get(i))); if (!ids.length) continue; const paket = ids.map(i => { const p = byId.get(i); return { paketId: i, nama: p.nama, pagu: +p.pagu, diMak: sum(rowsOf(p).filter(r => r.mak === a.key), r => +r.pagu), umum: isUmum(p, a.key, akunMap, nodes) }; }); const ganda = []; for (let i = 0; i < paket.length; i++) for (let j = i + 1; j < paket.length; j++) if (jaccard(tokens(paket[i].nama), tokens(paket[j].nama)) >= 0.6 || Math.abs(paket[i].diMak - paket[j].diMak) <= TOL) ganda.push([paket[i].paketId, paket[j].paketId]); const id = 'lebih:' + a.key; // Nilai yang tertukar antar-baris satu paket (352595: paket "Langganan Daya dan Jasa" mencatat listrik Rp136 jt di // akun pos 521114 dan pos Rp7 jt di akun listrik 522111): kelebihan di sini habis terserap kekurangan akun lain // di paket yang sama, jadi memotongnya lalu menambah akun itu tidak mengubah total paket → aman diputuskan otomatis. const akunLain = new Set(ids.flatMap(i => rowsOf(byId.get(i)).map(r => r.mak)).filter(m => m !== a.key)); const serap = sum([...akunLain], m => { const b = akunMap.get(m); return b && b.P > 0 ? Math.max(0, b.P - rup.get(m)) : 0; }); const geser = serap > 0 && lebih <= serap + TOL; const k = kartuBaru({ id, jenis: 'LEBIH', judul: `${bersih(a.nama)} · ${a.key}`, akun: a.akun, mak: a.key, ringkas: `RUP terumumkan Rp${fmt(a.rupU)} di ${ids.length} paket, pagu pengadaan DIPA Rp${fmt(a.P)} (lebih Rp${fmt(lebih)})${ganda.length ? ' · kemungkinan paket ganda' : ''}` + (geser ? ` · kelebihan ini tertutup kekurangan akun lain di paket yang sama (Rp${fmt(serap)}), jadi dipindahkan ke akun itu dan total paket tetap` : ''), lebih, paket, ganda, geser, opsi: [ { id: 'potong', label: 'Potong kelebihan', ket: 'Dikurangi dari baris MAK terbesar lebih dulu', dampak: -lebih }, ...paket.map(x => ({ id: 'batal:' + x.paketId, label: `Batalkan ${x.paketId}`, ket: `${x.nama} (Rp${fmt(x.diMak)} di MAK ini)`, dampak: -x.diMak })), { id: 'biarkan', label: 'Biarkan', ket: 'Tidak diubah', dampak: 0 }] }, lebih < BESAR || geser ? 'potong' : null); if (k.pilihan === 'potong') { let sisa = lebih; const rows = ids.flatMap(i => { const u = getUbah(byId.get(i)); return u.rows.filter(r => r.mak === a.key && r.pagu > 0).map(r => ({ u, r })); }).sort((x, y) => y.r.pagu - x.r.pagu); for (const { u, r } of rows) { if (sisa <= TOL) break; const cut = Math.min(r.pagu, sisa); r.pagu -= cut; sisa -= cut; addRup(a.key, -cut); dipotong.add(u.paket.id); u.sumber.add('lebih'); u.catatan.push(`${a.key}: pagu dikurangi Rp${fmt(cut)} (RUP melebihi pagu pengadaan DIPA)`); } } else if (k.pilihan && k.pilihan.startsWith('batal:')) { const p = byId.get(k.pilihan.slice(6)); if (p) { const u = getUbah(p); u.sumber.add('lebih'); for (const r of u.rows) if (r.mak === a.key) { addRup(a.key, -r.pagu); r.pagu = 0; } u.catatan.push(`Dikeluarkan dari ${a.key}: RUP melebihi pagu pengadaan DIPA (kemungkinan paket ganda)`); } } else if (k.pilihan == null) for (const i of ids) paketTertahan.add(i); } // ── 4. Item "perlu dicek" (keputusan diterapkan saat buildDipa) ── const cek = new Map(); for (const a of akunMap.values()) for (const i of a.items) { const al = i.cekAsal || (i.kelas === 'CEK' ? i.alasan : null); if (!al) continue; if (!cek.has(al)) cek.set(al, { alasan: al, total: 0, n: 0, akun: new Set(), kelas: i.kelas }); const c = cek.get(al); c.total += i.pagu; c.n++; c.akun.add(a.key); } for (const c of cek.values()) { const id = 'cek:' + c.alasan; const d = kep[id] || {}; kartu.push({ id, jenis: 'CEK', judul: `Perlu dicek · ${c.alasan.split(' — ')[0].split(' (')[0]}`, ringkas: `${c.n} item di ${c.akun.size} akun, Rp${fmt(c.total)}. ${c.alasan}`, total: c.total, akun: [...c.akun], pilihan: d.opsi != null ? d.opsi : null, diputuskan: d.opsi != null, otomatis: false, param: d, opsi: [ { id: 'P', label: 'Hitung sebagai pengadaan', ket: 'Masuk pagu pengadaan; kekurangan RUP ikut diusulkan', dampak: null }, { id: 'NP', label: 'Non-pengadaan', ket: 'Tidak perlu RUP; paket yang memuatnya ikut dikeluarkan', dampak: null }] }); } // ── 5. Final draft ─────────────────────────────────────────────── const umumkan = [], fdMasalah = []; const dalamGrup = new Set([...grup.values()].flatMap(g => g.rows.map(x => x.p.id))); const kembar = new Map(); // MAK|pagu (dibulatkan Rp100 rb) → paket terumumkan for (const q of U) for (const r of rowsOf(q)) kembar.set(`${r.mak}|${Math.round(+r.pagu / 1e5)}`, q); for (const p of FD.slice().sort((a, b) => b.pagu - a.pagu)) { if (dalamGrup.has(p.id)) continue; const rows = rowsOf(p); const masalah = []; let ganda = null; // hanya dianggap ganda bila MAK-nya juga sudah penuh (dua paket sah boleh bernilai sama) for (const r of rows) { const kls = kelasRow(r); if (kls === 'NP') masalah.push(`${r.mak} non-pengadaan`); else if (kls === 'CEK') masalah.push(`${r.mak} masih "perlu dicek"`); else if (kls === 'P') { const a = akunMap.get(r.mak); if (rup.get(r.mak) + +r.pagu > a.P + TOL) { const q = kembar.get(`${r.mak}|${Math.round(+r.pagu / 1e5)}`); if (q) { ganda = ganda || q; masalah.push(`${r.mak} sudah penuh dan kemungkinan ganda dengan paket terumumkan ${q.id} (${potong(q.nama, 40)})`); } else masalah.push(`${r.mak} sudah penuh (RUP Rp${fmt(rup.get(r.mak))} dari pagu Rp${fmt(a.P)})`); } } } if (!rows.length) masalah.push('tanpa baris anggaran'); const tidakRelevan = !rows.length || !!ganda || rows.every(r => kelasRow(r) === 'NP'); if (masalah.length) fdMasalah.push({ paketId: p.id, nama: p.nama, pagu: +p.pagu, masalah, saran: tidakRelevan ? 'kembalikan' : 'biarkan' }); else { umumkan.push({ id: 'u:' + p.id, paketId: p.id, nama: p.nama, pagu: +p.pagu, pilih: true }); for (const r of rows) addRup(r.mak, +r.pagu); } } if (fdMasalah.length) { const d = kep.fd || {}; const pil = d.pilih || {}; kartu.push({ id: 'fd', jenis: 'FD', judul: 'Final draft bermasalah', ringkas: `${fdMasalah.length} final draft Rp${fmt(sum(fdMasalah, x => x.pagu))} tidak bisa diumumkan apa adanya`, daftar: fdMasalah.map(x => ({ ...x, pilihan: pil[x.paketId] || x.saran })), pilihan: 'per-paket', diputuskan: true, otomatis: d.pilih == null, param: d, opsi: [{ id: 'biarkan', label: 'Biarkan' }, { id: 'kembalikan', label: 'Kembalikan ke PPK' }] }); for (const x of fdMasalah) if ((pil[x.paketId] || x.saran) === 'kembalikan') batalFD.push({ paketId: x.paketId, nama: x.nama, pagu: x.pagu, alasan: x.masalah.join('; '), sumber: 'fd' }); } // ── 6. Kekurangan: tambah pagu paket umum, paket baru, atau abaikan ── const kekurangan = [], sisaKecil = [], tertahan = [], butuhBaru = []; const dibatalkan = id => { const u = ubah.get(id); return u && u.rows.every(r => r.pagu <= 0); }; for (const a of [...akunMap.values()].sort((x, y) => x.key.localeCompare(y.key))) { if (a.P <= 0) continue; const sisa = a.P - rup.get(a.key); if (sisa <= TOL) continue; if (blok.has(a.key)) { tertahan.push({ mak: a.key, nama: a.nama, sisa, alasan: 'menunggu keputusan kartu MAK lama' }); continue; } // akun ini juga ada di paket yang kartunya belum diputuskan (mis. kelebihan di akun lain paket yang sama): // paket baru sekarang bisa dobel dengan pemindahan nilai di paket itu nanti → tunggu keputusan const ditunda = U.filter(p => paketTertahan.has(p.id) && rowsOf(p).some(r => r.mak === a.key)).map(p => p.id); if (ditunda.length) { tertahan.push({ mak: a.key, nama: a.nama, sisa, alasan: `menunggu keputusan kartu untuk paket ${ditunda.join(', ')}` }); continue; } const o = atur[a.key] || {}; // paket terumumkan yang punya baris MAK ini (setelah rencana) const kand = U.filter(p => !dibatalkan(p.id) && !paketTertahan.has(p.id) && (ubah.has(p.id) ? ubah.get(p.id).rows : rowsOf(p)).some(r => r.mak === a.key && r.pagu > 0)) .map(p => ({ paketId: p.id, nama: p.nama, pagu: +p.pagu, umum: isUmum(p, a.key, akunMap, nodes), diMak: sum((ubah.has(p.id) ? ubah.get(p.id).rows : rowsOf(p)).filter(r => r.mak === a.key), r => +r.pagu) })) .map(x => ({ ...x, dipotong: dipotong.has(x.paketId) })) .sort((x, y) => (y.dipotong - x.dipotong) || (y.umum - x.umum) || (y.diMak - x.diMak)); // paket yang baru dipotong di akun lain → nilai digeser di paket itu juga (satu revisi, tanpa paket baru) let cara = o.cara || (sisa <= AMBANG ? 'abaikan' : (kand.length && (kand[0].umum || kand[0].dipotong) ? 'tambah' : 'baru')); let paketId = o.paketId && kand.some(x => x.paketId === o.paketId) ? o.paketId : (kand[0] || {}).paketId; if (cara === 'tambah' && !paketId) cara = 'baru'; const real = sum(a.items.filter(i => i.kelas === 'P'), i => i.realisasi || 0); const x = { id: 'k:' + a.key, mak: a.key, nama: a.nama, P: a.P, rup: rup.get(a.key), sisa, cara, paketId, kandidat: kand, realisasi: a.P ? real / a.P : 0 }; kekurangan.push(x); if (cara === 'abaikan') { sisaKecil.push({ mak: a.key, nama: a.nama, sisa, alasan: sisa <= AMBANG ? `di bawah ambang Rp${fmt(AMBANG)}` : 'diabaikan' }); continue; } if (cara === 'tambah') { const u = getUbah(byId.get(paketId)); const row = u.rows.filter(r => r.mak === a.key).sort((p, q) => q.pagu - p.pagu)[0]; row.pagu += sisa; addRup(a.key, sisa); u.sumber.add('tambah'); u.catatan.push(`${a.key}: pagu ditambah Rp${fmt(sisa)} (DIPA pengadaan Rp${fmt(a.P)})`); continue; } butuhBaru.push({ a, sisa }); } // ── 7. Paket baru (dikelompokkan) ──────────────────────────────── const grupBaru = new Map(); const modeGrup = cfg.grupPaketBaru || 'sub'; for (const { a, sisa } of butuhBaru) { const teks = a.items.filter(i => i.kelas === 'P').map(i => `${i.grup} ${i.uraian}`).join(' '); const jenis = Classify.saranJenis(a.akun, teks); const khusus = MEETING.test(a.akun) ? '|meeting' : ''; const key = modeGrup === 'akun' ? a.key : `${seg(a.key, modeGrup === 'komp' ? 5 : 6)}|${jenis}${khusus}`; if (!grupBaru.has(key)) grupBaru.set(key, { key, jenis, akun: [] }); grupBaru.get(key).akun.push({ a, sisa }); } const paketBaru = []; for (const g of [...grupBaru.values()].sort((x, y) => x.key.localeCompare(y.key))) paketBaru.push(buatPaketBaru(g)); function buatPaketBaru(g) { const first = g.akun[0].a; const total = sum(g.akun, x => x.sisa); const anggaran = g.akun.flatMap(x => anggaranPerDana(x.a, x.sisa)); // item yang diwakili: item pengadaan yang belum tercakup nama paket existing di MAK yang sama const items = []; for (const { a } of g.akun) { const P = a.items.filter(i => i.kelas === 'P'); const khusus = itemTanpaPaket.get(a.key); let pilih = khusus ? P.filter(i => khusus.has(i.iid)) : P; if (!khusus) { const nm = a.rup.filter(r => r.status === '3').map(r => tokens(r.nama)); const bebas = P.filter(i => { const ti = tokens(`${i.grup} ${i.uraian}`); return !nm.some(t => jaccard(t, ti) >= 0.5); }); if (bebas.length) pilih = bebas; } items.push(...pilih.map(i => ({ ...i, akun: a.key }))); } items.sort((x, y) => y.pagu - x.pagu); const subK = seg(first.key, 6), kompK = seg(first.key, 5); const sub = nodes.get(subK) || {}, komp = nodes.get(kompK) || {}; const tempat = modeGrup === 'komp' ? (komp.uraian || sub.uraian || '') : (sub.uraian || komp.uraian || ''); const dom = items[0] ? bersihNama(items[0].uraian) : bersihNama(first.nama); const tempat2 = tempat || bersih(first.nama) || seg(first.key, 6); // tanda baca ASCII saja: teks RUP ikut diekspor ke sistem lain (SPSE, unduhan Excel) const nama = potong(`${dom}${tempat2 && tempat2 !== dom ? ' - ' + tempat2 : ''}`.trim(), 250); const daftar = items.map(i => `- ${i.grup ? bersih(i.grup) + ' > ' : ''}${bersih(i.uraian)}`); const uraian = potong(`${LABEL_JENIS[g.jenis] || 'Pengadaan'} untuk ${tempat || first.nama}${komp.uraian && komp.uraian !== tempat ? ' pada ' + komp.uraian : ''}.\n${daftar.join('\n')}`, 1000); const spekBaris = items.map(i => `- ${bersih(i.uraian)}${i.volume ? ': ' + i.volume.replace(/\s+/g, ' ') : ''}${i.harga ? ' x Rp' + fmt(i.harga) : ''}`); let spesifikasi = ''; for (let n = 0; n < spekBaris.length; n++) { const next = spesifikasi + (spesifikasi ? '\n' : '') + spekBaris[n]; const tail = n + 1 < spekBaris.length ? `\n(+${spekBaris.length - n - 1} item lain)` : ''; if ((next + tail).length > 1000) { spesifikasi += `\n(+${spekBaris.length - n} item lain)`; break; } spesifikasi = next; } const akunUtama = g.akun.slice().sort((x, y) => y.sisa - x.sisa)[0].a.akun; const metode = Classify.saranMetode(g.jenis, total, akunUtama, items.map(i => `${i.grup} ${i.uraian}`).join(' '), cfg); const realP = sum(g.akun, x => sum(x.a.items.filter(i => i.kelas === 'P'), i => i.realisasi || 0)); const paguP = sum(g.akun, x => x.a.P); const realisasi = paguP ? realP / paguP : 0; // paket saudara (terumumkan, sub-komponen lalu komponen yang sama) → lokasi & jadwal const saudara = cariSaudara(first.key, g.jenis); const jd = saudara.find(s => jadwalPaket(s)); let jadwal, jadwalSumber; if (jd) { jadwal = jadwalPaket(jd); jadwalSumber = `ikut paket ${jd.id} (${potong(jd.nama, 50)})`; } else { const mulai = realisasi > 0 ? `${TA}-01` : clampYm(ymAdd(ymNow(), 1), TA); jadwal = rapikanJadwal({ awalPengadaan: mulai, akhirPengadaan: mulai, awalPekerjaan: mulai, akhirPekerjaan: `${TA}-12`, awalKebutuhan: mulai, kebutuhan: `${TA}-12` }); jadwalSumber = realisasi > 0 ? 'mulai Januari karena sudah ada realisasi' : 'mulai bulan depan'; } const lok = saranLokasi(first.key, saudara); const peringatanP = []; if (realisasi >= 0.5 && /Tender|Seleksi/.test(metode)) peringatanP.push(`Sudah terealisasi ${Math.round(realisasi * 100)}% tetapi metode ${metode} — cek cara pengadaan yang sudah berjalan`); if (anggaran.some(r => SD_BELUM_DIUJI.has(r.danaApbn))) peringatanP.push('Sumber dana PLN/SBSN belum pernah diuji lewat tool ini — isi manual di SiRUP'); if (!lok.lokasiRaw.length) peringatanP.push('Lokasi belum bisa ditentukan — atur lokasi satker di Pengaturan'); return { id: 'b:' + g.key, baru: true, grupKey: g.key, akun: g.akun.map(x => x.a.key), nama, anggaran, jenis: g.jenis, metode, uraian, spesifikasi, volume: '1 Paket', praDipa: false, pdn: true, umkm: total <= 15e9, spp: { ekonomi: true, sosial: true, lingkungan: false }, lokasiRaw: lok.lokasiRaw, lokasiSumber: lok.sumber, lokasiTeks: lok.teks, jadwal, jadwalSumber, realisasi, items: items.length, total, pilih: !anggaran.some(r => SD_BELUM_DIUJI.has(r.danaApbn)), peringatan: peringatanP, }; } function anggaranPerDana(a, pagu) { const parts = Object.entries(a.PbySd || { '': pagu }).filter(([, v]) => v > 0); const bobot = parts.map(([, v]) => v); const alok = parts.length > 1 ? bagi(pagu, bobot) : [pagu]; return parts.map(([sd], i) => ({ mak: a.key, pagu: alok[i], danaApbn: SD_CODE[sd] || 'A', sd: sd || '', idLama: '' })).filter(r => r.pagu > 0); } function cariSaudara(mak, jenis) { const sub = seg(mak, 6) + '.', komp = seg(mak, 5) + '.', keg = seg(mak, 2) + '.'; const skor = p => { const rs = rowsOf(p); const s = rs.some(r => r.mak.startsWith(sub)) ? 3 : rs.some(r => r.mak.startsWith(komp)) ? 2 : rs.some(r => r.mak.startsWith(keg)) ? 1 : 0; return s * 2 + (jenisPaket(p) === jenis ? 1 : 0); }; return U.filter(p => skor(p) >= 4).sort((x, y) => skor(y) - skor(x) || (+y.pagu) - (+x.pagu)); } function saranLokasi(mak, saudara) { const kro = nodes.get(seg(mak, 3)) || {}; const teks = kro.lokasi || ''; const satker = ctx.lokasiSatker && ctx.lokasiSatker.id_kabupaten ? ctx.lokasiSatker : null; if (teks && ctx.lokasiRkk && ctx.lokasiRkk[teks]) { const l = ctx.lokasiRkk[teks]; const sdr = saudara.flatMap(s => s.lokasiRaw || []).find(x => +x.id_kabupaten === +l.id_kabupaten && x.detil); const detil = (satker && +satker.id_kabupaten === +l.id_kabupaten && satker.detil) || (sdr && sdr.detil) || (satker && satker.detil) || ctx.namaSatker || ''; return { lokasiRaw: [{ id: '', id_provinsi: l.id_provinsi, id_kabupaten: l.id_kabupaten, detil, prov: l.prov, kab: l.kab }], sumber: `lokasi KRO di RKK (${teks})`, teks }; } // lokasi paket saudara yang sama provinsi dengan satker (lokasi janggal tidak ditiru) for (const s of saudara) { const ls = (s.lokasiRaw || []).filter(l => l.id_kabupaten && l.detil); if (!ls.length) continue; if (satker && ls.some(l => +l.id_provinsi !== +satker.id_provinsi)) continue; return { lokasiRaw: ls.map(l => ({ ...l, id: '' })), sumber: `ikut paket ${s.id}`, teks }; } if (satker) return { lokasiRaw: [{ id: '', id_provinsi: satker.id_provinsi, id_kabupaten: satker.id_kabupaten, detil: satker.detil, prov: satker.prov, kab: satker.kab }], sumber: 'lokasi satker (Pengaturan)', teks }; return { lokasiRaw: [], sumber: '', teks }; } function ymNow() { const d = ctx.hariIni || new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`; } function clampYm(y, ta) { return y < `${ta}-01` ? `${ta}-01` : y > `${ta}-12` ? `${ta}-12` : y; } // peringatan pemecahan paket: ≥2 paket PL sejenis di satu sub-komponen, total > batas PL const batasPL = j => j === 'Pekerjaan Konstruksi' ? cfg.plKonstruksi : j === 'Jasa Konsultansi' ? cfg.plKonsultansi : cfg.plBarjas; for (const pb of paketBaru) { if (pb.metode !== 'Pengadaan Langsung') continue; const subs = new Set(pb.anggaran.map(r => seg(r.mak, 6))); const sejenis = [...paketBaru.filter(x => x !== pb && x.metode === 'Pengadaan Langsung' && x.jenis === pb.jenis && x.anggaran.some(r => subs.has(seg(r.mak, 6)))).map(x => x.total), ...U.filter(p => /Pengadaan Langsung/.test(p.metode || '') && jenisPaket(p) === pb.jenis && rowsOf(p).some(r => subs.has(seg(r.mak, 6)))).map(p => +p.pagu)]; const tot = pb.total + sum(sejenis); if (sejenis.length && tot > batasPL(pb.jenis)) pb.peringatan.push(`Ada ${sejenis.length} paket Pengadaan Langsung ${pb.jenis.toLowerCase()} lain di sub-komponen ini (total Rp${fmt(tot)} > batas PL) — pastikan bukan pemecahan paket`); } // ── 8. Titipan: paket existing yang direvisi satu ke banyak ───────── const maxPer = cfg.maxPaketPerRevisi || 15; const eligible = p => isU(p) && !dibatalkan(p.id) && !paketTertahan.has(p.id) && !rowsOf(p).some(r => SD_BELUM_DIUJI.has(r.danaApbn)) && !(ubah.get(p.id) && ubah.get(p.id).umumkanDulu && p.status !== '3'); const muat = new Map(); // hostId → jumlah paket baru const titip = new Map(); // hostId → [paketBaru] const tier = (p, mak) => { const rs = (ubah.has(p.id) ? ubah.get(p.id).rows.filter(r => r.pagu > 0) : rowsOf(p)); const lvl = rs.some(r => r.mak.startsWith(seg(mak, 5) + '.')) ? 0 : rs.some(r => r.mak.startsWith(seg(mak, 2) + '.')) ? 2 : rs.some(r => r.mak.startsWith(seg(mak, 1) + '.')) ? 4 : 6; return lvl + (ubah.has(p.id) ? 0 : 1) + (/Dikecualikan/.test(p.metode || '') ? 0.5 : 0); }; for (const pb of paketBaru) { const mak = pb.anggaran[0] ? pb.anggaran[0].mak : pb.akun[0]; const pilihan = U.filter(eligible).filter(p => (muat.get(p.id) || 0) < maxPer) .map(p => ({ p, t: tier(p, mak) })).sort((x, y) => x.t - y.t || (+x.p.pagu) - (+y.p.pagu)); const o = (atur[pb.id] || {}).host; const host = (o && pilihan.find(x => x.p.id === o)) || pilihan[0]; pb.kandidatHost = pilihan.slice(0, 8).map(x => ({ paketId: x.p.id, nama: x.p.nama, tier: x.t })); if (!host) { pb.hostId = null; pb.pilih = false; pb.peringatan.push('Tidak ada paket terumumkan yang bisa dititipi — minta PPK membuat satu paket dulu'); continue; } pb.hostId = host.p.id; pb.hostKet = host.t < 2 ? 'komponen yang sama' : host.t < 4 ? 'kegiatan yang sama' : host.t < 6 ? 'program yang sama' : 'komponen lain'; muat.set(host.p.id, (muat.get(host.p.id) || 0) + 1); if (!titip.has(host.p.id)) titip.set(host.p.id, []); titip.get(host.p.id).push(pb); } // ── 9. Susun daftar perubahan ──────────────────────────────────── const ALASAN = { np: 'mengeluarkan belanja non-pengadaan', pindah: 'penyesuaian MAK dengan DIPA revisi terakhir', lebih: 'penyesuaian pagu dengan pagu pengadaan DIPA', tambah: 'penambahan pagu sesuai DIPA revisi terakhir', susun: 'penyusunan ulang paket sesuai DIPA revisi terakhir', dana: 'penyesuaian sumber dana dengan DIPA' }; const perubahan = []; for (const u of ubah.values()) { const p = u.paket; const sesudah = u.rows.filter(r => r.pagu > 0); const asal = rowsOf(p); const sig = rs => rs.map(r => `${r.mak}|${Math.round(r.pagu)}|${r.danaApbn || 'A'}`).sort().join(';'); const tetap = sig(sesudah) === sig(asal.map(r => ({ mak: r.mak, pagu: +r.pagu, danaApbn: r.danaApbn || 'A' }))); if (tetap && !u.sumber.has('susun') && !Object.keys(u.override).length) continue; const batal = !sesudah.length; if (batal && p.status === '2') { if (!batalFD.some(x => x.paketId === p.id)) batalFD.push({ paketId: p.id, nama: p.nama, pagu: +p.pagu, alasan: 'Tidak ada MAK DIPA yang tersisa', sumber: 'susun' }); continue; } const label = [...u.sumber].map(s => ({ np: 'keluarkan non-pengadaan', pindah: 'pindah MAK', lebih: 'potong kelebihan', tambah: 'tambah pagu', susun: 'susun ulang', dana: 'sumber dana' }[s])); const pk = batal ? null : Object.assign(pkDari(p, sesudah, cfg), u.override); const ch = { id: 'p:' + p.id, paketId: p.id, nama: p.nama, status: p.status, jenis: batal ? 'batal' : 'ubah', umumkanDulu: p.status === '2', label: batal ? ['batalkan', ...label] : label, catatan: u.catatan, sebelum: +p.pagu, sesudah: sum(sesudah, r => r.pagu), rowsSebelum: asal.map(r => ({ mak: r.mak, pagu: +r.pagu, danaApbn: r.danaApbn })), rowsSesudah: sesudah.map(r => ({ mak: r.mak, pagu: r.pagu, danaApbn: r.danaApbn, dari: r.dari })), alasan: 'Revisi: ' + [...u.sumber].map(s => ALASAN[s]).filter(Boolean).join('; '), pk, pilih: true, peringatan: [], titipanHost: titip.has(p.id), }; if (asal.some(r => SD_BELUM_DIUJI.has(r.danaApbn))) { ch.pilih = false; ch.peringatan.push('Memuat sumber dana PLN/SBSN — belum pernah diuji lewat tool ini'); } if (pk) { if (pk.umkm && ch.sesudah > 15e9) { pk.umkm = false; ch.peringatan.push('Pagu > Rp15 M: penanda usaha kecil dilepas'); } const bj = batasPL(pk.jenis); if (/Pengadaan Langsung/.test(pk.metode) && ch.sesudah > bj && ch.sebelum <= bj) ch.peringatan.push(`Pagu naik melewati batas Pengadaan Langsung (Rp${fmt(bj)}) — cek metode`); if (!pk.jadwal) ch.peringatan.push('Jadwal paket tidak terbaca — isi manual'); if ((p.jenisRaw || []).length > 1 && !pk.jenisList) ch.peringatan.push('Paket ini punya lebih dari satu jenis pengadaan; setelah pagu berubah dikirim sebagai satu jenis — cek'); if (ctx.lokasiSatker && ctx.lokasiSatker.id_provinsi && (pk.lokasiRaw || []).length && pk.lokasiRaw.every(l => +l.id_provinsi !== +ctx.lokasiSatker.id_provinsi)) ch.lokasiLain = true; } perubahan.push(ch); } perubahan.sort((a, b) => a.paketId.localeCompare(b.paketId)); const titipan = [...titip.entries()].map(([hostId, pbs]) => { const host = byId.get(hostId); const ch = perubahan.find(c => c.paketId === hostId && c.jenis === 'ubah'); return { id: 't:' + hostId, hostId, nama: host.nama, pagu: +host.pagu, draft1: ch ? 'koreksi' : 'tetap', perubahanId: ch ? ch.id : null, paketBaru: pbs.map(x => x.id), pk1: Object.assign(pkDari(host, rowsOf(host).map(r => ({ idLama: r.id || '', mak: r.mak, pagu: +r.pagu, danaApbn: r.danaApbn || 'A', sumber: r.sumber, ta: r.ta, idKomponen: r.idKomponen, asal: r.asal, asalSatker: r.asalSatker, kodeInstansi: r.kodeInstansi, kodeEselon: r.kodeEselon, kodeSatker: r.kodeSatker })), cfg), { pertahankan: true }), pilih: true }; }); if (U.some(p => rowsOf(p).some(r => r.danaApbn && r.danaApbn !== 'A')) && ![...akunMap.values()].some(a => (a.sd || []).length)) peringatan.push('Satker ini memakai sumber dana selain RM, tetapi data DIPA tidak memuat sumber dana per akun (FA Detail). Unggah juga RKK supaya paket baru mendapat sumber dana yang benar; tanpa itu paket baru diisi RM.'); const r = { kartu, perubahan, paketBaru, titipan, umumkan, batalFD, kekurangan, sisaKecil, tertahan, peringatan, target: sum([...akunMap.values()], a => a.P), sekarang: sum(U, p => +p.pagu), rupSetelah: Object.fromEntries(rup) }; r.proyeksi = proyeksi(r); return r; } // Satu kelompok MAK lama → bagian (bucket) pembanding: per akun tujuan bila tujuannya >1; // bila tujuannya 1, per jenis pengadaan item DIPA, lalu dipadankan ke item lewat nama paket. function bucketize(rows, targets, rupNow) { const rupT = t => (rupNow ? rupNow(t.key) : t.rupU); const tokN = s => tokens(s); if (targets.length > 1) { const bs = targets.map((t, i) => ({ id: 'b' + i, label: t.key, jenis: '', target: t, items: t.items.filter(x => x.kelas === 'P'), dipa: t.P, sisa: Math.max(0, t.P - rupT(t)), rows: [] })); const isi = new Map(bs.map(b => [b, 0])); for (const x of rows.slice().sort((a, b) => b.r.pagu - a.r.pagu)) { const b = bs.slice().sort((p, q) => (q.sisa - isi.get(q)) - (p.sisa - isi.get(p)))[0]; b.rows.push(x); isi.set(b, isi.get(b) + +x.r.pagu); } return bs; } const t = targets[0]; const items = t.items.filter(i => i.kelas === 'P'); const jenisItem = i => Classify.saranJenis(t.akun, `${i.grup || ''} ${i.uraian}`); const byJ = new Map(); for (const i of items) { const j = jenisItem(i); if (!byJ.has(j)) byJ.set(j, { items: [], rows: [] }); byJ.get(j).items.push(i); } const semua = () => [{ id: 'b0', label: bersih(t.nama) || t.key, jenis: '', target: t, items, dipa: t.P, rows: rows.slice() }]; let buckets; if (!rows.every(x => byJ.has(jenisPaket(x.p)))) buckets = semua(); else { for (const x of rows) byJ.get(jenisPaket(x.p)).rows.push(x); buckets = []; for (const [j, g] of byJ) { if (g.items.length === 1 || !g.rows.length) { buckets.push({ jenis: j, items: g.items, rows: g.rows }); continue; } // Padankan paket ke item: kata yang membedakan antar-item (IDF antar-item), diberi bobot // menurut posisi di nama paket — kata jenis pekerjaan ada di depan ("Konsultan Pengawas …"). const itemTok = g.items.map(i => tokN(`${i.grup || ''} ${i.uraian}`)); const dfI = new Map(); for (const d of itemTok) for (const tk of d) dfI.set(tk, (dfI.get(tk) || 0) + 1); const idf = tk => Math.log((g.items.length + 1) / ((dfI.get(tk) || 0) + 0.5)); const sub = new Map(g.items.map(i => [i.iid, { jenis: j, items: [i], rows: [] }])); const lepas = []; for (const x of g.rows) { const urut = [...tokN(x.p.nama)]; const skor = g.items.map((i, n) => urut.reduce((s2, tk, pos) => s2 + (itemTok[n].has(tk) ? idf(tk) / (1 + pos) : 0), 0)); const order = skor.map((v, n) => [v, n]).sort((a, b) => b[0] - a[0]); const [b1, b2] = [order[0], order[1] || [0, -1]]; if (b1[0] > 0 && b1[0] >= 1.5 * b2[0]) sub.get(g.items[b1[1]].iid).rows.push(x); else lepas.push(x); } if (lepas.length) buckets.push({ jenis: j, items: g.items, rows: g.rows }); // tak bisa dipadankan → satu bagian per jenis else buckets.push(...sub.values()); } } const totalDipa = sum(buckets, b => sum(b.items, i => i.pagu)); return buckets.map((b, i) => { const dipa = b.dipa != null ? b.dipa : sum(b.items, x => x.pagu); const ada = totalDipa ? rupT(t) * dipa / totalDipa : 0; // RUP yang sudah ada di akun tujuan, dibagi sebanding return { id: b.id || 'b' + i, label: b.label || (b.items.length === 1 ? bersih(b.items[0].uraian) : b.jenis), jenis: b.jenis, target: t, items: b.items, dipa, sisa: Math.max(0, dipa - ada), rows: b.rows }; }); } function paketGrup(rows) { const m = new Map(); for (const x of rows) { const o = m.get(x.p.id) || { paketId: x.p.id, nama: x.p.nama, status: x.p.status, jenis: jenisPaket(x.p), metode: x.p.metode || '', pagu: +x.p.pagu, diGrup: 0 }; o.diGrup += +x.r.pagu; m.set(x.p.id, o); } return [...m.values()].sort((a, b) => b.diGrup - a.diGrup); } // Proyeksi RUP terumumkan untuk pilihan saat ini (pilih = Map id→bool, default dari rencana) function proyeksi(r, pilih) { const on = x => (pilih && pilih.has(x.id)) ? pilih.get(x.id) : x.pilih !== false; let v = r.sekarang; const rinci = { ubah: 0, batal: 0, baru: 0, umumkan: 0 }; for (const c of r.perubahan) { if (!on(c)) continue; if (c.jenis === 'batal') { v -= c.sebelum; rinci.batal -= c.sebelum; } else { const d = c.sesudah - (c.umumkanDulu ? 0 : c.sebelum); v += d; rinci.ubah += d; } } for (const pb of r.paketBaru) if (on(pb) && pb.hostId && (!pilih || !pilih.has('t:' + pb.hostId) || pilih.get('t:' + pb.hostId))) { v += pb.total; rinci.baru += pb.total; } for (const u of r.umumkan) if (on(u)) { v += u.pagu; rinci.umumkan += u.pagu; } return { sekarang: r.sekarang, setelah: v, target: r.target, rinci }; } // Pemeriksaan isian sebelum dikirim (aturan form paket SiRUP) function periksa(pk, ctx) { const err = []; if (pk.pertahankan) return err; const ymOk = s => /^\d{4}-\d{2}$/.test(s || ''); if (!pk.nama || !pk.nama.trim()) err.push('nama paket kosong'); else if (pk.baru && pk.nama.trim().length < 5) err.push('nama paket terlalu pendek'); const tot = sum(pk.anggaran, a => +a.pagu || 0); if (tot <= 0) err.push('pagu 0'); for (const a of pk.anggaran) { // kode komponen bisa alfanumerik (mis. placeholder "ZZ1"), bukan hanya 3 angka if (!/^[A-Z]{2}\.\d{4}\.[A-Z0-9]{3}\.[A-Z0-9]{3}\.[A-Z0-9]{3}\.[A-Z0-9]{1,2}\.\d{6}$/.test(a.mak)) err.push(`MAK ${a.mak} tidak 7 segmen`); if (!(+a.pagu > 0)) err.push(`pagu baris ${a.mak} kosong`); if (ctx && ctx.komponenId && !(a.idKomponen || ctx.komponenId(a.mak))) err.push(`komponen ${seg(a.mak, 5)} belum ada di PKKR`); } if (!pk.lokasiRaw || !pk.lokasiRaw.length) err.push('lokasi belum diisi'); else if (pk.lokasiRaw.some(l => !l.id_provinsi || !l.id_kabupaten)) err.push('provinsi/kabupaten lokasi belum dipilih'); else if (pk.lokasiRaw.some(l => !String(l.detil || '').trim())) err.push('detail lokasi kosong'); else if (pk.lokasiRaw.some(l => String(l.detil || '').length > 1000)) err.push('detail lokasi > 1000 karakter'); const j = pk.jadwal || {}; const K = ['awalPengadaan', 'akhirPengadaan', 'awalPekerjaan', 'akhirPekerjaan', 'awalKebutuhan', 'kebutuhan']; if (K.some(k => !ymOk(j[k]))) err.push('jadwal belum lengkap'); else { if (j.akhirPengadaan < j.awalPengadaan) err.push('akhir pemilihan sebelum awal pemilihan'); if (j.awalPekerjaan < j.akhirPengadaan) err.push('awal kontrak sebelum akhir pemilihan'); if (j.akhirPekerjaan < j.awalPekerjaan) err.push('akhir kontrak sebelum awal kontrak'); if (j.kebutuhan < j.awalKebutuhan) err.push('akhir pemanfaatan sebelum awal pemanfaatan'); } if (!String(pk.spesifikasi || '').trim()) err.push('spesifikasi kosong'); else if (String(pk.spesifikasi).length > 1000) err.push(`spesifikasi ${String(pk.spesifikasi).length} karakter (maks. 1000)`); if (!String(pk.volume || '').trim()) err.push('volume kosong'); if (!pk.metode) err.push('metode kosong'); if (pk.metode === 'Seleksi' && pk.jenis !== 'Jasa Konsultansi') err.push('metode Seleksi hanya untuk jasa konsultansi'); if (/^Tender/.test(pk.metode) && pk.jenis === 'Jasa Konsultansi') err.push('jasa konsultansi memakai Seleksi, bukan Tender'); if (pk.umkm && tot > 15e9) err.push('paket > Rp15 M tidak bisa ditandai usaha kecil'); if (pk.jenisList && Math.abs(sum(pk.jenisList, x => +x.pagu) - tot) > 1) err.push('jumlah pagu per jenis tidak sama dengan total'); return err; } return { susun, proyeksi, periksa, isUmum, tokens, jaccard, bagi, ym, rapikanJadwal, jadwalPaket, pkDari, bucketize, SD_BELUM_DIUJI, TOL }; })(); // ──────────────────────────────────────────────────────── PENYESUAIAN PKKR ── // Node PKKR hasil integrasi SAKTI terkunci (beku sejak 31 Juli 2026). Yang bisa // disesuaikan hanya node Manual: pagu & nama diubah lewat form "Ubah", node yang // tidak ada lagi di DIPA dinonaktifkan (bila tidak dipakai paket mana pun). // • node Manual "salinan" (kode sama dengan node Integrasi, mis. Program DL (Manual)): // pagu = jumlah pagu anak Manual-nya (rantai Manual paralel, cara BPPP Tegal); // bila tidak punya anak Manual (salinan penuh sampai daun) → pagu DIPA node itu. // • node Manual biasa (cabang baru): pagu = pagu DIPA node itu. // • salinan non-daun tanpa anak Manual (rantai belum selesai dibuat): pagunya dibiarkan. // SiRUP diam-diam menolak node yang membuat jumlah pagu anak melebihi pagu induknya // (redirect sama seperti berhasil, tanpa pesan), jadi induk harus dinaikkan lebih dulu. const PkkrPlan = (() => { const TOL = 1000; const LVN = ['prog', 'keg', 'kro', 'ro', 'komp', 'sub']; const bersih = s => String(s || '').replace(/\s*\[[^\]]*\]\s*/g, ' ').replace(/\s+/g, ' ').trim(); const normal = s => bersih(s).toLowerCase(); // pkkr: Map key → node (crawlPkkr); dipaNodes: Map key → {uraian, pagu}; pakets: paket RUP (sumberDana: mak, idKomponen); // swakelola: daftar paket swakelola (detail anggarannya tidak dibaca; yang ada hanya jalur komponen di daftar paket) // tertunda: kunci node DIPA (pengadaan) yang belum ada di PKKR — salinan di atasnya jangan diturunkan dulu // paketTerbaca: false bila paket RUP belum dibaca (pemakaian node belum bisa dicek) function susun({ dipaNodes, pkkr, pakets, swakelola, tertunda, paketTerbaca = true }) { const manual = []; for (const [key, n] of pkkr) { if (n.manual) manual.push({ ...n, key, salinan: false, integrasi: null }); else if (n.manualTwin) manual.push({ ...n.manualTwin, key, salinan: true, integrasi: n }); } const anakDari = k => manual.filter(m => m.key.startsWith(k + '.') && m.key.split('.').length === k.split('.').length + 1); const memo = new Map(); // salinan non-daun tanpa anak Manual: rantai Manual belum selesai (mis. gagal di tengah) → jangan diisi pagu DIPA penuh const kosong = m => m.salinan && m.level !== 'sub' && !anakDari(m.key).length; const target = m => { if (memo.has(m.key)) return memo.get(m.key); const d = dipaNodes.get(m.key); const anak = anakDari(m.key); let v; if (m.salinan && anak.length) { // anak yang tidak relevan tetapi tidak bisa dinonaktifkan (dipakai paket / belum dicek) tetap ada di SiRUP, // jadi pagunya tetap dihitung — SiRUP menolak induk yang lebih kecil dari jumlah anaknya v = 0; for (const c of anak) { const tc = target(c); v += relevanT(c, tc) ? tc : tetapAda(c) ? (+c.pagu || 0) : 0; } } else if (kosong(m)) v = d ? (+m.pagu || 0) : null; else v = d ? (+d.pagu || 0) : null; memo.set(m.key, v); return v; }; // paket yang memakai node: baris anggaran ber-id komponen di bawah node, atau (untuk cabang baru) MAK berawalan kode node const komponenDi = m => manual.filter(x => x.level === 'komp' && (x.key === m.key || x.key.startsWith(m.key + '.'))).map(x => String(x.id)); const relevanT = (m, t) => t != null && (dipaNodes.has(m.key) || t > 0); const tetapAda = c => !paketTerbaca || dipakai(c).length > 0; const aktif = (pakets || []).filter(p => p.status !== '51'); const sw = (swakelola || []).filter(p => p.status !== '51' && p.aktif !== 'false' && p.aktif !== false); const dipakai = m => { const ids = new Set(komponenDi(m)); if (m.level === 'sub' && m.parentId) ids.add(String(m.parentId)); const out = aktif.filter(p => (p.sumberDana || []).some(r => ids.has(String(r.idKomponen)) || (!m.salinan && String(r.mak || '').startsWith(m.key + '.')))).map(p => p.id); // swakelola: cocokkan jalur komponen (konservatif — node Integrasi berkode sama ikut dianggap dipakai) for (const p of sw) { const j = p.jalur || ''; if (j && (j === m.key || j.startsWith(m.key + '.') || m.key.startsWith(j + '.'))) out.push(p.id + ' (swakelola)'); } return [...new Set(out)]; }; const ubah = [], nonaktif = [], ditahan = []; const masihDitambah = m => m.salinan && (tertunda || []).some(k => k.startsWith(m.key + '.')); for (const m of manual) { const t = target(m); const relevan = relevanT(m, t); if (!relevan) { const pakai = dipakai(m); nonaktif.push({ key: m.key, level: m.level, id: m.id, parentId: m.parentId, nama: m.nama, pagu: m.pagu, salinan: m.salinan, dipakai: pakai, bisa: !pakai.length, alasan: m.salinan ? 'Node Integrasi aslinya tidak ada lagi di DIPA dan tidak punya cabang Manual yang masih berlaku' : 'Tidak ada di DIPA revisi terakhir' }); continue; } const d = dipaNodes.get(m.key) || {}; let namaBaru = m.salinan ? bersih((m.integrasi && m.integrasi.nama) || m.nama).replace(/\s*\(Manual\)$/i, '') + (['prog', 'keg'].includes(m.level) ? ' (Manual)' : '') : (bersih(d.uraian) || bersih(m.nama)); if (!namaBaru) namaBaru = m.nama; const gantiNama = normal(namaBaru) !== normal(m.nama) || /\[[^\]]*\]/.test(m.nama || ''); let gantiPagu = Math.abs((+m.pagu || 0) - t) > TOL; // cabang DIPA di bawahnya belum selesai dibuat (mis. "Tambahkan cabang" gagal di tengah): induk yang sudah // dinaikkan jangan diturunkan lagi, nanti tetap dibutuhkan if (gantiPagu && t < (+m.pagu || 0) && masihDitambah(m)) { ditahan.push({ key: m.key, level: m.level, nama: m.nama, pagu: +m.pagu || 0, paguBaru: t }); gantiPagu = false; } if (!gantiNama && !gantiPagu) continue; const ket = []; if (gantiPagu) ket.push(m.salinan ? 'pagu = jumlah cabang Manual di bawahnya' : 'pagu = pagu DIPA'); if (gantiNama) ket.push(/\[[^\]]*\]/.test(m.nama || '') ? 'buang catatan [..] dari SAKTI' : 'nama sesuai DIPA'); ubah.push({ key: m.key, level: m.level, id: m.id, parentId: m.parentId, salinan: m.salinan, nama: m.nama, namaBaru: gantiNama ? namaBaru : m.nama, pagu: +m.pagu || 0, paguBaru: t, ket: ket.join('; ') }); } // nonaktifkan dari bawah ke atas: anak lebih dulu nonaktif.sort((a, b) => b.key.split('.').length - a.key.split('.').length || a.key.localeCompare(b.key)); ubah.sort((a, b) => a.key.split('.').length - b.key.split('.').length || a.key.localeCompare(b.key)); // node Integrasi yang berbeda dari DIPA: hanya informasi (terkunci) const terkunci = []; for (const [key, n] of pkkr) { if (n.manual) continue; const d = dipaNodes.get(key); if (!d) terkunci.push({ key, level: n.level, nama: n.nama, pagu: n.pagu, paguDipa: null }); else if (Math.abs((+n.pagu || 0) - (+d.pagu || 0)) > TOL) terkunci.push({ key, level: n.level, nama: n.nama, pagu: n.pagu, paguDipa: +d.pagu || 0 }); } terkunci.sort((a, b) => a.key.localeCompare(b.key)); const tanpaCabang = manual.filter(m => kosong(m) && dipaNodes.has(m.key)).map(m => ({ key: m.key, level: m.level, nama: m.nama, pagu: +m.pagu || 0 })); return { ubah, nonaktif, terkunci, manual: manual.length, swTakTerbaca: sw.filter(p => !p.jalur).length, tanpaCabang, ditahan }; } // Rencana menambah cabang DIPA baru dalam rantai PKKR Manual paralel (cara BPPP Tegal). // pilih: node DIPA terpilih yang belum ada di PKKR ({key, level, kode, nama, pagu, parentKey}). // → buat: node yang dibuat (salinan induk dari level Program bila belum ada; pagunya = jumlah cabang baru di bawahnya), // naik: node Manual yang sudah ada dan pagunya harus dinaikkan dulu supaya muat anak lama + anak baru. function rencanaTambah({ pilih, pkkr, dipaNodes }) { const keys = new Set(pilih.map(n => n.key)); const dalam = k => k.split('.').length; const manualDi = k => { const n = pkkr.get(k); return n ? (n.manual ? n : n.manualTwin) || null : null; }; const plan = new Map(); for (const top of pilih.filter(n => !keys.has(n.parentKey))) { const parts = top.key.split('.'); for (let i = 1; i < parts.length; i++) { const k = parts.slice(0, i).join('.'); if (manualDi(k)) continue; // rantai Manual sudah ada di level ini const ada = pkkr.get(k), d = (dipaNodes && dipaNodes.get(k)) || {}; const x = plan.get(k) || { key: k, level: LVN[i - 1], kode: parts[i - 1], parentKey: parts.slice(0, i - 1).join('.'), nama: bersih((ada && ada.nama) || d.uraian || '').replace(/\s*\(Manual\)$/, '') + (i <= 2 ? ' (Manual)' : ''), pagu: 0, salinan: true }; x.pagu += +top.pagu || 0; plan.set(k, x); } } for (const n of pilih) plan.set(n.key, { ...n, salinan: false }); const buat = [...plan.values()].sort((a, b) => dalam(a.key) - dalam(b.key) || a.key.localeCompare(b.key)); // induk Manual yang sudah ada: hitung dari yang terdalam supaya kenaikan anak ikut terhitung di induknya const induk = new Set(); for (const n of buat) { const p = n.key.split('.'); for (let i = 1; i < p.length; i++) { const k = p.slice(0, i).join('.'); if (!plan.has(k) && manualDi(k)) induk.add(k); } } const paguBaru = new Map(), naik = []; for (const k of [...induk].sort((a, b) => dalam(b) - dalam(a))) { const m = manualDi(k); let perlu = 0; for (const [ck, cn] of pkkr) { if (dalam(ck) !== dalam(k) + 1 || !ck.startsWith(k + '.') || plan.has(ck)) continue; const cm = cn.manual ? cn : cn.manualTwin; if (!cm || (cm.parentId && String(cm.parentId) !== String(m.id))) continue; perlu += paguBaru.has(ck) ? paguBaru.get(ck) : (+cm.pagu || 0); } for (const n of buat) if (n.parentKey === k) perlu += +n.pagu || 0; if (perlu > (+m.pagu || 0) + 1) { paguBaru.set(k, perlu); naik.push({ key: k, level: m.level, id: m.id, nama: m.nama, pagu: +m.pagu || 0, paguBaru: perlu }); } } naik.sort((a, b) => dalam(a.key) - dalam(b.key)); // induk dulu return { buat, naik }; } return { susun, rencanaTambah, bersih, LVN }; })(); // ─────────────────────────────────────────────────────────────── API SiRUP ── // Semua akses ke SiRUP lewat sesi login pengguna (same-origin). Pembacaan // memakai endpoint DataTables/selfservice; penulisan meniru persis payload // form yang direkam dari UI SiRUP (lihat rekaman Dual-Control 2026-09-30). const Sirup = (() => { const BASE = '/sirup'; const JENIS_ID = { 'Barang': 1, 'Pekerjaan Konstruksi': 2, 'Jasa Konsultansi': 3, 'Jasa Lainnya': 4 }; const METODE_ID = { 'E-Purchasing': 9, 'Pengadaan Langsung': 8, 'Penunjukan Langsung': 7, 'Seleksi': 15, 'Tender': 13, 'Tender Cepat': 14 }; // id provinsi SiRUP (urutan lama LPSE); nama diverifikasi dari data paket const PROVINSI = ['', 'Aceh', 'Sumatera Utara', 'Sumatera Barat', 'Riau', 'Jambi', 'Sumatera Selatan', 'Bengkulu', 'Lampung', 'Kepulauan Bangka Belitung', 'Kepulauan Riau', 'DKI Jakarta', 'Jawa Barat', 'Jawa Tengah', 'DI Yogyakarta', 'Jawa Timur', 'Banten', 'Bali', 'Nusa Tenggara Barat', 'Nusa Tenggara Timur', 'Kalimantan Barat', 'Kalimantan Tengah', 'Kalimantan Selatan', 'Kalimantan Timur', 'Sulawesi Utara', 'Sulawesi Tengah', 'Sulawesi Selatan', 'Sulawesi Tenggara', 'Gorontalo', 'Sulawesi Barat', 'Maluku', 'Maluku Utara', 'Papua Barat', 'Papua', 'Kalimantan Utara', 'Papua Selatan', 'Papua Tengah', 'Papua Pegunungan', 'Papua Barat Daya']; let log = () => { }; const setLogger = fn => { log = fn; }; const sleep = ms => new Promise(r => setTimeout(r, ms)); async function get(url) { const r = await fetch(url, { credentials: 'same-origin' }); if (!r.ok) throw new Error(`GET ${url} → HTTP ${r.status}`); return r; } const getText = async url => (await get(url)).text(); // SiRUP membalas simpan/umumkan dengan 302 ke "http://…" (bukan https). fetch() yang mengikuti // redirect itu diblokir browser sebagai mixed content → "Failed to fetch", padahal server sudah // memproses. Karena itu POST non-JSON memakai redirect:'manual' dan hasilnya diverifikasi terpisah. async function post(url, body, { json = false } = {}) { let r; try { r = await fetch(url, { method: 'POST', credentials: 'same-origin', redirect: json ? 'follow' : 'manual', headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8', ...(json ? { 'X-Requested-With': 'XMLHttpRequest' } : {}) }, body: body instanceof URLSearchParams ? body.toString() : body, }); } catch (e) { throw new Error(`POST ${url.replace(BASE, '')} gagal di jaringan/browser (${e.message}). Cek apakah sesi SiRUP masih login.`); } if (json) { if (!r.ok) throw new Error(`POST ${url} → HTTP ${r.status}`); return r.json(); } if (r.type === 'opaqueredirect' || (r.status >= 300 && r.status < 400)) return { redirected: true, status: 302, text: async () => '' }; if (!r.ok) throw new Error(`POST ${url.replace(BASE, '')} → HTTP ${r.status}`); return { redirected: false, status: r.status, text: () => r.text() }; } // GET yang tidak mengikuti redirect (mis. kajiulangpaket → 302 ke form di http://) async function getManual(url) { try { return await fetch(url, { credentials: 'same-origin', redirect: 'manual' }); } catch (e) { throw new Error(`GET ${url.replace(BASE, '')} gagal (${e.message}).`); } } // pesan flash SiRUP (kotak alert) pada halaman berikutnya — dipakai untuk membaca alasan penolakan // Pesan SiRUP (flash/validasi) di sebuah halaman. Flash hanya hidup untuk SATU permintaan berikutnya, // jadi harus dibaca dari halaman tujuan redirect tepat setelah simpan. function pesanHalaman(h) { const d = new DOMParser().parseFromString(h || '', 'text/html'); // modal "Syarat dan Ketentuan" ada di setiap halaman → bukan pesan error return [...d.querySelectorAll('#alert, .alert-danger, .alert-warning, .alert-success, .alert-info, label.error, span.error, .help-block')] .filter(e => !e.closest('#popup, #overlay, .modal')) .map(e => e.textContent.replace(/\s+/g, ' ').replace(/^×\s*/, '').trim()) .filter(t => t && !/Syarat dan Ketentuan|Kebijakan Privasi|Geser Ke Bawah/i.test(t)).join(' | '); } async function bacaFlash(url) { try { return pesanHalaman(await getText(url)); } catch (e) { return ''; } } async function dt(url, extra = '') { const sep = url.includes('?') ? '&' : '?'; const t = await getText(`${url}${sep}sEcho=1&iColumns=12&iDisplayStart=0&iDisplayLength=2000&sSearch=${extra}`); return JSON.parse(t).aaData; } const isLoginPage = html => /name="txtUsername"/.test(html) && !/id="user-logout"/.test(html); // ── konteks login ──────────────────────────────────────────────────── async function context() { const html = document.documentElement.outerHTML; const ctx = { login: /id="user-logout"/.test(html) }; if (!ctx.login) return ctx; const dd = [...document.querySelectorAll('#modalPengguna dt')].reduce((o, dt) => { o[dt.textContent.replace(/:\s*$/, '').trim()] = (dt.nextElementSibling ? dt.nextElementSibling.textContent : '').replace(/ /g, ' ').trim(); return o; }, {}); ctx.nama = dd['Nama Lengkap'] || ''; ctx.role = dd['Role User'] || ''; ctx.satkerNama = dd['Satker'] || ''; const userLink = [...document.querySelectorAll('a.dropdown-toggle')].map(a => a.textContent.trim()).filter(Boolean); ctx.username = (document.querySelector('#user-logout') ? userLink.find(t => !/^\d{4}$/.test(t) && !/Rekap|RUP|Cari|Kelola|Unduh|Moner|FAQ|Berita|Dashboard|Ketentuan|Kontak/.test(t)) : '') || ''; const tahun = (document.querySelector('a[href="#"] .fa-calendar') || {}).parentElement; ctx.tahun = +(html.match(/tahunAnggaran\s*[:=]\s*(20\d\d)/) || [])[1] || +(userLink.find(t => /^20\d\d$/.test(t)) || new Date().getFullYear()); // id internal & kode satker dari form Program (hidden input) dan halaman integrasi const fp = await getText(`${BASE}/programctr/formprogram`); ctx.idSatker = (fp.match(/name="program\.id_satker" value="(\d+)"/) || [])[1] || ''; const ta = (fp.match(/name="program\.tahun_anggaran"[^>]*value="(\d{4})"/) || [])[1]; if (ta) ctx.tahun = +ta; const ig = await getText(`${BASE}/integrasimonsaktictr/index`); ctx.kodeSatker = (ig.match(/name="satker\.id_satker"[^>]*value="(\d{6})"/) || [])[1] || ''; // nama peran berbeda antar-instalasi ("PAKPA", "KPA", "PA/KPA", "Kuasa Pengguna Anggaran"); PPK/PP bukan KPA ctx.isKPA = /KPA|^PA$|pengguna anggaran/i.test(ctx.role) && !/^(PPK|PP|ADMIN)/i.test(ctx.role); return ctx; } // ── PKKR ───────────────────────────────────────────────────────────── const PKKR_LV = [ ['prog', 'datatablelistprogram', null], ['keg', 'datatablelistkegiatan', 'idProgram'], ['kro', 'datatablelistoutput', 'idKegiatan'], ['ro', 'datatablelistsuboutput', 'idOutput'], ['komp', 'datatablelistkomponen', 'idSubOutput'], ['sub', 'datatablelistsubkomponen', 'idKomponen'], ]; // Kolom pagu & PPK berbeda per level; baris manual ditandai kolom id_client 'N/A' function rowToNode(row, lvl, parentKey) { const kode = String(row[2]).trim(); const pagu = +(lvl === 'prog' ? row[4] : lvl === 'keg' ? row[3] : row[4]) || 0; const idClient = row[row.length - 1]; return { id: String(row[0]), level: lvl, kode, nama: row[1], pagu, key: parentKey ? `${parentKey}.${kode}` : kode, manual: !idClient || idClient === 'N/A' }; } async function crawlPkkr(tahun, onProgress) { const map = new Map(); let n = 0; async function walk(depth, parent) { const [lvl, ep, param] = PKKR_LV[depth]; const url = param ? `${BASE}/datatablectr/${ep}?tahun=${tahun}&${param}=${parent.id}` : `${BASE}/datatablectr/${ep}`; const rows = await dt(url); for (const r of rows) { const node = rowToNode(r, lvl, parent && parent.key); node.parentId = parent ? parent.id : null; // Rantai PKKR Manual paralel (cara BPPP Tegal) memakai kode yang sama dengan node // Integrasi. Map menyimpan node Integrasi sebagai utama dan salinan Manual di .manualTwin. const ada = map.get(node.key); if (!ada) map.set(node.key, node); else if (ada.manual && !node.manual) { node.manualTwin = ada; map.set(node.key, node); } else if (!ada.manualTwin) ada.manualTwin = node; if (onProgress) onProgress(++n, node.key); if (depth + 1 < PKKR_LV.length) await walk(depth + 1, node); } } await walk(0, null); return map; } // Simpan node PKKR baru. parentId = id node induk di SiRUP. async function tambahPkkr(ctx, node, parentId, idPpk) { const T = ctx.tahun, S = ctx.idSatker; const f = new URLSearchParams(); const add = (k, v) => f.append(k, v == null ? '' : v); switch (node.level) { case 'prog': add('program.id', ''); add('program.tahun_anggaran', T); add('program.id_satker', S); add('program.id', ''); add('program.nama', node.nama); add('program.kode_programs', node.kode); add('program.pagu', node.pagu); add('isEdit', ''); return kirimPkkr(`${BASE}/programctr/simpanprogram`, f, 'programctr/index'); case 'keg': add('kegiatan.tahun_anggaran', T); add('isEdit', ''); add('kegiatan.id_satker', S); add('kegiatan.id_program', parentId); add('kegiatan.id', ''); add('kegiatan.nama', node.nama); add('kegiatan.kode_kegiatans', node.kode); add('kegiatan.pagu', node.pagu); add('kegiatan.id_ppk', idPpk || ''); return kirimPkkr(`${BASE}/programctr/simpankegiatan`, f, `programctr/indexkegiatan?idProgram=${parentId}`); case 'kro': add('output.id', ''); add('output.tahun_anggaran', T); add('output.id_kegiatan', parentId); add('isEdit', ''); add('output.id_satker', S); add('output.nama', node.nama); add('output.kode_output_string', node.kode); add('output.pagu', node.pagu); add('output.id_ppk', idPpk || ''); return kirimPkkr(`${BASE}/programctr/simpanoutput`, f, `programctr/indexoutput?idKegiatan=${parentId}`); case 'ro': add('suboutput.id', ''); add('suboutput.tahun_anggaran', T); add('suboutput.id_output', parentId); add('isEdit', ''); add('suboutput.id_satker', S); add('suboutput.nama', node.nama); add('suboutput.kode_suboutput_string', node.kode); add('suboutput.pagu', node.pagu); add('suboutput.id_ppk', idPpk || ''); return kirimPkkr(`${BASE}/programctr/simpansuboutput`, f, `programctr/indexsuboutput?idOutput=${parentId}`); case 'komp': add('komponen.id', ''); add('komponen.tahun_anggaran', T); add('komponen.id_suboutput', parentId); add('komponen.satkerID', S); add('komponen.nama', node.nama); add('komponen.kode_komponen_string', node.kode); add('komponen.pagu', node.pagu); add('komponen.id_ppk', idPpk || ''); return kirimPkkr(`${BASE}/programctr/simpankomponen`, f, `programctr/indexkomponen?idSubOutput=${parentId}`); case 'sub': add('subkomponen.id', ''); add('subkomponen.tahun_anggaran', T); add('subkomponen.id_komponen', parentId); add('subkomponen.satkerID', S); add('subkomponen.nama', node.nama); add('subkomponen.kode_subkomponen_string', node.kode); add('subkomponen.pagu', node.pagu); add('subkomponen.id_ppk', idPpk || ''); return kirimPkkr(`${BASE}/programctr/simpansubkomponen`, f, `programctr/indexsubkomponen?idKomponen=${parentId}`); } throw new Error('Level PKKR tidak dikenal: ' + node.level); } // cari id node yang baru dibuat (berdasarkan kode di bawah induk) // POST simpan node PKKR lalu langsung buka halaman tujuan redirect-nya (rekaman 30 Sep & 2 Okt 2026: // index, indexkegiatan?idProgram=, indexoutput?idKegiatan=, indexsuboutput?idOutput=, indexkomponen?idSubOutput=, // indexsubkomponen?idKomponen=) untuk menangkap pesan SiRUP. Penolakan karena pagu anak melebihi induk tidak // memberi pesan, jadi keberhasilan tetap dicek dari daftar node. async function kirimPkkr(url, body, tujuan) { const r = await post(url, body); r.pesan = await bacaFlash(`${BASE}/${tujuan}`); return r; } async function cariNodeBaru(ctx, level, parentId, kode) { const i = PKKR_LV.findIndex(l => l[0] === level); const [, ep, param] = PKKR_LV[i]; const url = param ? `${BASE}/datatablectr/${ep}?tahun=${ctx.tahun}&${param}=${parentId}` : `${BASE}/datatablectr/${ep}`; // kode sub-komponen bisa tersimpan tanpa nol di depan ("A" untuk "0A") → bandingkan bentuk bakunya const baku = c => level === 'sub' ? String(c || '').trim().toUpperCase().replace(/^O(?=[A-Z0-9])/, '0').replace(/^0+(?=[A-Z0-9])/, '') : String(c || '').trim(); const rows = (await dt(url)).filter(x => baku(x[2]) === baku(kode)); if (!rows.length) return null; const manual = rows.filter(x => { const c = x[x.length - 1]; return !c || c === 'N/A'; }); const pick = (manual.length ? manual : rows).sort((a, b) => Number(b[0]) - Number(a[0]))[0]; return String(pick[0]); } // ── PKKR Manual: ubah & nonaktifkan (form dibaca dari SiRUP saat eksekusi) ── // Pola tautan dari halaman Kelola PKKR SiRUP; node Integrasi tidak punya tombol ini (terkunci). const PKKR_FORM = { prog: { edit: 'programctr/editProgram?idProgram=', hapus: 'programctr/deleteProgramConfirm?id=', prefix: 'program' }, keg: { edit: 'programctr/editKegiatan?idKegiatan=', hapus: 'programctr/deleteKegiatanConfirm?id=', prefix: 'kegiatan' }, kro: { edit: 'programctr/editOutput?id=', hapus: 'programctr/deleteOutputConfirm?id=', prefix: 'output' }, ro: { edit: 'programctr/editSubOutput?id=', hapus: 'programctr/deleteSubOutputConfirm?id=', prefix: 'suboutput' }, komp: { edit: 'programctr/editKomponen?id=', hapus: 'programctr/deleteKomponenConfirm?id=', prefix: 'komponen' }, sub: { edit: 'programctr/editSubKomponen?id=', hapus: 'programctr/deleteSubKomponenConfirm?id=', prefix: 'subkomponen' }, }; const nilaiForm = (f, k) => (f.entries.find(([n]) => n === k) || [])[1]; async function bacaFormPkkr(node) { const F = PKKR_FORM[node.level]; const f = formDari(await getText(`${BASE}/${F.edit}${node.id}`), /programctr\/simpan/i); if (!f) throw new Error(`Form ubah ${node.key} tidak terbuka.`); return { F, f }; } // Alasan baku form "Ubah" PKKR (sama di semua level): 1 Pengurangan Anggaran, 2 Penambahan Anggaran, 3 Pembatalan, // 4 Kesalahan Penulisan, 5 Lainnya, 6 Delegasi kepada PPK. Tanpa pilihan ini SiRUP menolak (kembali ke form ubah). const ALASAN_PKKR = { turun: '1', naik: '2', nama: '4' }; // Ubah nama/pagu node Manual: kirim form Ubah SiRUP apa adanya; yang diganti hanya nama/pagu dan alasan baku // (kolom alasan bebas dibiarkan seperti di form: SiRUP mengosongkannya untuk alasan 1-4) async function ubahPkkr(node, { nama, pagu }) { const { F, f } = await bacaFormPkkr(node); if (!f.entries.some(([k]) => k === `${F.prefix}.nama`)) throw new Error(`Form ubah ${node.key} tidak memuat isian nama — node mungkin node Integrasi.`); const lama = +nilaiForm(f, `${F.prefix}.pagu`) || 0; const arah = pagu != null && Math.round(pagu) > lama ? 'naik' : pagu != null && Math.round(pagu) < lama ? 'turun' : 'nama'; const body = new URLSearchParams(); for (const [k, v] of f.entries) { if (k === `${F.prefix}.nama` && nama != null) body.append(k, nama); else if (k === `${F.prefix}.pagu` && pagu != null) body.append(k, String(Math.round(pagu))); else if (k === `${F.prefix}.id_predifine`) body.append(k, v || ALASAN_PKKR[arah]); else body.append(k, v); } await post(f.action, body); // tujuan redirect = form ubah (bila ditolak) atau daftar; form ubah dibaca SEKALI: nilai tersimpan + pesan SiRUP const h = await getText(`${BASE}/${F.edit}${node.id}`); const cek = formDari(h, /programctr\/simpan/i); if (!cek) throw new Error(`Form ubah ${node.key} tidak terbuka setelah disimpan.`); const pg = +nilaiForm(cek, `${F.prefix}.pagu`) || 0, nm = nilaiForm(cek, `${F.prefix}.nama`) || ''; if ((pagu != null && Math.abs(pg - Math.round(pagu)) > 1) || (nama != null && nm.trim() !== nama.trim())) { const pesan = pesanHalaman(h); throw new Error(`SiRUP tidak menyimpan perubahan ${node.key} (terbaca: ${nm} · Rp${pg}).${pesan ? ' Pesan SiRUP: ' + pesan : ' SiRUP tidak memberi pesan — kemungkinan pagu anak-anaknya melebihi pagu baru, atau pagu induknya tidak cukup.'}`); } return { nama: nm, pagu: pg }; } // Nonaktifkan node Manual: buka dialog konfirmasi SiRUP, kirim form-nya (metode mengikuti form: GET bila tidak ditulis) async function nonaktifkanPkkr(node) { const F = PKKR_FORM[node.level]; const f = formDari(await getText(`${BASE}/${F.hapus}${node.id}`), /programctr\/delete/i); if (!f) throw new Error(`Dialog nonaktifkan ${node.key} tidak terbuka (node mungkin node Integrasi).`); const q = new URLSearchParams(f.entries); if (f.method === 'post') { const r = await post(f.action, q); if (!r.redirected) throw new Error(`Nonaktifkan ${node.key} ditolak: ${await pesanError(r)}`); } else { const r = await getManual(f.action + (f.action.includes('?') ? '&' : '?') + q.toString()); if (!(r.type === 'opaqueredirect' || r.ok)) throw new Error(`Nonaktifkan ${node.key} gagal (HTTP ${r.status}).`); } return true; } // Paket yang sudah dibatalkan bisa diaktifkan lagi (revisi "aktif"); hasilnya Final Draft berkode sama async function aktifkanPaket(id, alasan, jenis = 'penyedia') { await getManual(`${BASE}/rup/kajiulangpaket?id=${id}&penyediaAtauSwakelola=${jenis}&jenisMtl=&jenis=aktif`); const f = formDari(await getText(`${BASE}/rup/formkajiulangaktif?penyediaAtauSwakelola=${jenis}&id=${id}`), /kajiulangaktifkan/i); if (!f) throw new Error(`Form aktifkan paket ${id} tidak terbuka (paket mungkin bukan berstatus Dibatalkan).`); const body = new URLSearchParams(f.entries); body.set('alasan', alasan || 'Pembatalan dibatalkan (diaktifkan kembali)'); const r = await post(f.action, body); if (!r.redirected) throw new Error(`Aktifkan paket ${id} ditolak: ${await pesanError(r)}`); return r; } // daftar PPK diambil dari form sub komponen; idKomponen harus id nyata (0 → HTTP 500) async function daftarPpk(idKomponen) { if (!idKomponen) return []; const h = await getText(`${BASE}/programctr/formsubkomponen?idKomponen=${idKomponen}`).catch(() => ''); return [...h.matchAll(/