# STOK (Stock Management) - Source of Truth

**Last Updated:** 2026-06-08  
**Model Class:** `common\models\Stok`  
**Database Table:** `stok`

---

## 1. OVERVIEW & ARSITEKTUR

### 1.1 Tujuan dan Peran Global

Model `Stok` adalah **pusat manajemen inventory** di seluruh sistem. Setiap perubahan stok (baik masuk maupun keluar) **HARUS** melalui model ini sebagai single source of truth. Arsitektur ini dirancang untuk:

- **Mencegah race condition** melalui mekanisme locking per item + warehouse
- **Menjaga konsistensi data** dengan calculation mechanism untuk `latest_qty` (running balance)
- **Audit trail lengkap** melalui timestamp microsecond dan user tracking
- **Cost tracking** untuk keperluan financial reporting dan COGS calculation

### 1.2 Data Flow Architecture

```
Transaction Request
    ↓
[PayloadBuilder] (dari caller: sales, purchase, transfer, adjustment)
    ↓
[Stok Model]
    ├─ Acquire Lock (per barang_id + subwarehouse_id)
    ├─ Get Last Record (untuk baseline latest_qty)
    ├─ Calculate Latest Qty (lastQty + delta qty)
    ├─ Save with Transaction
    ├─ Recalculate All Affected Records (cascade update latest_qty)
    ├─ Release Lock
    └─ Return Result

Error Path:
    └─ Rollback Transaction & Release Lock
```

### 1.3 Struktur Database Schema

| Column | Type | Role | Notes |
|--------|------|------|-------|
| `id` | INT PRIMARY KEY | Unique identifier | Auto-increment |
| `barang_id` | INT FOREIGN KEY | Item reference | Links to `barang` table |
| `cabang_id` | INT FOREIGN KEY | Branch/warehouse | Links to `cabang` table |
| `subwarehouse_id` | INT FOREIGN KEY | Sub-warehouse detail | Links to `subwarehouse` table |
| `qty` | DOUBLE | Quantity delta | **Can be positive or negative** |
| `latest_qty` | DOUBLE | Running balance (cumulative) | **CALCULATED FIELD** |
| `type` | VARCHAR(10) | Transaction type | wip, sales, purchase, gr, adjustment, tfIn, tfOut, etc. |
| `desc` | VARCHAR(1000) | Description | Why the stock changed |
| `id_ref` | INT | Reference document ID | Links to source document (PO, SO, WIP, etc.) |
| `buy_price_id` | INT FOREIGN KEY | Cost reference | Links to `buy_price` table for cost tracking |
| `used_at` | DATETIME(6) | Transaction timestamp | Microsecond precision for correct ordering |
| `satuan_id` | INT | Unit of measurement | Redundant copy of barang.satuan |
| `satuan_name` | VARCHAR(50) | Unit name | Redundant copy for display |
| `is_consignment` | TINYINT | Consignment flag | 0 = regular stock, 1 = consignment |
| `created_at` | DATETIME | Audit timestamp | When record created |
| `updated_at` | DATETIME | Audit timestamp | When record updated |
| `created_by` | VARCHAR | Audit user | Who created |
| `updated_by` | VARCHAR | Audit user | Who updated |

### 1.4 Transaction Type Constants

```php
const TYPE_WIP           = "wip";           // Work In Progress (manufacturing)
const TYPE_WIP_PART      = "wip_part";      // WIP component/part
const TYPE_SALES         = "sales";         // Sales order fulfillment
const TYPE_OPENING       = "opening";       // Opening stock (initial)
const TYPE_BEGINING      = "begining";      // Beginning balance (period start)
const TYPE_PURCHASE      = "purchase";      // Purchase order receipt
const TYPE_PETTY_CASH    = "pty";          // Petty cash adjustment
const TYPE_GOODS_RECEIVE = "gr";           // Goods receive from supplier
const TYPE_ADJUSTMENT    = "adjustment";    // Manual stock adjustment
const TYPE_TRANSFER_IN   = "tfIn";         // Transfer in from another warehouse
const TYPE_TRANSFER_OUT  = "tfOut";        // Transfer out to another warehouse
```

