# PENJUALAN (Sales Transactions) - Source of Truth

**Last Updated:** 2026-06-08  
**Model Class:** `common\models\Penjualan`  
**Database Table:** `penjualan`  
**Related Models:** `PenjualanItem`, `Customer`, `SalesType`, `TableMap`, `CashierSession`

---

## 1. OVERVIEW & ARSITEKTUR

### 1.1 Tujuan dan Peran Global

Model `Penjualan` adalah **central sales transaction hub** yang merekam setiap transaksi penjualan di sistem. Ini adalah dokumen master untuk:

- **Track semua sales transactions** dengan receipt numbering, payment tracking, dan discount management
- **Handle multiple payment methods** (cash, digital, multi-payment, deferred)
- **Manage financial data** termasuk tax, service charge, rounding, dan gross/net sales
- **Support complex business logic** untuk F&B operations (dine-in, takeaway, delivery, reservations)
- **Enable comprehensive reporting** dengan breakdown per item, category, payment method, dan sales type
- **Integrate dengan inventory** melalui Stok dan BuyPrice untuk COGS tracking

### 1.2 Relasi Model Architecture

```
Penjualan (Sales Header)
    ├─ user_id → User (cashier/staff)
    ├─ cabang_id → Cabang (branch/location)
    ├─ customer_id → Customer (buyer)
    ├─ sales_type_id → SalesType (dine-in, takeaway, etc)
    ├─ table_map_id → TableMap (restaurant table)
    ├─ cashierSessionId → CashierSession (shift/session)
    ├─ reservation_id → ReservasiBiodata (reservation)
    │
    └─ PenjualanItem[] (Sales Line Items)
        ├─ barang_id → Barang (product)
        ├─ harga_beli (cost from BuyPrice)
        ├─ harga_jual (selling price)
        ├─ qty (quantity)
        ├─ j_addon (JSON array of addons)
        └─ bundle_status (parent/child for bundles)

Financial Flow:
    total = SUM(harga_jual * qty per item)
    - discount (PenjualanItem + Penjualan level)
    + service_charge
    + tax (pajak)
    ± rounding
    = final_amount_due
```

### 1.3 Struktur Database Schema (Key Columns)

| Column | Type | Role | Notes |
|--------|------|------|-------|
| `id` | INT PRIMARY KEY | Unique identifier | Auto-increment |
| `no_faktur` | VARCHAR(45) | Receipt number | YYYYMMDDCBGSERIESXXX format |
| `tanggal` | DATETIME | Transaction date | When sales happened |
| `total` | DOUBLE | Gross total | Before discount/tax/service |
| `diskon` | DOUBLE | Total discount | At header level |
| `pajak` | DOUBLE | Tax amount | Calculated based on config |
| `service_charge` | DOUBLE | Service charge | For restaurants |
| `rounding` | DOUBLE | Rounding adjustment | Payment convenience |
| `bayar` | DOUBLE | Amount paid | Can be less than total (unpaid) |
| `payment_amount` | DOUBLE | Actual payment | Final amount charged |
| `jenis_pembayaran` | VARCHAR | Primary payment method | Cash, Bank, etc |
| `bayar_detail` | JSON | Multi-payment detail | For multiple payment methods |
| `status` | VARCHAR(20) | Payment status | Belum/Lunas/Partial/Void |
| `user_id` | INT FOREIGN KEY | Cashier/staff | Who recorded the sale |
| `cabang_id` | INT FOREIGN KEY | Branch | Which location |
| `customer_id` | INT FOREIGN KEY | Customer | Buyer (optional) |
| `sales_type_id` | INT FOREIGN KEY | Sales type | Dine-in, Takeaway, Delivery |
| `table_map_id` | INT FOREIGN KEY | Table | Restaurant table (optional) |
| `food_service` | INT | Service type | 1=Dine-in, 2=Takeaway, 3=Delivery |
| `series` | INT | Series/tax group | Different series for tax diff |
| `type` | VARCHAR | Source | browser, cashier, kiosk, mobile |
| `delete` | VARCHAR | Soft delete | Y/N flag |
| `void_date` | DATETIME | Void timestamp | When transaction voided |
| `void_by` | INT | Void authority | Who voided |
| `void_note` | VARCHAR | Reason for void | Explanation |
| `cashierSessionId` | INT | Shift/session | Cashier session grouping |
| `promo_id` | INT | Promotion | Applied promotion |
| `pax` | INT | Number of guests | For F&B context |
| `reservation_id` | INT | Reservation | Linked reservation |
| `createAt` | DATETIME | Audit timestamp | When created |
| `updated_at` | DATETIME | Audit timestamp | Last updated |
| `created_by` | VARCHAR | Audit user | Who created |
| `updated_by` | VARCHAR | Audit user | Who updated |

