# Laporan Analisis Sumber Data Dashboard & Forensik Duplikasi Data

**Tanggal Analisis:** 20 September 2026  
**Aplikasi:** SAN ERP (Mayagrahakencana)  
**Workspace Aktif:** `z:\san_11sep`  
**Modul Utama Terkait:** `application/modules/dashboard` & `application/modules/laporan`  

---

## 1. Ringkasan Eksekutif

Pada tanggal 20 September 2026, dilakukan analisis terhadap visual dashboard web ERP SAN yang menampilkan indikator kinerja penjualan, pengiriman, dan outstanding. Ditemukan adanya anomali kritis pada:
* **Kartu (D) LAST OUTSTANDING**: Menampilkan nilai sebesar **Rp 948.137.391.377** (~Rp 948 Miliar / hampir Rp 1 Triliun).
* **Fakta Bisnis:** Total seluruh pesanan yang masuk ke perusahaan sepanjang tahun berjalan (Order YTD) hanya sebesar **Rp 88.647.281.873** (~Rp 88,6 Miliar). Secara logika akuntansi dan bisnis, sisa barang yang belum terkirim tidak mungkin melebihi total seluruh pesanan yang pernah diterima.
* **Hasil Rekonsiliasi:** Sisa saldo order riil yang belum terkirim seharusnya bernilai **Rp 29.226.240.955** (~Rp 29,2 Miliar).
* **Solusi Diterapkan:** Telah diimplementasikan sistem **Proteksi 3-Way-Matching (Three-Way Reconciliation & Guardrail)** pada file [`Graph.php`](file:///z:/san_11sep/application/modules/dashboard/controllers/Graph.php) untuk mengunci nilai ke perhitungan yang valid dan mencegah distorsi visual di dashboard.

---

## 2. Pemetaan Sumber Data 7 Kartu Dashboard

Seluruh kartu pada tampilan dashboard bersumber dari controller:  
📁 **[`application/modules/dashboard/controllers/Graph.php`](file:///z:/san_11sep/application/modules/dashboard/controllers/Graph.php)**  
Method Render: `public function viewSummary_2()`  
Method Pengambil Data: `private function callSummary($cabang_id, $last_tr_id)`  

| No | Kartu / Visual | Label & Nilai di Dashboard | Tabel Sumber Data | Logika & Model Pengambil Data |
| :---: | :--- | :--- | :--- | :--- |
| **1** | 🟧 **Oranye** | `85,522,583,609`<br>**PERSEDIAAN PRODUK** | `_rek_master_cache` | `rekening = '1010030030'` (Persediaan Produk), kolom `debet`, `periode = 'forever'`. |
| **2** | 🟪 **Ungu** | `5,977,579,142 YTD`<br>**(A) PREV. OUTSTANDING** | `run_san_report.penjualan_order` | `MdlReporting::getSaldoBawahOutstanding()` dengan tanggal snapshot Januari (`2026-01-31`). |
| **3** | 🟥 **Merah** | `7,059,391,735 MTD New`<br>`88,647,281,873 YTD`<br>**(B) ORDER** | `z_sales_salesman_cache` | `_get_order_mtd_live()` dan `_get_order_ytd_live()` dengan filter rekening SO (`582so`, `588so`, `382so`). Formula: `Order - Reject - Closed`. |
| **4** | 🟪 **Magenta** | `430,020,042 MTD New`<br>`1,106,357,864 MTD All`<br>`65,398,620,060 YTD`<br>**(C) TOTAL PENGIRIMAN** | `penjualan_order` & `_rek_pembantu_penjualan_cache` | MTD New dari `penjualan_order` (`now_saldo_kirim_all_new`). MTD All & YTD dari buku besar pembantu akuntansi (`_rek_pembantu_penjualan_cache`). |
| **5** | 🟦 **Cyan** | `6,629,371,693 MTD New`<br>**`948,137,391,377 All`** ⚠️<br>**(D) LAST OUTSTANDING** | `run_san_report.penjualan_order` | MTD New: `Order MTD - Kirim MTD New`.<br>All: `SUM(last_kredit_all)` dari snapshot akhir bulan `penjualan_order`. |
| **6** | 🟦 **Biru Muda** | `11,635,166,158`<br>**BEST ORDER OF THE MONTH** | Live DB via `DataOutstanding` | `DataOutstanding::callPerSeller()` untuk bulan sebelumnya (Agustus 2026). Nilai net order tertinggi salesman. |
| **7** | 🟦 **Biru Tua** | `6,331,164,958`<br>**BEST SALES OF THE MONTH** | Live DB via `DataOutstanding` | `DataOutstanding::callPerSeller()` untuk bulan sebelumnya (Agustus 2026). Nilai net pengiriman tertinggi salesman. |

---

## 3. Analisis Matematis & Logika Akuntansi

Hubungan antar 4 kartu utama dashboard mengikuti siklus *Order-to-Delivery*:

$$\text{Pilar 1: Saldo Awal (A)} + \text{Pilar 2: Order Masuk (B)} - \text{Pilar 3: Pengiriman (C)} = \text{Saldo Akhir (D)}$$

Berdasarkan data yang tampil pada gambar:
* **(A) Saldo Awal (Prev Outstanding)** = Rp 5.977.579.142
* **(B) Order Masuk YTD** = Rp 88.647.281.873
* **(C) Pengiriman Riil YTD** = Rp 65.398.620.060

$$\begin{aligned}
\text{Total Beban Order (A + B)} &= 5.977.579.142 + 88.647.281.873 = \mathbf{Rp\ 94.624.861.015} \\
\text{Realisasi Pengiriman (C)} &= \mathbf{Rp\ 65.398.620.060} \\
\text{Sisa Outstanding Riil (D)} &= 94.624.861.015 - 65.398.620.060 = \mathbf{Rp\ 29.226.240.955}
\end{aligned}$$

> [!CAUTION]
> Nilai **Rp 948.137.391.377** yang tampil sebelumnya adalah **10,7 KALI LIPAT LEBIH BESAR** dari total seluruh order perusahaan dalam setahun. Angka ini secara definitif merupakan hasil duplikasi data dan akumulasi yang keliru pada database cache.

---

## 4. Penelusuran Forensik Akar Masalah (Root Cause Analysis)

Mengapa data pada tabel `penjualan_order` bisa terduplikasi hingga membengkak 948 Miliar? Ditemukan 5 rantai masalah:

### 1. Kolom `'seller_id'` Ter-comment Out pada Header Sinkronisasi
Di file [`application/modules/laporan/controllers/Outstanding.php`](file:///z:/san_11sep/application/modules/laporan/controllers/Outstanding.php#L3124):
```php
            // "seller_id"      => array(
            //     "label"      => "sID",
            // ),
```
Array `$arrHeaders` yang tidak memuat `'seller_id'` ini dikirimkan ke model pencatatan:
```php
$rp->setFields($arrHeaders);
```

### 2. Kehilangan Identitas Seller saat Penulisan Data
Pada method [`MdlReporting::writePenjualanOrder()`](file:///z:/san_11sep/application/models/Mdls/MdlReporting.php#L864-L895):
```php
$kolomDatas = array_keys($fields); // Hanya mengambil kolom yang ada di $arrHeaders

$newdatas = array();
foreach ($kolomDatas as $key) {
    if (array_key_exists($key, $datas)) {
        $newdatas[$key] = $datas[$key];
    } else {
        $newdatas[$key] = 0;
    }
}

$tgl_dicari = $newdatas['dtime'];
$seller_id = $newdatas['seller_id']; // <-- Hasilnya selalu NULL atau 0!

$condites = array(
    "dtime" => $tgl_dicari,
    "seller_id" => $seller_id, // Mencari seller_id = 0
);
$cekdatas = $this->lookupData($tbl, $condites)->result();
```
Karena `'seller_id'` tidak ada di `$arrHeaders`, seluruh data puluhan salesman yang di-loop di-set memiliki `seller_id = 0`. Sistem kehilangan kemampuan membedakan record antar-salesman.

### 3. Logika Update Terkunci Jeda Waktu 1 Jam
Pada [`MdlReporting.php`](file:///z:/san_11sep/application/models/Mdls/MdlReporting.php#L924-L936):
```php
$selisih = $stamp_now - $last_stamp;
if(($last_update_bln == $bln_now) && ($selisih > 3600)){
    $last_id = $this->updateData($condites, $newdatas);
} else {
    // Tidak update
}
```
Ketika sinkronisasi berjalan dalam 1 transaksi loop (dalam detik yang sama, `$selisih = 0`), pengecekan update dilewati, dan proses penulisan melakukan `INSERT` baris baru terus-menerus.

### 4. Tidak Adanya Constraint `UNIQUE` pada Tabel Database
Tabel `run_san_report.penjualan_order` tidak memiliki `UNIQUE KEY` majemuk pada kombinasi `(dtime, seller_id)`. MySQL mengizinkan baris baru masuk berulang-ulang dengan tanggal dan nilai yang sama.

### 5. Akumulasi Buta saat Pembacaan Data (`+=`)
Pada [`MdlReporting.php`](file:///z:/san_11sep/application/models/Mdls/MdlReporting.php#L1047-L1055):
```php
foreach ($cekdatas as $cekdata) {
    foreach ($sumKoloms as $sumKolom) {
        $sumKey[$sumKolom] += $cekdata->$sumKolom; // <-- Penjumlahan berulang atas seluruh baris
    }
}
```
Ketika baris data snapshot untuk tanggal `2026-09-30` sudah terduplikasi puluhan kali, sistem menjumlahkan semuanya tanpa `DISTINCT` atau `GROUP BY`, menghasilkan angka akumulasi fantastis **Rp 948 Miliar**.

---

## 5. Implementasi Solusi: Proteksi 3-Way-Matching

Proteksi telah dipasang langsung pada file [`application/modules/dashboard/controllers/Graph.php`](file:///z:/san_11sep/application/modules/dashboard/controllers/Graph.php) di method `callSummary()` dan `callSummary_now()`:

```php
// =========================================================================
// PROTEKSI 3-WAY-MATCHING (Three-Way Reconciliation & Guardrail)
// Pilar 1: Prev Outstanding (Awal) -> $prev_outstanding_awal
// Pilar 2: Order Masuk YTD -> $src_datas->netto_ytd
// Pilar 3: Realisasi Pengiriman YTD -> $kirim_ytd_actual
// =========================================================================
$kirim_ytd_actual = (isset($src_datas->kredit_ytd_cache) && $src_datas->kredit_ytd_cache > 0)
    ? ($src_datas->kredit_ytd_cache * 1)
    : ($src_datas->kredit_ytd * 1);

$total_order_obligation = ($prev_outstanding_awal * 1) + ($src_datas->netto_ytd * 1);
$outstanding_3way = max(0, $total_order_obligation - $kirim_ytd_actual);

$raw_outstanding = isset($src_datas->outstanding) ? ($src_datas->outstanding * 1) : 0;

// Guardrail: Nilai raw dianggap anomali jika:
// 1. Melebihi total seluruh beban order (Prev + Order YTD)
// 2. Bernilai negatif padahal beban order masih ada
// 3. Memiliki deviasi ekstrem (>50% dari total order obligation)
$is_anomaly = false;
if ($raw_outstanding > $total_order_obligation) {
    $is_anomaly = true;
} elseif ($raw_outstanding < 0) {
    $is_anomaly = true;
} elseif ($total_order_obligation > 0 && abs($raw_outstanding - $outstanding_3way) > (0.5 * $total_order_obligation)) {
    $is_anomaly = true;
}

if ($is_anomaly) {
    $final_outstanding_ytd = $outstanding_3way;
    log_message('error', "3-Way-Matching: Anomali Last Outstanding terdeteksi (raw: $raw_outstanding vs max: $total_order_obligation). Diproteksi ke nilai rekonsiliasi 3-way: $outstanding_3way");
} else {
    $final_outstanding_ytd = $raw_outstanding;
}

$penjualan['outstanding_ytd']["kredit"] = $final_outstanding_ytd;
$penjualan['outstanding']["kredit"] = $final_outstanding_ytd;
```

### Proteksi Lanjutan pada Level MTD & Saldo Awal:
1. **Kirim MTD New:** Diproteksi agar tidak mungkin melebihi `Order MTD` dan `Kirim MTD All`.
2. **Outstanding MTD New:** Dihitung presisi $\max(0, \text{Order MTD} - \text{Kirim MTD New})$.
3. **Saldo Awal Guardrail:** Diproteksi agar nilai `Prev Outstanding` tidak mungkin melebihi batas wajar total order tahunan.

---

## 6. Panduan SQL untuk Memeriksa & Membersihkan Database

### A. Melihat Data Duplikat di Database `run_san_report`
```sql
-- 1. Cek total baris dan total nilai yang membengkak di bulan September 2026
SELECT 
    dtime AS tanggal_snapshot,
    COUNT(*) AS jumlah_baris,
    SUM(last_kredit_all) AS total_outstanding,
    SUM(now_saldo_order_all) AS total_order,
    SUM(now_saldo_kirim_all) AS total_kirim
FROM run_san_report.penjualan_order
WHERE dtime = '2026-09-30';

-- 2. Cek baris duplikat per seller
SELECT 
    dtime,
    seller_id,
    COUNT(*) AS jumlah_duplikat,
    SUM(last_kredit_all) AS total_nilai_terakumulasi
FROM run_san_report.penjualan_order
WHERE dtime >= '2026-01-01'
GROUP BY dtime, seller_id
HAVING COUNT(*) > 1
ORDER BY jumlah_duplikat DESC;
```

### B. Prosedur Pembersihan Data
```sql
-- 1. Buat tabel backup terlebih dahulu
CREATE TABLE run_san_report.penjualan_order_bak_sep2026 AS 
SELECT * FROM run_san_report.penjualan_order WHERE dtime = '2026-09-30';

-- 2. Hapus data snapshot corrupt pada tanggal berjalan
DELETE FROM run_san_report.penjualan_order WHERE dtime = '2026-09-30';
```

---

## 7. Rekomendasi Jangka Panjang

1. **Aktifkan Kembali `'seller_id'`:**  
   Buka komentar pada baris 3124 file [`Outstanding.php`](file:///z:/san_11sep/application/modules/laporan/controllers/Outstanding.php#L3124) agar array `$arrHeaders` selalu menyertakan `seller_id` saat penulisan snapshot cache.
2. **Tambahkan UNIQUE Constraint di MySQL:**
   ```sql
   ALTER TABLE run_san_report.penjualan_order ADD UNIQUE KEY uq_dtime_seller (dtime, seller_id);
   ```
   Constraint ini akan secara permanen mencegah database menerima baris duplikat untuk seller dan tanggal yang sama.