**Usage Pattern:**
```php
$stok = new Stok();
$stok->barang_id = $barangId;
$stok->cabang_id = $cabangId;
$stok->subwarehouse_id = $subwarehouseId;
$stok->qty = -10;  // Negative untuk keluar, positive untuk masuk
$stok->type = Stok::TYPE_SALES;
$stok->id_ref = $salesOrderId;
$stok->desc = "SO-2024-001: Sales Order processing";
$stok->finalSave();
```

---

## 2. CORE MECHANISMS

### 2.1 The `finalSave()` Method - Complete Lifecycle

`finalSave()` adalah **gateway WAJIB** untuk semua stock mutations. Method ini mengorkestra:

#### a. Timestamp Normalization

```php
$this->used_at = $this->used_at
    ? MyHelper::addMicroSecond(
        $this->used_at,
        strlen($this->used_at) <= 19  // Jika tanpa microsecond, tambahkan
    )
    : date('Y-m-d H:i:s.u');  // Jika kosong, gunakan now dengan microsecond
```

**Why Microsecond?** Ketika 2+ transaksi terjadi di detik yang sama, microsecond memastikan ordering tetap konsisten dalam cascading recalculation.

#### b. Satuan (Unit) Population

```php
$this->satuan_id = $this->barang->satuan ?? null;
if (isset($this->barang->satuanx->name)) {
    $this->satuan_name = $this->barang->satuanx->name;
}
```

Redundancy ini untuk performa query & display di UI tanpa JOIN setiap kali.

#### c. Distributed Lock Mechanism (CRITICAL)

```php
$lockName = self::acquireStockLock($this->barang_id, $this->subwarehouse_id);
// Lock format: "stok_{barangId}_{subwarehouseId}"
// Scope: PER ITEM PER WAREHOUSE (granular locking, bukan global)
```

**Mechanism Details:**
- Menggunakan MySQL `GET_LOCK(name, timeout)` - MySQL's built-in named lock
- Timeout 10 detik - jika tidak bisa acquire, throw exception
- Lock RELEASED explicit setelah transaction commit/rollback
- Scope per (barang_id, subwarehouse_id) untuk concurrent mutation di item berbeda

#### d. Get Last Record Baseline

```php
$mdlLastRec = self::find()
    ->where([
        'barang_id' => $this->barang_id,
        'subwarehouse_id' => $this->subwarehouse_id,
    ])
    ->andWhere(['<', 'used_at', $this->used_at])
    ->orderBy([
        'used_at' => SORT_DESC,
        'id' => SORT_DESC,  // Secondary sort untuk jika timestamp sama
    ])
    ->limit(1)
    ->one();

$lastQty = $mdlLastRec
    ? $mdlLastRec->latest_qty
    : 0;
```

**Purpose:** Dapatkan `latest_qty` record terakhir untuk menjadi baseline kalkulasi `latest_qty` baru.

#### e. Calculate Latest Qty (Running Balance)

```php
$this->latest_qty = $lastQty + $this->qty;
```

**Formula Sederhana:** Latest balance = Previous balance + Delta quantity

**Example:**
```
Last Record: latest_qty = 100
Current Record: qty = -15 (sales)
New Record: latest_qty = 100 + (-15) = 85
```

#### f. Save with Transaction

```php
$trx = Yii::$app->db->beginTransaction();
// ... all logic ...
$flag = $this->save(false);  // false = skip validation (already validated)
if (!$flag) {
    throw new \Exception('Gagal save stock');
}
$trx->commit();
```

Jika ada error, transaction automatically rollback.

#### g. Recalculate Cascade (CRITICAL for Consistency)

```php
self::recalculateLatestStock(
    $this->barang_id,
    $this->subwarehouse_id,
    $this->used_at
);
```

Setelah record baru disimpan, **SEMUA record yang created SETELAH record baru ini** harus di-recalculate `latest_qty` mereka. Lihat sub-section 2.2.

#### h. Error Handling & Cleanup

```php
catch (\Throwable $e) {
    $trx->rollBack();
    if ($lockName) {
        self::releaseStockLock($lockName);  // Ensure release
    }
    Yii::error($e->getMessage(), 'stock');
    throw $e;
}
```

**Critical:** Lock ALWAYS released, bahkan pada error.

---

### 2.2 The `recalculateLatestStock()` Method - Cascade Recalculation

Mechanism untuk menjaga **consistency of running balance** ketika ada record baru inserted di tengah-tengah timeline.

