# Purchase Module — Technical Overview

> **Scope:** Module Purchase — aplikasi Point of Sales (POS) multi-cabang berbasis Yii2 Framework.
> **Core Model:** `common\models\Pembelian`
> **Core Table:** `pembelian`
> **Satellite Models:** `PembelianItem`, `PembelianItemCost`, `PenerimaanBarang`, `PenerimaanBarangItem`, `PurchaseInvoice`, `PurchaseInvoiceItems`, `PurchasePayment`, `PurchasePaymentItems`

---

## 1. Overview & Business Rules

Model `Pembelian` adalah **pusat dari seluruh alur pembelian (purchase cycle)** dalam sistem POS ini. Satu record `pembelian` merepresentasikan satu dokumen transaksi pembelian — bisa berupa Purchase Requisition, Purchase Order, Petty Cash, Direct Receive, atau Return — tergantung nilai kolom `type`.

Desain ini menggunakan **Single Table, Multiple Types (polymorphic document)**. Artinya, satu tabel `pembelian` menangani semua tahapan alur pembelian. Setiap `type` memiliki aturan bisnis dan status lifecycle yang berbeda.

---

### 1.1 Tipe Dokumen (Constants `TYPE_*`)

| Konstanta | Nilai | Deskripsi |
|-----------|-------|-----------|
| `TYPE_SPP` | `'spp'` | Simple Purchase — pembelian langsung tanpa PR/PO |
| `TYPE_PR` | `'pr'` | Purchase Requisition — permintaan pembelian, supplier opsional |
| `TYPE_PO` | `'po'` | Purchase Order — pesanan resmi ke supplier |
| `TYPE_PO_FROM_DR` | `'pc'` | PO Consignment — PO dibuat dari Direct Receive (konsinyasi) |
| `TYPE_GR` | `'gr'` | Goods Receive — penerimaan barang (digunakan sebagai prefix nomor dokumen GR) |
| `TYPE_RT` | `'rt'` | Purchase Return — retur barang ke supplier |
| `TYPE_PETTY_CASH` | `'pty'` | Petty Cash — pembelian via kas kecil, supplier dicatat per-item |
| `TYPE_SPC` | `'spc'` | Simple Purchase (kategori khusus) |
| `TYPE_DIRECT_RECEIVE` | `'dr'` | Direct Receive — penerimaan barang langsung tanpa PO sebelumnya |
| `TYPE_PP` | `'pp'` | Purchase Payment — digunakan sebagai prefix nomor dokumen PP |

---

### 1.2 Aturan Bisnis Kritikal

| # | Rule | Detail |
|---|------|--------|
| 1 | **Polymorphic Document** | Kolom `type` menentukan jenis dokumen. Satu tabel menampung semua tipe alur pembelian. Validasi dan status lifecycle berbeda per tipe. |
| 2 | **Multi-Cabang** | Setiap dokumen terikat pada `cabang_id` (wajib). Nomor dokumen (`no_faktur`) bersifat unik **per cabang**, bukan global. |
| 3 | **Auto-Numbering Per Bulan** | `afterSave()` memanggil `MyHelper::saveIncrementMonthlyFaktur($cabang_id, $type)` setiap kali record baru dibuat. Hasilnya disimpan di `no_faktur` dengan format berbasis bulan. |
| 4 | **Unique Form Number Per Cabang** | `no_faktur` divalidasi unik per `cabang_id` melalui `checkFormNumber()`. Jika duplikat ditemukan, validasi gagal dengan pesan `"Form Number already exist"`. |
| 5 | **Soft Delete** | Penghapusan dilakukan via `deleted_at IS NOT NULL`. Tidak ada penghapusan fisik. Query aktif **harus** memfilter `deleted_at IS NULL`. |
| 6 | **Activity Log** | Semua perubahan direkam oleh `ActivityLogBehavior` (audit trail). |
| 7 | **Supplier Opsional pada PR** | Pada `TYPE_PR`, `produsen_id` boleh kosong saat pembuatan. Supplier ditetapkan saat PR dikonversi ke PO. |
| 8 | **Supplier Per-Item pada Petty Cash** | Pada `TYPE_PETTY_CASH`, supplier tidak dicatat di header `pembelian` tapi di `pembelian_item.petty_produsen_id` per baris. Tanggal beli juga per-item via `petty_tanggal_beli`. |
| 9 | **Term Snapshot** | Saat PO dibuat, nilai `produsen.term` di-snapshot ke `pembelian.produsen_term`. Perubahan term supplier di masa depan tidak mengubah PO yang sudah ada. |
| 10 | **Triple Status Tracking** | Satu PO memiliki tiga status independen: `po_status` (lifecycle PO), `gr_status` (penerimaan barang), `inv_status` (penagihan invoice). |
| 11 | **PR Fulfillment Tracking** | Item PR melacak fulfillment via `pembelian_item.used_by` (CSV of PO IDs) dan `po_qty` (qty yang sudah di-PO). Method `setStatusPR()` menghitung status PR berdasarkan rasio `req_qty` vs `po_qty`/`receive_qty`. |
| 12 | **Down Payment** | PO dapat memiliki uang muka (DP). Nilai DP disimpan di `pembelian.dp`, statusnya di `dp_status` (1=uninvoiced, 2=invoiced, 3=paid), dan flag apakah DP telah dipakai oleh invoice lain di `dp_used`. |
| 13 | **Consignment Period** | PO tipe konsinyasi (`TYPE_PO_FROM_DR`) menyimpan periode konsinyasi (`consignment_period_start`, `consignment_period_end`) dan referensi PO konsinyasi sebelumnya (`consignment_last_po_id`, self-referential FK). |
| 14 | **Warehouse Scope** | Sejak 2025, `pembelian` memiliki `warehouse_id` (FK → `warehouse`) di level header, dan `pembelian_item` memiliki `subwarehouse_id` (FK → `subwarehouse`) di level baris untuk menentukan lokasi penyimpanan per-item. |
| 15 | **FnB Flag** | Kolom `is_fnb` (TINYINT, default 1) membedakan item pembelian untuk produk FnB vs Non-FnB. |

---

### 1.3 Status Lifecycle per Tipe

#### `po_status` (Lifecycle PO)

| Konstanta | Nilai | Deskripsi |
|-----------|-------|-----------|
| `PO_STATUS_OPEN` | `1` | PO aktif, barang belum semua diterima |
| `PO_STATUS_CLOSED` | `2` | PO selesai, seluruh barang telah diterima |

#### `gr_status` (Status Penerimaan Barang)

