# Consignment (Konsinyasi) — Technical Overview

> **Scope:** Module Purchase — Konsinyasi (barang titipan supplier), aplikasi Point of Sales (POS) multi-cabang berbasis Yii2.
> **Core Model:** `common\models\Pembelian` (dua tipe: `type='dr'` dan `type='pc'`)
> **Controllers:** `backend\controllers\DirectReceiveController`, `backend\controllers\PembelianController`
> **Usecase:** `backend\usecases\pembelian\CreateConsignmentUsecase`

---

## 1. Overview & Business Rules

Alur konsinyasi di sistem ini terdiri dari **dua fase dokumen yang berbeda**, keduanya menggunakan tabel `pembelian` dengan `type` yang berbeda:

| Fase | `type` | Nama Dokumen | Deskripsi |
|------|--------|--------------|-----------|
| **Fase 1** | `'dr'` (`TYPE_DIRECT_RECEIVE`) | Direct Receive | Penerimaan fisik barang konsinyasi dari supplier tanpa PO terlebih dahulu. Harga belum diketahui. |
| **Fase 2** | `'pc'` (`TYPE_PO_FROM_DR`) | Consignment PO | PO resmi ke supplier, dibuat setelah periode konsinyasi berakhir. Berisi qty yang benar-benar terpakai + harga aktual. |

**Konsep inti konsinyasi:** Supplier menitipkan barang tanpa tagihan di awal. Pembeli (restoran/cabang) menggunakan barang tersebut. Di akhir periode, pembeli melaporkan berapa yang terpakai dan menerbitkan PO + Invoice untuk pembayaran.

---

### 1.1 Aturan Bisnis Kritikal

| # | Rule | Detail |
|---|------|--------|
| 1 | **Flag Produk Konsinyasi** | Hanya produk dengan `barang.is_consignment = 1` yang dapat diinput pada form Direct Receive dan Consignment PO. Produk non-konsinyasi tidak muncul di dropdown. |
| 2 | **DR Tanpa Harga** | Saat Direct Receive dibuat, `pembelian_item.harga_beli = 0` dan `total = 0`. Harga baru diketahui dan diinput saat Consignment PO dibuat. |
| 2a | **PC Bisa Diedit (Mode Pre-Save)** | Setelah Consignment PO dibuat, dapat diedit dengan data yang di-recalculate dari DB state terkini. Saat edit mode, semua field computed (total_received, current_stock, already_po) dihitung ulang untuk accuracy. |
| 3 | **Stok Masuk di Phase Released** | DR yang baru dibuat (`po_status = 1 / NEW`) belum memiliki record stok. Stok baru masuk ke ledger ketika DR di-"Received" (`actionReceived`) → `po_status = 2 / RELEASED`. |
| 4 | **PC Langsung Closed** | Consignment PO (`type='pc'`) dibuat langsung dengan `gr_status = ALL_RECEIVED (3)` dan `po_status = CLOSED (2)`. Tidak ada proses GR (penerimaan_barang) karena barang sudah fisik ada sejak DR. |
| 5 | **PC Tidak Punya GR** | Invoice (PI) untuk tipe `pc` langsung mereferensikan `purchase_invoice.pembelian_id = pc.id`, bukan via `gr_id`. Path PI berbeda dengan PO reguler. |
| 6 | **Periode Konsinyasi** | Consignment PO menyimpan `consignment_period_start` dan `consignment_period_end` untuk menentukan periode mana yang di-settle. Sistem mencegah double-PO pada periode yang sama. |
| 7 | **Formula Available Qty** | Qty yang bisa di-PO dihitung otomatis: `Available = Σ(DR qty dalam periode) − Stok terkini − Σ(PC qty dalam periode)`. Tidak bisa input qty melebihi available. |
| 8 | **Self-Referential PO Chain** | `pembelian.consignment_last_po_id` menyimpan ID Consignment PO sebelumnya dari supplier yang sama. Digunakan untuk auto-fill periode berikutnya (`start = last_pc.period_end + 1 hari`) untuk mencegah overlap periode. |
| 9 | **Edit/Delete DR Hanya Pre-Released** | DR hanya bisa diedit atau dihapus jika `po_status = NEW (1) / PO_STATUS_OPEN`. Setelah Released, stok sudah masuk dan tidak bisa diubah. |
| 10 | **Delete DR = Hard Delete Stok + BuyPrice** | Jika DR dalam status OPEN dihapus: `PembelianItem::deleteAll`, `Stok::deleteAll (type='dr')`, `BuyPrice::deleteAll (type='dr')`. |
| 11 | **Term Snapshot** | Saat PC dibuat, `pembelian.produsen_term = produsen.term` di-snapshot. Perubahan term supplier di masa depan tidak mempengaruhi PC yang sudah ada. |
| 12 | **Stok `is_consignment` Flag** | Record `stok` dari DR ditandai `stok.is_consignment = true`. Digunakan oleh `MyStockManagement::getBarangWithLatestStock()` untuk filter stok konsinyasi saat menghitung available qty. |
| 13 | **Harga dari Riwayat PC Sebelumnya** | Saat PC baru dibuat, `mappingPoItems()` mengisi `harga_beli` dari PC sebelumnya untuk supplier yang sama. Jika tidak ada, diambil dari DR (yang nilainya 0). |
| 14 | **Supplier Filter di PC** | Dropdown supplier pada form PC (`actionProdusenList`) hanya menampilkan supplier yang pernah memiliki DR aktif — bukan semua supplier. |
| 15 | **RBAC Terpisah** | DR menggunakan role `viewConsignment / createConsignment / updateConsignment / deleteConsignment`. PC menggunakan role `viewPurchaseOrder / createConsignment / updateConsignment`. |

---

### 1.2 Konstanta & Status

#### Status Direct Receive (`po_status`)

```php
// Pembelian.php
const DR_STATUS_NEW      = 1; // Draft, belum dirilis
const DR_STATUS_RELEASED = 2; // Sudah diterima, stok sudah masuk

const ARR_DIRECT_RECEIVE = [1 => 'Draft', 2 => 'Received'];
```

