# COGS (Cost of Goods Sold) — Source of Truth

> **Berlaku untuk:** seluruh proyek `ikiwae`
> **File utama:** `api/v1/controllers/PenjualanController.php`, `common/base/MyStockManagement.php`
> **Tanggal terakhir diperbarui:** 2026-06-09

---

## 1. Overview & Arsitektur

COGS dalam sistem ini **tidak dihitung pada saat barang masuk** ke stok, melainkan pada saat **transaksi penjualan selesai dibayar**. Proses ini dieksekusi oleh fungsi `kurangiStok($pjItems)` yang dipanggil secara otomatis setelah payment dikonfirmasi.

Hasil akhir COGS disimpan di kolom `penjualan_item.harga_beli` (bukan di tabel `stok`), sehingga laporan COGS cukup mengquery `SUM(harga_beli * qty)` dari tabel `penjualan_item`.

### Dua Track Berbeda (Stok Masuk vs Stok Keluar)

```
STOK MASUK (GR / PTY / DR)            STOK KELUAR (Sales)
─────────────────────────────          ──────────────────────────────────
BuyPrice dibuat dulu                   kurangiStok() dipanggil post-payment
  └─► Stok.buy_price_id = BuyPrice.id    └─► calcCostParentOrAddon()
                                             └─► getHargaBeliByBarangId()
                                             └─► nilai disimpan ke:
                                                 PenjualanItem.harga_beli
                                                 PenjualanItem.j_cost_parent_result
                                                 PenjualanItem.j_cost_addon_result
```

**Kenapa dipisah?** Barang yang terjual bisa merupakan hasil olahan (WIP) dari banyak bahan baku, sehingga COGS tidak bisa dibaca langsung dari satu `buy_price_id`. COGS harus dihitung secara rekursif dari resep barang tersebut.

---

## 2. Entry Point: `kurangiStok()`

**Lokasi:** `api/v1/controllers/PenjualanController.php:819`

### Siapa yang Memanggil

| Caller | Lokasi | Kondisi |
|--------|--------|---------|
| `validateCash()` | ~baris 578 | Saat cash payment divalidasi |
| action payment | ~baris 1237 | Saat non-cash payment dikonfirmasi |
| `actionTestKurangiStok()` | baris 799 | Testing manual via API |

### Pseudocode Alur

```javascript
function kurangiStok(pjItems) {
  const trx = db.beginTransaction()
  try {
    for (const itm of pjItems) {
      let hargaBeli = 0
      const mBrg = Barang.findCachedById(itm.barang_id)

      // === PATH A: Barang punya linked_fg (menu → WIP/bahan baku) ===
      if (mBrg.linked_fg) {
        const dtReturn = reSaveCostChild(mBrg.linked_fg, mBrg, itm, directSave=true)
        // dtReturn.value = total COGS parent untuk semua qty
        hargaBeli += round(dtReturn.value)
        // PenjualanItem.harga_beli dan j_cost_parent_result sudah disimpan di dalam reSaveCostChild
      }

      // === PATH B: Barang punya j_addon (topping, extra item) ===
      if (itm.j_addon) {
        for (const addon of JSON.parse(itm.j_addon)) {
          const dtReturn = reSaveCostChild(addon.id, mBrg, itm, directSave=false)
          hargaBeli += round(dtReturn.value)
          // harga_beli per addon ditulis ke dalam array j_addon itu sendiri
        }
        // Update PenjualanItem: harga_beli = parent + semua addon, j_addon + j_cost_addon_result
        PenjualanItem.save({ harga_beli: hargaBeli, j_addon: newJaddon, j_cost_addon_result })
      }
    }
    trx.commit()
  } catch (Throwable e) {
    trx.rollBack()
    Yii.error(e.message, 'kurangiStok')
    throw e
  }
}
```

### Perbedaan `directSave` pada `reSaveCostChild`

| Parameter | Nilai | Efek |
|-----------|-------|------|
| `directSave = true` | Parent (linked_fg) | `reSaveCostChild` langsung menulis `PenjualanItem.harga_beli` dan `j_cost_parent_result` |
| `directSave = false` | Addon | Nilai dikembalikan ke caller; `kurangiStok` yang mengagregasi dan menyimpan |