| Konstanta | Nilai | Deskripsi |
|-----------|-------|-----------|
| `STATUS_UNRECEIVED` | `1` | Belum ada barang yang diterima |
| `STATUS_PARTIAL_RECEIVED` | `2` | Sebagian barang diterima |
| `STATUS_ALL_RECEIVED` | `3` | Seluruh barang telah diterima |

#### `inv_status` (Status Invoicing)

| Konstanta | Nilai | Deskripsi |
|-----------|-------|-----------|
| `INV_STATUS_NO_INVOICE` | `1` | Belum ada Purchase Invoice |
| `INV_STATUS_PARTIAL_INVOICE` | `2` | Invoice sebagian (dari beberapa GR) |
| `INV_STATUS_COMPLETED_INVOICE` | `3` | Semua GR sudah ter-invoice |

#### `dp_status` (Status Down Payment)

| Konstanta | Nilai | Deskripsi |
|-----------|-------|-----------|
| `DP_STATUS_NO_INVOICE` | `1` | DP belum ter-invoice |
| `DP_STATUS_INVOICED` | `2` | DP sudah masuk ke Purchase Invoice |
| `DP_STATUS_PAID` | `3` | DP sudah dibayar |

---

## 2. Skema Database & Tipe Data

### 2.1 Tabel Utama: `pembelian`

```sql
CREATE TABLE `pembelian` (
    `id`                      INT(11)         NOT NULL AUTO_INCREMENT,
    `no_faktur`               VARCHAR(45)     DEFAULT NULL,
    `tanggal_pembelian`       DATETIME        DEFAULT NULL,
    `tanggal_dibutuhkan`      DATETIME        DEFAULT NULL,
    `pr_number`               VARCHAR(100)    DEFAULT NULL,
    `branch`                  VARCHAR(100)    DEFAULT NULL,
    `cabang_id`               BIGINT UNSIGNED NOT NULL,
    `warehouse_id`            INT(10) UNSIGNED DEFAULT NULL,
    `account_id`              INT UNSIGNED    DEFAULT NULL,
    `type`                    VARCHAR(100)    DEFAULT NULL,
    `rate`                    INT(2)          DEFAULT NULL,
    `jatuh_tempo`             INT(11)         DEFAULT NULL,
    `diskon`                  DOUBLE          DEFAULT NULL,
    `total`                   DOUBLE          DEFAULT NULL,
    `jenis_pembayaran`        VARCHAR(20)     DEFAULT NULL COMMENT 'cash/credit',
    `catatan`                 VARCHAR(500)    DEFAULT NULL,
    `tanggal_pembayaran`      DATETIME        DEFAULT NULL COMMENT 'jika pembayaran jatuh tempo',
    `ppn`                     INT(11)         DEFAULT 0,
    `bayar`                   INT(11)         DEFAULT 0,
    `is_fnb`                  TINYINT(4)      NOT NULL DEFAULT 1,
    `produsen_id`             INT(11)         DEFAULT NULL,
    `produsen_term`           INT UNSIGNED    DEFAULT 0,
    `dp`                      DOUBLE          DEFAULT NULL,
    `dp_status`               INT(11)         DEFAULT NULL,
    `dp_used`                 INT(11)         DEFAULT 0,
    `po_status`               TINYINT(1)      DEFAULT 1 COMMENT '1=open, 2=close',
    `gr_status`               TINYINT(1)      DEFAULT 1 COMMENT '1=unreceived, 2=partial, 3=allReceived',
    `inv_status`              TINYINT(1)      DEFAULT 1 COMMENT '1=no invoice, 2=partial, 3=complete',
    `po_number`               VARCHAR(100)    DEFAULT NULL,
    `link_po_pr`              BIGINT UNSIGNED DEFAULT NULL,
    `consignment_period_start` DATE           DEFAULT NULL,
    `consignment_period_end`   DATE           DEFAULT NULL,
    `consignment_last_po_id`   BIGINT UNSIGNED DEFAULT NULL,
    `rounding`                DOUBLE          DEFAULT NULL,
    `user_id`                 BIGINT UNSIGNED DEFAULT NULL,
    `created_by`              VARCHAR(45)     DEFAULT NULL,
    `updated_by`              VARCHAR(45)     DEFAULT NULL,
    `created_at`              DATETIME        DEFAULT NULL,
    `updated_at`              DATETIME        DEFAULT NULL,
    `deleted_at`              DATETIME        DEFAULT NULL,
    PRIMARY KEY (`id`),
    INDEX `fk_pembelian_cabang1_idx`   (`cabang_id`),
    INDEX `fk_pembelian_produsen1_idx` (`produsen_id`),
    INDEX `fk_pembelian_user1_idx`     (`user_id`),
    INDEX `fk_pembelian_account1_idx`  (`account_id`),
    INDEX `idx_pembelian_consignment`  (`produsen_id`, `type`, `consignment_period_start`, `consignment_period_end`),
    CONSTRAINT `fk_pembelian_cabang1`           FOREIGN KEY (`cabang_id`)              REFERENCES `cabang`    (`id`),
    CONSTRAINT `fk_pembelian_produsen1`         FOREIGN KEY (`produsen_id`)            REFERENCES `produsen`  (`id`),
    CONSTRAINT `fk_pembelian_user1`             FOREIGN KEY (`user_id`)                REFERENCES `user`      (`id`),
    CONSTRAINT `fk_pembelian_account1`          FOREIGN KEY (`account_id`)             REFERENCES `account`   (`id`),
    CONSTRAINT `fk-pembelian-warehouse_id`      FOREIGN KEY (`warehouse_id`)           REFERENCES `warehouse` (`id`),
    CONSTRAINT `fk-pembelian-consignment_last`  FOREIGN KEY (`consignment_last_po_id`) REFERENCES `pembelian` (`id`) ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
```

