# Perbaikan Bug Varian dan Integrasi Keranjang Belanja

## Sintesis 12 Dokumen Sumber: Modul Varian, Shopping Cart, dan Modul Pendukung

Dokumen ini menghimpun 12 catatan teknis (blueprint, bug report, checklist, analisis alur) menjadi satu rujukan tunggal. Fokus: **akar masalah -> solusi -> status**, plus dependensi silang antar dokumen.

| Status | Arti |
|---|---|
| SELESAI | Fix sudah diimplementasikan dan/atau diverifikasi |
| PARSIAL | Fix inti masuk, hardening sekunder tertunda |
| BELUM | Baru dianalisis, belum ada perubahan kode |
| BELUM-MULAI | Desain/blueprint, implementasi belum dimulai |

---

## 1. Ringkasan Eksekutif

Dua belas dokumen ini bukan dua belas topik terpisah, melainkan satu jaringan: **bug varian adalah gejala, `cart_key` yang tidak seragam adalah penyakitnya.**

**Empat akar masalah yang saling menumpuk:**

1. **Identitas varian tidak pernah diseragamkan.** Empat format `cart_key` hidup berdampingan: `variant:{variant_id}`, `variant:{produk_id}:{variant_id}`, `sku:{produk_id}:{sku}`, plus fallback seperti `produk_id|variant_id`. Parser di banyak titik masih berasumsi format 1-segmen (misal `str_replace('variant:', ...)`), sehingga `variant_id` salah resolve dan qty varian bisa collapse/merge antarbaris.

2. **`variant_id` gagal diteruskan ke lapisan loker stok.** Integrasi `ComLockerStockDualWrite` tidak menyuntikkan identitas varian, sehingga `ComLockerStockVariant` menganggap produk varian sebagai non-varian dan mendaratkannya ke produk induk (`variant_id = 1`). Karena hold produk induk kosong, penambahan minus memicu "Stok tidak cukup".

3. **`transaksi_data` untuk baris varian kosong.** Mapping `detail2_sum` di `coTransaksiCore.php` sudah benar, tapi PreProcessor `FifoProdukJadi` tidak meng-output lima field yang dibutuhkan (`label`, `kode`, `satuan`, `variant_id`, `sku`), dan `items2_sum` bisa dihapus-diisi-ulang oleh `addDetailData` sehingga data asli hilang.

4. **Arsitektur view tidak kompatibel dengan AJAX modal.** `variant_settings.php` adalah full HTML document (772 baris) yang dimuat via `BootstrapDialog.show()` + jQuery `.load()`. Urutan inisialisasi bootstrap-select terbalik: `render()` dipanggil sebelum inline script dievaluasi, sehingga konfigurasi kustom (`liveSearch: true`, `width: '320px'`) tidak pernah diterapkan.

**Satu bug yang sudah tuntas:** cross-row reset diskon/premi di `transaksi_reguler.html` -- SELESAI, pola row-scoped by `noid` pada 5 handler.

**Pola berulang: "perbaikan di satu titik menggeser bug ke titik lain".** Bug loker diperbaiki dengan menyuntik `variant_id`, tetapi pemecah `variant_id` dari `cart_key` masih rapuh di 20+ titik. Bug data kosong diperbaiki dengan menambah 5 key di PreProcessor, tetapi `addDetailData` masih bisa menimpa `items2_sum` dengan data terbatas. Bug modal diperbaiki dengan Opsi B, masih menyisakan masalah arsitektur full-document.

**Rekomendasi lintas dokumen:** tidak ada satu pun perbaikan varian yang boleh dianggap selesai sebelum normalisasi `cart_key` di layer model/bridge rampung. Normalisasi key adalah prasyarat; patch di titik-titik pemanggil adalah terapi gejala.

**Modul sampingan** (Estimate custom item, multi PIC, Sempoa, logo, sampah) tidak menyentuh alur varian secara langsung, tetapi tetap masuk sintesis karena: (a) `estimate_custom_items` memengaruhi blocking konversi ke Sales Order yang bergantung ke stabilitas `variant_id`; (b) `variant_cutover_backup/` adalah kandidat hapus paling berisiko karena berisi salinan `shoppingCart.php` yang masih dirujuk sebagai entry point modal.

---

## 2. Keranjang Belanja dan Kunci Varian (Cart Key, Flow Add-to-Cart)

### 2.1 Kontrak Kanonik `cart_key`

Format target tunggal: `variant:{produk_id}:{variant_id}` (canonical, 3 segmen).

| # | Format | Contoh | Sumber |
|---|---|---|---|
| 1 | `variant:{variant_id}` | `variant:68` | Dominan di workspace Holding (`san_varian`) |
| 2 | `variant:{produk_id}:{variant_id}` | `variant:1460:68` | Target subsidiary (`san_sarana_8apr`) |
| 3 | `sku:{produk_id}:{sku}` | `sku:1460:SKU-ABC` | Varian berbasis SKU |
| 4 | Fallback lain | `produk_id`, `produk_id\|variant_id`, `produk_id:variant_id` | Non-varian / legacy |

**Masalah inti:** `str_replace('variant:', ...)` menghasilkan `68` untuk format 1, tetapi `1460:68` untuk format 2 -- sehingga `variant_id` ter-resolve jadi string berisi titik dua dan perbandingan integer gagal.

**Kebijakan:** semua write baru dinormalisasi ke format 3-segmen; semua parser tetap menerima format lama tetapi harus menyimpan ulang ke canonical; CRM tidak perlu memaksakan `cart_key`, cukup menjamin payload outbound memuat `produk_id + variant_id` yang stabil.

### 2.2 Helper yang Harus Dibuat

`parseCartKey()` -- tolerant reader untuk 1, 2, 3 segmen, sku, dan legacy. `buildCartKeyCanonical(produk_id, variant_id, variant_sku)` -- builder writer. `parseCartKey()` adalah satu-satunya pintu masuk; seluruh `str_replace('variant:', ...)` mentah harus diganti dengannya.

### 2.3 Alur Add-to-Cart dari CRM

```
ORDER BARU DARI CRM (TransaksiCrm::viewOrderCrm)
   |  baca tabel penjualan_transaksi_data_crm_bridge
   v klik "followup" -> Create::previewCrm()
   |  - lookup customer di per_customers
   |  - build $listProduk dari bridge rows
   |  - simpan session $_SESSION[$cCode]["crmPreviewPayload"][$estimate_id]
   v preview + tombol "To Shopping Cart" -> followupCrmToShoppingCart(targetUrl)
   |  POST { pihakMainExec, use_session_payload: 1 }
   v _processSelectProduct::selectCrm()
   |  - baca enc payload, fallback ke crmPreviewPayload
   |  - set $_SESSION[$cCode]["main"]["pihakMainExec"]
   |  - loop items -> inisialisasi session item
   |  - set crm_harga, ref_order = "crm", ref_order_estimate_id
   v $_SESSION[$cCode]['items'][$sessionItemKey] = $tmp
   v refresh _shoppingCart/viewCart  ->  redirect Create/index
```

**Titik kritis pembentukan key** -- `_processSelectProduct.php:5591-5611`:

```php
$sessionItemKey = $variantId > 0 ? "variant:" . $variantId : (string)$produk_id;
```

Kode ini adalah **writer 1-segmen**: ia membangun `variant:{variant_id}` dan tidak pernah menyertakan `produk_id`. Inilah celah yang harus ditutup agar canonical bisa tercapai.

### 2.4 Rincian Kode Kunci Alur CRM ke Cart

Semua path relatif terhadap `application/modules/penjualan/`.

| File | Baris | Fungsi/Peran |
|---|---|---|
| `controllers/TransaksiCrm.php` | 83 | `$link = MODUL_PATH . "Create/previewCrm/" . $this->jenisTr . "/" . $tmp_0->estimate_id` |
| `controllers/TransaksiCrm.php` | 102 | Tombol followup tampilan lama via `showModal()` |
| `controllers/TransaksiCrm.php` | 128 | Tombol followup via `indexToCreate()` |
| `controllers/TransaksiCrm.php` | 232-234 | Tampilan baru; jalur lama `_processSelectProduct/selectCrm` dikomentari |
| `controllers/Create.php` | ~18192-18671 | `previewCrm()` -- baca `penjualan_transaksi_data_crm_bridge` |
| `controllers/Create.php` | 18649-18657 | Simpan `$_SESSION[$cCode]["crmPreviewPayload"][$estimate_id] = {pihak_id, items, updated_at}` |
| `controllers/Create.php` | 18671 | Target: `_processSelectProduct/selectCrm/{jenisTr}/{estimate_id}?pihakID=` |
| `views/create.php` | 297 | Tombol `btn-to-shopping-cart` aktif |
| `views/create.php` | 722-746 | `followupCrmToShoppingCart()` plus registrasi ke `window` dan `top` |
| `views/create.php` | 512 | `buildShoppingCartTarget()` menambah `pihakMainExec` |
| `controllers/_processSelectProduct.php` | 5208 | `public function selectCrm()` |
| `controllers/_processSelectProduct.php` | 5225-5231 | Baca payload `enc` atau fallback `crmPreviewPayload` |
| `controllers/_processSelectProduct.php` | 5234-5235 | Fallback `custID` dari `pihak_id` |
| `controllers/_processSelectProduct.php` | 5245-5246 | Fallback `$lsitProduk` dari `items` |
| `controllers/_processSelectProduct.php` | 5316-5327 | Set `pihakMainExec` (`"san"` / `"local"`) |
| `controllers/_processSelectProduct.php` | 5589-5590 | Set `ref_order = "crm"`, `ref_order_estimate_id` |
| `controllers/_processSelectProduct.php` | 5591-5611 | **Pembentukan `$sessionItemKey` (writer 1-segmen)** |
| `controllers/_processSelectProduct.php` | 5928-5940 | Inisialisasi session item bila key belum ada |
| `controllers/_processSelectProduct.php` | 6094-6102 | Simpan `crm_harga` plus `harga_ori` |
| `controllers/_processSelectProduct.php` | 6124 | `$_SESSION[$cCode]['items'][$sessionItemKey] = $tmp` |
| `controllers/_processSelectProduct.php` | 6267-6268 | Set `ref_order` per-item |
| `controllers/_processSelectProduct.php` | 6722-6723 | `unset()` payload setelah diproses |
| `controllers/_processSelectProduct.php` | 6756-6769 | Redirect `Create/index` |
| `controllers/_processSelectProduct.php` | 5203 | Refresh `top.$('#shopping_cart').load('_shoppingCart/viewCart/...')` |
| `views/transaksi.php` | 15316 | Header "ORDER BARU DARI CRM" |
| `views/transaksi.php` | 15427 | Class tombol `btn btn-xs btn-... btn-crm-followup` |
| `views/transaksi.php` | 15594-15609 | `runAction(action)` dispatch `indexToCreate` / `showModal` |
| `views/transaksi.php` | 15962 | `$('#arrayOnProgress_crm').on('click', '.btn-crm-followup', handleFollowUp)` |

### 2.5 Mekanisme Lock Harga CRM

`_processSelectProduct.php:6331-6372` -- saat `$isCrmPriceLocked` aktif, harga CRM disimpan di `$tmp['crm_harga']`, bukan langsung di `harga`:

```php
if ($isCrmPriceLocked) {
    $hargaStandar = $this->normalizeFloatValue($harga_yg_dipakai);
    $hargaNetCrm = $crmLockedPrice;
    // Jika CRM mengirim harga 0 (diskon 100%), nilai 0 harus tetap dipakai.
    // Fallback ke harga standar hanya untuk kasus payload harga CRM kosong.
    if ($crmLockedPriceIsExplicit === false) { $hargaNetCrm = $hargaStandar; }
    if ($hargaNetCrm >= $hargaStandar) { $premiCrm = $hargaNetCrm - $hargaStandar; }
    else { $diskonCrm = $hargaStandar - $hargaNetCrm; }
}
```

Distinction `harga_ori` versus `crm_harga` penting agar harga standar tetap tersedia untuk menghitung diskon/premi. Nilai 0 dari CRM (diskon 100%) wajib dihormati, tidak boleh dianggap "payload kosong".

---

## 3. Bug Varian Data Kosong (Step 2 Followup 7881/881)

### 3.1 Gejala

Semua field `transaksi_data` untuk baris varian target (step 2) kosong: `produk_id=0, produk_nama='', produk_kode='', produk_label='', satuan='', produk_ord_jml='', valid_qty='', variant_id=0, variant_sku=''`.

Sementara baris non-varian di step yang sama terisi penuh: `produk_id=1460, produk_nama='SLEEVE WHITE 1.5', ..., valid_qty=24216`.

### 3.2 Data Flow

`items (sumber)` -> `PreProcessor (FifoProdukJadi)` -> `rsltItems` -> `afterPreProcessorInjector` -> `items2_sum` -> `fillValues_he_value_builder` (bergantung pada mapping `detail2_sum`) -> `tableIn_detail2_sum` -> `writeDetailEntries` -> `transaksi_data`.

### 3.3 Bug Kritis: Field `id` di resultParams PreProcessor

Ini akar masalah paling penting. `FifoProdukJadiVarian` resultParams memetakan `"id" => "produk_id"`. Di `MdlFifoProdukJadiVarian`: `$row->produk_id` = **produk sumber** (1460), sedangkan `$row->variant_id` = **produk varian** (68). Akibatnya semua baris varian ditulis dengan `produk_id=1460`, sehingga momen `valid_qty` di `followupPrePreview` tercemar.

**Fix** -- `coTransaksiCore.php:953`: ubah menjadi `"id" => "variant_id"`. Fix ini sekaligus menghilangkan kebutuhan fallback `produk_id` di `afterPreProcessorInjector` maupun di blok enrichment.

### 3.4 Status Mapping `detail2_sum`

Mapping di `coTransaksiCore.php` sudah **benar dan lengkap**: `"produk_id" => "id"`, `"produk_kode" => "produk_kode"`, `"produk_label" => "label"`, `"produk_nama" => "nama"`, `"produk_ord_jml" => "jml"`, `"produk_ord_hrg" => "hpp"`, `"satuan" => "satuan"`, `"valid_qty" => "jml"`, `"variant_id" => "variant_id"`, `"variant_sku" => "sku"`.

**Implikasi:** mapping bukan masalahnya. Yang rusak adalah sumber datanya -- `items2_sum` tidak pernah menerima key-key tersebut.

### 3.5 Root Cause #1: Output PreProcessor Tidak Lengkap

**File:** `coTransaksiValues.php:203-219` (resultParams `FifoProdukjadi`). PreProcessor hanya output `id, nama, name, hpp, jml, qty, hpp_riil, ppv_riil, subtotal, ppn_in, ppn_in_nilai, suppliers_id, suppliers_nama`. Lima key yang dibutuhkan mapping tapi tidak di-output: `label` (bikin `produk_label` kosong), `kode` (bikin `produk_kode` kosong), `satuan` (bikin `satuan` kosong), `variant_id` (bikin `variant_id` = 0), `sku` (bikin `variant_sku` kosong).

### 3.6 Root Cause #2: `items2_sum` Dihapus dan Diisi Ulang

**File:** `FollowUp.php:5933-6032`. Jika config `addDetailData` diset untuk tipe `variant_cutover`:

```php
unset($_SESSION[$cCode]["items2_sum"]);   // HAPUS items2_sum
// ... loop items_child ...
$_SESSION[$cCode]["items2_sum"][$insertDataID] = $paramDetil;  // isi ulang data TERBATAS
```

Data asli dari PreProcessor hilang, diganti `$paramDetil` yang hanya punya field dari mapping `changeToItems` (belum tentu lengkap).

### 3.7 Root Cause #3: `fillValues` Dipanggil Ulang Setelah `items2_sum` Rusak

Regresi yang disebabkan oleh perubahan sebelumnya sendiri. Penambahan `fillValues_he_value_builder` di `FollowUp.php:6038-6039` (setelah loop `items_child`) me-rebuild `tableIn_detail2_sum` dari `items2_sum` yang sudah terkorupsi, menimpa data benar dari panggilan `fillValues` sebelumnya di **line 4880**.

### 3.8 Fix yang Diimplementasikan

| Fix | File | Perubahan |
|---|---|---|
| #1 | `coTransaksiValues.php` | Tambah 5 key di `resultParams.rsltItems` untuk **881 dan 7881**: `label`, `produk_kode`, `satuan`, `variant_id`, `sku` |
| #2 | `FollowUp.php` | REVERT -- hapus panggilan `fillValues` tambahan di ~6038 |
| #3 | `FollowUp.php` | BARU -- enrichment `items2_sum` via DB lookup sebelum `fillValues` di ~4878 |

Detail Fix #3: untuk setiap entry `items2_sum` yang punya `id` (produk_id) lebih besar dari 0 tetapi missing `label`, `produk_kode`, `satuan` -- query tabel `produk` by `id` untuk isi `label`, `produk_kode`, `satuan`, `sku`; query tabel `produk_variant` by `produk_id` untuk isi `variant_id`.

### 3.9 Status dan Verifikasi

**Status: PARSIAL** -- ketiga fix masuk, tetapi kontradiksi dengan Root Cause #2 belum tuntas (lihat Bagian 10, K2).

Verifikasi: cek `transaksi_data` step 2, `produk_id` bukan 0, `produk_nama`/`produk_kode`/`produk_label`/`satuan` terisi, `valid_qty = jml`, `variant_id` dan `variant_sku` terisi. Jika `produk_id` masih 0, tambahkan debug di `FollowUp.php` sekitar 4877: `file_put_contents("php://stderr", print_r($_SESSION[$cCode]["items2_sum"], true));`