---

## 3. Fungsi `reSaveCostChild()`

**Lokasi:** `api/v1/controllers/PenjualanController.php:912`

Fungsi ini adalah penghubung antara item yang terjual dengan logika penghitungan cost berbasis resep. Bertanggung jawab atas:
1. Menjalankan kalkulasi COGS via `calcCostParentOrAddon()`
2. Menentukan stok mana yang harus dikurangi berdasarkan `cook_type`
3. Menyimpan hasil COGS ke `PenjualanItem` (jika `directSave=true`)

### Decision Tree Pengurangan Stok

```
reSaveCostChild(barangId, ...)
  └─► calcCostParentOrAddon(mLinked, cabang_id, parentId, tanggal)
        └─► dtReturn.cook_type == ?

              ┌── COOK_TYPE_WIP (0) ──────────────────────────────────┐
              │  Barang sudah dimasak sebelumnya (pre-cooked WIP).     │
              │  Stok WIP yang dikurangi (bukan bahan bakunya).        │
              │  insertStock(barangId, qty * qty_perunit, TYPE_SALES)  │
              └───────────────────────────────────────────────────────┘

              ┌── COOK_TYPE_ONSALES (1) ──────────────────────────────┐
              │  Dimasak saat order (on-demand). Bahan baku yang       │
              │  dikurangi langsung dari stok, bukan WIP-nya.          │
              │  for each item in dtReturn.value_response:             │
              │    insertStock(item.barang_id, qty*qtyPerUnit, SALES)  │
              └───────────────────────────────────────────────────────┘
```

### Konstanta `cook_type` (dari `common/Constanta.php`)

```php
const COOK_TYPE_WIP     = 0; // Dimasak sebelumnya, stok WIP langsung dikurangi
const COOK_TYPE_ONSALES = 1; // Dimasak saat order, bahan baku yang dikurangi
```

### Return Value `reSaveCostChild`

```javascript
{
  wip_id: 123,           // ID barang yang menjadi WIP/linked_fg
  qty_perunit: 1,        // hasil_jadi dari Barang
  cook_type: 0 | 1,
  value: 15000,          // total COGS untuk semua qty pj item (bukan per unit)
  value_perunit: 15000,
  value_response: [      // detail per bahan
    {
      barang_id: 5,
      nama: "Daging Sapi",
      cook_type: 0,
      costPerUnit: 120000,
      qtyPerUnit: 0.125,   // amount * conversion (dalam satuan terkecil)
      qtyPerBom: 0.125,    // amount * qty * conversion
      totalCost: 15000,
      unit: "gram",
      valueWipLast: 15000,
      nested_recipe: [...]  // ada jika bahan ini juga punya resep
    }
  ]
}
```

---

## 4. Fungsi `calcCostParentOrAddon()`

**Lokasi:** `common/base/MyStockManagement.php:54`

Engine utama kalkulasi COGS berbasis resep. Membaca `BarangRecipe` (relasi ke tabel `barang_recipe`) dan merekursi jika bahan juga memiliki sub-resep.

### Formula Kalkulasi

```javascript
// Untuk setiap bahan dalam BarangRecipe:
qtyPerUnit  = recipe.amount * recipe.conversion
costPerUnit = getHargaBeliByBarangId(item_id, cabang_id, usedDate)?.harga_beli ?? 0

// Jika bahan memiliki nested_recipe:
nestedResult = calcCostParentOrAddon(jBarang, ...)
costPerUnit  = nestedResult.value / jBarang.hasil_jadi  // normalisasi ke per-unit

hasil = costPerUnit * qtyPerUnit

valueWip += hasil  // akumulasi = total COGS 1 porsi
```

### Filter Bahan yang Dilewati

Bahan dengan `barang.stok_ignore = 1` diabaikan sepenuhnya — tidak masuk hitungan COGS dan tidak dikurangi stoknya.

### Output Rekursif

`value_response` berisi flat array dari semua bahan (termasuk nested), lengkap dengan field `nested_recipe` untuk debugging.

---

## 5. Fungsi `getHargaBeliByBarangId()`