| Kolom | Tipe Data | Constraints | Label UI | Keterangan |
|-------|-----------|-------------|----------|------------|
| `id` | `INT(11)` | PK, AUTO_INCREMENT, NOT NULL | ID | Primary key |
| `no_faktur` | `VARCHAR(45)` | Nullable, Unique per `cabang_id` | Form Number | Nomor dokumen, di-generate otomatis oleh `afterSave()` |
| `tanggal_pembelian` | `DATETIME` | Nullable | PO Date | Tanggal dokumen dibuat |
| `tanggal_dibutuhkan` | `DATETIME` | Nullable | Delivery Date | Tanggal barang dibutuhkan/target pengiriman |
| `pr_number` | `VARCHAR(100)` | Nullable | PR Number | Nomor PR yang terhubung (diisi manual, bukan FK) |
| `branch` | `VARCHAR(100)` | Nullable | Branch | Nama cabang (denormalized, tidak digunakan untuk query) |
| `cabang_id` | `BIGINT UNSIGNED` | NOT NULL, FK → `cabang.id` | Branch | **Required.** ID cabang pemilik dokumen |
| `warehouse_id` | `INT(10) UNSIGNED` | Nullable, FK → `warehouse.id` | Warehouse | Gudang utama tujuan barang |
| `account_id` | `INT UNSIGNED` | Nullable, FK → `account.id` | Account | Akun keuangan untuk pembayaran (COA) |
| `type` | `VARCHAR(100)` | Nullable | Type | Tipe dokumen: `spp`, `pr`, `po`, `pc`, `rt`, `pty`, `dr` |
| `rate` | `INT(2)` | Nullable | Rate | Kurs mata uang (jika transaksi valas) |
| `jatuh_tempo` | `INT(11)` | Nullable | Due Date | Jatuh tempo pembayaran dalam hari |
| `diskon` | `DOUBLE` | Nullable | Diskon | Diskon header (nilai nominal) |
| `total` | `DOUBLE` | Nullable | Total | Total nilai dokumen setelah diskon dan PPN |
| `rounding` | `DOUBLE` | Nullable | Rounding | Nilai pembulatan total |
| `jenis_pembayaran` | `VARCHAR(20)` | Nullable | Payment Method | `cash` atau `credit` |
| `catatan` | `VARCHAR(500)` | Nullable | Additional Information | Catatan bebas |
| `tanggal_pembayaran` | `DATETIME` | Nullable | Tanggal Pembayaran | Tanggal jatuh tempo jika `jenis_pembayaran = credit` |
| `ppn` | `INT(11)` | Nullable, DEFAULT 0 | PPN | Nilai PPN yang dikenakan |
| `bayar` | `INT(11)` | Nullable, DEFAULT 0 | Bayar | Nilai yang sudah dibayar (untuk SPP cash) |
| `is_fnb` | `TINYINT(4)` | NOT NULL, DEFAULT 1 | Is FnB | Flag: 1=FnB, 0=Non-FnB |
| `produsen_id` | `INT(11)` | Nullable, FK → `produsen.id` | Supplier | ID supplier. Opsional pada PR, wajib pada PO/SPP |
| `produsen_term` | `INT UNSIGNED` | Nullable, DEFAULT 0 | — | Snapshot `produsen.term` saat PO dibuat (dalam hari) |
| `dp` | `DOUBLE` | Nullable | Down Payment | Nilai uang muka |
| `dp_status` | `INT(11)` | Nullable | DP Status | Status DP: 1=uninvoiced, 2=invoiced, 3=paid |
| `dp_used` | `INT(11)` | Nullable, DEFAULT 0 | — | Flag DP dipakai invoice lain: 0=unused, 1=used |
| `po_status` | `TINYINT(1)` | Nullable, DEFAULT 1 | PO Status | Status PO: 1=open, 2=closed |
| `gr_status` | `TINYINT(1)` | Nullable, DEFAULT 1 | GR Status | Status penerimaan: 1=unreceived, 2=partial, 3=received |
| `inv_status` | `TINYINT(1)` | Nullable, DEFAULT 1 | Invoice Status | Status invoice: 1=no inv, 2=partial, 3=complete |
| `po_number` | `VARCHAR(100)` | Nullable | PO Number | Nomor PO eksternal dari supplier |
| `link_po_pr` | `BIGINT UNSIGNED` | Nullable | — | ID `pembelian` PR yang menjadi asal PO ini (soft link, bukan FK) |
| `consignment_period_start` | `DATE` | Nullable | — | Awal periode konsinyasi (hanya tipe `pc`) |
| `consignment_period_end` | `DATE` | Nullable | — | Akhir periode konsinyasi (hanya tipe `pc`) |
| `consignment_last_po_id` | `BIGINT UNSIGNED` | Nullable, FK → `pembelian.id` | — | Self-referential: ID PO konsinyasi sebelumnya |
| `user_id` | `BIGINT UNSIGNED` | Nullable, FK → `user.id` | User | User pembuat dokumen |
| `created_by` | `VARCHAR(45)` | Nullable | — | Auto-set oleh `BlameableBehavior` (username/ID) |
| `updated_by` | `VARCHAR(45)` | Nullable | — | Auto-update oleh `BlameableBehavior` |
| `created_at` | `DATETIME` | Nullable | Input Date | Auto-set oleh `TimestampBehavior` |
| `updated_at` | `DATETIME` | Nullable | Updated At | Auto-update oleh `TimestampBehavior` |
| `deleted_at` | `DATETIME` | Nullable | Deleted At | Soft delete: `NULL` = aktif |

---

### 2.2 Tabel Baris Pembelian: `pembelian_item`

Setiap record `pembelian` memiliki satu atau lebih baris di `pembelian_item`, masing-masing mewakili satu SKU/barang dalam dokumen.

```sql
CREATE TABLE `pembelian_item` (
    `id`                BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT,
    `pembelian_id`      INT(11)          NOT NULL,
    `barang_id`         BIGINT UNSIGNED  NOT NULL,
    `subwarehouse_id`   INT(10) UNSIGNED DEFAULT NULL,
    `petty_produsen_id` INT(11)          DEFAULT NULL,
    `req_qty`           DOUBLE           DEFAULT NULL,
    `harga_beli`        DOUBLE           DEFAULT NULL,
    `harga_jual`        INT(11)          DEFAULT NULL,
    `disc`              DOUBLE           DEFAULT NULL,
    `tax_type`          VARCHAR(200)     DEFAULT NULL,
    `tax_rate`          FLOAT            DEFAULT NULL,
    `unit_pkg`          TINYINT UNSIGNED DEFAULT NULL,
    `conversion`        DOUBLE           DEFAULT NULL COMMENT 'ke gram',
    `po_qty`            DOUBLE           DEFAULT NULL,
    `receive_qty`       DOUBLE           DEFAULT NULL,
    `total`             DOUBLE           DEFAULT NULL,
    `used_by`           TEXT             DEFAULT NULL COMMENT 'CSV of pembelian_id (PO IDs that used this PR item)',
    `petty_tanggal_beli` DATETIME        DEFAULT NULL,
    `created_at`        DATETIME         DEFAULT NULL,
    `updated_at`        DATETIME         DEFAULT NULL,
    `deleted_at`        DATETIME         DEFAULT NULL,
    PRIMARY KEY (`id`),
    INDEX `fk_pembelian_item_pembelian1_idx` (`pembelian_id`),
    INDEX `fk_pembelian_item_barang1_idx`    (`barang_id`),
    CONSTRAINT `fk_pembelian_item_pembelian1`        FOREIGN KEY (`pembelian_id`)      REFERENCES `pembelian`  (`id`),
    CONSTRAINT `fk_pembelian_item_barang1`           FOREIGN KEY (`barang_id`)         REFERENCES `barang`     (`id`),
    CONSTRAINT `fk-pembelian_item-subwarehouse_id`   FOREIGN KEY (`subwarehouse_id`)   REFERENCES `subwarehouse` (`id`),
    CONSTRAINT `fk-pembelian_item-petty_produsen_id` FOREIGN KEY (`petty_produsen_id`) REFERENCES `produsen`   (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
```