| Nilai | Label | Deskripsi | Edit/Delete |
|-------|-------|-----------|-------------|
| `1` | Draft | DR baru dibuat, stok belum masuk | ✅ Boleh |
| `2` | Received | Stok sudah masuk ke ledger | ❌ Tidak boleh |

#### Status Consignment PO

PC dibuat langsung dengan status closed (tidak ada lifecycle):

```php
$model->gr_status = PenerimaanBarang::STATUS_ALL_RECEIVED; // = 3
$model->po_status = Pembelian::PO_STATUS_CLOSED;           // = 2
```

---

## 2. Skema Database & Tipe Data

Konsinyasi menggunakan tabel yang sama dengan modul purchase umum. Kolom-kolom spesifik konsinyasi di tabel `pembelian`:

### 2.1 Kolom Konsinyasi pada Tabel `pembelian`

> Skema lengkap tabel `pembelian` lihat `docs/features/PURCHASE.md`. Berikut hanya kolom yang relevan atau unik untuk konsinyasi.

| Kolom | Tipe Data | Berlaku untuk | Keterangan |
|-------|-----------|---------------|------------|
| `type` | `VARCHAR(100)` | DR & PC | `'dr'` = Direct Receive, `'pc'` = Consignment PO |
| `po_status` | `TINYINT(1)` | DR | 1=Draft/New, 2=Released. Di PC: selalu 2 (Closed) |
| `gr_status` | `TINYINT(1)` | PC | Selalu 3 (ALL_RECEIVED) untuk PC. DR tidak relevan. |
| `inv_status` | `TINYINT(1)` | PC | 1=Uninvoiced, 2=Partial, 3=Complete. DR tidak di-invoice. |
| `consignment_period_start` | `DATE` | PC | Awal periode konsinyasi yang di-settle |
| `consignment_period_end` | `DATE` | PC | Akhir periode konsinyasi yang di-settle |
| `consignment_last_po_id` | `BIGINT UNSIGNED` | PC | FK → `pembelian.id` (self-ref). ID PC periode sebelumnya. |
| `produsen_id` | `INT(11)` | DR & PC | **Required** untuk kedua tipe |
| `produsen_term` | `INT UNSIGNED` | PC | Snapshot `produsen.term` saat PC dibuat |
| `warehouse_id` | `INT UNSIGNED` | PC | Gudang untuk PC |
| `no_faktur` | `VARCHAR(45)` | DR & PC | Auto-generated, format berbeda per tipe |
| `tanggal_pembelian` | `DATETIME` | DR & PC | Tanggal DR diterima / tanggal PC dibuat |
| `jenis_pembayaran` | `VARCHAR(20)` | PC | `'cash'` atau `'credit'` |
| `jatuh_tempo` | `INT(11)` | PC | Jatuh tempo pembayaran (hari). Null jika `jenis_pembayaran = 'cash'` |
| `total` | `DOUBLE` | PC | Total nilai PC. DR selalu 0. |
| `cabang_id` | `BIGINT UNSIGNED` | DR & PC | NOT NULL, FK → `cabang.id` |

---

### 2.2 Kolom Konsinyasi pada Tabel `pembelian_item`

| Kolom | Tipe DR | Tipe PC | Keterangan |
|-------|---------|---------|------------|
| `pembelian_id` | ID DR | ID PC | Referensi ke dokumen header |
| `barang_id` | FK barang | FK barang | Hanya produk `is_consignment = 1` |
| `subwarehouse_id` | FK subwarehouse | FK subwarehouse | Sub-gudang tujuan |
| `po_qty` | Qty diterima dari supplier | Qty yang terpakai (dihitung dari formula) | Satuan: `unit_pkg` |
| `harga_beli` | **SELALU 0** | Harga aktual dari supplier | Di-fill dari harga PC sebelumnya |
| `disc` | **SELALU 0** | Diskon per item | |
| `unit_pkg` | ID satuan kemasan | ID satuan kemasan | FK → `satuan.id` |
| `conversion` | Faktor konversi ke satuan dasar | Faktor konversi ke satuan dasar | Dari `barang.showDetailSelectedUnit()` / `showQtyOnSelectedUnit()` |
| `total` | **SELALU 0** | `(po_qty × harga_beli) - disc` | |

---

### 2.3 Kolom Konsinyasi pada Tabel `barang`

```sql
ALTER TABLE `barang`
ADD COLUMN `is_consignment` TINYINT(1) DEFAULT 0 COMMENT '0:tidak, 1:ya';
```

| Kolom | Nilai | Dampak |
|-------|-------|--------|
| `is_consignment = 0` | Default | Produk reguler, tidak muncul di form DR/PC |
| `is_consignment = 1` | Konsinyasi | Muncul di dropdown DR dan PC, stoknya masuk sebagai konsinyasi |

---

### 2.4 Kolom Konsinyasi pada Tabel `stok`

```sql
ALTER TABLE `stok`
ADD COLUMN `is_consignment` BOOLEAN DEFAULT FALSE;
```

| Kolom | Nilai | Keterangan |
|-------|-------|------------|
| `is_consignment = false` | Default | Stok reguler |
| `is_consignment = true` | Konsinyasi | Stok dari DR konsinyasi. Digunakan oleh `getBarangWithLatestStock()` untuk filter available qty. |

---

### 2.5 Tabel `pembelian` — Kolom Self-Referential Konsinyasi (DDL)

```sql
-- Ditambahkan via migration m251215_100000_add_consignment_period_to_pembelian
ALTER TABLE `pembelian`
ADD COLUMN `consignment_period_start` DATE DEFAULT NULL AFTER `warehouse_id`,
ADD COLUMN `consignment_period_end`   DATE DEFAULT NULL AFTER `consignment_period_start`,
ADD COLUMN `consignment_last_po_id`   BIGINT UNSIGNED DEFAULT NULL AFTER `consignment_period_end`;

ALTER TABLE `pembelian`
ADD CONSTRAINT `fk-pembelian-consignment_last_po_id`
    FOREIGN KEY (`consignment_last_po_id`) REFERENCES `pembelian` (`id`)
    ON DELETE SET NULL ON UPDATE CASCADE;

CREATE INDEX `idx-pembelian-consignment_period`
    ON `pembelian` (`produsen_id`, `type`, `consignment_period_start`, `consignment_period_end`);
```