**Lokasi:** `common/base/MyStockManagement.php:312`

Fungsi ini adalah **sumber harga beli per unit** yang digunakan sebagai input `costPerUnit` di `calcCostParentOrAddon`. Mengimplementasikan **Weighted Average Cost** (rata-rata tertimbang berdasarkan sisa stok aktif).

### Query Logic

```sql
-- Ambil maksimal 5 record stok terbaru yang punya buy_price_id,
-- dari jenis GR, PTY, atau DR saja
SELECT stok.*, buy_price.harga_beli, buy_price.conversion
FROM stok
LEFT JOIN buy_price ON buy_price.id = stok.buy_price_id
WHERE stok.barang_id    = :brgId
  AND stok.cabang_id    = :cbgId
  AND stok.used_at     <= :asOfDate
  AND stok.type        IN ('gr', 'pty', 'dr')
  AND buy_price.deleted_at IS NULL
ORDER BY stok.used_at DESC, stok.id DESC
LIMIT 5
```

### Average Cost Method

```javascript
// latest_qty = sisa stok total pada asOfDate
const latest_qty = latestStokRow.latest_qty

// Dari 5 purchase terakhir, ambil yang masih relevan (totalQtyProcessed < latest_qty)
// Hitung average: sum(harga_beli) / jumlah_purchase_aktif
averageHargaBeli = totalValue / purchaseWithStock.length

// Edge case: jika latest_qty <= 0, pakai harga beli purchase terbaru (bukan average)
```

### Return Value

```javascript
{
  barang_id: 42,
  harga_beli: 12500,          // per unit (sudah di-average jika lebih dari 1 purchase)
  latest_qty: 10.5,
  jumlah_pembelian_aktif: 2,
  as_of_date: "2026-06-09 14:30:00",
  details: [...],             // detail per buy_price_id
  is_average_cost: true       // true jika dari lebih dari 1 purchase
}
```

### Tipe Stok yang Masuk Perhitungan Average Cost

| Type | Kode | Masuk Kalkulasi? |
|------|------|-----------------|
| Goods Receive | `gr` | ✅ Ya |
| Petty Cash | `pty` | ✅ Ya |
| Direct Receive (Consignment) | `dr` | ✅ Ya (sejak PC dibuat) |
| WIP | `wip` | ❌ Tidak |
| Sales | `sales` | ❌ Tidak |
| Transfer / Adjustment | `tfIn`, `tfOut`, `adjustment` | ❌ Tidak |
| Opening / Beginning | `opening`, `begining` | ❌ Tidak |

---

## 6. Penyimpanan Hasil COGS

Setelah `kurangiStok` selesai, hasil tersimpan di tiga field pada tabel `penjualan_item`:

| Field | Tipe | Isi |
|-------|------|-----|
| `harga_beli` | DOUBLE | Total COGS untuk seluruh qty item ini (parent + semua addon) |
| `j_cost_parent_result` | JSON | Raw output `calcCostParentOrAddon()` untuk barang parent / linked_fg |
| `j_cost_addon_result` | JSON | Array raw output `calcCostParentOrAddon()` untuk setiap addon |

### Struktur `j_addon` setelah diupdate

```javascript
// Sebelum kurangiStok: harga_beli tidak ada atau 0
[{ id: 10, qty: 1 }]

// Setelah kurangiStok: harga_beli ditambahkan per item
[{ id: 10, qty: 1, harga_beli: 3500 }]
```

---

## 7. Pengurangan Stok (Side Effect)

`kurangiStok` bukan hanya menghitung COGS — ia juga **mengurangi stok fisik** via `MyStockManagement::insertStock()`.

### Stok yang Dikurangi Per Skenario