| Kolom | Tipe Data | Constraints | Keterangan |
|-------|-----------|-------------|------------|
| `id` | `BIGINT UNSIGNED` | PK, AUTO_INCREMENT | Primary key |
| `pembelian_id` | `INT(11)` | NOT NULL, FK → `pembelian.id` | **Required.** Referensi ke dokumen header |
| `barang_id` | `BIGINT UNSIGNED` | NOT NULL, FK → `barang.id` | **Required.** SKU/produk yang dibeli |
| `subwarehouse_id` | `INT(10) UNSIGNED` | Nullable, FK → `subwarehouse.id` | Departemen/sub-gudang tujuan item ini |
| `petty_produsen_id` | `INT(11)` | Nullable, FK → `produsen.id` | Supplier per-item (khusus tipe `pty`/Petty Cash) |
| `req_qty` | `DOUBLE` | Nullable | Qty diminta (pada PR) |
| `harga_beli` | `DOUBLE` | Nullable | Harga beli per unit (dalam unit `unit_pkg`) |
| `harga_jual` | `INT(11)` | Nullable | Harga jual yang direkomendasikan |
| `disc` | `DOUBLE` | Nullable | Diskon per baris (%) |
| `tax_type` | `VARCHAR(200)` | Nullable | Tipe pajak (misal: `PPN`, `None`) |
| `tax_rate` | `FLOAT` | Nullable | Tarif pajak (persen) |
| `unit_pkg` | `TINYINT UNSIGNED` | Nullable, FK → `satuan.id` | Satuan kemasan pembelian (misal: karton, dus) |
| `conversion` | `DOUBLE` | Nullable | Konversi dari `unit_pkg` ke satuan dasar (gram/pcs) |
| `po_qty` | `DOUBLE` | Nullable | Qty yang di-PO (pada PR: qty yang sudah ter-cover PO) |
| `receive_qty` | `DOUBLE` | Nullable | Qty yang sudah diterima fisik (dari GR) |
| `total` | `DOUBLE` | Nullable | Total harga baris (`req_qty × harga_beli - disc + tax`) |
| `used_by` | `TEXT` | Nullable | CSV of `pembelian_id` PO yang menggunakan item PR ini. Contoh: `"15,22,30,"` |
| `petty_tanggal_beli` | `DATETIME` | Nullable | Tanggal beli per-item (khusus tipe `pty`) |
| `created_at` | `DATETIME` | Nullable | Auto timestamp |
| `updated_at` | `DATETIME` | Nullable | Auto timestamp |
| `deleted_at` | `DATETIME` | Nullable | Soft delete |

---

### 2.3 Tabel Biaya Tambahan: `pembelian_item_cost`

Biaya non-barang yang melekat pada satu dokumen pembelian (misal: ongkos kirim, biaya handling). Masing-masing biaya terhubung ke akun COA.

| Kolom | Tipe Data | Constraints | Keterangan |
|-------|-----------|-------------|------------|
| `id` | `INT(11)` | PK, AUTO_INCREMENT | Primary key |
| `pembelian_id` | `INT(11)` | NOT NULL, FK → `pembelian.id` | Referensi ke dokumen header |
| `account_id` | `INT(11)` | NOT NULL, FK → `account.id` | Akun COA untuk biaya ini |
| `amount` | `DOUBLE` | Nullable | Nilai biaya |
| `note` | `TEXT` | Nullable | Deskripsi biaya |
| `created_at` | `DATETIME` | Nullable | Auto timestamp |
| `updated_at` | `DATETIME` | Nullable | Auto timestamp |
| `deleted_at` | `DATETIME` | Nullable | Soft delete |

---

### 2.4 Tabel Penerimaan Barang: `penerimaan_barang` (Goods Receive)

Mencatat penerimaan fisik barang dari supplier. Dibuat setelah PO diterbitkan, bisa satu PO memiliki beberapa GR (partial delivery).

```sql
CREATE TABLE `penerimaan_barang` (
    `id`                 BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT,
    `pembelian_id`       INT(11)          DEFAULT NULL,
    `tanggal_penerimaan` DATETIME         DEFAULT NULL,
    `tanggal_dibutuhkan` DATETIME         DEFAULT NULL,
    `produsen_id`        INT(11)          DEFAULT NULL,
    `delivery_number`    VARCHAR(250)     DEFAULT NULL,
    `cabang_id`          BIGINT UNSIGNED  NOT NULL,
    `branch`             VARCHAR(100)     DEFAULT NULL,
    `catatan`            VARCHAR(500)     DEFAULT NULL,
    `user_id`            BIGINT UNSIGNED  DEFAULT NULL,
    `gr_number`          VARCHAR(100)     DEFAULT NULL,
    `inv_status`         INT(11)          DEFAULT NULL COMMENT '1=uninvoiced, 2=invoiced',
    `created_at`         DATETIME         DEFAULT NULL,
    `updated_at`         DATETIME         DEFAULT NULL,
    `deleted_at`         DATETIME         DEFAULT NULL,
    PRIMARY KEY (`id`),
    CONSTRAINT `fk_penerimaan_barang_cabang1`   FOREIGN KEY (`cabang_id`)   REFERENCES `cabang`   (`id`),
    CONSTRAINT `fk_penerimaan_barang_pembelian1` FOREIGN KEY (`pembelian_id`) REFERENCES `pembelian` (`id`),
    CONSTRAINT `fk_penerimaan_barang_produsen1` FOREIGN KEY (`produsen_id`) REFERENCES `produsen`  (`id`),
    CONSTRAINT `fk_penerimaan_barang_user1`     FOREIGN KEY (`user_id`)     REFERENCES `user`      (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
```