#### Scenario

```
Existing Records:
  T1 (10:00:00): qty=100, latest_qty=100
  T2 (11:00:00): qty=+50, latest_qty=150
  T3 (12:00:00): qty=-20, latest_qty=130

New Insert:
  T2.5 (11:30:00): qty=-30, latest_qty=70  ← New record inserted here

After finalSave(), recalculate called with startDate=11:30:00
Result:
  T1 (10:00:00): qty=100, latest_qty=100   [unchanged]
  T2 (11:00:00): qty=+50, latest_qty=150   [unchanged]
  T2.5 (11:30:00): qty=-30, latest_qty=120 ← Inserted
  T3 (12:00:00): qty=-20, latest_qty=100   ← Recalculated!
```

#### Algorithm

```php
public static function recalculateLatestStock(
    $barangId,
    $subwarehouseId,
    $startDate  // used_at threshold
) {
    // Step 1: Get all records >= startDate, ordered ascending
    $records = self::find()
        ->where(['barang_id' => $barangId, 'subwarehouse_id' => $subwarehouseId])
        ->andWhere(['>=', 'used_at', $startDate])
        ->orderBy(['used_at' => SORT_ASC, 'id' => SORT_ASC])
        ->all();

    // Step 2: Get baseline (latest record < startDate)
    $record = self::find()
        ->where(['barang_id' => $barangId, 'subwarehouse_id' => $subwarehouseId])
        ->andWhere(['<', 'used_at', $startDate])
        ->orderBy(['used_at' => SORT_DESC, 'id' => SORT_DESC])
        ->limit(1)
        ->one();

    $latestQty = $record ? $record->latest_qty : 0;

    // Step 3: Cascade calculation
    foreach ($records as $rec) {
        $latestQty += $rec->qty;
        
        if ((float)$rec->latest_qty !== (float)$latestQty) {
            $rec->latest_qty = $latestQty;
            $rec->update(false, ['latest_qty']);  // Update only latest_qty column
        }
    }
}
```

**Why this matters:**
- Memastikan tidak ada "gap" di running balance
- Jika record T3 sudah di-calculate ketika hanya T1 & T2 exist, dan kemudian T2.5 inserted, T3 perlu updated
- Tanpa ini, T3's `latest_qty` menjadi invalid

---

### 2.3 Lock Mechanism - Preventing Race Conditions

#### Why Named Locks?

Tanpa locking:
```
Process A (time=11:00:00): Read lastQty=100
Process B (time=11:00:00): Read lastQty=100
Process A: Calculate 100+50=150, Save
Process B: Calculate 100+(-30)=70, Save ← WRONG! Should be 120
```

Dengan MySQL GET_LOCK:
```
Process A: Acquire lock "stok_1_1"
           Read lastQty=100
Process B: [BLOCKED] Waiting for "stok_1_1"
Process A: Calculate 100+50=150, Save, Release lock
Process B: Acquire lock "stok_1_1"
           Read lastQty=150 (correct!)
           Calculate 150+(-30)=120, Save, Release lock
```

#### Implementation

```php
private static function acquireStockLock($barangId, $subwarehouseId)
{
    $lockName = "stok_{$barangId}_{$subwarehouseId}";
    
    $result = Yii::$app->db
        ->createCommand("SELECT GET_LOCK(:lockName, 10)")
        ->bindValue(':lockName', $lockName)
        ->queryScalar();

    if (!$result) {
        throw new \Exception("Gagal mendapatkan lock stock");
    }

    return $lockName;
}

private static function releaseStockLock($lockName)
{
    Yii::$app->db
        ->createCommand("SELECT RELEASE_LOCK(:lockName)")
        ->bindValue(':lockName', $lockName)
        ->execute();
}
```

**MySQL Lock Behavior:**
- `GET_LOCK(name, timeout)`: Returns 1 (success), 0 (timeout), NULL (error)
- `RELEASE_LOCK(name)`: Returns 1 (released), 0 (not locked), NULL (error)
- Automatic release jika connection closed

---

### 2.4 Relations & Foreign Keys

```php
public function getBarang()          // Stok -> Barang (item/product)
public function getCabang()          // Stok -> Cabang (warehouse/branch)
public function getSubwarehouse()    // Stok -> Subwarehouse (sub-warehouse detail)
public function getBuyPrice()        // Stok -> BuyPrice (cost reference)
```