```
Skenario 1: Menu tanpa linked_fg, tanpa addon
  → TIDAK ada stok yang dikurangi
  → harga_beli = 0 (tidak ada item untuk dihitung)

Skenario 2: Menu dengan linked_fg, cook_type = COOK_TYPE_WIP (0)
  → Stok WIP (linked_fg) dikurangi langsung
  → Stok bahan baku TIDAK dikurangi (sudah dikurangi saat WIP dibuat)
  → insertStock(linked_fg_id, qty * hasil_jadi, TYPE_SALES)

Skenario 3: Menu dengan linked_fg, cook_type = COOK_TYPE_ONSALES (1)
  → Stok setiap bahan baku (dari value_response) dikurangi
  → insertStock(bahan_id, qty * qtyPerUnit, TYPE_SALES)

Skenario 4: Menu dengan j_addon
  → Sama seperti skenario 2 atau 3, tergantung cook_type masing-masing addon
  → Setiap addon diproses independen via reSaveCostChild(addon.id, ...)
```

### Record Stok yang Dibuat

```php
// Setiap insertStock() membuat atau update record di tabel `stok`:
[
  'barang_id'      => $brgId,
  'cabang_id'      => $cbgId,
  'qty'            => -$porsiPerItem,   // negatif = pengurangan
  'type'           => Stok::TYPE_SALES, // 'sales'
  'id_ref'         => $penjualan_id,
  'buy_price_id'   => null,             // TIDAK punya buy_price_id
  'subwarehouse_id'=> mainKitchen.id,
]
```

**Catatan penting:** Stok tipe `sales` tidak punya `buy_price_id`. COGS sales disimpan di `penjualan_item.harga_beli`, bukan di stok.

---

## 8. Aggregasi COGS: `getCogs()`

**Lokasi:** `common/base/MyStockManagement.php:186`

```php
// COGS untuk range penjualan (berdasarkan penjualan_id range)
public static function getCogs($startPjId, $endPjId): float
{
    // SUM(harga_beli * qty) dari semua penjualan_item
    // j_addon TIDAK dijumlahkan lagi — sudah termasuk di harga_beli
}
```

```javascript
// Formula sederhana:
COGS = Σ (penjualan_item.harga_beli × penjualan_item.qty)
```

Tersedia juga varian:
- `getCogsByGroupId()` — filter per `barang_group_id`
- `getCogsByGroupIdNSalesTypeId()` — filter per group + sales type

---

## 9. Diagram Alur End-to-End

```
Payment dikonfirmasi
        │
        ▼
kurangiStok($pjItems)   ← wrapped dalam DB transaction
        │
        ├── untuk setiap PenjualanItem
        │         │
        │         ├── [ada linked_fg?] ──────────────────────────────┐
        │         │                                                    │
        │         │                                          reSaveCostChild(linked_fg)
        │         │                                                    │
        │         │                                          calcCostParentOrAddon()
        │         │                                                    │
        │         │                                          ┌─────────┴──────────┐
        │         │                                          │                    │
        │         │                                    cook_type=WIP        cook_type=ONSALES
        │         │                                          │                    │
        │         │                                    kurangi stok WIP    kurangi stok bahan baku
        │         │                                          │                    │
        │         │                                          └─────────┬──────────┘
        │         │                                                    │
        │         │                                          simpan ke PenjualanItem
        │         │                                          .harga_beli, .j_cost_parent_result
        │         │
        │         └── [ada j_addon?] ───────────────────────────────┐
        │                                                             │
        │                                               for each addon in j_addon
        │                                                             │
        │                                               reSaveCostChild(addon.id, directSave=false)
        │                                                             │
        │                                               akumulasi harga_beli
        │                                                             │
        │                                               simpan PenjualanItem.harga_beli (parent + addon)
        │                                               update j_addon dengan harga_beli per addon
        │                                               simpan j_cost_addon_result
        │
        ▼
  trx.commit()
```

---

## 10. Transaction Safety

Sejak refactor (`2026-06-09`), `kurangiStok` dibungkus dalam database transaction:

```php
$trx = Yii::$app->db->beginTransaction();
try {
    // ... loop seluruh pjItems ...
    $trx->commit();
} catch (\Throwable $e) {
    $trx->rollBack();
    Yii::error($e->getMessage(), 'kurangiStok');
    throw $e;
}
```

**Nested transaction aman:** `Stok::finalSave()` di dalam loop juga memanggil `beginTransaction()`. Yii2 secara otomatis menggunakan **MySQL savepoint** untuk nested transaction — ini aman di InnoDB.

**`\Throwable` bukan `\Exception`:** Digunakan agar `\Error` (fatal PHP error) juga tertangkap dan transaksi dirollback.