| Kolom | Tipe Data | Constraints | Keterangan |
|-------|-----------|-------------|------------|
| `id` | `BIGINT UNSIGNED` | PK | Primary key |
| `pembelian_id` | `INT(11)` | NOT NULL (by rule), FK → `pembelian.id` | **Required.** Referensi ke PO induk |
| `tanggal_penerimaan` | `DATETIME` | **Required** | Tanggal barang diterima secara fisik |
| `tanggal_dibutuhkan` | `DATETIME` | Nullable | Tanggal barang dibutuhkan |
| `produsen_id` | `INT(11)` | NOT NULL (by rule), FK → `produsen.id` | **Required.** Supplier pengirim |
| `delivery_number` | `VARCHAR(250)` | Nullable | Nomor surat jalan dari supplier |
| `cabang_id` | `BIGINT UNSIGNED` | NOT NULL, FK → `cabang.id` | **Required.** Cabang penerima |
| `branch` | `VARCHAR(100)` | Nullable | Nama cabang (denormalized) |
| `catatan` | `VARCHAR(500)` | Nullable | Catatan penerimaan |
| `user_id` | `BIGINT UNSIGNED` | Nullable, FK → `user.id` | User penerima |
| `gr_number` | `VARCHAR(100)` | Nullable | Nomor GR, di-generate oleh `afterSave()` |
| `inv_status` | `INT(11)` | Nullable | Status invoice: 1=uninvoiced, 2=invoiced |
| `created_at` | `DATETIME` | Nullable | Auto timestamp |
| `updated_at` | `DATETIME` | Nullable | Auto timestamp |
| `deleted_at` | `DATETIME` | Nullable | Soft delete |

#### Tabel Baris GR: `penerimaan_barang_item`

| Kolom | Tipe Data | Constraints | Keterangan |
|-------|-----------|-------------|------------|
| `id` | `INT(11)` | PK | Primary key |
| `penerimaan_barang_id` | `INT(11)` | NOT NULL, FK → `penerimaan_barang.id` | Referensi ke header GR |
| `barang_id` | `INT(11)` | NOT NULL, FK → `barang.id` | SKU/produk yang diterima |
| `subwarehouse_id` | `INT(11)` | Nullable, FK → `subwarehouse.id` | Sub-gudang tujuan per-item |
| `qty_order` | `INT(11)` | Nullable | Qty yang di-order di PO |
| `qty` | `DOUBLE` | Nullable | Qty aktual yang diterima |
| `buy_price` | `DOUBLE` | Nullable | Harga beli aktual saat penerimaan |
| `tax` | `DOUBLE` | Nullable | Nilai pajak per baris |
| `disc` | `DOUBLE` | Nullable | Diskon per baris |
| `notes` | `VARCHAR(200)` | Nullable | Catatan per baris (misal: kondisi barang) |
| `created_at` | `DATETIME` | Nullable | Auto timestamp |
| `updated_at` | `DATETIME` | Nullable | Auto timestamp |
| `deleted_at` | `DATETIME` | Nullable | Soft delete |

---

### 2.5 Tabel Purchase Invoice: `purchase_invoice`

Tagihan resmi dari supplier. Dibuat berdasarkan satu atau lebih GR yang sudah diterima.

| Kolom | Tipe Data | Constraints | Keterangan |
|-------|-----------|-------------|------------|
| `id` | `BIGINT UNSIGNED` | PK | Primary key |
| `cabang_id` | `BIGINT UNSIGNED` | NOT NULL (by rule), FK → `cabang.id` | **Required.** Cabang pemilik invoice |
| `produsen_id` | `INT(11)` | Nullable, FK → `produsen.id` | Supplier penerbit invoice |
| `produsen_invoice` | `VARCHAR(250)` | Nullable | Nomor invoice dari supplier |
| `inv_number` | `VARCHAR(200)` | Nullable | Nomor invoice internal (auto-generated) |
| `rate` | `INT(2)` | Nullable | Kurs mata uang |
| `invoice_date` | `DATETIME` | **Required** | Tanggal invoice |
| `vat_invoice_number` | `VARCHAR(200)` | Nullable | Nomor faktur pajak (VAT) |
| `ref_transaction` | `VARCHAR(100)` | Nullable | Nomor referensi transaksi |
| `invoice_due` | `INT UNSIGNED` | Nullable | Jatuh tempo dalam hari |
| `invoice_due_date` | `DATETIME` | **Required** | Tanggal jatuh tempo invoice |
| `note` | `TEXT` | Nullable | Catatan |
| `type` | `VARCHAR(20)` | Nullable | Tipe: `single`, `multiple`, `dp` |
| `dp` | `DOUBLE` | DEFAULT 0 | Uang muka yang diaplikasikan ke invoice ini |
| `discount` | `DOUBLE` | Nullable | Diskon header invoice |
| `tax` | `DOUBLE` | Nullable | Nilai pajak |
| `rounding` | `DOUBLE` | Nullable | Pembulatan |
| `total` | `DOUBLE` | Nullable | Total nilai invoice |
| `status` | `DECIMAL` | Nullable | Payment status: 0=no payment, 1=unpaid, 2=partial paid, 3=paid |
| `has_payment` | `INT(11)` | Nullable | Flag keberadaan payment: 0=none, 1=partial, 2=full |
| `gr_id` | `INT(11)` | Nullable, FK → `penerimaan_barang.id` | GR tunggal (untuk tipe `single`) |
| `gr_ids` | `VARCHAR(255)` | Nullable | JSON array GR IDs (untuk tipe `multiple`) |
| `pembelian_id` | `INT(11)` | Nullable, FK → `pembelian.id` | Referensi PO langsung (untuk tipe konsinyasi `pc`) |
| `user_id` | `BIGINT UNSIGNED` | Nullable, FK → `user.id` | User pembuat |
| `created_at` | `DATETIME` | Nullable | Auto timestamp |
| `updated_at` | `DATETIME` | Nullable | Auto timestamp |
| `deleted_at` | `DATETIME` | Nullable | Soft delete |

#### Tabel Baris PI: `purchase_invoice_items`

| Kolom | Tipe Data | Constraints | Keterangan |
|-------|-----------|-------------|------------|
| `id` | `INT(11)` | PK | Primary key |
| `purchase_invoice_id` | `INT(11)` | NOT NULL, FK → `purchase_invoice.id` | Header invoice |
| `barang_id` | `INT(11)` | NOT NULL, FK → `barang.id` | SKU/produk |
| `pembelian_id` | `INT(11)` | Nullable, FK → `pembelian.id` | Referensi PO per baris |
| `gr_id` | `INT(11)` | Nullable, FK → `penerimaan_barang.id` | Referensi GR per baris |
| `satuan_id` | `INT(11)` | Nullable, FK → `satuan.id` | Satuan per baris |
| `qty` | `DOUBLE` | Nullable | Kuantitas |
| `price` | `DOUBLE` | Nullable | Harga per unit |
| `disc` | `DOUBLE` | Nullable | Diskon (%) |
| `disc_value` | `DOUBLE` | Nullable | Nilai diskon (nominal) |
| `tax_type` | `VARCHAR(100)` | Nullable | Tipe pajak |
| `tax_rate` | `DOUBLE` | Nullable | Tarif pajak |
| `total` | `DOUBLE` | Nullable | Total baris |
| `created_at` | `DATETIME` | Nullable | Auto timestamp |
| `updated_at` | `DATETIME` | Nullable | Auto timestamp |
| `deleted_at` | `DATETIME` | Nullable | Soft delete |