### 1.4 Status Constants

```php
// Order Processing Status
const STATUS_FAIL            = -1;       // Order failed
const STATUS_NEW_ORDER       = 0;        // New order received
const STATUS_PROSES          = 1;        // Being processed (kitchen)
const STATUS_DONE            = 2;        // Order complete (ready)
const STATUS_CALL_TO_CASHIER = 3;        // Call to checkout
const STATUS_TAKE            = -2;       // Item taken/voided

// Payment Status
const STATUS_BELUM_LUNAS = "Belum";     // Unpaid
const STATUS_LUNAS       = "Lunas";     // Fully paid
const STATUS_PARTIAL     = "Partial";   // Partially paid
const STATUS_VOID        = "Void";      // Cancelled/voided
```

### 1.5 Payment Method Constants

```php
const METHOD_SAVE     = "-";               // Saved/draft
const METHOD_CASH     = "Cash";            // Cash payment
const METHOD_PAYLATER = "PayLater";        // Deferred payment
const METHOD_GOPAY    = "gopay";           // GoPay wallet
const METHOD_QRIS     = "Qris";            // QRIS/EDC
const METHOD_POINT    = "Point";           // Loyalty points
const METHOD_OVO_EDC  = "Ovo Edc";         // OVO via EDC
const METHOD_FREE     = "Free";            // Complimentary
const METHOD_DEPOSIT  = "Deposit";         // Deposit usage
const METHOD_DEPOSIT_RESTO   = "Deposit Resto";
const METHOD_DEPOSIT_BANQUET = "Deposit Banquet";
const METHOD_FOC      = "Foc";             // Free of charge
const METHOD_ALLOWANCE       = "Allowance";
const METHOD_OVO             = "Ovo";
const METHOD_SHOPEE_PAY_APP  = "Shopee Pay App";
const METHOD_PERMATA_EDC     = "Permata Edc";
const METHOD_YUKK            = "Yukk";
const METHOD_CASHBAC         = "Cashbac";
const METHOD_DANA            = "Dana";
const METHOD_GORESTO         = "Go Resto";
const METHOD_GRAB            = "Grab Food";
```

### 1.6 Source/Channel Constants

```php
const SOURCE_BROWSER = "BROWSER";      // Web browser (POS)
const SOURCE_KASIR   = "cashier";      // Cashier terminal
const SOURCE_BOX     = "KIOSK";         // Self-order kiosk
const SOURCE_MOBILE  = "mobile";        // Mobile app
```

### 1.7 Service Type Constants

```php
const FOOD_SERVICE_DINE_IN  = 1;        // Dine In (on-premises)
const FOOD_SERVICE_TAKEAWAY = 2;        // Take Away (off-premises pickup)
const FOOD_SERVICE_DELIVERY = 3;        // Delivery (off-premises shipped)
```

---

## 2. CORE MECHANISMS & PATTERNS

### 2.1 Receipt Number Generation (no_faktur)

Sistem generates unique receipt numbers per branch per day dengan series support untuk tax grouping.

#### Method: `generateNotaDaily()`

```php
public static function generateNotaDaily($cabangId, $series = 0)
{
    // Structure: YYMMDDcbgSeriesXXX
    // Example: 260608102001 (26-Jun-08, branch 1, series 0, sequence 001)
    
    // Step 1: Get or create NotaUrut record
    $type = 'Sales' . $series;
    $rsNota = NotaUrut::find()
        ->where(['cabang_id' => $cabangId, 'type' => $type])
        ->one();
    
    // Step 2: Check if same day - reset sequence daily
    $tgl = date('ymd');
    if ($rsNota) {
        if (date('d') == date('d', strtotime($rsNota->updateAt ?: 'now'))) {
            // Same day - increment
            $lastId = $rsNota->last_id + 1;
        } else {
            // New day - reset to 1
            $lastId = 1;
        }
    } else {
        // First time - create new record
        $lastId = 1;
    }
    
    // Step 3: Update NotaUrut
    $model->last_id = $lastId;
    $model->updateAt = date('Y-m-d');
    $model->save();
    
    // Step 4: Format sequence number (3 digits zero-padded)
    $dgt = substr("0000" . $lastId, -3);  // 001, 002, ..., 999
    
    // Step 5: Construct nota
    $nota = $tgl . $cabangId . $series . $dgt;  // 260608102001
    
    return ["nota" => $nota, "noUrut" => $lastId];
}
```

