# Cycle 5: Inventory & POS Gaps

## Goal
Add `suppliers` table, `pos_sessions` table, missing columns, ProductService, and align enums.

---

## New Migration: `2024_01_01_000056_create_suppliers_table.php`

```sql
CREATE TABLE suppliers (
    id                  BIGSERIAL PRIMARY KEY,
    uuid                UUID NOT NULL UNIQUE,
    academy_id          BIGINT NOT NULL REFERENCES academies(id),
    name                VARCHAR(200) NOT NULL,
    name_ar             VARCHAR(200) NOT NULL,
    contact_name        VARCHAR(100),
    email               VARCHAR(200),
    phone               VARCHAR(20),
    phone_secondary     VARCHAR(20),
    address             TEXT,
    city                VARCHAR(50),
    tax_number          VARCHAR(50),
    payment_terms       VARCHAR(100),         -- e.g., "Net 30", "COD"
    notes               TEXT,
    is_active           BOOLEAN NOT NULL DEFAULT TRUE,
    total_purchases     BIGINT NOT NULL DEFAULT 0,    -- piasters, denormalized
    last_purchase_at    TIMESTAMP,
    rating              DECIMAL(3,2) DEFAULT 0.00,    -- 0.00-5.00
    metadata            JSONB NOT NULL DEFAULT '{}',
    created_by          BIGINT NOT NULL REFERENCES users(id),
    created_at          TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMP NOT NULL DEFAULT NOW(),
    deleted_at          TIMESTAMP
);

CREATE UNIQUE INDEX suppliers_academy_name ON suppliers(academy_id, name) WHERE deleted_at IS NULL;
```

### Model: `app/Domain/Inventory/Models/Supplier.php`
```php
class Supplier extends Model
{
    use BelongsToAcademy, HasUuid, SoftDeletes, Auditable;

    protected $fillable = [
        'academy_id', 'name', 'name_ar', 'contact_name', 'email', 'phone',
        'phone_secondary', 'address', 'city', 'tax_number', 'payment_terms',
        'notes', 'is_active', 'total_purchases', 'last_purchase_at', 'rating',
        'metadata', 'created_by',
    ];

    protected $casts = [
        'total_purchases' => 'integer',
        'last_purchase_at' => 'datetime',
        'rating' => 'decimal:2',
        'is_active' => 'boolean',
        'metadata' => 'array',
    ];

    public function purchaseOrders(): HasMany { ... }
    public function creator(): BelongsTo { ... }
}
```

### Service: `app/Domain/Inventory/Services/SupplierService.php`
- `create(array $data, User $actor)` — validate unique name per academy
- `update(Supplier $supplier, array $data, User $actor)`
- `delete(Supplier $supplier, User $actor)` — soft delete, check no open POs
- `updatePurchaseStats(Supplier $supplier)` — recalculate total_purchases, last_purchase_at

---

## New Migration: `2024_01_01_000057_create_pos_sessions_table.php`

```sql
CREATE TABLE pos_sessions (
    id                  BIGSERIAL PRIMARY KEY,
    uuid                UUID NOT NULL UNIQUE,
    academy_id          BIGINT NOT NULL REFERENCES academies(id),
    branch_id           BIGINT NOT NULL REFERENCES branches(id),
    user_id             BIGINT NOT NULL REFERENCES users(id),
    cash_session_id     BIGINT REFERENCES cash_sessions(id),
    status              VARCHAR(20) NOT NULL DEFAULT 'open',
    transactions_count  INTEGER NOT NULL DEFAULT 0,
    total_sales         BIGINT NOT NULL DEFAULT 0,          -- piasters
    total_items_sold    INTEGER NOT NULL DEFAULT 0,
    opened_at           TIMESTAMP NOT NULL DEFAULT NOW(),
    closed_at           TIMESTAMP,
    notes               TEXT,
    metadata            JSONB NOT NULL DEFAULT '{}',
    created_at          TIMESTAMP NOT NULL DEFAULT NOW(),
    updated_at          TIMESTAMP NOT NULL DEFAULT NOW()
);

ALTER TABLE pos_sessions ADD CONSTRAINT pos_sessions_status_check
    CHECK (status IN ('open', 'closed', 'suspended'));

-- Only ONE open session per user per branch
CREATE UNIQUE INDEX pos_sessions_open_unique
    ON pos_sessions(user_id, branch_id)
    WHERE status = 'open';
```