---

### 2.6 Tabel Purchase Payment: `purchase_payment`

Mencatat pembayaran ke supplier atas satu atau lebih Purchase Invoice.

| Kolom | Tipe Data | Constraints | Keterangan |
|-------|-----------|-------------|------------|
| `id` | `BIGINT UNSIGNED` | PK | Primary key |
| `cabang_id` | `BIGINT UNSIGNED` | NOT NULL (by rule), FK → `cabang.id` | Cabang pemilik payment |
| `produsen_id` | `INT(11)` | Nullable, FK → `produsen.id` | Supplier yang dibayar |
| `produsen_bank_id` | `INT(11)` | Nullable, FK → `produsen_bank.id` | Rekening bank supplier yang dituju |
| `pp_number` | `VARCHAR(100)` | Nullable, Unique per `cabang_id` | Nomor Purchase Payment (auto-generated) |
| `payment_date` | `DATETIME` | Nullable | Tanggal pembayaran |
| `rate` | `VARCHAR(100)` | Nullable | Kurs valas |
| `pi_ref` | `VARCHAR(250)` | Nullable | Nomor BG/referensi pembayaran |
| `account_id` | `INT UNSIGNED` | NOT NULL, FK → `account.id` | Akun kas/bank yang digunakan |
| `bank_name` | `VARCHAR(100)` | Nullable | Nama bank pengirim |
| `bank_account_number` | `VARCHAR(100)` | Nullable | Nomor rekening pengirim |
| `bank_account_name` | `VARCHAR(100)` | Nullable | Nama rekening pengirim |
| `note` | `VARCHAR(500)` | Nullable | Catatan |
| `payment_method` | `INT(11)` | Nullable | Metode: 1=Cash, 2=Transfer Bank |
| `pi_amount` | `DOUBLE` | Nullable | Total nilai invoice yang akan dibayar |
| `pi_outstanding_balance` | `DOUBLE` | Nullable | Sisa hutang sebelum payment ini |
| `payment_amount` | `DOUBLE` | Nullable | Nilai pembayaran aktual |
| `payment_total` | `DOUBLE` | Nullable | Total payment (setelah rounding) |
| `rounding` | `DOUBLE` | Nullable | Pembulatan |
| `status` | `INT(11)` | Nullable | Status: 1=Draft, 2=Paid |
| `branch` | `VARCHAR(100)` | Nullable | Nama cabang (denormalized) |
| `user_id` | `BIGINT UNSIGNED` | Nullable, FK → `user.id` | User pembuat |
| `created_at` | `DATETIME` | Nullable | Auto timestamp |
| `updated_at` | `DATETIME` | Nullable | Auto timestamp |
| `deleted_at` | `DATETIME` | Nullable | Soft delete |

#### Tabel Alokasi Payment ke Invoice: `purchase_payment_items`

Satu payment dapat melunasi banyak invoice sekaligus. Tabel ini mencatat alokasi per invoice.

| Kolom | Tipe Data | Constraints | Keterangan |
|-------|-----------|-------------|------------|
| `id` | `INT(11)` | PK | Primary key |
| `purchase_payment_id` | `INT(11)` | NOT NULL, FK → `purchase_payment.id` | Header payment |
| `purchase_invoice_id` | `INT(11)` | NOT NULL, FK → `purchase_invoice.id` | Invoice yang dilunasi |
| `amount` | `DOUBLE` | Nullable | Nilai yang dialokasikan ke invoice ini |
| `branch` | `VARCHAR(100)` | Nullable | Nama cabang |
| `created_at` | `DATETIME` | Nullable | Auto timestamp |
| `updated_at` | `DATETIME` | Nullable | Auto timestamp |
| `deleted_at` | `DATETIME` | Nullable | Soft delete |

---

## 3. Relasi Antar Tabel (Entity Relationships)

### 3.1 Diagram Relasi (Teks Logis)

```
cabang (1) ──────────────────────────── (N) pembelian ────── (self) consignment_last_po_id
                                               │
                         ┌─────────────────────┼──────────────────────┐
                         │                     │                      │
                         ▼                     ▼                      ▼
                  produsen (N:1)         warehouse (N:1)        account (N:1)
                         
                         │ (pembelian.id)
                         │
              ┌──────────┴──────────────────────┐
              │                                 │
              ▼                                 ▼
    (N) pembelian_item                (N) pembelian_item_cost
              │                                 │
              ├──→ barang                        └──→ account
              ├──→ subwarehouse
              ├──→ produsen (petty_produsen_id)
              └──→ satuan (unit_pkg)

              │ (pembelian.id)
              ▼
    (N) penerimaan_barang [GR]
              │
              └──→ (N) penerimaan_barang_item
                          ├──→ barang
                          └──→ subwarehouse

              │ (gr_id / gr_ids JSON)
              ▼
    (N) purchase_invoice [PI]
              │
              └──→ (N) purchase_invoice_items
                          ├──→ barang
                          ├──→ pembelian
                          └──→ penerimaan_barang

              │ (purchase_payment_items.purchase_invoice_id)
              ▼
    (N) purchase_payment [PP]
              │
              └──→ (N) purchase_payment_items
                          └──→ purchase_invoice
```

---

### 3.2 Detail Setiap Relasi

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

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

- **Kolom FK:** `pembelian_item.pembelian_id` → `pembelian.id`
- **Fungsi:** Menyimpan detail barang per dokumen. Berlaku untuk semua `type` (PR, PO, SPP, PTY, RT).
- **PR Fulfillment Tracking via `used_by`:** Pada baris item PR, kolom `used_by` menyimpan CSV dari `pembelian_id` PO yang sudah memenuhi kebutuhan item ini. Contoh: jika item PR dipenuhi oleh PO #15 dan PO #22, maka `used_by = "15,22,"`.