**Usage Pattern:**
```php
$result = Penjualan::generateNotaDaily($cabangId, $seriesId);
$receiptNumber = $result['nota'];     // e.g., "260608102001"
$sequence = $result['noUrut'];         // e.g., 1
```

**Key Features:**
- Auto-reset sequence per day per series
- Support multiple series for different tax rates
- NotaUrut table tracks sequential numbering

---

### 2.2 Payment Handling

#### A. Single Payment Method

```php
$penjualan = new Penjualan();
$penjualan->no_faktur = $receiptNumber;
$penjualan->tanggal = date('Y-m-d H:i:s');
$penjualan->total = 150000;      // Gross before discount/tax
$penjualan->diskon = 10000;      // Total discount
$penjualan->pajak = 5000;        // Tax
$penjualan->bayar = 145000;      // Amount paid
$penjualan->jenis_pembayaran = Penjualan::METHOD_CASH;
$penjualan->status = Penjualan::STATUS_LUNAS;  // Fully paid
$penjualan->save();
```

#### B. Multi-Payment (Split Payment)

```php
$penjualan->bayar_detail = json_encode([
    (object)[
        'pay' => 'Cash',
        'amount' => 75000,
        'payment_amount' => 75000
    ],
    (object)[
        'pay' => 'gopay',
        'amount' => 70000,
        'payment_amount' => 70000
    ]
]);
$penjualan->jenis_pembayaran = Penjualan::METHOD_CASH;  // Primary method
$penjualan->payment_amount = 145000;  // Total from all methods
$penjualan->status = Penjualan::STATUS_LUNAS;
$penjualan->save();
```

#### C. Deferred/PayLater Payment

```php
$penjualan->jenis_pembayaran = Penjualan::METHOD_PAYLATER;
$penjualan->customer_id = $customerId;
$penjualan->bayar = 0;            // Not paid yet
$penjualan->status = Penjualan::STATUS_BELUM_LUNAS;  // Unpaid
$penjualan->inv_paylater_id = $invoiceId;
$penjualan->save();
```

#### D. Partial Payment

```php
$penjualan->total = 150000;
$penjualan->bayar = 75000;        // Only 50% paid
$penjualan->status = Penjualan::STATUS_PARTIAL;
$penjualan->save();
```

### 2.3 Payment Status Resolution

```php
public function getJenis_pembayarans()
{
    // Returns all payment methods used in this transaction
    // Combines jenis_pembayaran + bayar_detail
    
    $parts = [];
    
    // Add primary method
    if ($this->jenis_pembayaran && 
        $this->jenis_pembayaran !== self::METHOD_SAVE && 
        $this->jenis_pembayaran !== '-') {
        $parts[] = $this->jenis_pembayaran;
    }
    
    // Add methods from bayar_detail (multi-payment)
    if ($this->bayar_detail) {
        $details = json_decode($this->bayar_detail);
        foreach ($details as $bd) {
            if (isset($bd->pay) && $bd->pay !== '') {
                $parts[] = $bd->pay;
            }
        }
    }
    
    // Return unique, comma-separated
    $parts = array_values(array_unique($parts));
    return implode(', ', $parts);  // "Cash, gopay, Qris"
}
```

---

## 3. FINANCIAL CALCULATIONS

### 3.1 Price Components

```
Gross Sales = SUM(harga_jual * qty for all items)
             + service_charge
             - diskon (at item level)

Pajak (Tax) = Calculated based on tax_rate & rules
             Usually: (Gross Sales - some_exemptions) * tax_rate

Rounding = Manual adjustment for payment convenience
          Usually ±1,000 or ±10,000

Total (Payment Due) = Gross Sales + Pajak + Rounding
                     - ALL diskon (both item-level & header-level)
```