---

## 3. Relasi Antar Tabel (Entity Relationships)

### 3.1 Diagram Relasi Konsinyasi

```
                        produsen (supplier)
                              │
               ┌──────────────┼───────────────────┐
               │              │                   │
               ▼              ▼                   ▼
    [DR] pembelian     [PC] pembelian        pembelian
    (type='dr')        (type='pc')        (self-ref via
         │                   │            consignment_last_po_id)
         │                   │
         ▼                   ▼
  pembelian_item       pembelian_item
  (harga=0, qty=DR)   (harga=aktual, qty=terpakai)
         │                   │
         │                   └──→ purchase_invoice
         │                         (pembelian_id = pc.id, tanpa gr_id)
         │                               │
         │                               └──→ purchase_payment
         │
         ▼ (saat actionReceived)
       stok
  (type='dr', id_ref=dr.id, is_consignment=true)
         │
         ▼ (dikonsumsi oleh penjualan, dsb.)
   stok berkurang

barang (is_consignment=1)
  ├──→ direferensikan oleh DR.pembelian_item
  └──→ direferensikan oleh PC.pembelian_item
```

---

### 3.2 Detail Setiap Relasi

#### `pembelian (DR)` → `pembelian_item` (1:N)

```php
// Pembelian.php
public function getPembelianItems()
{
    return $this->hasMany(PembelianItem::class, ['pembelian_id' => 'id']);
}
```

- **Kolom FK:** `pembelian_item.pembelian_id` → `pembelian.id`
- **Fungsi untuk DR:** Menyimpan daftar produk konsinyasi yang diterima, dengan qty dari supplier dan `harga_beli = 0`.
- **Fungsi untuk PC:** Menyimpan daftar produk yang terpakai selama periode + harga aktual.
- **Delete behavior:** Saat DR dihapus atau di-edit, `PembelianItem::deleteAll(['pembelian_id' => $model->id])` dipanggil sebelum record baru dimasukkan.

---

#### `pembelian (DR)` → `stok` (1:N, via `id_ref`)

Dibuat saat `actionReceived()` dipanggil di `DirectReceiveController`.

```php
// DirectReceiveController::actionReceived()
foreach ($model->pembelianItems as $item) {
    $qtyInGram = $item->po_qty * $item->conversion;

    $stok                  = new Stok();
    $stok->barang_id       = $item->barang_id;
    $stok->cabang_id       = $model->cabang_id;
    $stok->subwarehouse_id = $item->subwarehouse_id;
    $stok->qty             = $qtyInGram;           // dalam satuan dasar (gram)
    $stok->desc            = $model->no_faktur;
    $stok->type            = Pembelian::TYPE_DIRECT_RECEIVE; // = 'dr'
    $stok->id_ref          = $model->id;           // ← referensi ke DR
    $stok->used_at         = $model->tanggal_pembelian;
    $stok->finalSave();
    // is_consignment otomatis true karena barang.is_consignment = 1
}
$model->po_status = Pembelian::DR_STATUS_RELEASED;
$model->save();
```

- **Kolom Referensi:** `stok.id_ref = dr.id`, `stok.type = 'dr'`, `stok.is_consignment = true`
- **Delete Behavior:** Saat DR dihapus, `Stok::deleteAll(['id_ref' => $model->id, 'type' => 'dr', 'cabang_id' => $model->cabang_id])`.

---

#### `pembelian (PC)` → `pembelian` self-referential via `consignment_last_po_id`

```php
// Pembelian.php
public function getConsignmentLastPo()
{
    return $this->hasOne(Pembelian::class, ['id' => 'consignment_last_po_id']);
}
```

- **Kolom FK:** `pembelian.consignment_last_po_id` → `pembelian.id` (ON DELETE SET NULL)
- **Fungsi:** Chain riwayat PC dari supplier yang sama. Setiap PC baru menyimpan ID PC sebelumnya.
- **Auto-fill Periode:** Saat form PC baru dibuka, sistem mencari PC terakhir dari supplier:

```php
// CreateConsignmentUsecase.php
$lastPO = Pembelian::getActiveAll(false, true)
    ->andWhere([
        'produsen_id' => $produsenId,
        'type'        => Pembelian::TYPE_PO_FROM_DR,
        'cabang_id'   => $curCabangId,
    ])
    ->orderBy(['tanggal_pembelian' => SORT_DESC])
    ->one();

// Auto-fill periode berikutnya (menambah 1 hari untuk mencegah overlap):
$periodStart = $lastPO && $lastPO->consignment_period_end
    ? date('Y-m-d', strtotime($lastPO->consignment_period_end . ' +1 day'))
    : date('Y-m-d', strtotime('-1 month'));
$periodEnd = date('Y-m-d H:i:s'); // today

$model->consignment_period_start = $periodStart;
$model->consignment_period_end   = $periodEnd;
$model->consignment_last_po_id   = $lastPO->id;
```

**Perubahan:** Periode start sekarang menambah 1 hari (`+1 day`) ke `period_end` untuk mencegah overlap dengan periode sebelumnya. Jika belum ada PC sebelumnya, default ke 1 bulan terakhir.

---

#### `pembelian (PC)` → `purchase_invoice` (1:N, via `pembelian_id`)

```php
// PurchaseInvoice.php
public function getPembelian()
{
    return $this->hasOne(Pembelian::class, ['id' => 'pembelian_id']);
}
```

- **Kolom FK:** `purchase_invoice.pembelian_id` → `pembelian.id` (PC)
- **Fungsi:** PC tidak memiliki GR (`gr_id = null`). Invoice dibuat langsung dari PC menggunakan `pembelian_id` sebagai referensi.
- **Invoice Status Update:** Setelah PI dibuat dari PC, `Pembelian::setStatusInvoice($pcId)` dipanggil:

```php
// Pembelian::setStatusInvoice() — cabang untuk TYPE_PO_FROM_DR
} else if (sizeof($mPb) == 0 && $mPmb->type == Pembelian::TYPE_PO_FROM_DR) {
    // tipe pc tidak punya gr, cek langsung dari purchase_invoice
    $mPi = PurchaseInvoice::find()
        ->innerJoin('purchase_invoice_items', '...')
        ->where(['purchase_invoice.deleted_at' => null])
        ->andWhere(['purchase_invoice_items.pembelian_id' => $pembelianId])
        ->one();
    $mPmb->inv_status = $mPi
        ? self::INV_STATUS_COMPLETED_INVOICE
        : self::INV_STATUS_NO_INVOICE;
}
```

---

#### `pembelian (PC)` → `pembelian (DR)` — Relasi Agregasi via Query

Tidak ada FK langsung antara PC dan DR. Relasi ini bersifat **query-time** berdasarkan `produsen_id` dan periode:

```php
// CreateConsignmentUsecase::mappingPoItems() — mengambil semua DR dalam periode
$pembelianItems = PembelianItem::find()
    ->innerJoin('pembelian', 'pembelian.id = pembelian_item.pembelian_id')
    ->where(['pembelian.produsen_id' => $produsenId])
    ->andWhere(['pembelian.type' => Pembelian::TYPE_DIRECT_RECEIVE])
    ->asArray()
    ->all();
```

Semua DR dari `produsenId` digunakan sebagai basis agregasi untuk menghitung `total_received`.

---

#### `barang` → `pembelian_item` (N:M via flag `is_consignment`)

```php
// DirectReceiveController::actionCreate() — filter produk konsinyasi
$product_data = Barang::getActiveAll(false, true)
    ->andWhere(['jenis' => Constanta::JENIS_RAW])
    ->andWhere(['is_consignment' => 1])
    ->all();
```

- **Aturan:** Hanya barang dengan `is_consignment = 1` yang tampil di form DR dan PC.
- **Dampak Stok:** Stok dari barang konsinyasi ditandai `stok.is_consignment = true` saat DR di-release.

---

## 4. Alur Bisnis Konsinyasi (Full Lifecycle)

```
[Supplier Titipkan Barang]
        │
        ▼
FASE 1: Direct Receive (DR)
────────────────────────────
DirectReceiveController::actionCreate()
  - type    = 'dr'
  - po_status = 1 (DR_STATUS_NEW / PO_STATUS_OPEN)
  - harga_beli = 0 per item (harga belum diketahui)
  - no_faktur auto-generated (TYPE_DIRECT_RECEIVE prefix)
  - Hanya produk is_consignment = 1

        │ DR tersimpan, stok BELUM masuk
        │
        ▼
RELEASE DR: actionReceived()
  - Per item: Stok baru dibuat (type='dr', id_ref=dr.id, is_consignment=true)
  - qty dalam satuan dasar (qty × conversion)
  - po_status = 2 (DR_STATUS_RELEASED)
  - Setelah ini: DR TIDAK bisa diedit atau dihapus

        │ Barang dipakai oleh dapur/operasional...
        │ Stok berkurang karena penjualan
        │
        ▼
FASE 2: Consignment PO (PC)
────────────────────────────
PembelianController::actionCreateConsignment($produsenId, $periodStart, $periodEnd)
→ CreateConsignmentUsecase::execute()

  Auto-fill periode dari PC terakhir supplier ini:
    consignment_period_start = lastPC.period_end + 1 hari
    consignment_period_end   = hari ini

  Auto-fill items via mappingPoItems():
    Untuk setiap barang konsinyasi dari supplier ini:
      available_qty =
        Σ(DR.po_qty dalam periode) ─ current_stock ─ Σ(PC.po_qty dalam periode)
      harga_beli = dari PC terakhir supplier ini (atau 0 jika belum ada)

  User review dan konfirmasi qty + harga

  Saat Save:
    - type     = 'pc'
    - gr_status = 3 (ALL_RECEIVED)  ← langsung closed, tidak perlu GR
    - po_status = 2 (PO_STATUS_CLOSED)
    - consignment_last_po_id = lastPC.id
    - produsen_term = snapshot produsen.term
    - Validasi: po_qty tidak boleh melebihi available_qty

        │
        ▼
INVOICING: Purchase Invoice dari PC
────────────────────────────────────
PurchaseInvoiceController::actionCreateMultiple()

  - purchase_invoice.pembelian_id = pc.id  ← referensi langsung ke PC
  - purchase_invoice.gr_id = null           ← TIDAK ada GR untuk konsinyasi
  - Setelah PI dibuat: pc.inv_status = 3 (COMPLETED)

        │
        ▼
PAYMENT: Purchase Payment
─────────────────────────
  - purchase_payment_items.purchase_invoice_id = pi.id
  - (Alur sama dengan purchase reguler)
```

---

## 5. Formula Available Qty

Ini adalah kalkulasi inti konsinyasi. Dijalankan oleh `CreateConsignmentUsecase::mappingPoItems()` dan `getAvailableConsignmentQty()`.

```
Available_Qty = Σ(DR_qty) − Current_Stock − Σ(PC_qty)

Di mana:
  Σ(DR_qty)       = SUM(pembelian_item.po_qty)
                    JOIN pembelian WHERE type='dr' AND produsen_id=X
                    AND tanggal_pembelian BETWEEN period_start AND period_end

  Current_Stock   = stok.latest_qty (dalam unit_pkg)
                    WHERE is_consignment = true AND barang_id = X

  Σ(PC_qty)       = SUM(pembelian_item.po_qty)
                    JOIN pembelian WHERE type='pc' AND produsen_id=X
                    AND periode PC overlap dengan periode yang sedang dibuat
                    (3 kondisi overlap: start-in, end-in, atau fully-contains)
```

### Kondisi Overlap Periode

```sql
-- Tiga kondisi periode yang dianggap overlapping:
WHERE (
    -- Kondisi 1: period_start PC lama ada di dalam periode baru
    (pembelian.consignment_period_start BETWEEN :startDate AND :endDate)
    OR
    -- Kondisi 2: period_end PC lama ada di dalam periode baru
    (pembelian.consignment_period_end BETWEEN :startDate AND :endDate)
    OR
    -- Kondisi 3: periode baru fully contained dalam periode PC lama
    (pembelian.consignment_period_start <= :startDate
     AND pembelian.consignment_period_end >= :endDate)
)
```

### Validasi Saat Submit

