# Goods Receive (Penerimaan Barang) — Technical Overview

> **Scope:** Module Purchase — aplikasi Point of Sales (POS) multi-cabang berbasis Yii2 Framework.
> **Core Model:** `common\models\PenerimaanBarang`
> **Core Table:** `penerimaan_barang`
> **Satellite Model:** `common\models\PenerimaanBarangItem`
> **Satellite Table:** `penerimaan_barang_item`

---

## 1. Overview & Business Rules

Model `PenerimaanBarang` (Goods Receive / GR) adalah **bukti penerimaan fisik barang** dari supplier. GR dibuat setelah Purchase Order (PO) diterbitkan dan barang tiba di gudang. Satu PO dapat menghasilkan lebih dari satu GR (partial delivery), sehingga tabel ini berelasi N:1 dengan `pembelian`.

GR adalah **titik masuk inventori**: saat GR disimpan, sistem secara atomik mencatat:
1. Penerimaan fisik barang (`penerimaan_barang_item`)
2. Pergerakan stok masuk ke ledger (`stok`, `type = 'gr'`)
3. Riwayat harga beli terbaru (`buy_price`, `type = 'gr'`)
4. Update status PO induk (`pembelian.gr_status`, `pembelian.po_status`)
5. Retroactive cost update pada penjualan yang terpengaruh (`CostManagementService`)

Karena berdampak langsung ke inventori dan pricing, GR memiliki **aturan ketat untuk edit dan hapus**.

---

### 1.1 Aturan Bisnis Kritikal

| # | Rule | Detail |
|---|------|--------|
| 1 | **Hanya dari PO Aktif** | GR hanya dapat dibuat dari `pembelian` dengan `type = 'po'`, `po_status = OPEN`, dan `gr_status < ALL_RECEIVED`. PR, SPP, dan tipe lain tidak memiliki GR. |
| 2 | **Supplier Diwarisi dari PO** | Saat GR dibuat, `produsen_id` otomatis diisi dari `pembelian.produsen_id` — tidak perlu input manual. |
| 3 | **Auto-Numbering GR** | `gr_number` di-generate sebelum form dibuka via `MyHelper::generateMonthlyFaktur($cabangId, 'gr')`. Format sequential per bulan per cabang. |
| 4 | **Atomik: Stok + BuyPrice + PO Status** | Semua operasi dalam satu DB transaction. Jika ada satu item gagal (unit konversi tidak ditemukan, stock save error), seluruh transaksi di-rollback. |
| 5 | **Konversi Unit Wajib** | Setiap item harus memiliki konversi unit (`barang.showQtyOnSelectedUnit(qty, unitPkg)`). Jika konversi tidak ditemukan, transaksi rollback dengan pesan error. |
| 6 | **Stok dalam Satuan Dasar** | Stok disimpan dalam satuan dasar (gram/pcs/base unit). Kolom `conversion` menyimpan faktor konversi dari `unit_pkg` ke satuan dasar. |
| 7 | **Edit Hanya Jika Belum Di-Invoice** | GR hanya dapat diedit jika `inv_status = NO_INVOICE (1)`. Jika sudah ter-invoice, tombol Edit dinonaktifkan. |
| 8 | **Delete Hanya Jika Stok Belum Bergerak** | GR hanya dapat dihapus jika: (a) `inv_status = NO_INVOICE`, **dan** (b) record stok terbaru untuk setiap item masih menunjuk ke GR ini (`stok.id_ref = gr.id AND stok.type = 'gr'`). Jika stok sudah bergerak (terjual, ditransfer), penghapusan ditolak. |
| 9 | **Delete = Soft Delete + Hard Delete Stok** | Penghapusan GR: header di-soft delete (`deleted_at = now()`), dan record `stok` terkait di-hard delete (`Stok::deleteAll` dengan filter `id_ref = gr.id AND type = 'gr'`). |
| 10 | **Update Status PO Otomatis** | Setelah GR disimpan atau dihapus, `Pembelian::setStatusPR($pembelianId, true)` dipanggil untuk menghitung ulang `gr_status` dan `po_status` pada PO induk. |
| 11 | **Retroactive Cost Update** | Setelah GR sukses, `CostManagementService::batchUpdatePenjualanItemCost($cabangId, $minTanggalTerima)` dipanggil untuk memperbarui cost pada item penjualan yang sudah terjadi sebelum GR, menggunakan harga beli baru. |
| 12 | **Soft Delete** | Header GR menggunakan soft delete (`deleted_at`). Query aktif selalu harus memfilter `deleted_at IS NULL`. |
| 13 | **Activity Log** | Semua perubahan direkam oleh `ActivityLogBehavior`. |
| 14 | **PO Filter di Create Form** | Form GR hanya menampilkan PO yang `gr_status < STATUS_ALL_RECEIVED` dan `po_status = OPEN`. PO yang sudah fully-received tidak muncul. |
| 15 | **Subwarehouse per Item** | Sejak 2025 (migration `m250712`), `subwarehouse_id` dipindahkan dari header GR ke level baris (`penerimaan_barang_item`). Satu GR dapat mendistribusikan item ke sub-gudang berbeda. |