### 3.2 Sales Item Calculation Query: `countSalesItem()`

Complex method untuk fetch sales items dengan breakdown per barang, handling addon items.

```php
public static function countSalesItem(
    $cbg_id,              // Cabang ID
    $start,               // Start date
    $end,                 // End date
    $opr = "<>",          // Operator for category filter (=, <>, IN)
    $kat = 0,             // Category ID (or array if opr=IN)
    $group = 0,           // Barang group ID (0=all)
    $withDiskon = true,   // Include discount in calculations
    $cashierSessionId = null,  // Optional: filter by session
    $series = 0           // Tax series
)
```

#### Processing Logic:

```
1. Query PenjualanItem + Join Barang
   - Select: harga_beli, harga_jual, qty, barang_group_id
   - Calculate: Gross Sales, Net Sales (with discount)
   - Filter by: cabang_id, date range, category, group, series
   - Exclude: bundle child items (only parent)

2. Iterate items with j_addon (addons)
   IF item has addons:
     - Explode j_addon JSON array
     - Add each addon as separate line item (barang_id from addon)
     - Reduce parent harga_jual by sum of addon amounts
   ELSE:
     - Keep as-is single item

3. Group by barang_id & aggregate
   - Sum harga_beli, harga_jual, qty per barang
   - Calculate discount proportion
   - Calculate gross profit (net_sales - cogs)
   - Calculate margins: HB% = (cost/sales)*100, GP% = (profit/sales)*100

4. Sort by quantity descending (top sellers first)

5. Return array with metrics:
   ['barang_id', 'nama', 'jml' (qty), 'hj' (gross), 'hb' (cost),
    'net_sales', 'gp' (gross profit), 'hb_margin', 'gp_margin', ...]
```

#### Usage Example:

```php
// Top selling items in category 5 for June
$items = Penjualan::countSalesItem(
    $cabangId,
    '2024-06-01',
    '2024-06-30',
    '=',              // Exact category match
    5,                // Category ID = 5
    0,                // All groups
    true,             // Include discount
    null,             // Any session
    0                 // Series 0
);

foreach ($items as $item) {
    echo "{$item['nama']}: {$item['jml']} units, ";
    echo "Sales: {$item['hj']}, Cost: {$item['hb']}, ";
    echo "Profit: {$item['gp']} ({$item['gp_margin']})\n";
}
```

### 3.3 Multi-Payment Aggregation: `countMultiPaymentBySessionId()`

Aggregates payments by method for a cashier session.

```php
public static function countMultiPaymentBySessionId(
    $cashierSessionId,    // Cashier shift/session
    $series = 0           // Tax series
)
```

#### Processing Logic:

```
1. Fetch all transactions in session
   - Get: tanggal, diskon, pajak, rounding, total, bayar_detail

2. For each transaction:
   IF bayar_detail exists (multi-payment):
     - Parse all payment methods (primary + from bayar_detail)
     - Calculate total payment amount
     - Distribute diskon, pajak, rounding proportionally
     - Aggregate per payment method
   ELSE (single payment):
     - Add entire amount to payment method
     - No proportional distribution needed

3. Return array of aggregated payments:
   [
     'jenis_pembayaran' => 'Cash',
     'total' => 1500000,
     'items' => 25,
     'diskon' => 150000,
     'pajak' => 120000,
     'rounding' => 5000,
     'gross_sales' => 1500000 + 150000 - 120000 - 5000,
     'net_sales' => gross_sales - diskon
   ]
```

#### Usage Example:

```php
// Cashier settlement for session
$paymentSummary = Penjualan::countMultiPaymentBySessionId($sessionId, $series);

foreach ($paymentSummary as $payment) {
    echo "{$payment['jenis_pembayaran']}: ";
    echo "Rp " . number_format($payment['total']) . " ";
    echo "({$payment['items']} items)\n";
}
```

---

## 4. BEST PRACTICES & BOILERPLATE

### 4.1 Creating a Sales Transaction (CORRECT WAY)

#### ✅ Pattern 1: Complete Transaction with Items