### Model: `app/Domain/POS/Models/POSSession.php`
```php
class POSSession extends Model
{
    use BelongsToAcademy, HasUuid, Auditable;

    protected $table = 'pos_sessions';

    protected $fillable = [
        'academy_id', 'branch_id', 'user_id', 'cash_session_id',
        'status', 'transactions_count', 'total_sales', 'total_items_sold',
        'opened_at', 'closed_at', 'notes', 'metadata',
    ];

    protected $casts = [
        'total_sales' => 'integer',
        'opened_at' => 'datetime',
        'closed_at' => 'datetime',
        'metadata' => 'array',
    ];

    public function user(): BelongsTo { ... }
    public function branch(): BelongsTo { ... }
    public function cashSession(): BelongsTo { ... }
    public function transactions(): HasMany { ... }
}
```

### POSService Update
- Add `openSession(User $user, Branch $branch, ?CashSession $cashSession)` — creates POSSession
- Add `closeSession(POSSession $session, User $actor)` — set closed_at, status
- Update `checkout()` to require open POSSession, link transaction to it, increment counters
- Guard: "يجب فتح جلسة بيع أولاً" if no open session

---

## Migration: `2024_01_01_000058_add_supplier_fk_to_purchase_orders.php`

```php
// Add supplier_id FK, keep supplier_name for legacy/display
$table->foreignId('supplier_id')->nullable()->constrained('suppliers');
```

### Update PurchaseOrderService
- When creating PO with `supplier_id`: auto-fill supplier_name from supplier record
- When receiving PO: call `SupplierService::updatePurchaseStats()`

---

## Migration: `2024_01_01_000059_add_missing_inventory_columns.php`

### On `products`:
```php
$table->string('brand', 100)->nullable();
$table->string('size', 20)->nullable();
$table->string('color', 20)->nullable();
$table->integer('reorder_quantity')->nullable();
$table->boolean('is_sellable')->default(true);
$table->boolean('is_purchasable')->default(true);
```

### On `kits`:
```php
$table->bigInteger('component_cost')->default(0);     -- denormalized sum of component costs
$table->string('season', 50)->nullable();
$table->boolean('is_required')->default(false);
$table->foreignId('required_for_program_id')->nullable()->constrained('training_programs');
$table->boolean('allow_individual_purchase')->default(true);
$table->date('available_from')->nullable();
$table->date('available_until')->nullable();
$table->integer('max_sold')->nullable();
$table->integer('total_sold')->default(0);
$table->text('description')->nullable();
```

### On `warehouses`:
```php
$table->boolean('is_default')->default(false);
```

### On `stock_counts`:
```php
$table->string('count_type', 20)->default('full');
$table->date('planned_date')->nullable();
$table->foreignId('approved_by')->nullable()->constrained('users');
$table->timestamp('approved_at')->nullable();
```
CHECK: `count_type IN ('full', 'partial', 'cycle', 'surprise')`
Add 'approved' to status CHECK

### On `pos_transactions`:
```php
$table->foreignId('pos_session_id')->nullable()->constrained('pos_sessions');
```

---

## ProductService (extract from Livewire)

Currently `ProductForm` Livewire component calls `Product::create()` directly. Extract to service:

```php
namespace App\Domain\Inventory\Services;

class ProductService
{
    public function create(array $data, User $actor): Product
    {
        return DB::transaction(function () use ($data, $actor) {
            $product = Product::create([...$data, 'created_by' => $actor->id]);

            if ($product->track_inventory) {
                // Auto-create inventory_level at default warehouse with qty 0
                $defaultWarehouse = Warehouse::where('academy_id', $product->academy_id)
                    ->where('is_default', true)->first();
                if ($defaultWarehouse) {
                    InventoryLevel::create([
                        'academy_id' => $product->academy_id,
                        'product_id' => $product->id,
                        'warehouse_id' => $defaultWarehouse->id,
                        'quantity_on_hand' => 0,
                        'quantity_reserved' => 0,
                        'quantity_available' => 0,
                    ]);
                }
            }

            return $product;
        });
    }

    public function update(Product $product, array $data, User $actor): Product { ... }
    public function delete(Product $product, User $actor): void { ... }
    public function toggleActive(Product $product): void { ... }
}
```

---

## Missing Livewire Components

### Supplier Management
- `Livewire/Inventory/SupplierList.php` — search, active filter, pagination
- `Livewire/Inventory/SupplierForm.php` — create/edit with validation