**Eager Loading Example:**
```php
$stok = Stok::find()
    ->joinWith('barang')
    ->joinWith('subwarehouse')
    ->where(['stok.barang_id' => $barangId])
    ->one();

// Access via relation
echo $stok->barang->nama;
echo $stok->subwarehouse->nama;
```

---

## 3. BEST PRACTICES & BOILERPLATE

### 3.1 Creating Stock Records (CORRECT WAY)

```php
// ✅ CORRECT: Use finalSave()
$stok = new Stok();
$stok->barang_id = 5;
$stok->subwarehouse_id = 2;
$stok->cabang_id = 1;
$stok->qty = -10;  // Outgoing stock
$stok->type = Stok::TYPE_SALES;
$stok->id_ref = $salesOrderId;
$stok->desc = "SO-2024-001 - Order processing";
$stok->finalSave();  // ← MANDATORY

// ❌ WRONG: Direct save()
$stok = new Stok();
$stok->barang_id = 5;
// ...
$stok->save();  // ← NO! Locks not acquired, recalc not triggered
```

### 3.2 Payload Builder Pattern

Common pattern di codebase untuk construct payload sebelum save:

```php
class StokPayloadBuilder
{
    private $stok;

    public function __construct()
    {
        $this->stok = new Stok();
    }

    public function setBarangId($barangId)
    {
        $this->stok->barang_id = $barangId;
        return $this;
    }

    public function setQuantity($qty)
    {
        $this->stok->qty = $qty;
        return $this;
    }

    public function setType($type)
    {
        $this->stok->type = $type;
        return $this;
    }

    public function setDescription($desc)
    {
        $this->stok->desc = $desc;
        return $this;
    }

    public function setReference($idRef)
    {
        $this->stok->id_ref = $idRef;
        return $this;
    }

    public function build()
    {
        return $this->stok;
    }

    public function saveAndGet()
    {
        $this->stok->finalSave();
        return $this->stok;
    }
}

// Usage
$stok = (new StokPayloadBuilder())
    ->setBarangId($barangId)
    ->setQuantity(-10)
    ->setType(Stok::TYPE_SALES)
    ->setDescription("Sales order fulfillment")
    ->setReference($soId)
    ->saveAndGet();
```

### 3.3 Batch Processing with Consistency

Ketika multiple stok mutations di single transaction:

```php
public function processSalesOrder($soId)
{
    $trx = Yii::$app->db->beginTransaction();

    try {
        $soItems = SalesOrderItem::find()
            ->where(['sales_order_id' => $soId])
            ->all();

        foreach ($soItems as $item) {
            $stok = new Stok();
            $stok->barang_id = $item->barang_id;
            $stok->subwarehouse_id = $item->subwarehouse_id;
            $stok->cabang_id = $item->cabang_id;
            $stok->qty = -$item->qty;
            $stok->type = Stok::TYPE_SALES;
            $stok->id_ref = $soId;
            $stok->desc = "SO-$soId Item";
            $stok->finalSave();  // finalSave handles its own transaction
        }

        $trx->commit();
        return true;

    } catch (\Throwable $e) {
        $trx->rollBack();
        Yii::error($e->getMessage(), 'sales');
        throw $e;
    }
}
```

**Note:** `finalSave()` has internal transaction, so calling multiple `finalSave()` dalam outer transaction is safe.

### 3.4 Validation Rules

```php
public function rules()
{
    return [
        [['barang_id', 'cabang_id'], 'required'],  // Mandatory
        [['qty', 'latest_qty'], 'number'],          // Must be numeric
        [['type'], 'string', 'max' => 10],
        [['desc'], 'string', 'max' => 1000],
        [['satuan_name'], 'string', 'max' => 50],
        // Foreign key validations
        [['barang_id'], 'exist', 'targetClass' => Barang::class],
        [['cabang_id'], 'exist', 'targetClass' => Cabang::class],
        [['subwarehouse_id'], 'exist', 'targetClass' => Subwarehouse::class],
    ];
}
```

**Validation Strategy:**
- `finalSave(false)` skips validation (assume pre-validated)
- Validate sebelum construction payload