```php
// Start transaction
$trx = Yii::$app->db->beginTransaction();

try {
    // Step 1: Generate receipt number
    $notaResult = Penjualan::generateNotaDaily($cabangId, $seriesId);
    
    // Step 2: Create Penjualan header
    $penjualan = new Penjualan();
    $penjualan->no_faktur = $notaResult['nota'];
    $penjualan->tanggal = date('Y-m-d H:i:s');
    $penjualan->cabang_id = $cabangId;
    $penjualan->user_id = Yii::$app->user->id;
    $penjualan->customer_id = $customerId;
    $penjualan->sales_type_id = $salesTypeId;
    $penjualan->food_service = 1;  // Dine-in
    $penjualan->pax = 4;           // 4 guests
    $penjualan->series = $seriesId;
    $penjualan->type = Penjualan::SOURCE_KASIR;
    
    // Placeholder totals (will be calculated)
    $penjualan->total = 0;
    $penjualan->diskon = 0;
    $penjualan->pajak = 0;
    $penjualan->save(false);
    
    $penjualanId = $penjualan->id;
    
    // Step 3: Add line items
    $grossTotal = 0;
    $totalDiscount = 0;
    
    foreach ($items as $item) {
        $penjualanItem = new PenjualanItem();
        $penjualanItem->penjualan_id = $penjualanId;
        $penjualanItem->barang_id = $item['barang_id'];
        $penjualanItem->qty = $item['qty'];
        $penjualanItem->harga_jual = $item['price'];
        $penjualanItem->harga_beli = Stok::getCost($item['barang_id'], $cabangId);
        $penjualanItem->diskon = $item['diskon'] ?? 0;
        
        // Handle addons if any
        if (!empty($item['addons'])) {
            $penjualanItem->j_addon = json_encode($item['addons']);
        }
        
        $penjualanItem->save(false);
        
        $itemSubtotal = ($item['qty'] * $item['price']) - ($item['diskon'] ?? 0);
        $grossTotal += $itemSubtotal;
        $totalDiscount += ($item['diskon'] ?? 0);
    }
    
    // Step 4: Calculate totals
    $pajak = round($grossTotal * 0.1, 0);  // 10% tax example
    $serviceCharge = 0;  // If applicable
    $rounding = 0;       // Calculate if needed
    
    $totalAmount = $grossTotal + $pajak + $serviceCharge - $totalDiscount + $rounding;
    
    // Step 5: Update Penjualan with calculated totals
    $penjualan->total = $grossTotal;
    $penjualan->diskon = $totalDiscount;
    $penjualan->pajak = $pajak;
    $penjualan->service_charge = $serviceCharge;
    $penjualan->rounding = $rounding;
    $penjualan->bayar = $totalAmount;
    $penjualan->payment_amount = $totalAmount;
    $penjualan->jenis_pembayaran = Penjualan::METHOD_CASH;
    $penjualan->status = Penjualan::STATUS_LUNAS;
    $penjualan->save(false);
    
    $trx->commit();
    
    return $penjualanId;
    
} catch (\Throwable $e) {
    $trx->rollBack();
    Yii::error($e->getMessage(), 'sales');
    throw $e;
}
```

#### ✅ Pattern 2: PayLater (Deferred Payment)

```php
$penjualan = new Penjualan();
// ... set all fields like above ...
$penjualan->jenis_pembayaran = Penjualan::METHOD_PAYLATER;
$penjualan->customer_id = $customerId;  // MUST have customer
$penjualan->bayar = 0;                  // Not paid
$penjualan->status = Penjualan::STATUS_BELUM_LUNAS;
$penjualan->inv_paylater_id = $invoiceId;
$penjualan->save();
```

#### ✅ Pattern 3: Multi-Payment

```php
$penjualan->bayar_detail = json_encode([
    (object)[
        'pay' => 'Cash',
        'amount' => 100000,
        'payment_amount' => 100000
    ],
    (object)[
        'pay' => 'gopay',
        'amount' => 80000,
        'payment_amount' => 80000
    ]
]);
$penjualan->jenis_pembayaran = Penjualan::METHOD_CASH;  // Primary
$penjualan->payment_amount = 180000;  // Total from all methods
$penjualan->status = Penjualan::STATUS_LUNAS;
$penjualan->save();
```

### 4.2 ❌ WRONG Patterns to Avoid