Caller wajib menangani exception yang dilempar:

```php
// Contoh caller yang benar:
try {
    $this->kurangiStok($mdl->penjualanItems);
} catch (\Throwable $ex) {
    Yii::error('kurangiStok gagal: ' . $ex->getMessage(), 'validateCash');
    throw $ex; // atau return error response
}
```

---

## 11. Field Kritis di Tabel Terkait

### `penjualan_item`

| Field | Diisi oleh | Keterangan |
|-------|-----------|------------|
| `harga_beli` | `kurangiStok()` | Total COGS item ini × seluruh qty (termasuk addon) |
| `j_cost_parent_result` | `reSaveCostChild()` | JSON raw output `calcCostParentOrAddon` untuk parent |
| `j_cost_addon_result` | `kurangiStok()` | JSON array raw output per addon |
| `j_addon` | `kurangiStok()` | Diupdate: tambah key `harga_beli` pada setiap addon object |

### `stok`

| Field | Nilai untuk record TYPE_SALES |
|-------|------------------------------|
| `type` | `'sales'` |
| `qty` | Negatif (pengurangan) |
| `buy_price_id` | `NULL` — by design |
| `id_ref` | `penjualan_id` |
| `subwarehouse_id` | mainKitchen subwarehouse |

### `barang`

| Field | Relevansi ke COGS |
|-------|------------------|
| `linked_fg` | FK ke barang lain — menu item → WIP/bahan baku yang dikurangi stoknya |
| `cook_type` | `0`=WIP pre-cooked, `1`=on-sales; menentukan stok mana yang dipotong |
| `hasil_jadi` | Yield per batch; normalisasi cost per unit untuk nested recipe |
| `stok_ignore` | `1` = barang ini diabaikan dari kalkulasi COGS dan pengurangan stok |

---

## 12. Keterbatasan & Catatan Desain

| # | Keterbatasan | Dampak |
|---|-------------|--------|
| 1 | `harga_beli = 0` jika barang tidak punya `linked_fg` dan tidak punya `j_addon` | Menu tanpa resep tidak memiliki COGS |
| 2 | `getHargaBeliByBarangId()` hanya ambil 5 record terakhir | Average cost mungkin tidak akurat jika ada >5 batch aktif |
| 3 | Average cost = arithmetic mean, bukan weighted mean | Mengabaikan perbedaan volume antar batch |
| 4 | Stok `opening` dan `adjustment` tidak masuk `getHargaBeliByBarangId()` | Stok awal tanpa buy_price tidak berkontribusi ke average |
| 5 | `calcCostParentOrAddon()` dapat rekursi tanpa batas | Resep yang saling mereferens dapat menyebabkan infinite loop; tidak ada cycle-detection |
| 6 | `kurangiStok` dipanggil post-payment, bukan saat order dibuat | Stok belum terpotong saat order in-progress |

---

## 13. Panduan untuk AI Developer

**Saat menambah jenis barang baru yang ingin dihitung COGS-nya:**
1. Pastikan `barang.linked_fg` diisi dengan `barang_id` dari WIP/bahan baku-nya
2. Set `barang.cook_type` sesuai kapan barang dimasak
3. Daftarkan resep di tabel `barang_recipe` (`BarangRecipe`)
4. Pastikan `barang.stok_ignore = 0` untuk semua bahan baku

**Saat debugging COGS = 0:**
1. Cek apakah `penjualan_item.j_cost_parent_result` NULL → berarti `calcCostParentOrAddon` return null
2. Cek apakah `barang.linked_fg` terisi
3. Cek apakah `barang_recipe` memiliki record untuk barang tersebut
4. Cek apakah bahan baku punya record di tabel `stok` dengan type `gr`/`pty`/`dr` dan `buy_price_id` tidak NULL

**Saat debugging COGS tidak akurat:**
1. Inspect `j_cost_parent_result` untuk melihat `costPerUnit` per bahan
2. Trace ke `getHargaBeliByBarangId()` dengan `barang_id` dan tanggal transaksi
3. Cek apakah ada batch yang `buy_price_id = null` (akan dilewati average cost)