```php
$stok = new Stok(['scenario' => 'create']);
if (!$stok->validate()) {
    throw new \Exception(json_encode($stok->getErrors()));
}
$stok->finalSave();
```

### 3.5 Behavioral Hooks

```php
public function behaviors()
{
    return [
        MyBehavior::timestampBehavior('created_at', 'updated_at'),
        BlameableBehavior::class,  // Auto-populate created_by, updated_by
    ];
}
```

**Auto-Populated Fields:**
- `created_at`: Set automatically on insert
- `updated_at`: Set automatically on insert/update
- `created_by`: Set from `Yii::$app->user->id`
- `updated_by`: Set from `Yii::$app->user->id`

### 3.6 Table Header Generation (For DataTables)

```php
public static function tableHeader($columns)
{
    $self = new self();
    $attr = $self->attributeLabels();
    $retArray = [];
    foreach ($columns as $value) {
        $retArray[] = is_array($value) ? $value : [
            'data' => $value,
            'title' => $attr[$value] ?? $value,
        ];
    }
    return $retArray;
}

// Usage in controller
$columns = ['barang_nama', 'satuan_name', 'qty', 'latest_qty', 'type'];
$headers = Stok::tableHeader($columns);
// Returns array ready for ag-datatable or similar
```

---

## 4. QUERY PATTERNS & COMMON OPERATIONS

### 4.1 Get Latest Stock Per Warehouse

Returns latest stock record untuk setiap item di warehouse, as of specific date:

```php
public static function getLatestStockPerWarehouse($subwarehouse_id, $endDate = null)
{
    if(!$endDate){
        $endDate = date('Y-m-d H:i:s');
    }
    
    // Subquery untuk mendapatkan max used_at per barang
    $subquery = (new Query())
        ->select([
            'barang_id',
            'latest_time' => new Expression('MAX(used_at)')
        ])
        ->from('stok')
        ->where(['subwarehouse_id' => $subwarehouse_id])
        ->andWhere(['<=', 'used_at', $endDate])
        ->groupBy('barang_id');

    // Main query: join dengan subquery
    $stokList = Stok::find()
        ->alias('s')
        ->innerJoin(['latest' => $subquery], 
            'latest.barang_id = s.barang_id AND latest.latest_time = s.used_at'
        )
        ->joinWith('barang')
        ->where(['s.subwarehouse_id' => $subwarehouse_id])
        ->andWhere(['<=', 'used_at', $endDate])
        ->all();

    return $stokList;  // Array of Stok models with latest qty per item
}

// Usage
$stocks = Stok::getLatestStockPerWarehouse($subwarehouseId, '2024-12-31 23:59:59');
foreach ($stocks as $stok) {
    echo "{$stok->barang->nama}: {$stok->latest_qty}";
}
```

**Time Complexity:** O(n) where n = number of unique barang in warehouse. Efficient for inventory snapshot.

### 4.2 Get Latest Stock Per Specific Item

```php
public static function getLatestStockPerWarehousePerBarangId(
    $subwarehouse_id,
    $barangId,
    $endDate = null
) {
    if(!$endDate){
        $endDate = date('Y-m-d H:i:s');
    }
    
    return Stok::find()
        ->alias('s')
        ->where(['s.subwarehouse_id' => $subwarehouse_id])
        ->andWhere(['barang_id' => $barangId])
        ->andWhere(['<=', 'used_at', $endDate])
        ->orderBy('used_at desc')
        ->one();
}

// Usage: Get current stock
$currentStock = Stok::getLatestStockPerWarehousePerBarangId($subwarehouseId, $barangId);
if ($currentStock) {
    echo "Current stock: {$currentStock->latest_qty}";
} else {
    echo "No stock record for this item";
}
```

**Returns:** Single Stok record with highest `used_at`, or null if no record.

### 4.3 Get Cost (Harga Beli) Per Item

Cost calculation untuk item, diambil dari:
- **Raw Materials / Non-FnB:** Dari `MyStockManagement::getHargaBeliByBarangId()` (stock management tracking)
- **WIP / Finished Goods:** Dari `BarangStok.last_harga_beli` (last purchase price)