#### ❌ DON'T: Create without generating receipt number

```php
// WRONG: Hardcoded or skipped receipt number
$penjualan->no_faktur = "N000001";  // No structure
$penjualan->save();
```

**Why:** Receipt numbers must be unique and follow structured format for tax/audit compliance.

#### ❌ DON'T: Forget to include harga_beli in PenjualanItem

```php
// WRONG: Missing cost tracking
$penjualanItem->harga_jual = 50000;
$penjualanItem->save();  // harga_beli is NULL!
```

**Why:** harga_beli essential for COGS calculation and financial reporting.

#### ❌ DON'T: Set status without payment verification

```php
// WRONG: Assuming payment
$penjualan->status = Penjualan::STATUS_LUNAS;  // But bayar != total
$penjualan->save();
```

**Why:** Status must match actual payment state. Use STATUS_PARTIAL or STATUS_BELUM_LUNAS if needed.

### 4.3 Voiding/Cancellation

```php
public function voidTransaction($reason = null)
{
    $this->status = Penjualan::STATUS_VOID;
    $this->void_date = date('Y-m-d H:i:s');
    $this->void_by = Yii::$app->user->id;
    $this->void_note = $reason;
    $this->delete = 'Y';  // Mark as deleted
    $this->save();
}

// Usage
$penjualan = Penjualan::findOne($id);
$penjualan->voidTransaction('Customer request - duplicate order');
```

### 4.4 Validation Rules

```php
public function rules()
{
    return [
        // Required
        [['user_id'], 'required'],
        
        // Data types
        [['cabang_id', 'customer_id', 'sales_type_id'], 'integer'],
        [['total', 'diskon', 'bayar', 'pajak', 'service_charge'], 'double'],
        
        // Relationships
        [['user_id'], 'exist', 'targetClass' => User::class],
        [['cabang_id'], 'exist', 'targetClass' => Cabang::class],
        [['customer_id'], 'exist', 'targetClass' => Customer::class],
        
        // String validations
        [['status'], 'string', 'max' => 20],
        [['jenis_pembayaran'], 'string', 'max' => 100],
        [['no_faktur'], 'string', 'max' => 45],
    ];
}
```

### 4.5 Behavioral Hooks

```php
public function behaviors()
{
    return [
        MyBehavior::timestampBehavior('createAt', 'updated_at'),
        MyBehavior::blameableBehavior(),      // Auto created_by, updated_by
        MyBehavior::activityLogBehavior()     // Audit trail logging
    ];
}
```

**Auto-populated Fields:**
- `createAt`: Set on insert
- `updated_at`: Set on insert/update
- `created_by`: Current user ID
- `updated_by`: Current user ID
- Activity logs: All changes tracked

---

## 5. REPORTING & ANALYTICS QUERIES

### 5.1 Revenue by Date Range

```php
public static function queryRevenueByDate($cbg_id, $start, $end)
{
    // Uses VPenjualanReport view (pre-calculated for performance)
    return Penjualan::queryRevenueRaw()
        ->andWhere(['cabang_id' => $cbg_id])
        ->andWhere(['between', 'date(tanggal)', $start, $end]);
}

// Usage
$dailyRevenue = Penjualan::queryRevenueByDate($cabangId, '2024-06-01', '2024-06-30');
foreach ($dailyRevenue as $record) {
    echo "Sales: " . number_format($record->gross_sales) . " ";
    echo "(Tax: " . number_format($record->pajak) . ")\n";
}
```

### 5.2 Revenue by Sales Type

```php
public static function queryRevenueBySalesType($cbg_id, $start, $end)
{
    // Aggregates by sales_type_id (e.g., dine-in vs takeaway)
    // Returns: sales_type_id, trx_count, total, gross_sales, etc
}

// Usage
$byType = Penjualan::queryRevenueBySalesType($cabangId, $start, $end);
foreach ($byType as $type) {
    echo "Type {$type->sales_type_id}: ";
    echo number_format($type->gross_sales) . " ";
    echo "({$type->trx_count} transactions)\n";
}
```

### 5.3 Unpaid Invoices