```php
// Logika setStatusPR: hitung apakah req_qty sudah terpenuhi oleh po_qty dari semua PO yang ada di used_by
public static function getQtyItemUsedIds($usedBy, $brgId)
{
    $arUsedBy = array_unique(explode(",", $usedBy));
    return PembelianItem::find()
        ->select('sum(po_qty) as jml')
        ->where(['in', 'pembelian_id', $arUsedBy])
        ->andWhere(['barang_id' => $brgId])
        ->groupBy('barang_id')->asArray()->one()['jml'];
}
```

---

#### `pembelian` → `pembelian_item_cost` (1:N)

```php
public function getPembelianItemCosts()
{
    return $this->hasMany(PembelianItemCost::class, ['pembelian_id' => 'id']);
}
```

- **Kolom FK:** `pembelian_item_cost.pembelian_id` → `pembelian.id`
- **Fungsi:** Biaya non-barang (ongkos kirim, handling) yang terhubung ke akun COA (`account_id`). Digunakan untuk alokasi biaya ke laporan keuangan.

---

#### `pembelian` → `penerimaan_barang` (1:N) via GR

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

- **Kolom FK:** `penerimaan_barang.pembelian_id` → `pembelian.id`
- **Fungsi:** Satu PO dapat menghasilkan lebih dari satu GR (partial delivery). Setiap GR merepresentasikan satu kali penerimaan fisik.
- **Status Update:** Setiap GR yang disimpan memicu `Pembelian::setStatusPR()` yang memperbarui `gr_status` dan `po_status` di header PO berdasarkan rasio qty diterima vs qty di-order.

```sql
-- Query menghitung total receive_qty dari semua GR untuk satu item PR
SELECT SUM(penerimaan_barang_item.qty) as jml
FROM penerimaan_barang_item
INNER JOIN penerimaan_barang ON penerimaan_barang.id = penerimaan_barang_item.penerimaan_barang_id
WHERE penerimaan_barang_item.barang_id = :barangId
  AND penerimaan_barang_item.pembelian_id = :prId
  AND penerimaan_barang.deleted_at IS NULL;
```

---

#### `penerimaan_barang` → `purchase_invoice` (1:N atau N:M)

```php
// Dari purchase_invoice: ambil GR tunggal
public function getGr()
{
    return $this->hasOne(PenerimaanBarang::class, ['id' => 'gr_id']);
}

// Dari purchase_invoice: ambil multiple GR (via JSON)
// $this->gr_ids berisi JSON array: "[5, 12, 18]"
$grIds = json_decode($this->gr_ids, true);
$pembelianIds = PenerimaanBarang::find()
    ->select('pembelian_id')->where(['in', 'id', $grIds])->all();
```

- **Tipe Single GR:** `purchase_invoice.gr_id` menunjuk satu GR.
- **Tipe Multiple GR:** `purchase_invoice.gr_ids` menyimpan JSON array of GR IDs. Digunakan ketika satu invoice mencakup beberapa penerimaan barang.
- **Tipe Langsung dari PO (konsinyasi):** `purchase_invoice.pembelian_id` langsung menunjuk PO (untuk `type=pc`), tanpa GR.
- **Status Update:** Setelah PI dibuat, `Pembelian::setStatusInvoice($pembelianId)` dipanggil untuk memperbarui `pembelian.inv_status`.

---

#### `purchase_invoice` → `purchase_payment` (N:M via `purchase_payment_items`)

Relasi many-to-many: satu Payment dapat melunasi banyak Invoice, dan satu Invoice dapat dibayar melalui beberapa Payment (partial).

```php
// Hitung total yang sudah dibayar (status PAID) untuk satu invoice
public static function getPaidPayment($piId)
{
    return PurchasePaymentItems::find()
        ->innerJoin('purchase_payment', 'purchase_payment.id = purchase_payment_items.purchase_payment_id')
        ->where(['purchase_invoice_id' => $piId])
        ->andWhere(['purchase_payment.deleted_at' => null])
        ->andWhere(['purchase_payment.status' => PurchasePayment::STATUS_PAID])
        ->sum('amount');
}
```

Setelah payment dikonfirmasi (`status = STATUS_PAID`), `PurchasePayment::setStatusPi($piId)` dijalankan untuk memperbarui `purchase_invoice.status` dan `purchase_invoice.has_payment`.

---

#### `pembelian` → `pembelian` (Self-referential: PR → PO)

```php
// PO mereferensikan PR via link_po_pr (soft link, bukan FK database)
// PO.link_po_pr = PR.id
```

- **Kolom:** `pembelian.link_po_pr` (BIGINT, bukan FK constraint di database)
- **Fungsi:** Melacak asal PR dari sebuah PO. Ketika PO dibuat dari PR, `link_po_pr` diisi dengan ID PR.
- Satu PR dapat dipecah menjadi beberapa PO (ke supplier berbeda).

---

#### `pembelian` → `pembelian` (Self-referential: Consignment PO)

```php
// PO konsinyasi referensi PO sebelumnya
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:** Untuk PO konsinyasi (`type=pc`), `consignment_last_po_id` mereferensikan PO konsinyasi periode sebelumnya dari supplier yang sama. Berguna untuk mencegah duplikasi dan melacak riwayat konsinyasi.

---

## 4. Behaviors & Cross-Cutting Concerns

### 4.1 Behaviors yang Aktif pada `Pembelian`

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

| Behavior | Kolom yang Diisi | Keterangan |
|----------|-----------------|------------|
| `TimestampBehavior` | `created_at`, `updated_at` | Diisi otomatis oleh Yii2 |
| `BlameableBehavior` | `created_by`, `updated_by` | Diisi dengan user yang sedang login |
| `ActivityLogBehavior` | Tabel terpisah | Merekam old/new value setiap perubahan |

### 4.2 Auto-Numbering `afterSave()`

```php
public function afterSave($insert, $changedAttributes)
{
    parent::afterSave($insert, $changedAttributes);
    if ($insert) {
        return MyHelper::saveIncrementMonthlyFaktur($this->cabang_id, $this->type);
    }
}
```

Setiap insert record baru: `MyHelper::saveIncrementMonthlyFaktur()` dipanggil untuk meng-generate dan menyimpan `no_faktur` dengan format sequential per bulan per cabang. Format dan sequence berbeda per `type`.

---

## 5. Method Bisnis Penting

### `Pembelian::setStatusPR($prId, $isReceiveQty)`

Menghitung dan memperbarui `gr_status` dan `po_status` pada dokumen PR/PO berdasarkan perbandingan kuantitas.

```
Logic:
- Jika semua item: req_qty <= receive_qty → gr_status = ALL_RECEIVED, po_status = CLOSED
- Jika semua item: receive_qty == 0        → gr_status = UNRECEIVED, po_status = OPEN
- Selain itu                               → gr_status = PARTIAL_RECEIVED
```

### `Pembelian::setStatusInvoice($pembelianId)`

Menghitung `inv_status` berdasarkan status `inv_status` semua GR yang terhubung ke PO.

```
Logic:
- Semua GR = NO_INVOICE                → inv_status = INV_STATUS_NO_INVOICE
- Campuran status                      → inv_status = INV_STATUS_PARTIAL_INVOICE  
- Khusus tipe PC (tanpa GR, ada PI)    → inv_status = INV_STATUS_COMPLETED_INVOICE
- Selainnya (semua GR sudah ter-invoice) → inv_status = INV_STATUS_COMPLETED_INVOICE
```

### `Pembelian::lastBuyProductByProdusen($cabangId, $produsenId, $barangId, $unitPkg)`

Mencari harga beli terakhir suatu barang dari supplier tertentu. Memperhitungkan dua jalur supplier:
- PO langsung: `pembelian.produsen_id = $produsenId`
- Petty Cash: `pembelian_item.petty_produsen_id = $produsenId`

```sql
SELECT pembelian_item.*, pembelian.tanggal_pembelian,
    CASE WHEN pembelian.type = 'pty' THEN pembelian_item.petty_tanggal_beli
         ELSE pembelian.tanggal_pembelian END AS sort_date