```php
// CreateConsignmentUsecase::execute() — validasi per item
$availableQty = $this->getAvailableConsignmentQty(
    $barangId, $unitPkg, $produsenId,
    $model->consignment_period_start,
    $model->consignment_period_end,
    $model->cabang_id
);

if ($po_qty > $availableQty) {
    $model->addError('id', "{$barang->nama}: Qty melebihi yang tersedia (Available: {$availableQty})");
    $transaction->rollBack();
}
```

---

## 6. Behaviors & Cross-Cutting Concerns

Model `Pembelian` (termasuk DR dan PC) menggunakan behavior yang sama dengan purchase umum:

```php
public function behaviors()
{
    return [
        MyBehavior::timestampBehavior(),    // auto created_at, updated_at
        MyBehavior::blameableBehavior(),    // auto created_by, updated_by
        MyBehavior::activityLogBehavior(),  // audit trail
    ];
}
```

### Auto-Numbering

DR menggunakan prefix `TYPE_DIRECT_RECEIVE ('dr')`, PC menggunakan prefix `TYPE_PO_FROM_DR ('pc')`:

```php
// DirectReceiveController::actionCreate()
$model->no_faktur = MyHelper::generateMonthlyFaktur($curCabangId, Pembelian::TYPE_DIRECT_RECEIVE);

// CreateConsignmentUsecase::execute()
$model->no_faktur = MyHelper::generateMonthlyFaktur($curCabangId, Pembelian::TYPE_PO_FROM_DR);
```

---

## 7. Akses Kontrol (RBAC)

### DirectReceiveController (DR)

| Action | Method | Route | RBAC Permission |
|--------|--------|-------|-----------------|
| Lihat list DR | `actionIndex` | `direct-receive/index` | `viewConsignment` |
| Lihat detail DR | `actionView` | `direct-receive/view/{id}` | `viewConsignment` |
| Buat DR baru | `actionCreate` | `direct-receive/create[/{produsenId}]` | `createConsignment` |
| Edit DR (pre-released) | `actionUpdate` | `direct-receive/update/{id}` | `updateConsignment` |
| Release DR (terima stok) | `actionReceived` | `direct-receive/received/{id}` | `updateConsignment` |
| Hapus DR | `actionDelete` | `direct-receive/delete/{id}` | `deleteConsignment` |

**Catatan pemisahan Create/Update:**
- `actionCreate()`: Hanya untuk membuat DR baru. Jika supplier dipilih via dropdown, redirect otomatis ke `direct-receive/create/{produsenId}` (path parameter)
- `actionUpdate($id)`: Hanya untuk edit DR yang sudah ada (status OPEN). Memerlukan parameter `id` (hashId)

#### Direct Receive Form Behavior

**Supplier Dropdown:**
- Saat user memilih supplier, form otomatis redirect ke: `/direct-receive/create/{produsenId}`
- Controller akan pre-populate supplier field dan load product list
- Event handler: `select2:select` → `DirectReceiveUrl::toCreate() + '/' + produsenId`

### PembelianController (PC - Consignment PO)

| Action | Method | Route | RBAC Permission | Status |
|--------|--------|-------|-----------------|--------|
| Buat PC baru | `actionCreateConsignment` | `pembelian/create-consignment[/{produsenId}]` | `createConsignment` | ✅ Aktif |
| Edit PC | `actionCreateConsignment` | `pembelian/create-consignment/{id}` | `updateConsignment` | ❌ Disabled (read-only setelah create) |

**Catatan:** Mulai commit 8f4308da, PC tidak dapat diedit setelah dibuat. Menu edit action ditampilkan dengan styling disabled (`text-secondary`). Untuk perubahan data PC, harus membuat PC baru.

---

## 8. PC Bebas Design & Formula Update (v2.1)

**Perubahan Fundamental:** Mulai commit ini, Consignment PO mengadopsi desain **"PC Bebas"** — periode menjadi metadata saja, bukan filter kalkulasi. Item dari DR dapat di-settle di PC kapanpun, tidak terikat periode.

### 8.0.1 Available Qty Formula (Updated)

**SEBELUM (v2.0 - Period-Locked):**
```
Available_Qty = Σ(DR_qty dalam periode) − Current_Stock − Σ(PC_qty dalam periode yang overlap)
```

**SESUDAH (v2.1 - PC Bebas):**
```
Available_Qty = Σ(DR_qty ALL-TIME) − Current_Stock − Σ(PC_qty ALL-TIME, no period filter)

Di mana:
  Σ(DR_qty)       = SUM(pembelian_item.po_qty) dari semua DR released (tanpa filter periode)
  
  Current_Stock   = stok.latest_qty terkini
  
  Σ(PC_qty)       = SUM(pembelian_item.po_qty) dari SEMUA PC sebelumnya (tanpa filter periode)
```

**Implikasi:**
- ✅ Item dari DR Mei bisa di-PO di PC Juni (tidak terikat periode DR)
- ✅ Formula lebih fleksibel untuk settlement pattern yang kompleks
- ❌ Periode PC jadi label, bukan constraint kalkulasi
- ✅ Overlap validation berubah ke item-level, bukan header-level

### 8.0.2 Overlap Validation (Changed to Per-Item)

**SEBELUM (v2.0 - Header-Level):**
```
Jika ada PC dari supplier yang sama dengan periode overlap → BLOCK semua item
Masalah: Terlalu ketat, user tidak bisa buat PC overlapping meski item berbeda
```

**SESUDAH (v2.1 - Item-Level):**
```
Untuk setiap item dengan po_qty > 0:
  Jika ada PC lain dengan ITEM YANG SAMA + periode overlap + po_qty > 0
    → Error spesifik untuk item tersebut saja
  Jika item berbeda meski periode overlap
    → OK, bisa di-PO di PC berbeda

Contoh:
  - PC#1 (June): Item A (qty=50), Item B (qty=30)
  - PC#2 (June): Item C (qty=100) ✅ OK (item berbeda, periode overlap)
  - PC#2 (June): Item A (qty=10) ❌ ERROR (item sama, periode overlap)
```

### 8.0.3 Form Enhancements

#### A. Edit Mode — Re-Calculate Data from DB