```php
public static function getBelumlunas()
{
    // Get all unpaid transactions for current branch
    $cbg_id = Yii::$app->user->identity->cabang_id;
    return Penjualan::find()
        ->where(['status' => Penjualan::STATUS_BELUM_LUNAS, 'cabang_id' => $cbg_id])
        ->orderBy('createAt desc')
        ->all();
}

// Usage
$unpaid = Penjualan::getBelumlunas();
$totalUnpaid = array_sum(array_column($unpaid, 'total'));
echo "Total unpaid: Rp " . number_format($totalUnpaid);
```

### 5.4 Unpaid by Customer

```php
public static function queryUnpaidByCustomer($customerId, $startDate, $endDate)
{
    // Uses VPenjualanUnpaidPaylater view
    return VPenjualanUnpaidPaylater::find()
        ->where(['customer_id' => $customerId])
        ->andWhere(['between', 'date(tanggal)', $startDate, $endDate])
        ->all();
}

// Usage
$customerUnpaid = Penjualan::queryUnpaidByCustomer($customerId, '2024-01-01', '2024-12-31');
echo "Customer outstanding: Rp " . array_sum(array_column($customerUnpaid, 'unpaid'));
```

---

## 6. ADDON HANDLING (j_addon)

### 6.1 Addon Structure

Addons disimpan sebagai JSON dalam PenjualanItem.j_addon:

```json
[
  {
    "id": 5,
    "name": "Extra Sauce",
    "amount": 5000,
    "harga_beli": 2000
  },
  {
    "id": 8,
    "name": "Extra Cheese",
    "amount": 10000,
    "harga_beli": 3000
  }
]
```

### 6.2 Addon Calculation in Sales Item Reports

```php
// From countSalesItem()
IF item has j_addon:
    // For each addon:
    addon_hj = qty * addon_amount
    addon_hb = qty * addon_harga_beli
    
    // Add as separate line item
    $newModel[] = [
        'barang_id' => $addon_id,
        'nama' => $addon_name,
        'hj' => $addon_hj,
        'hb' => $addon_hb,
        'jml' => $qty,
        // ... other fields
    ];
    
    // Reduce parent item's harga_jual
    parent_hj -= SUM(addon_hj)
```

### 6.3 Addon Cost Tracking

```php
// Addon costs included in PenjualanItem.harga_beli calculation
// When querying COGS:
// - Parent item cost = calculated from recipe or purchase price
// - Addon costs = from addon's own purchase price
// - Total = parent_cost - sum(addon_costs) if addon reduces parent
```

---

## 7. SERVICE TYPE & PAYMENT FLOW

### 7.1 Food Service Types (food_service)

```php
// 1 = Dine In
//   - Customer eats at premise
//   - Usually with table_map_id
//   - May have reservation

// 2 = Take Away
//   - Customer picks up order
//   - No table assignment
//   - May have time_to_take

// 3 = Delivery
//   - Customer address delivery
//   - is_delivery flag = true
//   - Customer details tracked
```

### 7.2 Payment Flow Diagram

```
New Sales Order
    ↓
Create Penjualan header + PenjualanItem details
    ↓
Calculate totals: gross, diskon, pajak, service, rounding
    ↓
Customer pays or defers
    ├─ CASH/DIGITAL: STATUS_LUNAS
    ├─ PARTIAL: STATUS_PARTIAL
    └─ PAYLATER: STATUS_BELUM_LUNAS → Invoice created
    ↓
Void possible: STATUS_VOID
    ↓
Reporting & Analytics (countSalesItem, countMultiPayment, etc)
```

---

## 8. PERFORMANCE CONSIDERATIONS

### 8.1 Indexing Strategy

**Recommended Indexes:**
```sql
-- Primary queries
CREATE INDEX idx_penjualan_cabang_date ON penjualan(cabang_id, tanggal DESC);
CREATE INDEX idx_penjualan_user_date ON penjualan(user_id, tanggal DESC);

-- Reporting
CREATE INDEX idx_penjualan_customer ON penjualan(customer_id);
CREATE INDEX idx_penjualan_status ON penjualan(status, cabang_id);
CREATE INDEX idx_penjualan_session ON penjualan(cashierSessionId);

-- Soft delete filtering
CREATE INDEX idx_penjualan_delete ON penjualan(delete, tanggal DESC);

-- Views/Reporting
CREATE INDEX idx_penjualan_series ON penjualan(series, tanggal DESC);
```