**Rollback plan:** revert `coTransaksiValues.php` (Fix #1); hapus blok enrichment dan kembalikan `fillValues` di `FollowUp.php`; kembalikan `fillValues` di line 6038.

---

## 4. Bug Varian Settings Modal (Bootstrap-Select Gagal)

### 4.1 Matriks Gejala per Entry Point

`application/views/products/variant_settings.php` adalah **standalone full HTML document**: `<!doctype html>`, `<html>`, `<head>` berisi 8 CSS `<link>` dan 8 JS `<script src>` (termasuk jQuery, Bootstrap, DataTables, bootstrap-select), `<body>` dengan 15+ blok `<script>` inline, plus `<style>` kustom. Total **772 baris**.

| Point | Cara Buka | Mekanisme | Status |
|---|---|---|---|
| 1 | Master Data Produk (Data controller) | `<iframe>` di halaman edit produk | Berfungsi |
| 2 | New Tab / Open New Window | Full page load | Berfungsi |
| 3 | Module `variant_cutover` | `BootstrapDialog.show()` via AJAX `.load()` | selectpicker gagal |
| 4 | Module `konversi_varian` | Sama seperti point 3 | selectpicker gagal |

### 4.2 Enam Masalah Teridentifikasi

| # | Masalah | Lokasi | Analisis |
|---|---|---|---|
| 1 | Script external tidak pernah di-load ulang | `shoppingCart.php:580-586` | `content.find('script').each(... $.globalEval(this.text ...))` -- untuk `<script src="...">`, `this.text` = string kosong, jadi `$.globalEval("")` tidak ada efek. jQuery/Bootstrap/bootstrap-select/DataTables dari `<head>` tidak pernah dimuat ulang. Tidak fatal karena parent page sudah punya semua library (template `transaksi-modul.html` line 34-53). |
| 2 | CSS `<link>` dan `<style>` diproses ulang browser | `variant_settings.php` `<head>` | Saat full HTML di-set sebagai `innerHTML` dari `<div>`, perilaku `<head>` children dan `<style>` inline tidak konsisten antarbrowser; CSS bootstrap-select bisa tidak terapilkasi. |
| 3 | **Urutan inisialisasi salah -- ROOT CAUSE UTAMA** | `shoppingCart.php:568-586` | Lihat 4.3. |
| 4 | Event handler tidak terikat ke elemen benar | `variant_settings.php:289-306` | Script mengikat `focus`/`mousedown` ke `#sel-attr-master` (native `<select>`), tetapi bootstrap-select menyembunyikan `<select>` dan membuat `<button>` baru. Handler menempel ke elemen tersembunyi. |
| 5 | Referensi jQuery `jq` | `variant_settings.php:211` | `window.jq = window.$ \|\| window.jQuery \|\| null`. Karena external script tidak di-reload, `window.$` milik parent page. Harmless karena library sama. |
| 6 | `DOMContentLoaded` sudah terlewat | `variant_settings.php:756-770` | Listener memakai `DOMContentLoaded` yang sudah lewat saat script dievaluasi via `$.globalEval()`. Badge coloring logic tidak jalan. |

### 4.3 Akar Masalah Utama: Step A vs Step B

Handler `onshown` di `shoppingCart.php:568-586` menjalankan dua langkah dalam urutan **terbalik**.

**Step A (line 570-578)** -- SEBELUM inline script dievaluasi: `content.find('.selectpicker').selectpicker('render');` -- menginisialisasi selectpicker dengan **default options** (tanpa `liveSearch: true`, tanpa konfigurasi kustom).

**Step B (line 580-586)** -- evaluasi inline scripts via `content.find('script').each(function() { $.globalEval(...); });`. Salah satu script yang dievaluasi adalah `scheduleAttrMasterInit()` (line 750) yang memanggil `initAttrMasterSelect()` (line 213):

```javascript
window.initAttrMasterSelect = function() {
    var el = window.jq('#sel-attr-master');
    if (el.parent('.bootstrap-select').length > 0) {
        el.selectpicker('refresh');   // SUDAH di-INIT oleh Step A!
        return;                        // KELUAR tanpa konfigurasi kustom
    }
    // Kode berikut TIDAK PERNAH tercapai:
    el.selectpicker({liveSearch: true, width: '320px', dropupAuto: false});
    el.selectpicker('refresh');
};
```

Karena Step A sudah `render()`, maka `el.parent('.bootstrap-select').length > 0` bernilai **TRUE**, sehingga fungsi **return tanpa** menerapkan `liveSearch: true` dan `width: '320px'`.

**Akibat:** `liveSearch` mungkin tidak aktif (meski ada `data-live-search` di HTML, tergantung versi bootstrap-select), `width` tidak sesuai, dan event handler kustom (line 289-306, 360-379) ter-attach ke elemen yang salah karena DOM sudah dimodifikasi bootstrap-select.

### 4.4 Opsi Solusi

**Opsi A -- Konversi View Menjadi Partial (direkomendasikan).** Pecah view menjadi `variant_settings_full.php` (full HTML document, untuk iframe dan new tab) dan `variant_settings_partial.php` (hanya konten form plus inline script, tanpa `<html>`, `<head>`, `<body>`, tanpa load external JS/CSS). Tambahkan parameter `?partial=1` atau `?attached=1` di `application/controllers/ProductVariantSettings.php`, atau buat method `edit_partial()`. Ubah URL di `shoppingCart.php` line 561 menjadi `...&partial=1` dan hapus `onshown` handler selectpicker `'render'`. Pindahkan inisialisasi bootstrap-select ke fungsi yang dipanggil eksplisit setelah `.load()` selesai. *Kelebihan: arsitektur bersih, maintainable. Kekurangan: perubahan lebih banyak, perlu testing ulang semua entry point.*

**Opsi B -- Perbaiki Inisialisasi di `onshown` (minimal changes).** Ubah `onshown` handler di `shoppingCart.php` line 568-586, dan identik di `konversi_varian` serta `variant_cutover_backup`. Urutannya: set `$.fn.selectpicker.Constructor.BootstrapVersion = '3'` bila `$.fn.tooltip.Constructor` tersedia; evaluasi inline scripts **dahulu** dengan `try/catch` per script; baru loop `content.find('.selectpicker')` untuk `destroy` bila `parent('.bootstrap-select').length > 0`, lalu `selectpicker({liveSearch: true, width: '320px', dropupAuto: false})` dan `selectpicker('refresh')`; terakhir `setTimeout` 100ms memanggil `window.bootAttrMasterSelect()`. Tambahan opsional di `variant_settings.php:750` untuk guard double-fire:

```javascript
if (window.scheduleAttrMasterInit && !window.__variantInited) {
    window.__variantInited = true;
    window.scheduleAttrMasterInit();
}
```

Jika CSS `<style>` dari `<head>` tidak terapilkasi, pindahkan ke inline `<style>` di `<body>` atau append manual dengan `$('head').append(content.find('style').first());`. *Kelebihan: perubahan minimal, risiko rendah. Kekurangan: tidak menyelesaikan root cause arsitektur.*

**Opsi C -- Hybrid (paling direkomendasikan).** Buat `variant_settings_partial.php`, controller mendeteksi `?partial=1`, semua module bermodal memanggil dengan `&partial=1`, dan `onshown` handler disederhanakan menjadi jalankan inline script lalu panggil fungsi init.

### 4.5 Peta Kode Kunci

**`shoppingCart.php` (3 module) baris 561-589:**

```php
$editVarianTombol = base_url() . "ProductVariantSettings/edit?sID=$iID&ctx=konversi&jenisTr=" . $this->jenisTr;
$link_tombol = "BootstrapDialog.show({ title: 'Tambah Data Varian Produk',
  message: $('<div></div>').load('$editVarianTombol'),
  size: BootstrapDialog.SIZE_WIDE, draggable: false, closable: true,
  onshown: function(dialogRef) { /* ... */ } });";
```

**Inline script `variant_settings.php` (772 baris):**

| Line | Fungsi | Keterangan |
|---|---|---|
| 211 | `window.jq` | Referensi jQuery |
| 213-242 | `initAttrMasterSelect()` | Inisialisasi bootstrap-select dengan opsi kustom |
| 244-264 | `bootAttrMasterSelect()` + `scheduleAttrMasterInit()` | Wrapper + scheduler |
| 267-287 | Toggle checkbox | Show/hide step sections |
| 289-306 | Event listener `focus`/`mousedown` pada `#sel-attr-master` | Trigger init |
| 308-336 | `syncPriceMode()` | Toggle kolom harga |
| 360-379 | `change` handler pada `#sel-attr-master` | Trigger add + refresh |
| 750-752 | Panggil `scheduleAttrMasterInit()` | Init otomatis |
| 756-770 | `DOMContentLoaded` | Tidak berguna via `$.globalEval` |

**File yang terlibat:**

| File | Path | Peran |
|---|---|---|
| Controller | `application/controllers/ProductVariantSettings.php` | Controller halaman variant settings |
| View (utama) | `application/views/products/variant_settings.php` | Standalone HTML page (772 baris) |
| View (copy) | `application/views/products/variant_settings - Copy.php` | Backup/old version (544 baris) |
| Module 1 | `application/modules/variant_cutover/views/shoppingCart.php` | Line 561-589 memanggil modal |
| Module 2 | `application/modules/variant_cutover_backup/views/shoppingCart.php` | Sama (backup) |
| Module 3 | `application/modules/konversi_varian/views/shoppingCart.php` | Sama |
| Config | `application/config/heDataBehaviour.php` | Line 1405: deklarasi `dataExtRelation` untuk iframe |
| Template Data | `application/template/data.html` | Template master data (punya bootstrap-select) |
| Template Module | `application/modules/variant_cutover/template/transaksi-modul.html` | Template variant_cutover (punya bootstrap-select) |
| Route | `application/config/routes.php` | Line 63-68: routing ProductVariantSettings |

### 4.6 Matriks Pengujian Pasca-Fix

10 skenario wajib: (1) Master Data via iframe, panel varian muncul dan selectpicker berfungsi; (2) New Tab, halaman utuh berfungsi; (3) modal `variant_cutover`, selectpicker bisa search/pilih; (4) modal `konversi_varian`, sama seperti point 3; (5) save di modal sukses; (6) save di full page sukses; (7) tombol "Buat Atribut" di modal dan full page; (8) tombol "Tambah Nilai" di modal dan full page; (9) refresh setelah save, data varian muncul; (10) sync ke parent (`ctx=konversi`) setelah save di modal.

**Status: PARSIAL** -- blueprint menyediakan 3 opsi, belum ada catatan implementasi mana yang dipilih atau dieksekusi.

---

## 5. Bug JS Diskon dan Premi (Cross-Row Reset)

### 5.1 Gejala dan Dampak

Saat user mengisi **`Disc (%)`** di satu row (misal row 5), handler JS lama memproses **banyak row sekaligus**, bukan hanya row aktif. Akibatnya row lain yang punya **premi** (misal row 2-4 dengan premi 20.000) ikut di-reset ke `0`, dan `Price(Net)` balik ke `Harga Jual Excl. Ppn` (contoh 700.000).

Dampak bisnis: nilai `nett` dan `subtotal` antar-item tidak konsisten; potensi salah total transaksi yang berdampak ke nilai penjualan/diskon di jurnal.

### 5.2 Perbaikan yang Diterapkan (SELESAI)

**File:** `application/modules/penjualan/template/transaksi_reguler.html`. Lima handler di-row-scope by `noid`: `disc_percent` keyup (line 1245), `_diskon_nilai` keyup (line 1310), `premi_percent` keyup (line 1360), `premi` keyup (line 1400), `nett1` keyup (line 1441).

```javascript
$("input[keyid=disc_percent]").on('keyup', function(){
    var noid = $(this).attr('noid');                    // <-- AMBIL ROW SAJA
    var diskon_persen = parseFloat(removeCommas($('#disc_percent_'+noid).val())) || 0;
    var harga_non = parseFloat(removeCommas($('#harga_exclude_ppn_'+noid).html())) || 0;
    // update hanya row noid ini -- tidak menyentuh row lain
});
```

Kunci: setiap operasi DOM di-scope dengan `'#field_'+noid`. Inilah yang memutus rantai side-effect antar-row.

### 5.3 Kalkulasi Total: `calcShoppingCartPettycash()`

**Line 1118-1199** -- fungsi ini membaca semua row dengan `jQuery.each(indexed, ...)` tetapi **tidak mengubah** nilai diskon/premi row lain, hanya membaca untuk kalkulasi grand total. **Line 1133-1156** -- kalkulasi premi versus diskon per row:

```javascript
if (harga_non <= 0) { diskon_absolute = 0; premi_absolute = hargaNettInput > 0 ? hargaNettInput : 0; }
else if (diskon_absolute > harga_non) { diskon_absolute = harga_non; }
else if (diskon_absolute <= 0 && hargaNettInput > harga_non) {
    premi_absolute = hargaNettInput - harga_non;
    premi_persen = (premi_absolute / harga_non) * 100;
    diskon_absolute = 0;
}
```

Tiga guard penting: harga 0 memaksa diskon 0; diskon melebihi harga di-clamp ke harga; diskon kurang dari atau sama dengan 0 dengan nett di atas harga dihitung sebagai **premi**, bukan diskon negatif.

### 5.4 Flow Diagram Shopping Cart

```
User input di shopping cart row
       v
keyup/input event handler (row-scoped by noid)
       |- disc_percent -> diskon absolute      |- _diskon_nilai -> diskon persen
       |- premi_percent -> premi absolute      |- premi_absolute -> premi persen
       |- nett1 (Price Net) -> diskon/premi dari harga nett
       v
calcShoppingCartPettycash()
       |- Loop semua row via jQuery.each(indexed)
       |- Baca harga_non, diskon_absolute, premi dari DOM per row
       |- Hitung hrg_net = harga_non - diskon + premi, lalu subtotal per row
       |- Hitung grand_total, grand_total_bruto, totalPPN
       v
Update DOM: nett1_[noid], subtotal_[noid], grand total display, PPN display
```

### 5.5 Risiko Tersisa (BELUM)

| # | Risiko | Lokasi | Level | Detail dan Rekomendasi |
|---|---|---|---|---|
| 1 | Validasi `Disc (%) > 100` lemah | `transaksi_reguler.html:1268` | Medium | `if( Math.round(diskon_persen).toFixed(3) *1 > 100 )` -- nilai `100.4` di-round ke `100`, lolos warning padahal seharusnya ditolak. Rekomendasi: `if (diskon_persen > 100) { /* reset + warning */ return; }` |
| 2 | `Disc (%)` negatif belum di-clamp | `transaksi_reguler.html:1245` | Low | Input `Disc (%) = -5` tidak langsung di-clamp ke 0; nilai negatif bisa berubah jadi premi lewat alur kalkulasi total. Rekomendasi: `if (diskon_persen < 0) { diskon_persen = 0; }` |
| 3 | Event binding hanya `keyup` | Line 1245, 1310, 1360, 1400, 1441 | Medium | Input via **paste** (Ctrl+V) atau context-menu hanya memicu `input`/`change`, bukan `keyup`, sehingga kalkulasi telat. Rekomendasi: `.on('input keyup change', function(){...})` |

### 5.6 Status Pengujian

**Status: SELESAI** untuk cross-row reset; **BELUM** untuk ketiga risiko hardening.

Verifikasi manual: isi premi di row 2/3/4 (misal 20.000), lalu di row 5 isi `Disc (%)` (misal 10), pastikan row 2/3/4 tetap premi dan `Price(Net)` tetap 720.000, lalu cek subtotal per row dan grand total berubah hanya sesuai row yang diedit.

Edge cases tercover: `Harga Jual Excl. Ppn = 0` membuat diskon dipaksa 0 dan premi diperlakukan sesuai input nett/premi; diskon di atas 100% ditolak dan dikembalikan aman; input negatif diskon/premi dinormalisasi ke 0. **Validasi sintaks: LOLOS** -- `php -l application/modules/penjualan/template/transaksi_reguler.html`.

---

## 6. Bug Loker Stok di Shopping Cart

**Tanggal:** 23 Juli 2026. **Lingkup:** Modul Inventori dengan fitur `locker stock` aktif (15 modul). **Konteks:** Rollout Single Variant Standard (Dual-Write Locker).

### 6.1 Dua Gejala

1. **Stok Tidak Cukup (Error Hold Minus).** Saat user memanipulasi qty produk Varian dari dalam shopping cart sehingga terjadi penurunan (misal 5 menjadi 3, hold harus dikurangi 2), sistem melempar error **"Stok tidak cukup"**.
2. **Kuantitas Mentok di 1.** Produk (khususnya Varian Baru) yang belum pernah masuk keranjang, di-input dengan `newQty` tertentu (contoh 23), jumlah tercatat di keranjang tetap `1`. Jika produk sudah ada di keranjang, perubahan kuantitas berjalan normal.

### 6.2 Akar Masalah #1: Variant Leakage (Identitas Varian Tidak Diteruskan)

Saat mengurangi jumlah di keranjang, sistem harus memindahkan selisih dari status `hold` (booking) kembali ke `active`. Namun di **147 file controller** `_processSelect*.php` yang memakai integrasi `ComLockerStockDualWrite`, **sistem alpa meneruskan identitas `variant_id` ke dalam perisai pelindung (static)**.

Rantai kegagalan: (1) `variant_id` tidak masuk ke payload `ComLockerStockDualWrite->pair()`; (2) `ComLockerStockVariant` menganggap produk yang diubah adalah produk non-varian; (3) perintah minus (`-2`) mendarat secara buta ke **produk Induk** (`variant_id = 1`); (4) produk induk memiliki nilai `hold` kosong (`null`); (5) penambahan angka minus terdeteksi sebagai tindakan ilegal, memicu **"Stok tidak cukup"**.

### 6.3 Akar Masalah #2: Hardcode `$tmpJml = 1`

Setiap kali produk dimasukkan ke session shopping cart, parameter `jml` diinisialisasi dari variabel bayangan `$tmpJml` yang di-hardcode `$tmpJml = 1;` pada baris awal, sebelum validasi loker. Normalnya, di bawah baris tersebut ada validasi ke database `stock_locker` fisik, dan bila barang punya riwayat stok `active`, sistem me-replace angka 1 dengan input user via `$tmpJml = $jml_diperlukan;`.

**Namun**, produk Varian Baru benar-benar belum punya jejak fisik stok `active` di `stock_locker` lama, sehingga blok pencarian mengembalikan **0 baris**. Akibatnya penugasan `$tmpJml = $jml_diperlukan` dilewati. Saat pembuatan sesi keranjang, sistem mendaftarkan barang dengan angka `1`, mengabaikan angka `23` yang diketik user. Perbedaan perilaku dengan *update* keranjang terjadi karena jalur *update* langsung menembak `$_GET['newQty']` -- ini mengonfirmasi bug hanya pada jalur *insert*.

### 6.4 Solusi yang Diterapkan

**Fix 1 -- Variant Injection.** Skrip `patch_variant_leak.php` dijalankan pada **39 file** yang memiliki blok pengolah dual write `ComLockerStockDualWrite->pair()`. Dua key disuntikkan sebelum `"cabang_id" => $this->session->login['cabang_id']` dan `"gudang_id" => $this->session->login['gudang_id']`:

```php
"cart_key"    => isset($cartKey) ? $cartKey : (isset($id) ? $id : 0),
"variant_id"  => (isset($_GET['variant_id']) ? $_GET['variant_id'] : (isset($variant_id) ? $variant_id : ((isset($cartKey) && strpos($cartKey, 'variant:') === 0) ? explode(':', $cartKey)[2] : 1))),
```

**Catatan kritis:** `explode(':', $cartKey)[2]` mengasumsikan format 3-segmen. Jika `cartKey` berformat `variant:{variant_id}` (2 segmen), indeks `[2]` tidak ada sehingga varian kembali ter-default ke `1` dan **bug leakage berulang**. Fix ini karena itu bergantung langsung pada normalisasi `cart_key` di Bagian 2.1.

**Fix 2 -- Dynamic `$tmpJml`.** Skrip `patch_tmpjml.php` dijalankan pada **51 file** pengolah seleksi produk lintas modul: `$tmpJml = 1;` menjadi `$tmpJml = isset($jml) ? $jml : 1;`. Dengan ini, sebelum kode mencapai blok loker, sistem sudah memegang kuantitas target dan tidak lagi memotongnya menjadi `1`.

### 6.5 Status

**Status: PARSIAL**

| Aspek | Status |
|---|---|
| Patch `variant_id` injection | 39 file dipatch |
| Patch `$tmpJml` dinamis | 51 file dipatch |
| Syntax check | `php -l` lulus 100% dari total 147 file `_processSelect*.php` |
| Verifikasi ujung-ke-ujung | Qty dari URL diteruskan utuh ke `stock_locker_variant` |
| Ketahanan terhadap `cart_key` 1 dan 2 segmen | **BELUM** -- lihat Bagian 6.4 dan Bagian 10 K1 |

---

## 7. Alur CRM ke Subsidiary ke Holding

### 7.1 Tiga Workspace

| Workspace | Peran | Karakter `cart_key` |
|---|---|---|
| `san_ibb_master` | CRM | Tidak memakai `cart_key` sama sekali, hanya mengirim `produk_id + variant_id` |
| `san_sarana_8apr` | Subsidiary | Condorcet ke arah 2-segmen `variant:{produk_id}:{variant_id}` |
| `san_varian` | Holding | Dominan format 1-segmen `variant:{variant_id}` |

**Arah normalisasi:** Subsidiary menulis canonical, Holding menerima, Holding **wajib** punya adapter parser. CRM tidak perlu berubah.

### 7.2 Titik Perbaikan Subsidiary (`san_sarana_8apr`) -- KRITIS

Semua path relatif terhadap `application/modules/`.

**A. Modul Pembelian (Critical)**

| Kategori | File:Baris |
|---|---|
| Parser 1-segmen | `pembelian/controllers/_processSelectProduct.php:30`, `:33`, `:162`; `pembelian/controllers/_shoppingCart.php:185` |
| Writer 1-segmen | `pembelian/controllers/_selectorItem.php:88`, `:111`, `:577`; `pembelian/controllers/_processSelectProduct.php:199` |
| Writer 2-segmen (sudah sesuai target) | `pembelian/models/MdlPembelianTransaksi.php:6645`; `pembelian/controllers/CliTransaksi.php:250` |

**B. Modul Penjualan (Critical)**

| Kategori | File:Baris |
|---|---|
| Writer 1-segmen | `penjualan/controllers/_selectorItem.php:559`; `penjualan/controllers/_processSelectProduct.php:25`, `:5498` |
| Writer/validator 2-segmen | `penjualan/controllers/FollowUp.php:352`, `:4800`; `penjualan/models/MdlPenjualanTransaksi.php:6446` |

**Dampak:** potensi split/merge qty varian tidak konsisten jika key dari jalur selector berbeda dengan key followup/model.

**C. Modul Distribusi (High)**

| Kategori | File:Baris |
|---|---|
| Parser 1-segmen | `distribusi/controllers/_processSelectProduct.php:29`, `:155` |
| Writer 1-segmen | `distribusi/controllers/_selectorItem.php:88`, `:111`, `:591`; `distribusi/controllers/_processSelectProduct.php:192` |
| Writer 2-segmen (sudah sesuai) | `distribusi/models/MdlDistribusiTransaksi.php:6112` |

**D. Bridge / Model Layer (Critical).** Propagasi field `cart_key` sudah luas, tetapi **belum ada normalizer tunggal**. Titik prioritas: `pembelian/models/MdlPembelianTransaksi.php`, `penjualan/models/MdlPenjualanTransaksi.php`, `distribusi/models/MdlDistribusiTransaksi.php`.

### 7.3 Titik Perbaikan Holding (`san_varian`) -- KRITIS untuk kompatibilitas inbound

| Kategori | File:Baris |
|---|---|
| Parser 1-segmen | `pembelian/controllers/_processSelectProduct.php:143`; `penjualan/controllers/_processSelectProduct.php:443`; `pembelian/controllers/FollowUp.php:603`; `penjualan/controllers/FollowUp.php:493` |
| Writer 1-segmen (dominan) | `pembelian/controllers/_selectorItem.php:88`; `penjualan/controllers/_selectorItem.php:556`; `penjualan/controllers/Transaksi.php:5801` |

**Catatan:** workspace ini dominan format `variant:{variant_id}`. Jika menerima payload dari subsidiary yang sudah `variant:{produk_id}:{variant_id}`, **wajib ada adapter parser**; tanpanya `str_replace('variant:', ...)` menghasilkan string berisi titik dua. **Modul Distribusi:** folder `application/modules/distribusi` **tidak tersedia** di workspace Holding; titik distribusi varian mengikuti modul lain (misal `distribusifg`) dan **perlu audit terpisah** pada fase berikutnya.

### 7.4 Titik Perbaikan CRM (`san_ibb_master`)

`cart_key` **tidak ditemukan** pada `app/*.php`. CRM menulis dan mengirim `variant_id` di estimate items (`app/Controllers/Estimates.php:4527`). Jalur kirim order: `app/Controllers/Estimates.php:5923` (`sendOrderSubsidiary`), `app/Controllers/Estimates.php:5937` (`sendOrderDirectHolding`), `app/Services/WebhookService.php:200`.

**Keputusan:** tidak perlu memaksakan `cart_key` di CRM. Yang wajib: pastikan payload outbound memuat `produk_id + variant_id` yang stabil agar downstream bisa membentuk canonical `cart_key`.

### 7.5 Urutan Eksekusi yang Disarankan

(1) Buat helper normalizer/parser tunggal per workspace: `parseCartKey()` dan `buildCartKeyCanonical(produk_id, variant_id, variant_sku)`. (2) Ganti parser raw `str_replace('variant:', ...)` dengan helper parser. (3) Ganti writer 1-segmen di selector/process dengan builder canonical. (4) **Pasang compatibility adapter di Holding terlebih dahulu** (agar inbound dari subsidiary aman). (5) Aktifkan logging sementara untuk mendeteksi key non-canonical. (6) Jalankan UAT point-to-point **P00..P10** dengan 3 skenario: varian normal, non-varian, legacy payload.

### 7.6 Risiko dan Kriteria Selesai

**Risiko jika tidak diperbaiki:** (1) salah resolve `variant_id` saat parsing key 3-segmen; (2) qty varian bisa collapse/merge lintas baris yang tidak semestinya; (3) data bridge, printing, dan history menampilkan item tidak konsisten; (4) callback/cancel orchestration berpotensi gagal map item karena identity key berbeda.

**Kriteria selesai:** (1) semua write baru menghasilkan `variant:{produk_id}:{variant_id}`; (2) semua parser menerima format lama tetapi menyimpan ulang ke canonical; (3) tidak ada regresi transaksi non-varian; (4) UAT P00..P10 lulus untuk semua skenario varian lintas CRM-subsidiary-holding.

**Status: BELUM** -- blueprint Mapping selesai, implementasi belum dimulai.

---

## 8. Modul Sampah dan Kodifikasi Produk, Sempoa, Logo

### 8.1 Blueprint Sampah Modul Batch 1

Audit dead code. Semua item terverifikasi tidak ada route, tidak ada include/require, tidak ada referensi config (`routes.php`, `config/*.php`, `heMenu`, `heNavigation`).

**A. Folder Backup -- 17 Folder, ~1.039 file, ~144 MB**

| Folder | File | Size | Folder | File | Size |
|---|---|---|---|---|---|
| `variant_cutover_backup/` | 70 | 9.7 MB | `pindahgudang_29apr2026/` | 65 | 10.3 MB |
| `pembelian_05mei2026/` | 79 | 11.4 MB | `pembelianimport_26apr2026/` | 74 | 9.4 MB |
| `pembelian_26apr2026/` | 78 | 11.4 MB | `taxes_24apr2026/` | 69 | 10.0 MB |
| `pembelian_28apr2026/` | 74 | 8.6 MB | `pembelianprojek__/` | 72 | 8.2 MB |
| `kasOLD/` | 73 | 10.8 MB | `akunting_/` | 3 | 51 KB |
| `penerimaanOLD/` | 59 | 10.0 MB | `produksi_sebelum_geser_bom/` | 62 | 9.8 MB |
| `opname_v0/` | 57 | 10.6 MB | `laporankeuangan_ori_sebelum_suport_neraca_adjustment/` | 7 | 2.6 MB |
| `opname_ori/` | 54 | 10.3 MB | `laporankeuangan_sebelum_adj_20240118/` | 7 | 2.6 MB |
| `pindahgudang_/` | 58 | 7.8 MB | | | |

**B. File Controller Backup -- 17 File, ~2.5 MB**

| File | Size | Bukti |
|---|---|---|
| `Data_.php` | 82 KB | Tidak ada di routes/config. Class name tidak match filename |
| `Data_korekiai.php` | 324 KB | Class `Data` (duplikat `Data.php`). Tidak bisa di-load CI karena filename beda dengan class name |
| `Data_ori.php` | 263 KB | Tidak ada referensi routes/config |
| `Ledger_ori.php` | 257 KB | Sama |
| `ReportORI.php` | 237 KB | Sama |
| `ReportModif.php` | 244 KB | Sama |
| `AutoLoanInterest_old_20200919.php` | 168 KB | Nama mengandung "old" |
| `AutoLoanInterest_old_20200919 - Copy.php` | 168 KB | File copy |
| `AutoLoanInterest_sblm_update_30-08-2020.php` | 168 KB | Sama |
| `SetupLoanInterest_old_20200919.php` | 104 KB | Sama |
| `SetupLoanInterest_old_20200919 - Copy.php` | 104 KB | File copy |
| `RunRugilaba_bak.php` | 126 KB | "bak" berarti backup |
| `SetupDepresiasiTmp.php` | 149 KB | "Tmp" berarti temporary |
| `_tray_bak.php` | 57 KB | Sama |
| `Welcome_original.php` | 30 KB | Sama |
| `ProductPkgEditor1.php` | 17 KB | Class `ProductPkgEditor1`, tidak ada route |
| `arrayjs.js` | 70 KB | File JavaScript di folder controller, tidak bisa di-load CI |

**C. File Sisa:** `application/modules/pembelian.rar` (~3-5 MB, bukan PHP), `application/controllers/nbproject/` (NetBeans), `application/modules/.idea/` dan `application/controllers/.idea/` (PHPStorm).

**Eksekusi:** target `W:\san_staging\...` dengan PowerShell `Remove-Item -Recurse -Force` untuk folder dan `Remove-Item -Force` untuk file. **Verifikasi pasca-hapus** dengan `Select-String -Path "W:\san_staging\application\config\routes.php" -Pattern "Data_|Data_korekiai|Data_ori|Ledger_ori|ReportORI|ReportModif"` dan `Select-String -Path "W:\san_staging\application\config" -Pattern "variant_cutover_backup|pembelian_05mei2026|pembelian_26apr2026|kasOLD"`. **Status: BELUM eksekusi.**

**Prosedur audit per modul:** pilih modul dari checklist, buat `blueprint-sampah-modul-{nama_folder_modul}.md` di `docs/` berisi scan dependency, referensi route, pemanggil, review dan konfirmasi, eksekusi hapus, update checklist dan `AGENTS.md`. Legenda: `[ ]` belum dicek, `[~]` sedang dicek, `[x]` sudah diverifikasi.

**Empat kelompok inventaris:** (1) **Modul Backup/Duplikasi** -- 17 folder plus `pembelian.rar`, semua `[ ]`; (2) **Controller Backup/Duplikasi** -- 16 file, semua `[ ]`; (3) **Modul Aktif** -- 44 modul (penjualan, pembelian, pindahgudang, variant_cutover, konversi_varian, konversi, pembayaran, penerimaan, penerimaanprojek, pembelianimport, pembelianjasa, pembelianprojek, penjualanproject, distributifg, distributijasa, distributiproduksi, distributisupplies, produksi, produksiproses, opname, kas, banking, pettycast, akunting, adjustment, adjustmentjurnal, asetmanagement, biaya, saham, valas, laporan, laporankeuangan, taxes, pembatalan, requeststok, openbalance, deviden, dashboard, addons, api, webservice, tools, statik), semua `[ ]`; (4) **File Controller Mencurigakan** -- `arrayjs.js`, `nbproject/`, `phpstorm.php`, `Tester.php`, `Testing.php`, `TestMongo.php`, `Cli_dummy.php`, `Cli.php`, `CliRaw.php`, `CliTransaksi.php`, `CliTransaksiLocker.php`.

**Peringatan lintas dokumen:** `variant_cutover_backup/views/shoppingCart.php` adalah salah satu dari **3 entry point modal variant settings** (Bagian 4.5). Padahal sudah dinyatakan "aman dihapus" karena tidak ada route, penghapusan harus memastikan tidak ada dynamic module resolution yang memuatnya. Bandingkan juga `application/views/products/variant_settings - Copy.php` (544 baris), yaitu view copy dengan nama ber-spasi yang bisa jadi target AJAX `.load()` secara tidak langsung.

### 8.2 Kodifikasi Sempoa dan Arsitektur Data Produk

Metode **positional coding** fixed-length untuk hierarki produk 3 tingkat. Format hierarki `DD-KK-SS`, format produk `DD-KK-SS-NNN`.

| Segmen | Panjang | Makna | Maks |
|---|---|---|---|
| `DD` | 2 digit | Produk Department | 99 |
| `KK` | 2 digit | Produk Kategori | 99 per Department |
| `SS` | 2 digit | Produk Sub-Kategori | 99 per Kategori |
| `NNN` | 3 digit | Nomor Urut Produk | 999 per Sub-Kategori |

**A. `m_department`:** `kode_dept` CHAR(2) PRIMARY KEY; `nama_dept` VARCHAR(100) NOT NULL. Contoh `01` = Makanan dan Minuman, `02` = Pakaian dan Mode.

**B. `m_kategori`:** `kode_kategori` CHAR(4) PRIMARY KEY (DD+KK); `kode_dept` CHAR(2) FOREIGN KEY ke `m_department.kode_dept`; `nama_kategori` VARCHAR(100) NOT NULL. Contoh `0101`/Camilan, `0102`/Minuman Dingin, `0201`/Pakaian Pria.

**C. `m_sub_kategori`:** `kode_sub_kategori` CHAR(6) PRIMARY KEY (DD+KK+SS); `kode_kategori` CHAR(4) FOREIGN KEY ke `m_kategori.kode_kategori`; `nama_sub_kategori` VARCHAR(100) NOT NULL. Contoh `010101`/Keripik, `010102`/Biskuit, `020101`/Kemeja.

**D. `m_produk`:** `kode_produk` CHAR(9) PRIMARY KEY (sempoa plus running number); `kode_sub_kategori` CHAR(6) FOREIGN KEY ke `m_sub_kategori.kode_sub_kategori`; `nama_produk` VARCHAR(150) NOT NULL; `harga_jual` DECIMAL(12,2) NOT NULL; `stok` INT DEFAULT 0. Contoh `010101001`/Keripik Singkong Balado 150g/15000.00, `020101001`/Kemeja Flanel Pria Slimfit M/150000.00.

Relasi: `m_department` 1-ke-banyak `m_kategori` 1-ke-banyak `m_sub_kategori` 1-ke-banyak `m_produk`. Query referensi memakai `INNER JOIN` dari bawah ke atas: `m_produk` ke `m_sub_kategori` ke `m_kategori` ke `m_department`, menselect `kode_produk, nama_produk, nama_dept AS department, nama_kategori AS kategori, nama_sub_kategori AS sub_kategori, harga_jual`.

**Keunggulan desain:** (1) integritas data terjamin karena tidak ada duplikasi teks nama kategori di tabel produk; (2) indexing cepat karena filter `0101%` berjalan instan pada tipe `CHAR`; (3) pelaporan fleksibel lewat `LEFT(kode_produk, 2)` untuk report per department.

**Status: BELUM-MULAI** (dokumen desain). **Catatan relevansi:** skema ini **berbeda** dari skema produk/varian yang dipakai di Bagian 2 sampai 7 (`produk`, `produk_variant`, `produk_kode`, `variant_sku`). Tidak ada bridge yang didokumentasikan antara `m_produk.kode_produk` dan `produk.produk_id`/`produk_variant`. Lihat Bagian 10, K5.

### 8.3 Sistem Logo Multi-Tenant Berbasis ID

Standar pengelolaan aset visual untuk 4 aplikasi agar tidak tertukar, memakai Validasi Berbasis Nomor ID. Naming convention: ID numerik unik 3 digit dengan zero padding, yaitu `001`/`002`/`003`/`004` untuk `logo_001.svg`..`logo_004.svg` dan `favicon_001.png`..`favicon_004.png`. **Aturan penting:** jangan gunakan nama brand, nama desainer, atau status revisi (contoh salah `logo_perusahaanA_v2_final.png`). Struktur folder: `proyek-aplikasi/assets/logos/` dan `proyek-aplikasi/src/components/LogoLoader.js`.

Implementasi jangan hardcode path, gunakan loader component dengan tiga lapis validasi: (1) regex `/^[0-9]{3}$/` memvalidasi format `companyId`, return `<img src="/assets/logos/logo_placeholder.svg">` bila gagal; (2) rekonstruksi `const logoPath = '/assets/logos/logo_' + companyId + '.svg'` bila valid; (3) handler `onError` yang set `e.target.onerror = null` lalu `e.target.src = defaultLogo` sebagai fallback terakhir.

**Validasi sisi database** -- kunci relasi ID dengan nomor logo via CHECK constraint pada tabel `perusahaan`: `id_perusahaan VARCHAR(3) PRIMARY KEY`, `nama_perusahaan VARCHAR(100) NOT NULL`, `nomor_logo VARCHAR(3) NOT NULL`, `CONSTRAINT chk_nomor_logo CHECK (nomor_logo = id_perusahaan)`.

**Pengujian otomatis (Jest):** `test('Memastikan ID aplikasi memuat file logo yang tepat')` dengan `activeCompanyId = "002"`, `expectedLogoName = logo_${activeCompanyId}.svg`, lalu `expect(generatedPath).toContain(expectedLogoName)`.

**Workflow:** Tim Desain mengekspor `logo_00X.svg`, Tim QA memeriksa nomor di file sudah sesuai ID perusahaan, CI/CD menjalankan automated testing untuk memastikan file ada sebelum build.

**Status: BELUM-MULAI** (dokumen standar/SOP). **Catatan relevansi:** pola "ID numerik sebagai kunci tunggal dengan validasi berlapis" **sejalan** dengan Bagian 7.1 (canonical `cart_key` dengan `parseCartKey()` dan `buildCartKeyCanonical()`). Keduanya mencegah identitas item salah resolve. Namun implementasi logo memakai regex ketat plus fallback, sedangkan `cart_key` masih memakai `str_replace` longgar.

### 8.4 Produk Custom pada Estimate CRM (Blueprint Poin 1)

**Konteks:** CRM Update v14 Poin 1, diskusi 2026-07-02. Sales perlu menambah produk ad-hoc ke penawaran tanpa merujuk Data Center (`items`). Di akhir siklus, produk ad-hoc **wajib di-mapping** ke produk riil sebelum konversi menjadi Sales Order.

**Keputusan desain:** penyimpanan di tabel baru `estimate_custom_items` terpisah dari `items`; eksklusivitas terikat ke 1 `estimate_id` dan tidak reusable antar penawaran; field input `item_name`, `unit`, `rate`, `description` semuanya tidak mandatory; mapping produk riil via `mapped_item_id` (FK ke `items`) plus `mapped_by` dan `mapped_at`; konversi ke Sales Order ditolak jika ada baris `mapped_item_id IS NULL`; hak akses mapping mengikuti hak akses penawaran existing tanpa perubahan role; tabel `estimate_items` tidak perlu dimodifikasi karena JOIN via `estimate_id` sudah cukup; urutan item via kolom `sort_order`; audit trail soft delete plus full timestamp.

**Skema `estimate_custom_items`:** kolom `id` INT(11) UNSIGNED AUTO_INCREMENT PK, `estimate_id` INT(11) UNSIGNED NOT NULL (FK ke `estimates`), `item_name` VARCHAR(255) NULL, `unit` VARCHAR(50) NULL, `rate` DECIMAL(15,2) NULL, `description` TEXT NULL, `mapped_item_id` INT(11) UNSIGNED NULL (FK ke `items`, NULL berarti belum di-mapping), `mapped_by` INT(11) UNSIGNED NULL, `mapped_at` DATETIME NULL, `created_by`/`updated_by`/`deleted_by` INT(11) UNSIGNED NULL, `created_at`/`updated_at`/`deleted_at` DATETIME NULL, `is_deleted` TINYINT(1) NOT NULL DEFAULT 0, `sort_order` INT(11) NOT NULL DEFAULT 0. Index `idx_estimate_id` dan `idx_mapped_item_id`. Engine InnoDB, charset utf8mb4.

**Rendering dengan UNION query** (tabel `estimate_items` tidak dimodifikasi), diurutkan `sort_order ASC`: SELECT `'regular' AS item_type, ei.id, ei.item_id, ei.item_name, ei.rate, ei.quantity, ei.sort_order FROM estimate_items ei WHERE ei.estimate_id = <estimate_id>` UNION ALL SELECT `'custom' AS item_type, eci.id, NULL AS item_id, eci.item_name, eci.rate, NULL AS quantity, eci.sort_order FROM estimate_custom_items eci WHERE eci.estimate_id = <estimate_id> AND eci.is_deleted = 0`.

Kolom `item_type` dipakai View/Controller untuk membedakan rendering, misalnya badge "Custom" atau tombol "Mapping ke Produk Riil".

**Lifecycle:** UNRESOLVED saat DRAFT/INPUT SALES dengan `is_custom=1`, `custom_item_id=<id>`, `mapped_item_id=NULL`; menjadi RESOLVED setelah user dengan hak akses penawaran melakukan mapping, yaitu `mapped_item_id=<id dari items>`, `mapped_by`, `mapped_at`; konversi ke Sales Order baru diizinkan setelah RESOLVED.

**Blokir konversi:** `SELECT COUNT(*) FROM estimate_custom_items WHERE estimate_id = <estimate_id> AND mapped_item_id IS NULL AND is_deleted = 0;` -- jika hasil lebih besar dari 0, batalkan konversi dan tampilkan notifikasi berisi daftar item yang belum di-mapping.

**Komponen terdampak:** `app/Views/estimates/item_modal_form.php` (tombol/opsi "Buat Baru" plus form input ad-hoc); `app/Controllers/Estimates.php` fungsi `save_item` (simpan ke `estimate_custom_items` plus set flag `is_custom=1` di `estimate_items`); `app/Controllers/Estimates.php` fungsi konversi ke SO (validasi cek `mapped_item_id IS NULL` sebelum konversi); `app/Models/Estimate_items_model.php` atau sejenis (query CRUD `estimate_custom_items`); view mapping (UI untuk melakukan mapping item custom ke produk riil). **Backlog:** alur ERP saat Won.

**Status: BELUM-MULAI.** Dokumen menyatakan "Implementasi belum dimulai." **Catatan relevansi:** `mapped_item_id` (FK ke `items`) adalah **produk non-varian**. Belum ada kolom `variant_id` di `estimate_custom_items`, padahal estimate items di `app/Controllers/Estimates.php:4527` sudah menulis `variant_id` (Bagian 7.4). Artinya produk ad-hoc tidak bisa dipetakan ke produk varian. Lihat Bagian 10, K6.

### 8.5 Multi PIC Prospek Konsumen (Tipe Perusahaan)

Menambahkan **multi PIC** saat input/edit prospek dengan `account_type=organization`. Data PIC minimal nama dan kontak pribadi, opsional email, jabatan, catatan, dan bisa tampil di list prospek.

**Tabel `rise_client_pics`** (sudah dibuat manual di DB): `id` PK, `lead_id` FK ke `clients.id`, `pic_name` varchar(150), `pic_phone` varchar(30), `pic_email` varchar(150) nullable, `is_primary` tinyint default 0, `sort_order` int default 0, `created_by`/`created_at`/`updated_at`, `deleted` tinyint default 0 (soft delete). Index `idx_lead_pics_lead_id_deleted (lead_id, deleted)` dan opsional `idx_lead_pics_primary (lead_id, is_primary, deleted)`.

**Keputusan teknis:** pakai tabel baru khusus, bukan reuse `lead_contacts`, karena lebih sederhana untuk validasi, tidak bergantung flow kontak profil lengkap, dan mudah dipakai di modal add/edit lead serta list prospek.

| # | Langkah | Status |
|---|---|---|
| 1 | Database dan model dasar | `rise_client_pics` dibuat manual; `Client_pics_model.php` dibuat dengan `get_by_client_id()`, `replace_by_client_id()`, `get_primary_or_first_by_client_ids()` |
| 2 | Integrasi modal add/edit lead | Section `PIC Perusahaan` di `lead_form_fields.php` (hanya jika `organization`); repeater dengan `pic_name[]`, `pic_phone[]`, `pic_email[]`, `pic_primary_index`; preload di `Leads.php::make_lead_modal_form_data` |
| 3 | Simpan di proses save | Parse di `Leads::save()`; validasi server-side: min 1 PIC jika organization, `pic_name` wajib, `pic_phone` wajib numeric-length, maks 1 `is_primary=1`; dalam transaksi yang sama (`transBegin` sudah ada) |
| 4 | Tampilkan di list prospek | Kolom `PIC` di `app/Views/leads/index.php`; prefetch PIC by current page lead IDs untuk menghindari query per-row, di `Leads.php::list_data` dan `_make_row` |
| 4b | Subquery SQL di model | Opsional, `Clients_model::get_details()` untuk `primary_pic_name` dan `primary_pic_phone` |
| 5 | Rule UI/UX | `person` menyembunyikan section, PIC tidak wajib; ganti `organization` ke `person` dengan implementasi saat ini PIC dibersihkan (replace empty) saat save `person`; list menampilkan `Nama PIC Utama` plus nomor kontak utama |
| 6 | Akses dan keamanan | `validate_lead_access` dan `validate_lead_edit_access`; sanitasi `clean_data`, trimming, escape output di view |
| 7 | Pengujian | Semua 5 item belum diuji: create org 2-3 PIC; edit tambah/hapus PIC dan ubah primary; create person tanpa PIC; list menampilkan PIC terbaru; tidak ada N+1 query signifikan di `list_data` |
| 8 | Bahasa/label | Key `lead_pic`, `lead_pics`, `pic_name`, `pic_phone`, `pic_email`, `add_pic`, `primary_pic` |

**File terlibat:** `app/Controllers/Leads.php`, `app/Views/leads/lead_form_fields.php`, `app/Views/leads/modal_form.php`, `app/Views/leads/index.php`, `app/Models/Clients_model.php`, `app/Database/Migrations/<timestamp>_CreateLeadPicsTable.php`, `app/Models/Client_pics_model.php`.

**Acceptance criteria:** prospek `organization` bisa punya multi PIC dalam 1 modal add/edit; data PIC tersimpan konsisten (nama dan kontak pribadi) dan bisa diubah; list prospek menampilkan PIC utama; tidak ada regresi pada save lead existing dan performa list tetap stabil.

**Status: PARSIAL** -- implementasi [x], seluruh pengujian [ ].

---

## 9. Checklist Implementasi

### 9.1 Prasyarat (WAJIB -- Menggatalkkan Patch Lain)

| # | Prasyarat | Alasan |
|---|---|---|
| P1 | Buat `parseCartKey()` tolerant reader (1, 2, 3 segmen, sku, legacy) | 20+ titik parser masih `str_replace` longgar |
| P2 | Buat `buildCartKeyCanonical(produk_id, variant_id, variant_sku)` | Semua writer harus emit format kanonik |
| P3 | Pasang compatibility adapter di Holding (`san_varian`) lebih dulu | Inbound dari subsidiary sudah 2-segmen, parser 1-segmen akan salah resolve |
| P4 | Aktifkan logging key non-canonical | Mendeteksi writer yang belum dinormalisasi |
| P5 | Audit `variant_cutover_backup/views/shoppingCart.php` sebelum hapus | Salah satu 3 entry point modal variant settings |

### 9.2 Per Baris Root Cause

**Bug: Varian Data Kosong (Step 2)**

- [ ] `coTransaksiCore.php:953` -- ubah `"id" => "produk_id"` menjadi `"id" => "variant_id"`
- [ ] `coTransaksiValues.php:203-219` -- tambah 5 key ke `resultParams.rsltItems` untuk 881 dan 7881
- [ ] `FollowUp.php` sekitar 6038 -- pastikan panggilan `fillValues` tambahan sudah dihapus (revert)
- [ ] `FollowUp.php` sekitar 4878 -- tambahkan enrichment `items2_sum` (query `produk` by `id`, `produk_variant` by `produk_id`)
- [ ] Perhatikan `addDetailData` di `FollowUp.php:5933-6032` agar tidak menimpa `items2_sum` dengan `$paramDetil` terbatas
- [ ] Verifikasi `transaksi_data` step 2 dan siapkan rollback plan (3 titik revert)

**Bug: Variant Settings Modal**

- [ ] Pilih Opsi A, B, atau C (rekomendasi: C, hybrid)
- [ ] Buat `variant_settings_partial.php` tanpa `<html>/<head>/<body>` dan tanpa load external JS/CSS
- [ ] `ProductVariantSettings.php` -- deteksi `?partial=1` atau `?attached=1`, atau method `edit_partial()`
- [ ] Ubah URL di 3 module: `variant_cutover`, `variant_cutover_backup`, `konversi_varian`
- [ ] Perbaiki urutan di `onshown` (`shoppingCart.php:568-586`): script dulu, baru destroy + selectpicker + refresh
- [ ] Set `$.fn.selectpicker.Constructor.BootstrapVersion = '3'` bila `$.fn.tooltip.Constructor` tersedia
- [ ] Tambah guard `window.__variantInited` di `variant_settings.php:750` agar tidak double-fire
- [ ] Pastikan `<style>` dari `<head>` terapilkasi (append manual bila perlu)
- [ ] Ganti `DOMContentLoaded` (line 756-770) dengan pemicu eksplisit agar badge coloring jalan
- [ ] Jalankan 10 skenario matriks uji (Bagian 4.6)

**Bug: Diskon dan Premi**

- [x] 5 handler row-scoped by `noid` (line 1245, 1310, 1360, 1400, 1441)
- [x] `calcShoppingCartPettycash()` tidak memodifikasi row lain
- [x] Guard edge case (harga 0, diskon melebihi harga, diskon 0 dengan nett di atas harga menjadi premi)
- [x] `php -l` lolos
- [ ] Risiko 1 -- `transaksi_reguler.html:1268`: ganti `Math.round(...).toFixed(3) *1 > 100` dengan `diskon_persen > 100`
- [ ] Risiko 2 -- `transaksi_reguler.html:1245`: clamp `diskon_persen` ke 0 bila negatif
- [ ] Risiko 3 -- 5 handler: `.on('input keyup change', ...)` agar paste terdeteksi
- [ ] Uji manual skenario cross-row (row 2-4 premi 20.000, row 5 diskon 10 persen)

**Bug: Loker Stok**

- [x] `patch_variant_leak.php` -- 39 file dengan `ComLockerStockDualWrite->pair()` dapat `cart_key` dan `variant_id`
- [x] `patch_tmpjml.php` -- 51 file: `$tmpJml = isset($jml) ? $jml : 1;`
- [x] `php -l` lulus 100% dari 147 file `_processSelect*.php`
- [ ] Ubah `explode(':', $cartKey)[2]` menjadi `parseCartKey()` agar aman untuk format 1, 2, 3 segmen
- [ ] Uji: kurangi qty varian 5 menjadi 3 (hold berkurang 2, tanpa "Stok tidak cukup")
- [ ] Uji: varian baru `newQty=23` (harus tercatat 23, bukan 1)
- [ ] Uji: varian yang sudah ada di keranjang (jalur `$_GET['newQty']`)
- [ ] Uji: produk non-varian (regresi)

**Normalisasi `cart_key` Subsidiary (`san_sarana_8apr`)**

- [ ] Ganti 4 parser 1-segmen modul Pembelian (`_processSelectProduct.php:30,33,162`, `_shoppingCart.php:185`)
- [ ] Ganti 4 writer 1-segmen modul Pembelian (`_selectorItem.php:88,111,577`, `_processSelectProduct.php:199`)
- [ ] Ganti 3 writer 1-segmen modul Penjualan (`_selectorItem.php:559`, `_processSelectProduct.php:25,5498`)
- [ ] Ganti 2 parser dan 4 writer 1-segmen modul Distribusi
- [ ] Pasang normalizer di 3 model: `MdlPembelianTransaksi.php`, `MdlPenjualanTransaksi.php`, `MdlDistribusiTransaksi.php`
- [ ] Verifikasi writer 2-segmen sudah sesuai (tidak diubah)

**Normalisasi `cart_key` Holding (`san_varian`)**

- [ ] Prioritas -- pasang compatibility adapter sebelum audit titik lain
- [ ] Ganti 4 parser 1-segmen (`pembelian/_processSelectProduct.php:143`, `penjualan/_processSelectProduct.php:443`, `pembelian/FollowUp.php:603`, `penjualan/FollowUp.php:493`)
- [ ] Ganti 3 writer 1-segmen dominan (`pembelian/_selectorItem.php:88`, `penjualan/_selectorItem.php:556`, `penjualan/Transaksi.php:5801`)
- [ ] Audit terpisah titik distribusi varian (misal `distribusifg`)

**CRM (`san_ibb_master`)**

- [ ] Verifikasi `app/Controllers/Estimates.php:4527` menulis `produk_id + variant_id` stabil di estimate items
- [ ] Verifikasi payload di `Estimates.php:5923` (`sendOrderSubsidiary`), `:5937` (`sendOrderDirectHolding`), `app/Services/WebhookService.php:200` konsisten
- [ ] Tidak perlu memaksakan `cart_key` di CRM

**UAT:** jalankan skenario P00..P10 untuk varian normal, untuk non-varian (regresi nol), dan untuk legacy payload (variant kosong atau format key lama); pastikan tidak ada regresi transaksi non-varian.

### 9.3 Modul Sampingan

**Sampah:** review dan konfirmasi blueprint Batch 1 (17 folder sekitar 144 MB, 17 file controller sekitar 2.5 MB, 4 artefak IDE); **tunda hapus `variant_cutover_backup/` sampai Bagian 4 selesai**; tunda hapus `variant_settings - Copy.php` sampai audit AJAX `.load()`; eksekusi `Remove-Item` pada `W:\san_staging\...`; verifikasi `Select-String` pada `routes.php` dan `config/`; update `checklist-sampah-modul.md` dan `AGENTS.md`; lanjutkan checklist modul aktif (44 modul) dan file mencurigakan (11 item).

**Estimate custom item (CRM):** jalankan migrasi `estimate_custom_items`; tambah tombol "Buat Baru" di `item_modal_form.php`; implementasi `save_item` di `Estimates.php`; model CRUD `estimate_custom_items`; validasi blokir konversi SO; view atau UI mapping item custom ke produk riil; backlog alur ERP saat Won.

**Multi PIC:** 5 skenario pengujian (Bagian 8.5 langkah 7) yang seluruhnya belum diuji; opsional subquery `primary_pic_name` dan `primary_pic_phone` di `Clients_model::get_details()`.

**Sempoa:** migrasi 4 tabel (`m_department`, `m_kategori`, `m_sub_kategori`, `m_produk`); isi data master; jembatankan ke skema `produk` dan `produk_variant` yang sudah ada (lihat Bagian 10, K5).

**Logo:** siapkan 4 set `logo_00X.svg`, `favicon_00X.png`, dan `logo_placeholder.svg`; implementasikan `LogoLoader` dengan 3 lapis validasi; terapkan CHECK constraint di tabel `perusahaan`; tambahkan unit test Jest ke CI/CD.

---

## 10. Temuan Lintas-Dokumen dan Kontradiksi

**K1 -- Kontradiksi Fatal: `explode(':', $cartKey)[2]` versus format 1-segmen.** *Sumber: `bug-locker-stock-shopping-cart.md` Solusi 1 versus `MAP_TITIK_PERBAIKAN_CART_KEY_VARIAN.md`.* Patch loker stok menyuntik ekspresi `strpos($cartKey, 'variant:') === 0` lalu `explode(':', $cartKey)[2]`. Untuk `cartKey = "variant:68"`, indeks `[2]` **tidak ada** karena string hanya punya 2 segmen (`variant`, `68`). Subsidiary (yang nulis canonical 2-segmen) menghasilkan `[2]` valid; Holding (yang dominan nulis 1-segmen) menghasilkan `[2]` undefined sehingga fallback ke `1` dan **"Stok tidak cukup" berulang**. Selain itu `explode()` di PHP 8 melempar `TypeError` bila `cartKey` bukan string. *Resolusi:* ganti seluruh ekspresi dengan `parseCartKey($cartKey)['variant_id']` yang tolerant terhadap 1, 2, 3 segmen. Severity TINGGI, status belum diatasi.

**K2 -- Kontradiksi Temporal: Fix #2 versus Root Cause #2 (Bug Data Kosong).** *Sumber: `BLUEPRINT-FIX-VARIANT-DATA-EMPTY.md`.* Fix #2 membatalkan pemanggilan `fillValues` di `FollowUp.php:6038` karena ia me-rebuild `tableIn_detail2_sum` dari `items2_sum` yang sudah terkorupsi. Namun Root Cause #2 (`FollowUp.php:5933-6032`) menyatakan `addDetailData` aktif dan melakukan `unset($_SESSION[$cCode]["items2_sum"])` lalu mengisi ulang dengan `$paramDetil` yang terbatas. Artinya Ashe masih terjadi; Fix #2 hanya mencegah gejala lanjutan, sementara Fix #3 (enrichment via DB lookup) menutupi kekurangan field tanpa memulihkan data yang hilang. *Resolusi:* saat `addDetailData` aktif untuk `variant_cutover`, `items2_sum` tidak boleh di-unset dan diisi ulang dengan payload terbatas, atau `$paramDetil` harus diperluas agar memuat kelima field (`label`, `kode`, `satuan`, `variant_id`, `sku`). Severity TINGGI, status sebagian.

**K3 -- Inkonsistensi Kode: `variant_id` di MdlFifoProdukJadiVarian.** *Sumber: `BLUEPRINT-FIX-VARIANT-DATA-EMPTY.md` bagian Critical.* Setelah `coTransaksiCore.php:953` diubah menjadi `"id" => "variant_id"`, **semua** baris, varian maupun non-varian, akan menulis `produk_id` dari field `variant_id`. Untuk baris non-varian, `variant_id` bernilai 0 atau null; jika tabel `transaksi_data` tidak punya default yang benar, baris non-varian bisa ter-write `produk_id=0`. *Status: tidak dibahas di dokumen sumber.* Perlu diuji eksplisit pada skenario non-varian. Severity SEDANG.

**K4 -- Ketergantungan Silang: Hapus `variant_cutover_backup` versus Fix Modal.** *Sumber: `blueprint-sampah-modul.md` bagian A versus `BLUEPRINT-FIX-VARIANT-SETTINGS-MODAL.md` bagian File Terlibat.* Blueprint sampah menyatakan `variant_cutover_backup/` (70 file, 9.7 MB) aman dihapus karena tidak ada referensi di `routes.php`, `config/*.php`, `heMenu`, `heNavigation`. Namun dokumen modal mendaftarkannya sebagai salah satu dari 3 entry point yang perlu fix handler `onshown` (line 568-586 identik di ketiga module). *Resolusi:* penghapusan ditunda sampai fix modal di 3 module selesai. Fix di `konversi_varian` dan `variant_cutover` bisa dikerjakan sekarang; jika hapus tetap dilakukan, fix untuk backup menjadi sia-sia. Severity SEDANG.

**K5 -- Dua Tabel Produk yang Tidak Terhubung.** *Sumber: `sempoa_codifikasi.md` versus seluruh dokumen varian.*

| Aspek | `sempoa_codifikasi.md` | Dokumen varian (Bagian 2-7) |
|---|---|---|
| Tabel produk | `m_produk` | `produk` |
| Kode produk | `kode_produk` CHAR(9) sempoa | `produk_kode` (diisi dari `label`/`kode` PreProcessor) |
| Tabel varian | tidak ada | `produk_variant` (`variant_id`, `sku`) |
| Relasi | `m_department` ke `m_kategori` ke `m_sub_kategori` ke `m_produk` | `produk` ke `produk_variant` |

Tidak ada bridge yang didokumentasikan. Applikasi saat ini berjalan di `produk` dan `produk_variant`; Sempoa adalah desain alternatif yang belum diintegrasikan. Severity SEDANG.

**K6 -- `estimate_custom_items` Tidak Punya `variant_id`.** *Sumber: `blueprint_CRM_point1_buat_baru_estimates.md` versus `MAP_TITIK_PERBAIKAN_CART_KEY_VARIAN.md`.* Map cart key menyatakan CRM sudah menulis dan mengirim `variant_id` di estimate items (`app/Controllers/Estimates.php:4527`). Blueprint custom item membuat `mapped_item_id` sebagai FK ke `items` saja, **tanpa kolom `variant_id`**. Konsekuensi: produk ad-hoc tidak bisa dipetakan ke produk varian. Selain itu `item_id` pada baris `custom` di UNION query bernilai NULL, pola NULL yang secara historis menjadi biang `produk_id=0` di Bagian 3. *Saran:* tambahkan `mapped_variant_id` ke `estimate_custom_items`, dan lengkapi baris UNION agar `item_id` tidak NULL untuk item custom yang sudah termapping. Severity SEDANG.

**K7 -- Pipeline Uji Terbagi Tiga, Tidak Ada Gate Tunggal.** *Sumber: `BUG_JS_SHOPPING_CART_DISKON_PREMI.md` (`php -l` pada `.html`), `bug-locker-stock-shopping-cart.md` (`php -l` pada 147 file `.php`), `BLUEPRINT-FIX-VARIANT-DATA-EMPTY.md` (verifikasi manual `transaksi_data`).* Tiga pipeline uji berbeda tanpa gate yang menyatukan. `php -l` pada `.html` hanya memvalidasi blok PHP yang tertanam dan **tidak** memvalidasi JavaScript, sehingga Risiko 1, 2, 3 di Bagian 5.5 lolos seluruhnya. *Saran:* gate gauntlet wajib sebelum rilis varian: (1) lint PHP semua file terpengaruh, (2) lint JS `transaksi_reguler.html` plus 3 `shoppingCart.php`, (3) UAT manual 10 skenario modal, (4) UAT `transaksi_data` step 2 varian dan non-varian, (5) UAT loker stok 4 skenario, (6) UAT `cart_key` P00..P10 tiga skenario. Severity SEDANG.

**K8 -- Multi PIC: Implementasi [x], Pengujian Kosong.** *Sumber: `lead-pic-company-checklist.md`.* Langkah 1-6 dan 8 seluruhnya [x], tetapi langkah 7 (5 skenario pengujian) seluruhnya [ ]. Risiko khusus: `replace_by_client_id()` melakukan soft delete lalu insert ulang, harus diuji bahwa `is_primary` tidak ganda dan `sort_order` stabil. Acceptance criteria "tidak ada N+1 query signifikan di `list_data`" juga belum diverifikasi. Severity RENDAH.

**K9 -- Urutan Patch Loker versus Normalisasi Key.** *Sumber: `bug-locker-stock-shopping-cart.md` (23 Juli 2026) versus `MAP_TITIK_PERBAIKAN_CART_KEY_VARIAN.md` (tanpa tanggal).* Bug loker sudah **ditutup** (23 Juli 2026), sedangkan normalisasi `cart_key` masih **belum dimulai**. Akibatnya loker dipatch terhadap asumsi format key yang belum dinormalisasi, lihat K1. Patch loker harus diulang setelah normalisasi key rampung. Severity SEDANG.

**K10 -- Rekomendasi Opsi Modal Belum Final.** *Sumber: `BLUEPRINT-FIX-VARIANT-SETTINGS-MODAL.md`.* Dokumen menyatakan Opsi C "PALING RECOMMENDED", tetapi penomoran opsi menandai Opsi A "DIREKOMENDASIKAN". Tidak ada catatan keputusan final. Karena Opsi A dan C memberi urutan prioritas berbeda, implementasi bisa berjalan dua arah berbeda. Severity RENDAH.

| # | Kontradiksi | Severity | Sumber | Status |
|---|---|---|---|---|
| K1 | `explode(':', $cartKey)[2]` tidak valid untuk format 1-segmen | TINGGI | Locker stock vs Cart key | Belum diatasi |
| K2 | Fix #2 hanya mencegah gejala, Root Cause #2 belum ditutup | TINGGI | Data empty | Sebagian |
| K3 | `produk_id` dari `variant_id` untuk non-varian | SEDANG | Data empty | Belum dibahas |
| K4 | Hapus `variant_cutover_backup` vs fix modal | SEDANG | Sampah vs Modal | Perlu tunda |
| K5 | `m_produk` vs `produk`/`produk_variant` | SEDANG | Sempoa vs Varian | Belum diintegrasikan |
| K6 | `estimate_custom_items` tanpa `variant_id` | SEDANG | Custom item vs Cart key | Belum dibahas |
| K7 | 3 pipeline uji terpisah tanpa gate tunggal | SEDANG | Lintas dokumen | Belum |
| K8 | PIC implementasi [x], pengujian [ ] | RENDAH | Multi PIC | Belum diuji |
| K9 | Patch loker mendahului normalisasi key | SEDANG | Locker vs Cart key | Perlu diulang |
| K10 | Opsi A vs Opsi C sama-sama direkomendasikan | RENDAH | Modal | Belum diputuskan |

---

## 11. Lampiran: Daftar File Sumber

Jumlah file sumber: **12 dokumen markdown**. Workspace: `W:\_md_result\file_md_san\`

| # | Nama File | Ukuran | Topik H1 | Peran dalam Sintesis |
|---|---|---|---|---|
| 1 | `BLUEPRINT-FIX-VARIANT-DATA-EMPTY.md` | 5.2 KB, 108 baris | Blueprint: Fix Variant Data Empty di Step 2 Followup (7881/881) | **Sumber Bagian 3.** Akar masalah plus 3 root cause (PreProcessor output, `items2_sum` di-unset, `fillValues` ulang) plus 3 fix. Sumber K2 dan K3. |
| 2 | `BLUEPRINT-FIX-VARIANT-SETTINGS-MODAL.md` | 13.9 KB, 254 baris | BLUEPRINT: Fix Bootstrap-Select Tidak Berfungsi di Modal Variant Settings | **Sumber Bagian 4.** Matriks 4 entry point, 6 masalah, 3 opsi solusi (A, B, C), 10 skenario uji. Sumber K4 dan K10. |
| 3 | `BUG_JS_SHOPPING_CART_DISKON_PREMI.md` | 6.8 KB, 154 baris | Bug JS Shopping Cart: Cross-Row Reset Diskon/Premi dan Risiko Lain | **Sumber Bagian 5.** Bug selesai (5 handler row-scoped by `noid`) plus 3 risiko belum. Sumber K7. |
| 4 | `bug-locker-stock-shopping-cart.md` | 4.7 KB, 56 baris | Laporan Analisis dan Resolusi Bug Loker Stok (Shopping Cart) | **Sumber Bagian 6.** Gejala "Stok tidak cukup" (variant leakage) dan qty mentok di 1 (hardcode `$tmpJml`). 2 patch (39 + 51 file), 147 file terverifikasi. Sumber K1 dan K9. |
| 5 | `MAP_TITIK_PERBAIKAN_CART_KEY_VARIAN.md` | 6.2 KB, 112 baris | Map Titik Perbaikan Cart Key Varian (CRM - Subsidiary - Holding) | **Sumber Bagian 2.1 dan Bagian 7.** Kontrak kanonik `variant:{produk_id}:{variant_id}`, titik parser dan writer per workspace, urutan eksekusi, kriteria selesai. Sumber K1 dan K6. |
| 6 | `blueprint_CRM_point1_buat_baru_estimates.md` | 6.0 KB, 124 baris | Blueprint: Fitur "Buat Baru" pada Penawaran (Estimates) | **Sumber Bagian 8.4.** Tabel `estimate_custom_items`, UNION query, lifecycle UNRESOLVED ke RESOLVED, blokir konversi SO. Sumber K6. |
| 7 | `ALUR_CRM_TO_SHOPPING_CART.md` | 13.5 KB, 298 baris | Analisis Alur: ORDER BARU DARI CRM ke Shopping Cart | **Sumber Bagian 2.3 dan 2.4.** Rantai 6 tahap CRM ke Cart dengan 25+ titik kode (file dan baris), mechanism lock harga CRM, `$sessionItemKey` 1-segmen. |
| 8 | `lead-pic-company-checklist.md` | 4.9 KB, 101 baris | Checklist Implementasi PIC Khusus Prospek Konsumen (Tipe Perusahaan) | **Sumber Bagian 8.5.** Tabel `rise_client_pics`, 8 langkah implementasi (7 sudah [x], pengujian [ ]). Sumber K8. |
| 9 | `logo.md` | 4.3 KB, 93 baris | Sistem Manajemen dan Validasi Logo Aplikasi Berbasis ID (Multi-Tenant) | **Sumber Bagian 8.3.** Naming convention 3-digit, `LogoLoader` 3 lapis validasi, CHECK constraint, unit test Jest, workflow CI/CD. |
| 10 | `sempoa_codifikasi.md` | 5.0 KB, 91 baris | Blueprint Sistem Kodifikasi Sempoa dan Arsitektur Data Produk | **Sumber Bagian 8.2.** Positional coding 6 digit (DD-KK-SS) plus 3 digit NNN, 4 tabel master, relasi, query INNER JOIN. Sumber K5. |
| 11 | `checklist-sampah-modul.md` | 4.7 KB, 116 baris | Checklist Sampah Modul | **Sumber Bagian 8.1 (audit).** 4 kelompok inventaris: 17 folder backup, 16 controller duplikat, 44 modul aktif, 11 file mencurigakan, semua [ ]. Prosedur 6 langkah per modul. |
| 12 | `blueprint-sampah-modul.md` | 5.9 KB, 124 baris | Blueprint Sampah Modul Batch 1 | **Sumber Bagian 8.1 (bukti).** Bukti aman-hapus: 17 folder (sekitar 1.039 file, 144 MB), 17 file controller (sekitar 2.5 MB), 4 artefak IDE; perintah PowerShell dan verifikasi `Select-String`. Sumber K4. |

**Pemetaan Topik Sintesis ke File Sumber**

| Topik Sintesis | Nomor File | Jumlah |
|---|---|---|
| Bagian 2 Keranjang Belanja dan Kunci Varian | 5, 7 | 2 |
| Bagian 3 Bug Varian Data Kosong | 1 | 1 |
| Bagian 4 Bug Variant Settings Modal | 2 | 1 |
| Bagian 5 Bug JS Diskon dan Premi | 3 | 1 |
| Bagian 6 Bug Loker Stok | 4 | 1 |
| Bagian 7 Alur CRM ke Subsidiary ke Holding | 5, 7 | 2 (sama dengan Bagian 2) |
| Bagian 8 Modul Sampah dan Pendukung | 11, 12, 10, 9, 6, 8 | 6 |
| **Total file unik** | | **12** |

---

*Dokumen sintesis ini menghimpun 12 catatan teknis menjadi satu rujukan. Status implementasi harus diverifikasi ulang terhadap codebase aktual sebelum dijadikan dasar keputusan rilis.*