---

### 1.2 Status `inv_status` (Invoice Status GR)

| Konstanta | Nilai | Label UI | Deskripsi |
|-----------|-------|----------|-----------|
| `NO_INVOICE` | `1` | Uninvoiced | GR belum memiliki Purchase Invoice |
| `HAS_INVOICE` | `2` | Invoiced | GR sudah ter-cover oleh Purchase Invoice |

> **Catatan:** Nilai `INVOICE_PAID = 3` (konstanta didefinisikan di model) adalah status `Paid`, namun di UI hanya dua nilai aktif (1 dan 2). Status ini diperbarui oleh `PurchaseInvoice` saat invoice dibuat.

---

## 2. Skema Database & Tipe Data

### 2.1 Tabel Utama: `penerimaan_barang`

```sql
CREATE TABLE `penerimaan_barang` (
    `id`                  BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT,
    `pembelian_id`        BIGINT UNSIGNED  DEFAULT NULL,
    `inv_status`          TINYINT(1)       DEFAULT 1  COMMENT '1=blmAdaInv, 2=adaInv',
    `gr_number`           VARCHAR(50)      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,
    `created_by`          VARCHAR(5)       DEFAULT NULL,
    `updated_by`          VARCHAR(5)       DEFAULT NULL,
    `created_at`          DATETIME         DEFAULT NULL,
    `updated_at`          DATETIME         DEFAULT NULL,
    `deleted_at`          DATETIME         DEFAULT NULL,
    PRIMARY KEY (`id`),
    INDEX `fk_penerimaan_barang_pembelian_id1_idx` (`pembelian_id`),
    INDEX `fk_penerimaan_barang_produsen1_idx`     (`produsen_id`),
    INDEX `fk_penerimaan_barang_user1_idx`         (`user_id`),
    INDEX `fk_penerimaan_barang_cabang1_idx`       (`cabang_id`),
    CONSTRAINT `fk_penerimaan_barang_cabang1`      FOREIGN KEY (`cabang_id`)    REFERENCES `cabang`    (`id`),
    CONSTRAINT `fk_penerimaan_barang_pembelian_id1` 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 | Label UI | Keterangan |
|-------|-----------|-------------|----------|------------|
| `id` | `BIGINT UNSIGNED` | PK, AUTO_INCREMENT | ID | Primary key |
| `pembelian_id` | `BIGINT UNSIGNED` | NOT NULL (by rule), FK → `pembelian.id` | PO Number | **Required.** Referensi ke PO induk. Satu PO bisa punya banyak GR. |
| `inv_status` | `TINYINT(1)` | Nullable, DEFAULT 1 | Invoice Status | Status invoicing: 1=Uninvoiced, 2=Invoiced. Diperbarui oleh `PurchaseInvoice` saat PI dibuat. |
| `gr_number` | `VARCHAR(50)` | Nullable | GR Number | Nomor GR, di-generate oleh `MyHelper::generateMonthlyFaktur($cabangId, 'gr')` sebelum form dibuka. |
| `tanggal_penerimaan` | `DATETIME` | **Required** | Received Date | Tanggal fisik barang diterima di gudang. Dipakai sebagai `used_at` pada record stok. |
| `tanggal_dibutuhkan` | `DATETIME` | Nullable | Needed Date | Tanggal barang dibutuhkan (diambil dari PO, opsional). |
| `produsen_id` | `INT(11)` | NOT NULL (by rule), FK → `produsen.id` | Supplier | **Required.** Diisi otomatis dari `pembelian.produsen_id`. Tidak diinput manual. |
| `delivery_number` | `VARCHAR(250)` | Nullable | Delivery Number | Nomor surat jalan dari supplier (dokumen fisik). |
| `cabang_id` | `BIGINT UNSIGNED` | NOT NULL, FK → `cabang.id` | Branch | **Required.** Cabang penerima barang. Diisi dari session user yang login. |
| `branch` | `VARCHAR(100)` | Nullable | Branch | Nama cabang (denormalized, tidak digunakan untuk query). |
| `catatan` | `VARCHAR(500)` | Nullable | Notes | Catatan penerimaan bebas. |
| `user_id` | `BIGINT UNSIGNED` | Nullable, FK → `user.id` | User | User penerima barang. Diisi dari `Yii::$app->user->identity->id`. |
| `created_by` | `VARCHAR(5)` | Nullable | — | Auto-set oleh `BlameableBehavior` |
| `updated_by` | `VARCHAR(5)` | 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, non-`NULL` = terhapus |

---

### 2.2 Tabel Baris GR: `penerimaan_barang_item`

Setiap GR memiliki satu atau lebih baris item. Setiap baris mewakili satu SKU yang diterima beserta informasi harga, diskon, pajak, satuan, dan sub-gudang tujuan.

```sql
CREATE TABLE `penerimaan_barang_item` (
    `id`                    BIGINT UNSIGNED  NOT NULL AUTO_INCREMENT,
    `penerimaan_barang_id`  BIGINT UNSIGNED  NOT NULL,
    `barang_id`             BIGINT UNSIGNED  NOT NULL,
    `subwarehouse_id`       INT(10) UNSIGNED DEFAULT NULL,
    `qty_order`             INT(11)          DEFAULT NULL,
    `qty`                   DOUBLE           DEFAULT NULL,
    `buy_price`             DOUBLE           DEFAULT NULL,
    `disc`                  FLOAT            DEFAULT NULL,
    `tax`                   FLOAT            DEFAULT NULL,
    `unit_pkg`              TINYINT(4)       DEFAULT NULL,
    `conversion`            DOUBLE           DEFAULT NULL,
    `satuan_id`             INT(1) UNSIGNED  DEFAULT NULL,
    `notes`                 VARCHAR(200)     DEFAULT NULL,
    `created_at`            DATETIME         DEFAULT NULL,
    `updated_at`            DATETIME         DEFAULT NULL,
    `deleted_at`            DATETIME         DEFAULT NULL,
    PRIMARY KEY (`id`),
    INDEX `fk_penerimaan_barang_item_pembelian1_idx` (`penerimaan_barang_id`),
    INDEX `fk_penerimaan_barang_item_barang1_idx`    (`barang_id`),
    CONSTRAINT `fk_penerimaan_barang_item_barang1`        FOREIGN KEY (`barang_id`)            REFERENCES `barang`           (`id`),
    CONSTRAINT `fk_penerimaan_barang_item_pembelian1`     FOREIGN KEY (`penerimaan_barang_id`) REFERENCES `penerimaan_barang` (`id`),
    CONSTRAINT `fk_penerimaan_barang_item_satuan_id`      FOREIGN KEY (`satuan_id`)            REFERENCES `satuan`           (`id`),
    CONSTRAINT `fk-penerimaan_barang_item-subwarehouse_id` FOREIGN KEY (`subwarehouse_id`)     REFERENCES `subwarehouse`      (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8 COLLATE=utf8_unicode_ci;
```

| Kolom | Tipe Data | Constraints | Keterangan |
|-------|-----------|-------------|------------|
| `id` | `BIGINT UNSIGNED` | PK, AUTO_INCREMENT | Primary key |
| `penerimaan_barang_id` | `BIGINT UNSIGNED` | NOT NULL, FK → `penerimaan_barang.id` | **Required.** Referensi ke header GR |
| `barang_id` | `BIGINT UNSIGNED` | NOT NULL, FK → `barang.id` | **Required.** SKU/produk yang diterima |
| `subwarehouse_id` | `INT(10) UNSIGNED` | Nullable, FK → `subwarehouse.id` | Sub-gudang tujuan item ini. Per-item, bukan per-GR. |
| `qty_order` | `INT(11)` | Nullable | Qty yang di-order di PO untuk item ini (referensi) |
| `qty` | `DOUBLE` | Nullable | Qty aktual yang diterima fisik, dalam satuan `unit_pkg` |
| `buy_price` | `DOUBLE` | Nullable | Harga beli per unit dalam satuan `unit_pkg` |
| `disc` | `FLOAT` | Nullable | Diskon per baris (nilai persen atau nominal) |
| `tax` | `FLOAT` | Nullable | Nilai pajak per baris |
| `unit_pkg` | `TINYINT(4)` | Nullable | Satuan kemasan (ID dari tabel `satuan`). Misal: karton=5, kg=2 |
| `conversion` | `DOUBLE` | Nullable | Faktor konversi dari `unit_pkg` ke satuan dasar (gram/pcs). Dihitung dari `barang.showQtyOnSelectedUnit()`. |
| `satuan_id` | `INT UNSIGNED` | Nullable, FK → `satuan.id` | ID satuan (kolom FK formal untuk `satuan`, berbeda dari `unit_pkg` yang legacy tinyint) |
| `notes` | `VARCHAR(200)` | Nullable | Catatan per baris (misal: kondisi barang, lot number) |
| `created_at` | `DATETIME` | Nullable | Auto timestamp |
| `updated_at` | `DATETIME` | Nullable | Auto timestamp |
| `deleted_at` | `DATETIME` | Nullable | Soft delete |

> **Catatan `unit_pkg` vs `satuan_id`:** Kolom `unit_pkg` adalah legacy kolom (TINYINT) yang menyimpan ID satuan langsung. Kolom `satuan_id` adalah FK formal ke tabel `satuan` yang ditambahkan kemudian. Keduanya merepresentasikan satuan yang sama; pada kode terbaru, `unit_pkg` masih digunakan sebagai parameter lookup konversi.

---

## 3. Relasi Antar Tabel (Entity Relationships)

### 3.1 Diagram Relasi (Teks Logis)

```
pembelian (1) ─────────────────────────────── (N) penerimaan_barang [GR]
                                                          │
               ┌──────────────────────────────────────────┤
               │                                          │
               ▼                                          ▼
    (N) penerimaan_barang_item                   (N) purchase_invoice
               │   ├──→ barang                            (gr_id / gr_ids)
               │   ├──→ subwarehouse                      │
               │   └──→ satuan                            └──→ (N) purchase_invoice_items
               │                                                      (gr_id)
               ▼ (generated per item)
    stok (type='gr', id_ref=penerimaan_barang.id)
               │
    buy_price (type='gr', no_ref=penerimaan_barang.id)

                                          penerimaan_barang
                                                  │
                                                  ▼
                                          (N) retur
                                              (gr_id)

                                          penerimaan_barang
                                                  │
                                                  ▼
                                   (N) purchase_invoice_items_account
                                              (gr_id)
```

---

### 3.2 Detail Setiap Relasi

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

```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:** Menghubungkan GR ke PO induk. Satu PO dapat memiliki banyak GR (partial delivery).
- **Cascade Effect:** Setiap save/delete GR memicu `Pembelian::setStatusPR($pembelianId, true)` untuk menghitung ulang:
  - `pembelian.gr_status` (1=unreceived, 2=partial, 3=all received)
  - `pembelian.po_status` (1=open, 2=closed)

```sql
-- Query di form GR: hanya tampilkan PO yang masih bisa di-GR
SELECT pembelian.*
FROM pembelian
WHERE cabang_id = :cabangId
  AND type = 'po'
  AND gr_status < 3         -- belum all received
  AND po_status = 1         -- masih open
  AND deleted_at IS NULL
ORDER BY updated_at DESC;
```

---

#### `penerimaan_barang` → `penerimaan_barang_item` (1:N)

```php
// common/models/PenerimaanBarang.php
public function getPenerimaanBarangItems()
{
    return $this->hasMany(PenerimaanBarangItem::class, ['penerimaan_barang_id' => 'id']);
}
```

- **Kolom FK:** `penerimaan_barang_item.penerimaan_barang_id` → `penerimaan_barang.id`
- **Fungsi:** Setiap baris mewakili satu SKU yang diterima.
- **Delete Behavior:** Saat GR di-edit (bukan hapus), item lama di-hard delete dulu (`PenerimaanBarangItem::deleteAll`), kemudian item baru dimasukkan kembali. Bersama itu, record `stok` lama juga dihapus dan dibuat ulang.

---

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

Bukan relasi ActiveRecord formal (tidak ada `hasMany` di model), tetapi pola referensi via dua kolom:

```php
// CreateUsecase.php — saat GR disimpan
$stok = new Stok();
$stok->barang_id      = $barangId;
$stok->cabang_id      = $model->cabang_id;
$stok->subwarehouse_id = $subwarehouseId;
$stok->qty            = $qtyInGram;       // sudah dikonversi ke satuan dasar
$stok->type           = Pembelian::TYPE_GR; // = 'gr'
$stok->id_ref         = $model->id;       // ← referensi ke GR
$stok->desc           = $model->gr_number;
$stok->buy_price_id   = $mBuy->id;
$stok->used_at        = $model->tanggal_penerimaan;
$stok->finalSave();
```

- **Kolom Referensi:** `stok.id_ref = penerimaan_barang.id`, `stok.type = 'gr'`
- **Fungsi:** Setiap item dalam GR menghasilkan satu entri stok positif (stok masuk).
- **Delete Behavior:** Saat GR dihapus, `Stok::deleteAll(['id_ref' => $grId, 'type' => 'gr', 'cabang_id' => $cabangId])` dijalankan.
- **Delete Guard:** Sebelum hapus, sistem cek apakah `stok` terbaru untuk setiap item masih `id_ref = gr.id`. Jika sudah ada stok bergerak lebih baru (terjual, transfer), penghapusan **ditolak** untuk menjaga konsistensi ledger.

```php
// PenerimaanBarangController::cekDelete()
protected function cekDelete($model)
{
    foreach ($model->penerimaanBarangItems as $value) {
        $stock_chek = Stok::find()
            ->where(['barang_id' => $value->barang_id, 'cabang_id' => $this->_curCabangId])
            ->orderBy(['id' => SORT_DESC])->one();
        // GR hanya bisa dihapus jika stok terbaru masih milik GR ini
        if ($stock_chek->id_ref !== $model->id || $stock_chek->type !== Pembelian::TYPE_GR) {
            return false;
        }
    }
    return true;
}
```

---

#### `penerimaan_barang` → `buy_price` (1:N, via `no_ref`)

Bukan relasi ActiveRecord formal.

```php
// CreateUsecase.php — saat GR disimpan, per item
$mBuy               = new BuyPrice();
$mBuy->barang_id    = $barangId;
$mBuy->cabang_id    = $model->cabang_id;
$mBuy->harga_beli   = $hargaBeli / $conversion;  // dinormalisasi ke satuan dasar
$mBuy->no_ref       = $model->id;                 // ← referensi ke GR
$mBuy->applies_date = $model->tanggal_penerimaan;
$mBuy->unit_pkg     = $barang->satuan;
$mBuy->conversion   = $conversion;
$mBuy->type         = BuyPrice::TYPE_GR;           // = 'gr'
$mBuy->save();
```

- **Kolom Referensi:** `buy_price.no_ref = penerimaan_barang.id`, `buy_price.type = 'gr'`
- **Fungsi:** Setiap item GR mencatat harga beli terbaru ke tabel `buy_price`. Harga sudah dinormalisasi ke satuan dasar (`harga_beli / conversion`). Tabel ini digunakan sebagai referensi historical harga beli per produk per cabang.

---

#### `penerimaan_barang` → `purchase_invoice` (1:N via `gr_id`)

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

- **Kolom FK:** `purchase_invoice.gr_id` → `penerimaan_barang.id`
- **Fungsi:** Menghubungkan Purchase Invoice ke GR yang menjadi dasarnya.
- **Tipe Single:** `purchase_invoice.gr_id` digunakan ketika satu PI mencakup satu GR (`type = 'single'`).
- **Tipe Multiple:** `purchase_invoice.gr_ids` (JSON array) digunakan ketika satu PI mencakup beberapa GR (`type = 'multiple'`). Contoh: `"[5, 12, 18]"`.
- **Status Update:** Saat PI dibuat, `penerimaan_barang.inv_status` diperbarui dari `NO_INVOICE (1)` menjadi `HAS_INVOICE (2)`.
- **Constraint Edit/Delete GR:** Karena `inv_status = HAS_INVOICE` memblokir edit dan delete, GR yang sudah ter-invoice tidak bisa diubah tanpa menghapus PI-nya terlebih dahulu.

```sql
-- Filter GR yang eligible untuk dijadikan PI baru
SELECT penerimaan_barang.*
FROM penerimaan_barang
WHERE cabang_id  = :cabangId
  AND inv_status = 1          -- belum ada invoice
  AND deleted_at IS NULL
ORDER BY id DESC;
```

---

#### `penerimaan_barang` → `purchase_invoice_items` (1:N via `gr_id`)

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

- **Kolom FK:** `purchase_invoice_items.gr_id` → `penerimaan_barang.id`
- **Fungsi:** Setiap baris item PI mereferensikan GR spesifik yang menjadi sumber item tersebut. Berguna ketika satu PI mencakup beberapa GR untuk melacak asal-usul setiap baris.

---

#### `penerimaan_barang` → `purchase_invoice_items_account` (1:N via `gr_id`)

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

- **Kolom FK:** `purchase_invoice_items_account.gr_id` → `penerimaan_barang.id`
- **Fungsi:** Tabel alokasi akun COA per baris PI, dengan referensi ke GR untuk traceability asal.

---

#### `penerimaan_barang` → `retur` (1:N via `gr_id`)

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

- **Kolom FK:** `retur.gr_id` → `penerimaan_barang.id`
- **Fungsi:** Retur barang ke supplier **selalu berbasis GR**. Retur hanya bisa dibuat jika ada GR sebagai referensi fisik barang yang pernah diterima. Satu GR dapat menjadi dasar beberapa retur parsial.

---

## 4. Alur Lengkap Penerimaan Barang (GR Lifecycle)

```
[PO Diterbitkan]
  pembelian (type='po', gr_status=1, po_status=1)
        │
        │ User membuka form Create GR
        │ Sistem filter PO: gr_status < 3, po_status = 1
        │ gr_number di-pre-generate
        ▼
[Form GR Diisi User]
  - Pilih PO (dropdown)
  - tanggal_penerimaan di-default dari PO.tanggal_dibutuhkan
  - produsen_id otomatis dari PO
  - Isi baris item: barang, subwarehouse, qty, unit_pkg, buy_price, disc, tax
        │
        │ Submit
        ▼
[DB Transaction BEGIN]
  ①  Simpan penerimaan_barang (header)
  ②  Hapus item lama (jika edit): PenerimaanBarangItem::deleteAll + Stok::deleteAll
  ③  Untuk setiap item:
      a. Lookup konversi: barang.showQtyOnSelectedUnit(qty, unitPkg)
         → jika tidak ada: ROLLBACK
      b. Simpan penerimaan_barang_item
      c. Simpan BuyPrice (harga/conversion, type='gr', no_ref=gr.id)
      d. Simpan Stok (+qty dalam satuan dasar, type='gr', id_ref=gr.id)
         → jika gagal: ROLLBACK
[DB Transaction COMMIT]
        │
        ├── Pembelian::setStatusPR($pembelianId, isReceiveQty=true)
        │   → hitung ulang gr_status & po_status di PO induk
        │
        └── CostManagementService::batchUpdatePenjualanItemCost($cabangId, $minDate)
            → update cost pada penjualan item yang sudah terjadi
            → menggunakan harga beli terbaru dari GR ini

[GR Aktif]
  inv_status = 1 (NO_INVOICE)
  → GR bisa diedit / dihapus
        │
        │ Pembuatan Purchase Invoice (PI)
        ▼
[PI Dibuat dari GR]
  purchase_invoice.gr_id = penerimaan_barang.id
  penerimaan_barang.inv_status = 2 (HAS_INVOICE)
  → GR tidak bisa diedit / dihapus lagi

        │
        │ (opsional) Pembuatan Retur
        ▼
[Retur Dibuat dari GR]
  retur.gr_id = penerimaan_barang.id
  → Stok berkurang, jumlah retur tercatat
```

---

## 5. Behaviors & Cross-Cutting Concerns

### 5.1 Behaviors yang Aktif pada `PenerimaanBarang`

```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` | Auto oleh Yii2 |
| `BlameableBehavior` | `created_by`, `updated_by` | Diisi dengan user ID yang sedang login |
| `ActivityLogBehavior` | Tabel terpisah | Rekam old/new value semua perubahan field |

### 5.2 `afterSave()` — Auto Numbering

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

Setiap insert GR baru, `saveIncrementMonthlyFaktur()` dijalankan untuk generate `gr_number` sequential per bulan per cabang. Format nomor GR mengikuti pola yang sama dengan dokumen purchase lainnya.

---

## 6. Akses Kontrol (RBAC)

| Action | Permission RBAC |
|--------|-----------------|
| View / List | `viewGoodReceive` |
| Create | `createGoodReceive` atau `updateGoodReceive` |
| Edit | `updateGoodReceive` dan `inv_status = NO_INVOICE` |
| Delete | `deleteGoodReceive` dan `inv_status = NO_INVOICE` dan stok belum bergerak |

---

## 7. Query Referensi

### Semua GR aktif per cabang dengan status invoice

```sql
SELECT
    pb.id,
    pb.gr_number,
    pb.tanggal_penerimaan,
    pb.delivery_number,
    pb.inv_status,
    pr.nama AS supplier_name,
    pbl.no_faktur AS po_number
FROM penerimaan_barang pb
INNER JOIN produsen pr  ON pr.id  = pb.produsen_id
INNER JOIN pembelian pbl ON pbl.id = pb.pembelian_id
WHERE pb.cabang_id  = :cabangId
  AND pb.deleted_at IS NULL
ORDER BY pb.id DESC;
```

### Akumulasi qty diterima per barang dari satu PO

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

### GR yang eligible untuk Purchase Invoice baru

```sql
SELECT
    pb.id,
    pb.gr_number,
    pb.tanggal_penerimaan,
    pr.nama AS supplier
FROM penerimaan_barang pb
INNER JOIN produsen pr ON pr.id = pb.produsen_id
WHERE pb.cabang_id  = :cabangId
  AND pb.inv_status = 1         -- NO_INVOICE
  AND pb.deleted_at IS NULL
ORDER BY pb.id DESC;
```

### Verifikasi stok terbaru masih dari GR tertentu (untuk validasi delete)

```sql
-- Per barang: apakah record stok terbaru masih berasal dari GR ini?
SELECT s.barang_id, s.id, s.id_ref, s.type
FROM stok s
WHERE s.barang_id  = :barangId
  AND s.cabang_id  = :cabangId
ORDER BY s.id DESC
LIMIT 1;
-- Jika id_ref != gr.id ATAU type != 'gr', maka GR tidak bisa dihapus
```

### Riwayat harga beli dari GR untuk satu produk

```sql
SELECT
    bp.applies_date,
    bp.harga_beli,
    bp.conversion,
    pb.gr_number,
    pr.nama AS supplier
FROM buy_price bp
INNER JOIN penerimaan_barang pb ON pb.id = bp.no_ref
INNER JOIN produsen pr ON pr.id = pb.produsen_id
WHERE bp.barang_id = :barangId
  AND bp.cabang_id = :cabangId
  AND bp.type      = 'gr'
ORDER BY bp.applies_date DESC;
```