### 8.2 Query Optimization

**AVOID:**
```php
// ❌ N+1 Query: Item fetched per sales
$penjualans = Penjualan::find()->all();
foreach ($penjualans as $pj) {
    foreach ($pj->penjualanItems as $item) {  // ← Extra query per item
        echo $item->barang->nama;
    }
}

// ✅ GOOD: Eager load
$penjualans = Penjualan::find()
    ->with('penjualanItems.barang')  // Batch load
    ->all();
foreach ($penjualans as $pj) {
    foreach ($pj->penjualanItems as $item) {  // ← No extra query
        echo $item->barang->nama;
    }
}
```

### 8.3 View-Based Reporting

System uses pre-calculated database views for complex reports:

```php
// VPenjualanReport - Pre-aggregated sales data
// VPenjualanUnpaidPaylater - Unpaid invoice view
// VWipUsageReport - WIP consumption report
```

These views are more performant than ad-hoc calculations.

---

## 9. COMMON PITFALLS & TROUBLESHOOTING

| Pitfall | Symptom | Solution |
|---------|---------|----------|
| Receipt number duplicates | Same nota generated twice | Ensure NotaUrut record exists and updates atomically |
| Missing harga_beli in items | COGS calculation wrong | Always fetch & set harga_beli when creating PenjualanItem |
| Total doesn't match items | Payment discrepancy | Verify discount & tax calculations match formula |
| Multi-payment proportions wrong | Revenue reports misaligned | Use countMultiPaymentBySessionId() for aggregation |
| N+1 queries on reporting | Slow reports | Use eager loading with joinWith() |
| Addon costs doubled | COGS overstated | Ensure addon handling in countSalesItem() correctly excludes from parent |
| Unpaid status not updated | Old invoices still marked unpaid | Create payment records linking to original penjualan_id |

---

## 10. RELATIONS & MODEL DEPENDENCIES

```php
public function getUser()              // → User (cashier)
public function getCabang()            // → Cabang (branch)
public function getCustomer()          // → Customer (buyer)
public function getSalesType()         // → SalesType (dine-in, etc)
public function getTableMap()          // → TableMap (restaurant table)
public function getPenjualanItems()    // → PenjualanItem[] (line items)
public function getSession()           // → CashierSession (shift)
public function getReservasiBiodata()  // → ReservasiBiodata (reservation)
```

---

## 11. INTEGRATION CHECKLIST

When implementing sales functionality:

- [ ] Generate receipt number using `generateNotaDaily()`
- [ ] Create Penjualan header with all required fields
- [ ] Create PenjualanItem for each line with harga_beli
- [ ] Calculate totals: gross, diskon, pajak, service, rounding
- [ ] Update Penjualan with calculated amounts
- [ ] Handle payment method (single, multi, or deferred)
- [ ] Set status based on payment state
- [ ] Test: Receipt unique per day, multi-payment aggregation
- [ ] Verify: COGS in reports matches PenjualanItem.harga_beli
- [ ] Monitor: Receipt gaps, duplicate transactions

---

## 12. REFERENCES & RELATED COMPONENTS

| Related Component | Purpose | File |
|------------------|---------|------|
| `PenjualanItem` | Sales line items | `common/models/PenjualanItem.php` |
| `Customer` | Customer/buyer info | `common/models/Customer.php` |
| `Barang` | Product/item master | `common/models/Barang.php` |
| `CashierSession` | Cashier shift/session | `common/models/CashierSession.php` |
| `SalesType` | Dine-in, Takeaway, Delivery | `common/models/SalesType.php` |
| `TableMap` | Restaurant seating | `common/models/TableMap.php` |
| `Stok` | Inventory & cost tracking | `common/models/Stok.php` |
| `BuyPrice` | Historical cost data | `common/models/BuyPrice.php` |
| `VPenjualanReport` | Reporting view | Database view |
| `NotaUrut` | Receipt number sequencing | `common/models/NotaUrut.php` |

---

**Document Status:** PRODUCTION  
**Last Reviewed:** 2026-06-08  
**Maintainer:** Tech Lead - Sales & POS  
**See Also:** `docs/features/STOCK.md`, `docs/features/BUY_PRICE.md`, `docs/COST_MANAGEMENT_CONNECTED_ITEMS.md`