**File:** `backend/usecases/pembelian/CreateConsignmentUsecase::buildItemDataForEdit()`

Saat user membuka form edit PC, semua computed field di-calculate ulang:

```php
// Computed fields yang di-recalculate:
$totalReceived  // Σ(DR released, all-time)
$currentStock   // Stok terkini dari MyStockManagement
$alreadyPOQty   // Σ(PC sebelumnya, all-time, no period filter)
```

**Benefit:**
- ✅ Data selalu fresh (akurat dengan state terkini)
- ✅ User bisa see updated numbers setiap kali edit
- ✅ Tidak ada stale snapshot

#### B. DateRangePicker + Auto-Reload

**File:** `backend/views/pembelian/_form_consignment.php`

Menggantikan dua text input disabled dengan Kartik `DateRangePicker`:

```php
<?= DateRangePicker::widget([
    'name'   => 'consignment_period',
    'value'  => $model->consignment_period_start . ' - ' . $model->consignment_period_end,
    'pluginOptions' => [
        'locale' => ['format' => 'Y-m-d', 'separator' => ' - '],
        'opens'  => 'left',
    ],
]) ?>
```

**Auto-Reload Behavior:**
- Saat user ubah periode → Kartik dialog muncul
- User klik Reload → Form reload dengan parameter periode baru
- Backend recalculate items untuk periode baru
- URL: `/pembelian/create-consignment?id=...&periodStart=2026-06-01&periodEnd=2026-06-30`

#### C. Client-Side Validation (6 Validasi)

**File:** `backend/views/pembelian/_form_consignment.php` (JS validation block)

Form kini memiliki validasi comprehensive sebelum submit:

1. **Supplier required** — Error message: "Supplier harus dipilih"
2. **Period required** — Error message: "Periode harus dipilih"
3. **Minimal 1 item** — Error message: "Minimal 1 item harus ditambahkan"
4. **No duplicate barang** — Error message: "Barang ini sudah ada di item lain"
5. **Barang per item required** — Error message: "Barang harus dipilih"
6. **Qty per item required & > 0** — Error message: "Qty harus diisi dan lebih besar dari 0"

**Features:**
- ✅ Real-time error highlighting (red border + background)
- ✅ Error rows untuk detail pesan per item
- ✅ Auto-scroll ke error pertama
- ✅ Console logging untuk debugging
- ✅ Qty parsing dengan decimal separator support (Fmt::nq)

#### D. Item Removal with Error Cleanup

**File:** `backend/web/js/purchase.order.consignment.js`

Saat user click btn-remove-item, semua error-row consecutive di bawahnya otomatis dihapus:

```javascript
// Loop untuk remove multiple error-row
var nextRow = row.next();
while (nextRow.length && nextRow.hasClass('error-row')) {
    var tempRow = nextRow;
    nextRow = nextRow.next();
    tempRow.remove();
}
```

---

## 9. Form Handling & Parameter Routing

### Direct Receive (DR) Form Flow

#### Scenario A: Create New DR

```
Flow: User selects supplier → Auto-redirect
─────────────────────────────────────────
1. User membuka form create: /direct-receive/create
   - Dropdown supplier kosong (readonly untuk edit)
   
2. User memilih supplier dari dropdown
   - Trigger event: select2:select
   - Event handler: DirectReceiveUrl::toCreate() + '/{produsenId}'
   
3. Browser redirect otomatis ke: /direct-receive/create/2
   (Contoh: produsenId = 2)
   
4. Controller actionCreate() menerima parameter:
   - $produsenId dari path parameter
   - Pre-load product list dari supplier tersebut
   - Form kembali dengan supplier sudah terisi
```

**Form Action:**
```php
// _form.php (line 59)
$postUrl = $model->isNewRecord 
    ? Url::to(['create', 'produsenId' => $model->produsen_id])
    : Url::to(['update', 'id' => $model->hashId]);
$form->action = $postUrl;
```

#### Scenario B: Edit Existing DR

```
Flow: User edit DR yang sudah ada
──────────────────────────────────
1. User klik Edit pada DR yang ada: /direct-receive/view/{id}
   
2. Link Edit → /direct-receive/update/{hashId}
   
3. Controller actionUpdate($id) menerima hashId:
   - Load existing DR record
   - Validate status = OPEN (hanya bisa edit pre-released)
   - Supplier dropdown: disabled (tidak bisa ubah supplier)
   
4. Form action ke update/{hashId}
   - Post akan update existing record
```

### Parameter Structure

#### Path Parameter vs Query Parameter

```php
// Direct Receive
/direct-receive/create            // New form, no supplier
/direct-receive/create/2          // New form, pre-fill supplier_id=2
/direct-receive/update/abc123     // Edit existing (hashId)
/direct-receive/received/abc123   // Release DR to inventory

// Consignment PO
/pembelian/create-consignment           // New form
/pembelian/create-consignment/abc123    // Edit existing (hashId)
```

#### produsenId Path Parameter Handling

**DirectReceiveController::actionCreate()**

```php
// SEBELUM (combined create & update):
public function actionCreate($id = null) { ... }

// SESUDAH (create only):
public function actionCreate($produsenId = null) {
    $model = new Pembelian();
    
    // Jika ada produsenId di path, pre-load supplier
    if ($produsenId) {
        $model->produsen_id = $produsenId;
        // Pre-load product list dari supplier ini
    }
    
    // Render form dengan supplier pre-filled
    return $this->render('_form', [
        'model'         => $model,
        'produsen_data' => $produsenData,
        // ...
    ]);
}
```

**Form Behavior (Select2)**

```javascript
// _form.php (line 125-131)
'pluginEvents' => [
    'select2:select' => "function(e) {
        var produsenId = e.params.data.id;
        // Construct path parameter (not query string)
        var url = '" . DirectReceiveUrl::toCreate() . "/' + produsenId;
        location.replace(url);
    }",
],
```

Result: `/direct-receive/create/2` (path param) — bukan `/direct-receive/create?produsenid=2`

#### Consignment PO URL Routing

**PurchaseUrl::toCreateConsignment()**