```php
public static function getCost($brgId, $cbgId, $usedAt = null)
{
    $mBarang = Barang::findCachedById($brgId);
    
    if ($mBarang['jenis'] == Constanta::JENIS_RAW || 
        $mBarang['jenis'] == Constanta::JENIS_NONFNB) {
        // Raw materials: use stock management tracking
        $result = MyStockManagement::getHargaBeliByBarangId($brgId, $cbgId, $usedAt);
        if ($result) {
            return $result['harga_beli'];
        }
    } else {
        // WIP / Finished goods: use last purchase price
        $mBS = BarangStok::find()
            ->where(['barang_id' => $brgId, 'cabang_id' => $cbgId])
            ->asArray()
            ->one();
        if ($mBS) {
            return $mBS['last_harga_beli'];
        }
    }
    
    return 0;  // Default if no cost found
}

// Usage
$cost = Stok::getCost($barangId, $cabangId, '2024-06-01');
echo "Unit cost: Rp " . number_format($cost, 0);
```

### 4.4 Get Cost By Date

Get cost dari stok record yang paling dekat dengan date tertentu:

```php
public static function getCostByDate($brgId, $cbgId, $date)
{
    $mStok = Stok::find()
        ->where(['barang_id' => $brgId, 'cabang_id' => $cbgId])
        ->andWhere(['is not', 'buy_price_id', null])
        ->andWhere(['<=', 'used_at', $date])
        ->orderBy('used_at desc')
        ->one();

    if ($mStok) {
        return $mStok->buyPrice->harga_beli;
    } else {
        return 0;
    }
}

// Usage: Cost as of specific date (e.g., month-end valuation)
$costMonthEnd = Stok::getCostByDate($barangId, $cabangId, '2024-06-30 23:59:59');
```

### 4.5 Get Stok Per Sub-warehouse (Range Date)

Get semua stok mutations untuk item tertentu dalam date range:

```php
public static function getStokPerSubwarehouse(
    $brgId,
    $subwarehouseId,
    $startDate,
    $endDate
) {
    return Stok::getActiveAll(false, true)
        ->select(['type', 'barang_id', 'qty', 'latest_qty', 'used_at', 'buy_price_id', 'desc', 'id_ref'])
        ->andWhere(['and', ['barang_id' => $brgId], ['subwarehouse_id' => $subwarehouseId]])
        ->andWhere(['between', 'used_at', $startDate . ' 00:00:00', $endDate . ' 23:59:59'])
        ->orderBy(['used_at' => SORT_ASC])
        ->all();
}

// Usage: Stock movements report
$movements = Stok::getStokPerSubwarehouse($barangId, $subwarehouseId, '2024-06-01', '2024-06-30');
foreach ($movements as $m) {
    echo "{$m->type}: {$m->qty} (balance: {$m->latest_qty})\n";
}
```

### 4.6 Below Stock Alert (CRITICAL)

Get items yang stock-nya di bawah minimum alert threshold:

```php
public static function getBelowStockAlert($cabangId)
{
    $sql = "
        SELECT 
            b.id AS barang_id,
            b.code,
            b.nama,
            b.satuan,
            b.jenis,
            bs.stok_alert,
            b.konversi_pkg,
            b.satuan_pkg,
            sat.name AS satuan_name,
            SUM(s.latest_qty) AS latest_qty
        FROM barang b
        INNER JOIN (
            SELECT s1.*
            FROM stok s1
            INNER JOIN (
                SELECT 
                    barang_id,
                    subwarehouse_id,
                    MAX(used_at) AS max_used_at
                FROM stok
                WHERE cabang_id = :cabang_id_sub
                GROUP BY barang_id, subwarehouse_id
            ) s2
                ON s1.barang_id = s2.barang_id
                AND s1.subwarehouse_id = s2.subwarehouse_id
                AND s1.used_at = s2.max_used_at
        ) s 
            ON s.barang_id = b.id
        INNER JOIN barang_stok bs 
            ON bs.barang_id = b.id
            AND bs.cabang_id = :cabang_id
        INNER JOIN satuan sat 
            ON b.satuan_pkg = sat.id
        WHERE 
            b.is_consignment = 0
        GROUP BY 
            b.id, b.code, b.nama, b.satuan, b.jenis,
            bs.stok_alert, b.konversi_pkg, b.satuan_pkg, sat.name
        HAVING 
            SUM(s.latest_qty) <= bs.stok_alert
        ORDER BY 
            latest_qty ASC
        LIMIT 10;
    ";

    $results = Yii::$app->db->createCommand($sql)
        ->bindValue(':cabang_id_sub', $cabangId)
        ->bindValue(':cabang_id', $cabangId)
        ->queryAll();
    
    return $results;
}

// Usage: Dashboard alert
$alerts = Stok::getBelowStockAlert($cabangId);
foreach ($alerts as $alert) {
    echo "[ALERT] {$alert['nama']}: {$alert['latest_qty']} (min: {$alert['stok_alert']})\n";
}
```