FROM pembelian_item
INNER JOIN pembelian ON pembelian.id = pembelian_item.pembelian_id
WHERE pembelian.cabang_id = :cabangId
  AND pembelian_item.barang_id = :barangId
  AND pembelian_item.unit_pkg  = :unitPkg
  AND (
    (pembelian.type = 'po'  AND pembelian.produsen_id           = :produsenId) OR
    (pembelian.type = 'pty' AND pembelian_item.petty_produsen_id = :produsenId)
  )
ORDER BY sort_date DESC LIMIT 1;
```

---

## 6. Peta Alur Bisnis Lengkap (Purchase Cycle)

```
                    ┌──────────────────────────────────────────┐
                    │          PURCHASE CYCLE                  │
                    └──────────────────────────────────────────┘

Jalur 1: PR → PO → GR → PI → PP
────────────────────────────────
[PR] type=pr                       Supplier opsional
  │  link_po_pr ← diisi di PO
  │
  ▼
[PO] type=po                       Supplier wajib
  │  link_po_pr = PR.id            po_status=1 (OPEN)
  │  produsen_term = snapshot term  gr_status=1 (UNRECEIVED)
  │                                 inv_status=1 (NO_INVOICE)
  │
  ▼ (partial delivery allowed)
[GR] penerimaan_barang             pembelian.gr_status diperbarui
  │  pembelian_id = PO.id          penerimaan_barang.inv_status = 1
  │
  ▼ (satu atau banyak GR)
[PI] purchase_invoice              type = 'single' | 'multiple'
  │  gr_id = GR.id (single)        pembelian.inv_status diperbarui
  │  gr_ids = [GR1,GR2] (multiple) penerimaan_barang.inv_status = 2
  │
  ▼ (bisa partial payment)
[PP] purchase_payment              purchase_invoice.status diperbarui
     purchase_payment_items        0=no payment → 1=unpaid → 2=partial → 3=paid
       └── purchase_invoice_id

Jalur 2: SPP (Simple Purchase)
────────────────────────────────
[SPP] type=spp    Pembelian langsung, langsung masuk ke inventori
                  Tidak perlu GR terpisah

Jalur 3: Petty Cash
───────────────────
[PTY] type=pty    Supplier per-item (petty_produsen_id)
                  Tanggal beli per-item (petty_tanggal_beli)

Jalur 4: Direct Receive → Consignment PO
─────────────────────────────────────────
[DR] type=dr      Penerimaan barang tanpa PO sebelumnya
  │
  ▼
[PC] type=pc      PO dibuat setelah DR
     consignment_period_start / end  = periode konsinyasi
     consignment_last_po_id          = PC sebelumnya dari supplier ini
     purchase_invoice.pembelian_id   = PC.id (tanpa GR)

Jalur 5: Purchase Return
─────────────────────────
[RT] type=rt      Pengembalian barang ke supplier
                  qty negatif / terpisah dari alur normal PI
```

---

## 7. Query Referensi

### Mengambil PO aktif per cabang dengan status lengkap

```sql
SELECT
    p.id,
    p.no_faktur,
    p.tanggal_pembelian,
    pr.nama AS supplier,
    p.total,
    p.po_status,
    p.gr_status,
    p.inv_status
FROM pembelian p
LEFT JOIN produsen pr ON pr.id = p.produsen_id
WHERE p.cabang_id = :cabangId
  AND p.type      = 'po'
  AND p.deleted_at IS NULL
ORDER BY p.tanggal_pembelian DESC;
```

### Mengambil semua item dari PO beserta status penerimaan

```sql
SELECT
    pi.barang_id,
    b.nama AS nama_barang,
    pi.req_qty,
    pi.po_qty,
    COALESCE(SUM(pbi.qty), 0) AS received_qty
FROM pembelian_item pi
INNER JOIN barang b ON b.id = pi.barang_id
LEFT JOIN penerimaan_barang_item pbi ON pbi.barang_id = pi.barang_id
    AND pbi.deleted_at IS NULL
LEFT JOIN penerimaan_barang pb ON pb.id = pbi.penerimaan_barang_id
    AND pb.pembelian_id = pi.pembelian_id
    AND pb.deleted_at IS NULL
WHERE pi.pembelian_id = :poId
  AND pi.deleted_at IS NULL
GROUP BY pi.barang_id;
```

### Menghitung outstanding payable per supplier (Aging)

```sql
SELECT
    pr.nama AS supplier,
    pi.id AS invoice_id,
    pi.invoice_date,
    pi.total,
    COALESCE(SUM(ppi.amount), 0) AS paid,
    (pi.total - COALESCE(SUM(ppi.amount), 0)) AS outstanding,
    DATEDIFF(NOW(), pi.invoice_date) AS age_days
FROM purchase_invoice pi
INNER JOIN produsen pr ON pr.id = pi.produsen_id
LEFT JOIN purchase_payment_items ppi ON ppi.purchase_invoice_id = pi.id
LEFT JOIN purchase_payment pp ON pp.id = ppi.purchase_payment_id
    AND pp.deleted_at IS NULL AND pp.status = 2
WHERE pi.cabang_id = :cabangId
  AND pi.deleted_at IS NULL
  AND pi.status < 3
GROUP BY pi.id
ORDER BY pr.nama, pi.invoice_date;
```