### Purchase Order UI
- `Livewire/Inventory/PurchaseOrderList.php` — status filter, date range, supplier filter
- `Livewire/Inventory/PurchaseOrderForm.php` — create with dynamic item rows, supplier selection
- `Livewire/Inventory/PurchaseOrderShow.php` — view details, receive items, confirm

### Kit Management
- `Livewire/Inventory/KitList.php` — search, active filter
- `Livewire/Inventory/KitForm.php` — create/edit with component picker (product + quantity)

### Stock Count UI
- `Livewire/Inventory/StockCountList.php` — status filter, date range
- `Livewire/Inventory/StockCountForm.php` — create count, select warehouse, add products
- `Livewire/Inventory/StockCountProcess.php` — enter counted quantities, see discrepancies, apply

### POS Session UI
- Add session open/close to POSTerminal (before starting sales)
- Show active session info in POS header

---

## POSTerminal Enhancement: Product & Kit Sales

Currently POSTerminal only adds programs and manual items. Must add:

```php
// In POSTerminal.php:
public function addProduct(int $productId, int $quantity = 1): void
{
    $product = Product::findOrFail($productId);
    // Validate: is_active, is_sellable
    // If track_inventory: check stock >= quantity
    // Add to cart with type = 'product', unit_price = product.selling_price
}

public function addKit(int $kitId, int $quantity = 1): void
{
    $kit = Kit::findOrFail($kitId);
    // Validate: is_active
    // Check all components have sufficient stock (via KitService::checkAvailability)
    // Add to cart with type = 'kit', unit_price = kit.selling_price
}
```

### Cart Processing in checkout():
- For products with track_inventory: create inventory_movement (type = 'sale', direction = 'out')
- For kits: create one movement PER component (type = 'kit_assembly', direction = 'out')

---

## Enum Alignment

### WarehouseType — Change values:
Current: `main, sub, transit, returns`
Doc: `warehouse, store, room, virtual`

**Decision:** Keep current values (they're more descriptive for sports context). Document the deviation.

### StockCountStatus — Add 'approved':
Current: `open, in_progress, completed, cancelled`
Add: `approved` (after manager reviews completed count)

### POSPaymentStatus — Align:
Current: `completed, refunded, partially_refunded`
Doc: `paid, pending, refunded`

**Decision:** Keep current (more granular). Add 'pending' for split payments awaiting confirmation.

---

## Routes to Add
```php
Route::middleware(['auth', 'permission:suppliers.list'])->group(function () {
    Route::get('/suppliers', SupplierList::class)->name('suppliers.index');
    Route::get('/suppliers/create', SupplierForm::class)->name('suppliers.create');
    Route::get('/suppliers/{supplier}/edit', SupplierForm::class)->name('suppliers.edit');
});

Route::middleware(['auth', 'permission:purchase_orders.list'])->group(function () {
    Route::get('/purchase-orders', PurchaseOrderList::class)->name('purchase-orders.index');
    Route::get('/purchase-orders/create', PurchaseOrderForm::class)->name('purchase-orders.create');
    Route::get('/purchase-orders/{purchaseOrder}', PurchaseOrderShow::class)->name('purchase-orders.show');
});

Route::middleware(['auth', 'permission:kits.list'])->group(function () {
    Route::get('/kits', KitList::class)->name('kits.index');
    Route::get('/kits/create', KitForm::class)->name('kits.create');
    Route::get('/kits/{kit}/edit', KitForm::class)->name('kits.edit');
});

Route::middleware(['auth', 'permission:stock_counts.list'])->group(function () {
    Route::get('/stock-counts', StockCountList::class)->name('stock-counts.index');
    Route::get('/stock-counts/create', StockCountForm::class)->name('stock-counts.create');
    Route::get('/stock-counts/{stockCount}', StockCountProcess::class)->name('stock-counts.process');
});
```

---

## Permissions to Seed
```
suppliers.list, suppliers.create, suppliers.update, suppliers.delete
purchase_orders.list, purchase_orders.create, purchase_orders.confirm, purchase_orders.receive, purchase_orders.cancel
kits.list, kits.create, kits.update, kits.delete, kits.assemble
stock_counts.list, stock_counts.create, stock_counts.process, stock_counts.approve
pos.open_session, pos.close_session
```

---

## Verification

- [ ] Supplier CRUD works, linked to purchase orders
- [ ] POS session opens before sales, closes with totals
- [ ] Product added to POS cart → stock deducted on checkout
- [ ] Kit added to POS cart → all component stocks deducted
- [ ] Purchase order received → inventory levels increase
- [ ] Stock count completed → discrepancies shown → adjustments applied