**Query Strategy:**
- Window function untuk get latest record per (barang, subwarehouse)
- SUM aggregate untuk total stock across subwares
- HAVING clause untuk filter below alert
- Excludes consignment items (is_consignment = 0)

### 4.7 Get Query Cost (For COGS Calculation)

```php
public static function getQueryCost($brgId, $cbgId)
{
    // Note: Requires MySQL sql_mode = '' (ANSI SQL compliant)
    return Stok::getActiveAll(false, true)
        ->select(['type', 'barang_id', 'qty', 'used_at', 'buy_price_id', 'desc'])
        ->andWhere(['and', ['barang_id' => $brgId], ['cabang_id' => $cbgId]])
        ->orderBy(['used_at' => SORT_ASC])
        ->all();
}

// Usage: COGS calculation (Cost of Goods Sold)
$stokHistory = Stok::getQueryCost($barangId, $cabangId);
foreach ($stokHistory as $record) {
    if ($record['type'] == Stok::TYPE_SALES) {
        // Apply costing method (FIFO, LIFO, Weighted Average, etc.)
        $cost = Stok::getCostByDate($barangId, $cabangId, $record['used_at']);
        $totalCost += $record['qty'] * $cost;
    }
}
```

---

## 5. ADVANCED PATTERNS

### 5.1 Handling Negative Stock (Overselling Scenario)

System memungkinkan negative stock (overshoot):

```php
// Sales overselling scenario
$stok = new Stok();
$stok->barang_id = 5;
$stok->subwarehouse_id = 2;
$stok->qty = -100;  // Current stock only 80
$stok->type = Stok::TYPE_SALES;
$stok->finalSave();

// Result: latest_qty = 80 + (-100) = -20
// Negative balance indicates backorder/oversold
```

**Usage:**
- Allowed untuk flexibility (menghindari sales rejection)
- Dashboard harus highlight negative balances
- Replenishment logic harus prioritize negative stock items

### 5.2 Adjustment & Recalculation

Manual stock adjustment (e.g., physical count difference):

```php
// Physical count shows 75, system shows 80 (diff = -5)
$adjustment = new Stok();
$adjustment->barang_id = 5;
$adjustment->subwarehouse_id = 2;
$adjustment->qty = -5;  // Adjustment quantity
$adjustment->type = Stok::TYPE_ADJUSTMENT;
$adjustment->desc = "Physical count adjustment - Loss detected";
$adjustment->finalSave();

// New balance = 80 + (-5) = 75 ✓
```

### 5.3 Transfer Between Sub-warehouses

```php
// Step 1: Reduce source warehouse
$transferOut = new Stok();
$transferOut->barang_id = 5;
$transferOut->subwarehouse_id = 1;  // Source
$transferOut->qty = -50;
$transferOut->type = Stok::TYPE_TRANSFER_OUT;
$transferOut->id_ref = $transferDocId;
$transferOut->desc = "Transfer to warehouse 2";
$transferOut->finalSave();

// Step 2: Increase destination warehouse
$transferIn = new Stok();
$transferIn->barang_id = 5;
$transferIn->subwarehouse_id = 2;  // Destination
$transferIn->qty = 50;
$transferIn->type = Stok::TYPE_TRANSFER_IN;
$transferIn->id_ref = $transferDocId;
$transferIn->desc = "Transfer from warehouse 1";
$transferIn->finalSave();

// Result: Item 5 reduced by 50 in warehouse 1, increased by 50 in warehouse 2
```

### 5.4 Consignment Handling

Consignment items tracked but not owned:

```php
// Receive consignment stock
$stok = new Stok();
$stok->barang_id = 5;
$stok->subwarehouse_id = 2;
$stok->qty = 100;
$stok->type = Stok::TYPE_GOODS_RECEIVE;
$stok->is_consignment = 1;  // Mark as consignment
$stok->desc = "Consignment from Supplier ABC";
$stok->finalSave();

// Query only non-consignment (owned) stock
$ownedStock = Stok::find()
    ->where(['barang_id' => $barangId])
    ->andWhere(['is_consignment' => 0])
    ->one();
```

---

## 6. PERFORMANCE CONSIDERATIONS

### 6.1 Indexing Strategy (Critical)

**Recommended Indexes:**
```sql
-- Primary lookups
CREATE INDEX idx_stok_barang_subware ON stok(barang_id, subwarehouse_id, used_at DESC);
CREATE INDEX idx_stok_cabang_barang ON stok(cabang_id, barang_id, used_at DESC);

-- Timestamp-based queries
CREATE INDEX idx_stok_used_at ON stok(used_at DESC);

-- For latest record queries
CREATE INDEX idx_stok_latest_record ON stok(barang_id, subwarehouse_id, used_at DESC, id DESC);
```

### 6.2 Query Optimization

**AVOID:**
```php
// ❌ N+1 query problem
$stoks = Stok::find()->all();
foreach ($stoks as $stok) {
    echo $stok->barang->nama;  // ← Query executed per iteration
}

// ✅ GOOD: Eager load
$stoks = Stok::find()
    ->joinWith('barang')
    ->all();
foreach ($stoks as $stok) {
    echo $stok->barang->nama;  // ← No extra query
}
```

### 6.3 Lock Timeout Handling

```php
try {
    $stok->finalSave();
} catch (\Exception $e) {
    if (strpos($e->getMessage(), 'Gagal mendapatkan lock') !== false) {
        // Lock timeout - retry with exponential backoff
        sleep(1 + random_int(0, 2));  // 1-3 seconds
        $stok->finalSave();  // Retry
    } else {
        throw $e;
    }
}
```

---

## 7. COMMON PITFALLS & TROUBLESHOOTING

| Pitfall | Symptom | Solution |
|---------|---------|----------|
| Using `save()` instead of `finalSave()` | Lock not acquired, recalc not triggered | Always use `finalSave()` |
| Not handling transaction rollback | Orphaned locks | Wrap in try-catch with explicit release |
| Missing microsecond in used_at | Incorrect ordering if 2+ records same second | `finalSave()` auto-handles via `addMicroSecond()` |
| Querying deleted records | Inflated stock counts | Use `getActiveAll()` which filters `deleted_at IS NULL` |
| Not eager-loading relations | N+1 queries, slow reports | Use `joinWith()` in queries |
| Negative stock alerts ignored | Backorders undetected | Implement dashboard dashboard check on `latest_qty < 0` |

---

## 8. INTEGRATION CHECKLIST

When implementing stock mutations in a new feature:

- [ ] Create Stok model instance
- [ ] Populate: `barang_id`, `subwarehouse_id`, `cabang_id`, `qty`, `type`
- [ ] Set: `id_ref` (source document ID), `desc` (reason)
- [ ] Call: `finalSave()` (NOT `save()`)
- [ ] Handle exception: Log error, don't swallow
- [ ] Verify: Check `latest_qty` updated correctly
- [ ] Test: Concurrent mutations, negative stock scenarios
- [ ] Monitor: Alert on stock below minimum

---

## 9. REFERENCES & RELATED MODELS

| Related Model | Purpose | File |
|---------------|---------|------|
| `Barang` | Product/item master | `common/models/Barang.php` |
| `BarangStok` | Per-branch product config (last cost, alert) | `common/models/BarangStok.php` |
| `Cabang` | Branch/warehouse master | `common/models/Cabang.php` |
| `Subwarehouse` | Sub-warehouse detail (location) | `common/models/Subwarehouse.php` |
| `BuyPrice` | Historical purchase price tracking | `common/models/BuyPrice.php` |
| `MyStockManagement` | Recipe cost calculation, weighted avg | `common/base/MyStockManagement.php` |

---

**Document Status:** PRODUCTION  
**Last Reviewed:** 2026-06-08  
**Maintainer:** Tech Lead - Inventory & Finance  
**See Also:** `docs/COST_MANAGEMENT_CONNECTED_ITEMS.md`, `docs/FITUR_STOK_MINUS_AUTOMATION.md`
