# RULE FILE 06 — Inventory & POS Rules

---

## Inventory Movement — THE IRON LAW

**You NEVER modify `inventory_levels.quantity_on_hand` directly.** Every change goes through an `inventory_movements` record. The service that creates the movement also updates the level. This is non-negotiable — it's the audit trail.

```php
InventoryService::createMovement(
    Product $product,
    Warehouse $warehouse,
    string $type,        // movement type
    string $direction,   // 'in' or 'out'
    int $quantity,       // ALWAYS positive
    ?string $reason,
    ?Model $reference    // what caused this (PurchaseOrder, POSTransaction, etc.)
): InventoryMovement

// Inside:
DB::transaction(function () {
    $level = InventoryLevel::where('product_id', $product->id)
        ->where('warehouse_id', $warehouse->id)
        ->lockForUpdate()
        ->firstOrCreate([...], ['quantity_on_hand' => 0, 'quantity_reserved' => 0]);

    $before = $level->quantity_on_hand;
    
    if ($direction === 'in') {
        $level->quantity_on_hand += $quantity;
    } else {
        if ($level->quantity_on_hand < $quantity) {
            throw new InsufficientStockException(...);
        }
        $level->quantity_on_hand -= $quantity;
    }
    
    $level->quantity_available = $level->quantity_on_hand - $level->quantity_reserved;
    $level->save();

    return InventoryMovement::create([
        'product_id' => $product->id,
        'warehouse_id' => $warehouse->id,
        'type' => $type,
        'direction' => $direction,
        'quantity' => $quantity,
        'quantity_before' => $before,
        'quantity_after' => $level->quantity_on_hand,
        'unit_cost' => $product->base_cost,
        'reference_type' => $reference ? get_class($reference) : null,
        'reference_id' => $reference?->id,
        'reason' => $reason,
        'created_by' => auth()->id(),
    ]);
});
```

### Movement Types — Direction Mapping (HARDCODED)

| Type | Direction | When |
|------|-----------|------|
| `purchase_received` | `in` | Goods received from supplier |
| `sale` | `out` | Sold at POS or via invoice |
| `transfer_out` | `out` | Sent to another warehouse |
| `transfer_in` | `in` | Received from another warehouse |
| `return_to_stock` | `in` | Customer return |
| `return_to_supplier` | `out` | Defective return to vendor |
| `adjustment_up` | `in` | Manual increase (found extra) |
| `adjustment_down` | `out` | Manual decrease (damage, loss) |
| `damage` | `out` | Written off as damaged |
| `loss` | `out` | Missing/stolen |
| `consumption` | `out` | Used internally (cleaning, office) |
| `kit_assembly` | `out` | Components used to build kit |
| `kit_disassembly` | `in` | Kit broken back into components |
| `initial_stock` | `in` | Opening balance at setup |
| `count_adjustment_up` | `in` | Stock count found MORE than system |
| `count_adjustment_down` | `out` | Stock count found LESS than system |

**ENFORCEMENT**: The direction is DERIVED from the type. Service validates that the passed direction matches the type. If mismatch → throw.

---

## Product Rules

### SKU Generation