```php
// SEBELUM (commit 2839bcc2):
public static function toCreateConsignment($id=null, $produsenid = null)
{
    // $hashId = self::convertIdToHashId($id);
    return Url::to([self::CONTROLLER.'/create-consignment','id'=>$id, 'produsenid'=>$produsenid]);
}

// SESUDAH (commit 2839bcc2):
public static function toCreateConsignment($id=null, $produsenid = null)
{
    $hashId = self::convertIdToHashId($id);
    return Url::to([self::CONTROLLER.'/create-consignment','id'=>$hashId, 'produsenid'=>$produsenid]);
}
```

**Perubahan:** ID sekarang di-hash untuk keamanan. URL yang di-generate berubah dari `/pembelian/create-consignment?id=123` menjadi `/pembelian/create-consignment?id=abc123xyz`

### Unit Conversion Form Logic (JavaScript)

**File:** `backend/web/js/purchase.order.consignment.js`

Saat user mengubah unit dropdown di form consignment:

```javascript
/**
 * ASUMSI: Semua nilai dari PHP response dikirim dalam satuan DEFAULT
 * (unit_pkg yang dipilih saat halaman dimuat), BUKAN dalam base unit.
 */

// 1. Saat page load: initBaseValues()
//    - Simpan base_val = raw × initConv (konversi ke "base-unit")
//    - Semua kolom qty (total_dr, current_stock, already_po, product_qty)
//      disimpan dengan cara yang sama

// 2. Saat user change unit dropdown: cmbProductUnit change handler
//    - Display = base_val / new_conversion_factor
//    - Konversi untuk SEMUA kolom qty: total_dr, current_stock, already_po, product_qty
//    - Price = price_per_base × conversion_factor

// 3. Saat user switch kembali ke unit asli
//    - Semua nilai kembali ke asli (dari base_val)
//    - Contoh: qty 50 (base) / 10 = 5 (unit_pkg asli)
```

---

## 9. Consignment Report (Laporan Konsinyasi)

### 9.1 Ringkasan Fitur

Report konsinyasi menampilkan ringkasan penerimaan, pemakaian, dan settlement produk konsinyasi dalam satu periode. Data ditampilkan dengan server-side grouping per supplier dengan subtotal otomatis.

**Route:** `/direct-receive/report`
**Controller:** `DirectReceiveController::actionReport()` dan `actionDatatablesReport()`
**RBAC:** `viewConsignment`

### 9.2 Skema Data Report

Report mengagregasi data dari tiga sumber:

```
1. DR Items (Released) 
   SELECT pembelian_item dari DR yang sudah di-release (po_status = 2)
   
2. PC Items (Settlement)
   SELECT pembelian_item dari PC dalam periode yang overlap
   
3. Current Stock
   SELECT latest_qty dari stok dengan is_consignment = true
```

Setiap row dalam report menampilkan:

| Kolom | Sumber | Deskripsi |
|-------|--------|-----------|
| **supplier** | `produsen.nama` | Nama supplier, digunakan sebagai group header |
| **barang** | `barang.nama` | Nama produk konsinyasi |
| **unit** | `satuan.name` | Satuan kemasan (unit_pkg) |
| **total_receive** | Σ(DR.po_qty) | Total qty diterima dari DR dalam periode |
| **current_stock** | `stok.latest_qty` | Stok terkini (dalam satuan unit_pkg) |
| **total_used** | `total_receive - current_stock` | Qty yang sudah dipakai/berkurang |
| **qty_settlement** | Σ(PC.po_qty) | Qty yang di-settle dalam PC periode overlap |
| **nilai_settlement** | `qty_settlement × harga_beli` | Nilai settlement dalam Rp |

### 9.3 Struktur Response Datatable

Response API `actionDatatablesReport()` mengembalikan rows dengan `row_type` marker:

```php
[
    // --- Group Header (Supplier) ---
    [
        'row_type'       => 'group_header',
        'supplier_id'    => 1,
        'supplier_name'  => 'PT Supplier ABC',
        'barang'         => null,  // null untuk header
        'unit'           => null,
        'total_receive'  => null,
        'current_stock'  => null,
        'total_used'     => null,
        'qty_settlement' => null,
        'nilai_settlement' => null,
    ],
    
    // --- Items untuk supplier tersebut ---
    [
        'row_type'       => 'item',
        'supplier_id'    => 1,
        'supplier_name'  => 'PT Supplier ABC',
        'barang'         => 'Produk A',
        'unit'           => 'Pcs',
        'total_receive'  => 100,
        'current_stock'  => 30,
        'total_used'     => 70,
        'qty_settlement' => 70,
        'nilai_settlement' => 700000,
    ],
    [
        'row_type'       => 'item',
        'supplier_id'    => 1,
        'supplier_name'  => 'PT Supplier ABC',
        'barang'         => 'Produk B',
        'unit'           => 'Kg',
        'total_receive'  => 50,
        'current_stock'  => 10,
        'total_used'     => 40,
        'qty_settlement' => 40,
        'nilai_settlement' => 400000,
    ],
    
    // --- Group Subtotal (Supplier) ---
    [
        'row_type'       => 'group_subtotal',
        'supplier_id'    => 1,
        'supplier_name'  => 'PT Supplier ABC',
        'barang'         => 'SUBTOTAL PT Supplier ABC',
        'unit'           => null,
        'total_receive'  => 150,  // sum per supplier
        'current_stock'  => 40,
        'total_used'     => 110,
        'qty_settlement' => 110,
        'nilai_settlement' => 1100000,
    ],
    
    // ... (repeat untuk supplier berikutnya)
]
```

### 9.4 Filter & Parameter

Report mendukung filter berikut via query string:

```php
// GET /direct-receive/report?start_date=2026-05-01&end_date=2026-05-31&branch_id=1
[
    'start_date'  => date('Y-m-d'),  // default hari ini
    'end_date'    => date('Y-m-d'),  // default hari ini
    'branch_id'   => (int) cabang_id // required, dari session user
]
```

**Note:** Filter diaplikasikan pada:
- DR: `tanggal_pembelian BETWEEN start_date AND end_date`
- PC: `consignment_period_start OR consignment_period_end` overlap dengan filter range

### 9.5 Grand Total Row

API `actionDatatablesReportTotals()` mengembalikan grand total untuk footer row yang di-pin:

```php
[
    'total_suppliers'     => 3,              // jumlah supplier unik
    'total_products'      => 12,             // jumlah produk unik
    'total_receive'       => 500,            // sum semua DR qty
    'total_current_stock' => 150,            // sum semua current stock
    'total_used'          => 350,            // sum semua used qty
    'total_settlement'    => 350,            // sum semua PC qty
    'grand_total_nilai'   => 3500000,        // sum semua nilai settlement
]
```

### 9.6 Implementasi Query

**Query Aggregasi per Barang:**

```php
// CreateConsignmentUsecase::mappingPoItems() atau custom query report
$items = PembelianItem::find()
    ->innerJoin('pembelian', 'pembelian.id = pembelian_item.pembelian_id')
    ->innerJoin('barang', 'barang.id = pembelian_item.barang_id')
    ->leftJoin('satuan', 'satuan.id = pembelian_item.unit_pkg')
    ->where([
        'pembelian.type'      => Pembelian::TYPE_DIRECT_RECEIVE,
        'pembelian.po_status' => Pembelian::PO_STATUS_RELEASED,
        'pembelian.cabang_id' => $cabangId,
    ])
    ->andWhere([
        'BETWEEN',
        'pembelian.tanggal_pembelian',
        $startDate . ' 00:00:00',
        $endDate . ' 23:59:59',
    ])
    ->groupBy([
        'pembelian.produsen_id',
        'pembelian_item.barang_id',
        'pembelian_item.unit_pkg',
    ])
    ->asArray()
    ->all();
```

**Untuk setiap item, hitung:**
- Total DR qty (dari above query)
- Current stock: `MyStockManagement::getBarangWithLatestStock($barangId, $unitPkg)`
- PC qty: Query PC items dengan overlap check
- Nilai: `pc_qty × harga_beli`

### 9.7 Fitur Responsif

- **Sorting:** Per kolom support ASC/DESC
- **Pagination:** Server-side via ag-datatable
- **Export Excel:** Klik tombol Export untuk download CSV/Excel
- **Date Range:** Custom date picker untuk filter periode

---

## 10. Query Referensi

### List semua DR aktif per cabang

```sql
SELECT
    p.id,
    p.no_faktur,
    p.tanggal_pembelian,
    p.po_status,
    pr.nama AS supplier_name
FROM pembelian p
INNER JOIN produsen pr ON pr.id = p.produsen_id
WHERE p.type       = 'dr'
  AND p.cabang_id  = :cabangId
  AND p.deleted_at IS NULL
ORDER BY p.id DESC;
```

### Hitung available qty untuk satu barang dalam satu periode

```sql
-- 1. Total DR dalam periode
SELECT COALESCE(SUM(pi.po_qty), 0) AS total_dr
FROM pembelian_item pi
INNER JOIN pembelian p ON p.id = pi.pembelian_id
WHERE p.produsen_id    = :produsenId
  AND p.type           = 'dr'
  AND p.cabang_id      = :cabangId
  AND pi.barang_id     = :barangId
  AND p.tanggal_pembelian BETWEEN :startDate AND :endDate;

-- 2. Stok terkini (dari MyStockManagement, dalam satuan dasar)
SELECT s.latest_qty
FROM stok s
WHERE s.barang_id    = :barangId
  AND s.is_consignment = 1
ORDER BY s.id DESC LIMIT 1;

-- 3. Already PO dalam periode (overlap check)
SELECT COALESCE(SUM(pi.po_qty), 0) AS total_pc
FROM pembelian_item pi
INNER JOIN pembelian p ON p.id = pi.pembelian_id
WHERE p.produsen_id = :produsenId
  AND p.type        = 'pc'
  AND p.cabang_id   = :cabangId
  AND pi.barang_id  = :barangId
  AND (
    (p.consignment_period_start BETWEEN :startDate AND :endDate) OR
    (p.consignment_period_end   BETWEEN :startDate AND :endDate) OR
    (p.consignment_period_start <= :startDate AND p.consignment_period_end >= :endDate)
  );

-- Available = total_dr - current_stock_in_unit - total_pc
```

### List riwayat PC per supplier (chaining konsinyasi)

```sql
WITH RECURSIVE pc_chain AS (
    -- Ambil PC terbaru
    SELECT id, no_faktur, consignment_period_start, consignment_period_end,
           consignment_last_po_id, total, tanggal_pembelian
    FROM pembelian
    WHERE produsen_id = :produsenId
      AND type        = 'pc'
      AND cabang_id   = :cabangId
      AND consignment_last_po_id IS NULL  -- PC paling awal
    UNION ALL
    -- Traverse ke PC berikutnya
    SELECT p.id, p.no_faktur, p.consignment_period_start, p.consignment_period_end,
           p.consignment_last_po_id, p.total, p.tanggal_pembelian
    FROM pembelian p
    INNER JOIN pc_chain c ON p.consignment_last_po_id = c.id
    WHERE p.type = 'pc'
)
SELECT * FROM pc_chain
ORDER BY consignment_period_start;
```

### Supplier yang memiliki DR aktif (untuk dropdown PC)

```sql
SELECT DISTINCT pr.id, pr.nama
FROM produsen pr
INNER JOIN pembelian pb ON pb.produsen_id = pr.id
WHERE pb.type       = 'dr'
  AND pb.deleted_at IS NULL
  AND pr.deleted_at IS NULL
ORDER BY pr.nama ASC;
```

### Detail breakdown item untuk satu PC

```sql
SELECT
    b.nama   AS barang_nama,
    pi.unit_pkg,
    s.name   AS satuan_nama,
    pi.po_qty         AS qty_terpakai,
    pi.harga_beli     AS harga_per_unit,
    pi.disc           AS diskon,
    pi.total          AS total_per_item,
    sw.nama  AS subwarehouse_nama
FROM pembelian_item pi
INNER JOIN barang b        ON b.id  = pi.barang_id
INNER JOIN satuan s        ON s.id  = pi.unit_pkg
LEFT  JOIN subwarehouse sw ON sw.id = pi.subwarehouse_id
WHERE pi.pembelian_id = :pcId
  AND pi.deleted_at   IS NULL;
```