- Format: `{CATEGORY_CODE}-{SEQUENTIAL}` → `FB-001`, `SW-042`
- Unique per academy: `unique(['academy_id', 'sku'])`
- Cannot be reused after deletion (soft-delete doesn't free the SKU)

### Product Tracking Flag

- `track_inventory = true`: stock movements are tracked, quantities enforced
- `track_inventory = false`: for services, digital items, things without physical stock
- If `track_inventory = false`: POS sale does NOT create inventory movement
- If `track_inventory = true`: POS sale MUST create inventory movement (direction: out, type: sale)

### Product ↔ Pricing Engine

- `products.selling_price` is the DEFAULT price (used if no pricing rule overrides)
- Pricing engine CAN apply rules to products (same as programs)
- If pricing engine returns a different price → POS shows the engine price, not `selling_price`
- `selling_price` is effectively the "sticker price" — base for calculations

---

## Warehouse Rules

### Warehouse Types

```
CHECK (type IN ('warehouse', 'store', 'room', 'virtual'))
```

- `warehouse`: main storage
- `store`: point-of-sale storage (what the receptionist can sell from)
- `room`: equipment room at a facility
- `virtual`: for items that don't have physical location (digital, services)

### Default Warehouse

- One warehouse per branch can be `is_default = true`
- When selling at POS: stock deducted from the DEFAULT warehouse of that branch
- If no default → admin must select warehouse at sale time (or system blocks sale)

### Transfer Rules

1. Transfer between warehouses creates TWO movements: `transfer_out` from source + `transfer_in` to destination
2. Both movements share a reference (the Transfer record)
3. Warehouses can be in different branches (inter-branch transfer)
4. Transfer requires approval if value exceeds threshold (configurable)

---

## Stock Count Rules

### Count Types

```
CHECK (count_type IN ('full', 'partial', 'cycle', 'surprise'))
```

- `full`: every product in the warehouse counted
- `partial`: only selected products/categories
- `cycle`: rotating subsets (A items weekly, B items monthly, C quarterly)
- `surprise`: unannounced, random selection

### Stock Count Workflow

```
1. Create stock_count (status='planned')
2. Generate stock_count_items:
   - For 'full': one item per product in that warehouse
   - For 'partial/cycle': selected products only
   - system_quantity filled from current inventory_levels
3. Counter performs count (status='in_progress')
4. For each item: enter counted_quantity
   - discrepancy = counted_quantity - system_quantity
   - discrepancy_value = abs(discrepancy) × product.base_cost
5. Count completed (status='completed')
6. Manager reviews discrepancies:
   - Small discrepancies (below threshold): auto-approve
   - Large discrepancies: require explanation + manual approval
7. On approval (status='approved'):
   - For each item where discrepancy != 0:
     - If discrepancy > 0: create movement(type='count_adjustment_up', quantity=discrepancy)
     - If discrepancy < 0: create movement(type='count_adjustment_down', quantity=abs(discrepancy))
   - inventory_levels are now synced with physical reality
```

### Integrity Check (Weekly Job)

```php
RecalculateInventoryLevelsJob::handle()

// For each product+warehouse combination:
// 1. Sum all movements: total_in = sum(quantity WHERE direction='in'), total_out = sum(direction='out')
// 2. expected_quantity = total_in - total_out
// 3. If expected_quantity != inventory_levels.quantity_on_hand:
//    → LOG WARNING (integrity violation detected)
//    → Create admin notification
//    → Do NOT auto-fix (human must investigate)
```

---

## Kit Rules

### Kit Selling Workflow

When a kit is sold at POS:
1. Check availability: for EACH required (non-optional) component, check `inventory_levels.quantity_available >= kit_item.quantity`
2. If ANY required component is out of stock:
   - Check if `substitute_product_id` exists and has stock → offer substitute
   - If no substitute or substitute also out → kit is UNAVAILABLE for sale
3. On sale confirmation:
   - For each component: create movement(type='kit_assembly', direction='out', quantity=kit_item.quantity)
   - Kit `total_sold` incremented
4. Kit price comes from `kits.selling_price` (NOT sum of component prices)
5. Kit CAN go through pricing engine (e.g., family discount on kit purchase)

### Kit Availability Check

```php
KitService::checkAvailability(Kit $kit, Warehouse $warehouse): KitAvailability

// Returns:
// - available: bool (all required components in stock)
// - components: [{product, required_qty, available_qty, in_stock: bool, substitute: ?Product}]
// - out_of_stock_components: [{product, shortfall}]
```

### Optional Components

- `kit_items.is_optional = true`: customer chooses whether to include
- Optional items don't affect kit availability (kit is available without them)
- If customer adds optional item: its stock is also deducted
- Optional item pricing: added to kit price (kit_price + optional_item.selling_price)

---

## POS Transaction Rules

### POS Session Lifecycle

```
1. Operator opens POS session:
   - Creates pos_sessions record (status='open')
   - Links to a cash_session (or creates one)
   - Only ONE open POS session per user at a time

2. During session: all transactions belong to this session
   - pos_transactions.pos_session_id = current session

3. Operator closes session:
   - System tallies: total_sales = sum of all transaction totals
   - transactions_count = count of transactions
   - Session links to cash_session for financial reconciliation
   - status = 'closed'
```

### Transaction Flow — THE EXACT 10 STEPS (no shortcuts)

```
Step 1: IDENTIFY PARTICIPANT (optional for walk-in product sales)
  → Search by name, phone, national_id, or QR code
  → System loads: active programs, outstanding balance, wallet balance, classification

Step 2: ADD ITEMS TO CART
  → Each item type handled differently:
  → Product: quantity × price (check stock if track_inventory=true)
  → Kit: check availability, add with kit price
  → Program enrollment: triggers pricing engine for subscription fee
  → Service (private session, event fee): manual price or from service catalog

Step 3: CALCULATE PRICES PER ITEM
  → For each item: run pricing engine (participant context + item + branch + date)
  → Engine returns final price per item
  → Cart shows: original price, discounts applied, final price

Step 4: APPLY COUPON (optional)
  → Validate coupon (all 8 checks from pricing rules doc)
  → If valid: apply coupon discount to subtotal

Step 5: CALCULATE TOTALS
  → subtotal = sum of all item final prices × quantities
  → discount_amount = coupon discount (if any)
  → tax_amount = 0 (Egyptian sports academies typically don't charge VAT — configurable)
  → total_amount = subtotal - discount_amount + tax_amount

Step 6: SELECT PAYMENT METHOD
  → cash: operator receives physical cash
  → card: operator processes card terminal externally, records auth number
  → wallet: system checks available_balance >= amount
  → split: combination of above (e.g., 500 from wallet + 300 cash)

Step 7: VALIDATE PAYMENT
  → Cash: always valid (make change)
  → Card: operator confirms terminal approved
  → Wallet: available_balance >= amount (lockForUpdate check)
  → Split: sum of all methods >= total_amount

Step 8: COMMIT TRANSACTION (all in one DB::transaction)
  → Create pos_transactions record
  → Create pos_transaction_items records
  → Create invoice + invoice_items
  → Create payment(s) → double-entry transactions
  → If wallet: deduct wallet balance + create wallet_transaction
  → If products: create inventory_movements (type='sale', direction='out')
  → If kit: create inventory_movements for each component
  → If program enrollment: create enrollment record
  → Update cash_session totals
  → Update pos_session totals
  → Increment coupon usage_count (if used)

Step 9: GENERATE RECEIPT
  → Receipt shows: academy name+logo, date, receipt number, participant name,
    items with prices, discounts, total, payment method, cashier name
  → Option: print (thermal printer) or send via email/WhatsApp

Step 10: DISPATCH EVENTS
  → POSTransactionCompleted (triggers: inventory alerts, financial logging)
  → EnrollmentCreated (if program was purchased)
  → PaymentReceived (financial module listens)
```

### POS Edge Cases — HANDLE ALL

| Case | Behavior |
|------|----------|
| Product out of stock | Warning: "المنتج غير متوفر (الكمية المتبقية: X)" — allow override with confirmation |
| Wallet insufficient | "رصيد المحفظة غير كافٍ ({balance} ج.م) — هل تريد الدفع بطريقة أخرى؟" |
| Participant suspended | Block new purchases. Allow ONLY: payment on existing invoice. Show: "هذا المشترك معلق" |
| Duplicate program enrollment | Block: "هذا المشترك مسجل بالفعل في هذا البرنامج" |
| No POS session open | Block all sales: "يجب فتح وردية أولاً" |
| Split payment doesn't cover total | Block: "المبلغ المدفوع ({paid}) أقل من الإجمالي ({total})" |
| Cart is empty | Disable "إتمام البيع" button |
| Participant not found (walk-in) | Allow product sale without participant. Enrollment requires participant. |
| Refund at POS | Separate flow: select original transaction → select items to return → process refund → inventory return movement |

### POS Transaction Number Format

`RCP-{BRANCH_CODE}-{YYYYMMDD}-{SEQUENTIAL}` → `RCP-HQ-20260629-0042`

Sequential resets daily per branch.

---

## POS ↔ Financial Integration

Every POS transaction creates:
1. `invoices` record (with items mirroring the transaction items)
2. `payments` record(s) (matching the payment method)
3. `transactions` (double-entry: debit Cash/Card/Wallet, credit Revenue)

The invoice is the financial record of truth. The POS transaction is the operational record. They are LINKED but serve different purposes.

---

## Low Stock Alert Rules

```php
// Daily job
LowStockAlertJob::handle()

// For each product where track_inventory = true:
// 1. Sum quantity_on_hand across all warehouses for this product
// 2. If total < product.min_stock_level AND product.is_active:
//    → Create notification for academy admin
//    → Notification includes: product name, current stock, min level, suggested reorder quantity
// 3. Don't send duplicate alerts (check if alert already sent today for this product)
```
