Commit 055ddfa1 authored by Mahmoud Aglan's avatar Mahmoud Aglan

Add inventory UI (Purchase Orders, Kits, Stock Counts) + wire email to poste.io

- PurchaseOrderList: status filter, receive/cancel actions
- KitList + KitForm: CRUD with dynamic components, assemble/disassemble
- StockCountList + StockCountForm: warehouse picker, variance display
- Register routes for all new inventory components
- config/mail.php: add SSL stream options for self-signed cert bypass
- Update gap-analysis.md to mark completed items
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent 13f1bb72
<?php
namespace App\Livewire\Inventory;
use App\Domain\Inventory\Models\Kit;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Services\KitService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('الأطقم')]
class KitForm extends Component
{
public ?Kit $kit = null;
public bool $editing = false;
public string $name_ar = '';
public string $name = '';
public string $sku = '';
public string $selling_price = '';
public string $description_ar = '';
public string $assembly_instructions = '';
public bool $is_active = true;
/** @var array<int, array{product_id: string, quantity: string}> */
public array $components = [];
public function mount(?Kit $kit = null): void
{
$this->authorize('inventory.manage');
if ($kit && $kit->exists) {
$this->kit = $kit;
$this->editing = true;
$this->name_ar = $kit->name_ar ?? '';
$this->name = $kit->name ?? '';
$this->sku = $kit->sku ?? '';
$this->selling_price = $kit->selling_price ? (string) ($kit->selling_price / 100) : '';
$this->description_ar = $kit->description_ar ?? '';
$this->assembly_instructions = $kit->assembly_instructions ?? '';
$this->is_active = $kit->is_active;
$kit->load('components');
foreach ($kit->components as $component) {
$this->components[] = [
'product_id' => (string) $component->product_id,
'quantity' => (string) $component->quantity,
];
}
}
// Always have at least one empty row
if (empty($this->components)) {
$this->components[] = ['product_id' => '', 'quantity' => '1'];
}
}
public function addComponent(): void
{
$this->components[] = ['product_id' => '', 'quantity' => '1'];
}
public function removeComponent(int $index): void
{
unset($this->components[$index]);
$this->components = array_values($this->components);
// Keep at least one row
if (empty($this->components)) {
$this->components[] = ['product_id' => '', 'quantity' => '1'];
}
}
public function rules(): array
{
$uniqueSku = $this->editing
? 'unique:kits,sku,' . $this->kit->id
: 'unique:kits,sku';
return [
'name_ar' => 'required|string|max:255',
'name' => 'nullable|string|max:255',
'sku' => ['required', 'string', 'max:50', $uniqueSku],
'selling_price' => 'required|numeric|min:0',
'description_ar' => 'nullable|string|max:1000',
'assembly_instructions' => 'nullable|string|max:2000',
'is_active' => 'boolean',
'components' => 'required|array|min:1',
'components.*.product_id' => 'required|exists:products,id',
'components.*.quantity' => 'required|integer|min:1',
];
}
public function messages(): array
{
return [
'name_ar.required' => 'اسم الطقم بالعربية مطلوب',
'name_ar.max' => 'اسم الطقم يجب ألا يتجاوز 255 حرف',
'sku.required' => 'رمز الطقم (SKU) مطلوب',
'sku.unique' => 'رمز الطقم مستخدم بالفعل',
'sku.max' => 'رمز الطقم يجب ألا يتجاوز 50 حرف',
'selling_price.required' => 'سعر البيع مطلوب',
'selling_price.numeric' => 'سعر البيع يجب أن يكون رقمًا',
'selling_price.min' => 'سعر البيع يجب ألا يكون سالبًا',
'components.required' => 'يجب إضافة مكون واحد على الأقل',
'components.min' => 'يجب إضافة مكون واحد على الأقل',
'components.*.product_id.required' => 'يجب اختيار المنتج',
'components.*.product_id.exists' => 'المنتج غير موجود',
'components.*.quantity.required' => 'الكمية مطلوبة',
'components.*.quantity.integer' => 'الكمية يجب أن تكون عددًا صحيحًا',
'components.*.quantity.min' => 'الكمية يجب أن تكون 1 على الأقل',
];
}
public function save(KitService $kitService): void
{
$this->validate();
try {
$data = [
'academy_id' => app('current_academy')->id,
'name_ar' => $this->name_ar,
'name' => $this->name ?: null,
'sku' => $this->sku,
'selling_price' => (int) round((float) $this->selling_price * 100),
'description_ar' => $this->description_ar ?: null,
'assembly_instructions' => $this->assembly_instructions ?: null,
'is_active' => $this->is_active,
];
$componentData = collect($this->components)
->filter(fn ($c) => !empty($c['product_id']))
->map(fn ($c, $i) => [
'product_id' => (int) $c['product_id'],
'quantity' => (int) $c['quantity'],
'sort_order' => $i,
])
->values()
->all();
if ($this->editing) {
$kitService->update($this->kit, $data, $componentData);
session()->flash('success', __('تم تحديث الطقم بنجاح'));
} else {
$kitService->create($data, $componentData, auth()->user());
session()->flash('success', __('تم إنشاء الطقم بنجاح'));
}
$this->redirect(route('inventory.kits'), navigate: true);
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
return view('livewire.inventory.kit-form', [
'products' => Product::where('is_active', true)
->orderBy('name_ar')
->get(['id', 'name_ar', 'name', 'sku']),
]);
}
}
<?php
namespace App\Livewire\Inventory;
use App\Domain\Inventory\Models\Kit;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\KitService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('الأطقم')]
class KitList extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $activeFilter = '';
public bool $showAssembleModal = false;
public bool $showDisassembleModal = false;
public ?int $selectedKitId = null;
public ?int $selectedWarehouseId = null;
public int $assemblyQuantity = 1;
public function mount(): void
{
$this->authorize('inventory.manage');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedActiveFilter(): void
{
$this->resetPage();
}
public function toggleActive(int $kitId): void
{
$kit = Kit::findOrFail($kitId);
$kit->update(['is_active' => !$kit->is_active]);
}
public function openAssembleModal(int $kitId): void
{
$this->selectedKitId = $kitId;
$this->assemblyQuantity = 1;
$this->selectedWarehouseId = null;
$this->showAssembleModal = true;
}
public function openDisassembleModal(int $kitId): void
{
$this->selectedKitId = $kitId;
$this->assemblyQuantity = 1;
$this->selectedWarehouseId = null;
$this->showDisassembleModal = true;
}
public function assemble(KitService $kitService): void
{
$this->validate([
'selectedWarehouseId' => 'required|exists:warehouses,id',
'assemblyQuantity' => 'required|integer|min:1',
], [
'selectedWarehouseId.required' => 'يجب اختيار المستودع',
'assemblyQuantity.required' => 'الكمية مطلوبة',
'assemblyQuantity.min' => 'الكمية يجب أن تكون 1 على الأقل',
]);
try {
$kit = Kit::findOrFail($this->selectedKitId);
$warehouse = Warehouse::findOrFail($this->selectedWarehouseId);
$kitService->assemble($kit, $warehouse, $this->assemblyQuantity, auth()->user());
$this->showAssembleModal = false;
session()->flash('success', __('تم تجميع الطقم بنجاح'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
$this->showAssembleModal = false;
}
}
public function disassemble(KitService $kitService): void
{
$this->validate([
'selectedWarehouseId' => 'required|exists:warehouses,id',
'assemblyQuantity' => 'required|integer|min:1',
], [
'selectedWarehouseId.required' => 'يجب اختيار المستودع',
'assemblyQuantity.required' => 'الكمية مطلوبة',
'assemblyQuantity.min' => 'الكمية يجب أن تكون 1 على الأقل',
]);
try {
$kit = Kit::findOrFail($this->selectedKitId);
$warehouse = Warehouse::findOrFail($this->selectedWarehouseId);
$kitService->disassemble($kit, $warehouse, $this->assemblyQuantity, auth()->user());
$this->showDisassembleModal = false;
session()->flash('success', __('تم تفكيك الطقم بنجاح'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
$this->showDisassembleModal = false;
}
}
public function render()
{
$query = Kit::query()
->withCount('components')
->when($this->search, function ($q) {
$search = $this->search;
$q->where(function ($q2) use ($search) {
$q2->where('name_ar', 'ilike', "%{$search}%")
->orWhere('name', 'ilike', "%{$search}%")
->orWhere('sku', 'ilike', "%{$search}%");
});
})
->when($this->activeFilter !== '', function ($q) {
if ($this->activeFilter === '1') {
$q->where('is_active', true);
} elseif ($this->activeFilter === '0') {
$q->where('is_active', false);
}
})
->orderByDesc('created_at');
return view('livewire.inventory.kit-list', [
'kits' => $query->paginate(20),
'warehouses' => Warehouse::orderBy('name_ar')->get(['id', 'name_ar']),
]);
}
}
<?php
namespace App\Livewire\Inventory;
use App\Domain\Inventory\Enums\PurchaseOrderStatus;
use App\Domain\Inventory\Models\PurchaseOrder;
use App\Domain\Inventory\Services\PurchaseOrderService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use App\Domain\Shared\Traits\UsesBranchScope;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('أوامر الشراء')]
class PurchaseOrderList extends Component
{
use WithPagination, UsesBranchScope;
#[Url]
public string $search = '';
#[Url]
public string $status = '';
public function mount(): void
{
$this->authorize('inventory.list');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatus(): void
{
$this->resetPage();
}
public function receive(int $purchaseOrderId): void
{
try {
$po = PurchaseOrder::with('items')->findOrFail($purchaseOrderId);
// Auto-receive all remaining quantities
$receivedItems = $po->items
->filter(fn ($item) => $item->quantity_received < $item->quantity_ordered)
->map(fn ($item) => [
'item_id' => $item->id,
'quantity_received' => $item->quantity_ordered - $item->quantity_received,
])
->values()
->toArray();
if (empty($receivedItems)) {
session()->flash('error', __('جميع الأصناف مستلمة بالفعل'));
return;
}
app(PurchaseOrderService::class)->receive($po, $receivedItems, auth()->user());
session()->flash('success', __('تم استلام أمر الشراء بنجاح'));
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function cancel(int $purchaseOrderId): void
{
try {
$po = PurchaseOrder::findOrFail($purchaseOrderId);
app(PurchaseOrderService::class)->cancel($po, auth()->user());
session()->flash('success', __('تم إلغاء أمر الشراء'));
} catch (InvalidStatusTransitionException|DomainException $e) {
session()->flash('error', $e->getMessage());
}
}
public function render()
{
$branchId = $this->getActiveBranchId();
$query = PurchaseOrder::query()
->withCount('items')
->when($branchId, fn ($q) => $q->where('branch_id', $branchId))
->when($this->search, function ($q) {
$search = $this->search;
$q->where(function ($q2) use ($search) {
$q2->where('order_number', 'ilike', "%{$search}%")
->orWhere('supplier_name', 'ilike', "%{$search}%");
});
})
->when($this->status, fn ($q) => $q->where('status', $this->status))
->orderByDesc('created_at');
return view('livewire.inventory.purchase-order-list', [
'purchaseOrders' => $query->paginate(20),
'statuses' => PurchaseOrderStatus::cases(),
]);
}
}
<?php
namespace App\Livewire\Inventory;
use App\Domain\Inventory\Models\InventoryLevel;
use App\Domain\Inventory\Models\Product;
use App\Domain\Inventory\Models\StockCount;
use App\Domain\Inventory\Models\Warehouse;
use App\Domain\Inventory\Services\StockCountService;
use App\Domain\Shared\Exceptions\DomainException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Layout('layouts.app')]
#[Title('جرد مخزني جديد')]
class StockCountForm extends Component
{
public ?int $warehouse_id = null;
public ?int $stockCountId = null;
public string $notes = '';
/** @var array<int, array{product_id: int, name_ar: string, sku: string, system_quantity: int, counted_quantity: ?int}> */
public array $items = [];
public function mount(?StockCount $stockCount = null): void
{
$this->authorize('inventory.manage');
if ($stockCount && $stockCount->exists) {
$this->stockCountId = $stockCount->id;
$this->warehouse_id = $stockCount->warehouse_id;
$this->notes = $stockCount->notes ?? '';
$this->loadExistingItems($stockCount);
}
}
public function rules(): array
{
return [
'warehouse_id' => 'required|exists:warehouses,id',
'items' => 'required|array|min:1',
'items.*.counted_quantity' => 'nullable|integer|min:0',
'notes' => 'nullable|string|max:1000',
];
}
public function messages(): array
{
return [
'warehouse_id.required' => 'يجب اختيار المستودع',
'warehouse_id.exists' => 'المستودع غير موجود',
'items.required' => 'يجب وجود منتجات للجرد',
'items.min' => 'يجب وجود منتج واحد على الأقل',
'items.*.counted_quantity.integer' => 'الكمية يجب أن تكون عددًا صحيحًا',
'items.*.counted_quantity.min' => 'الكمية لا يمكن أن تكون سالبة',
'notes.max' => 'الملاحظات يجب ألا تتجاوز 1000 حرف',
];
}
public function updatedWarehouseId(): void
{
if ($this->stockCountId) {
return; // Don't reload if editing existing count
}
$this->items = [];
if (!$this->warehouse_id) {
return;
}
$this->loadWarehouseProducts();
}
private function loadWarehouseProducts(): void
{
$levels = InventoryLevel::where('warehouse_id', $this->warehouse_id)
->with('product:id,name_ar,sku,cost_price')
->get();
$this->items = $levels->map(fn ($level) => [
'product_id' => $level->product_id,
'name_ar' => $level->product->name_ar ?? '',
'sku' => $level->product->sku ?? '',
'cost_price' => $level->product->cost_price ?? 0,
'system_quantity' => $level->quantity_on_hand,
'counted_quantity' => null,
])->toArray();
}
private function loadExistingItems(StockCount $stockCount): void
{
$stockCount->load('items.product:id,name_ar,sku,cost_price');
$this->items = $stockCount->items->map(fn ($item) => [
'item_id' => $item->id,
'product_id' => $item->product_id,
'name_ar' => $item->product->name_ar ?? '',
'sku' => $item->product->sku ?? '',
'cost_price' => $item->product->cost_price ?? 0,
'system_quantity' => $item->system_quantity,
'counted_quantity' => $item->counted_quantity,
])->toArray();
}
public function save(): void
{
$this->validate();
try {
$service = app(StockCountService::class);
$user = auth()->user();
if ($this->stockCountId) {
// Continue existing count — record counts for items
$stockCount = StockCount::findOrFail($this->stockCountId);
foreach ($this->items as $item) {
if ($item['counted_quantity'] === null || $item['counted_quantity'] === '') {
continue;
}
$countItem = $stockCount->items()
->where('id', $item['item_id'] ?? 0)
->orWhere(function ($q) use ($item, $stockCount) {
$q->where('stock_count_id', $stockCount->id)
->where('product_id', $item['product_id']);
})
->first();
if ($countItem) {
$service->recordCount($countItem, (int) $item['counted_quantity'], $user);
}
}
if ($this->notes) {
$stockCount->update(['notes' => $this->notes]);
}
session()->flash('success', __('تم تحديث بيانات الجرد بنجاح'));
$this->redirect(route('inventory.stock-counts'), navigate: true);
} else {
// Create new stock count
$productIds = collect($this->items)->pluck('product_id')->toArray();
$stockCount = $service->create($this->warehouse_id, $productIds, $user);
if ($this->notes) {
$stockCount->update(['notes' => $this->notes]);
}
// Record any counts already entered
foreach ($this->items as $index => $item) {
if ($item['counted_quantity'] === null || $item['counted_quantity'] === '') {
continue;
}
$countItem = $stockCount->items()->where('product_id', $item['product_id'])->first();
if ($countItem) {
$service->recordCount($countItem, (int) $item['counted_quantity'], $user);
}
}
session()->flash('success', __('تم إنشاء جلسة الجرد بنجاح'));
$this->redirect(route('inventory.stock-counts'), navigate: true);
}
} catch (DomainException $e) {
session()->flash('error', $e->getMessage());
} catch (\Exception $e) {
session()->flash('error', __('حدث خطأ أثناء حفظ الجرد'));
}
}
public function render()
{
return view('livewire.inventory.stock-count-form', [
'warehouses' => Warehouse::active()->orderBy('name_ar')->get(['id', 'name_ar', 'code']),
]);
}
}
<?php
namespace App\Livewire\Inventory;
use App\Domain\Inventory\Enums\StockCountStatus;
use App\Domain\Inventory\Models\StockCount;
use App\Domain\Inventory\Services\StockCountService;
use App\Domain\Shared\Exceptions\DomainException;
use App\Domain\Shared\Exceptions\InvalidStatusTransitionException;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
#[Layout('layouts.app')]
#[Title('الجرد المخزني')]
class StockCountList extends Component
{
use WithPagination;
#[Url]
public string $search = '';
#[Url]
public string $statusFilter = '';
public function mount(): void
{
$this->authorize('inventory.manage');
}
public function updatedSearch(): void
{
$this->resetPage();
}
public function updatedStatusFilter(): void
{
$this->resetPage();
}
public function finalize(int $stockCountId): void
{
try {
$stockCount = StockCount::findOrFail($stockCountId);
$service = app(StockCountService::class);
$service->completeCount($stockCount, auth()->user());
session()->flash('success', __('تم إكمال الجرد بنجاح'));
} catch (DomainException|InvalidStatusTransitionException $e) {
session()->flash('error', $e->getMessage());
} catch (\Exception $e) {
session()->flash('error', __('حدث خطأ أثناء إكمال الجرد'));
}
}
public function cancel(int $stockCountId): void
{
try {
$stockCount = StockCount::findOrFail($stockCountId);
$service = app(StockCountService::class);
$service->cancel($stockCount, auth()->user());
session()->flash('success', __('تم إلغاء الجرد'));
} catch (InvalidStatusTransitionException $e) {
session()->flash('error', $e->getMessage());
} catch (\Exception $e) {
session()->flash('error', __('حدث خطأ أثناء إلغاء الجرد'));
}
}
public function render()
{
$query = StockCount::query()
->with(['warehouse', 'creator'])
->withCount('items')
->when($this->search, function ($q) {
$search = $this->search;
$q->where(function ($q2) use ($search) {
$q2->where('count_number', 'ilike', "%{$search}%")
->orWhereHas('warehouse', fn ($w) => $w->where('name_ar', 'ilike', "%{$search}%"));
});
})
->when($this->statusFilter, fn ($q) => $q->where('status', $this->statusFilter))
->orderByDesc('created_at');
return view('livewire.inventory.stock-count-list', [
'stockCounts' => $query->paginate(20),
'statuses' => StockCountStatus::cases(),
]);
}
}
...@@ -47,6 +47,13 @@ ...@@ -47,6 +47,13 @@
'password' => env('MAIL_PASSWORD'), 'password' => env('MAIL_PASSWORD'),
'timeout' => null, 'timeout' => null,
'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)), 'local_domain' => env('MAIL_EHLO_DOMAIN', parse_url((string) env('APP_URL', 'http://localhost'), PHP_URL_HOST)),
'stream' => [
'ssl' => [
'verify_peer' => env('MAIL_VERIFY_PEER', true),
'verify_peer_name' => env('MAIL_VERIFY_PEER', true),
'allow_self_signed' => !env('MAIL_VERIFY_PEER', true),
],
],
], ],
'ses' => [ 'ses' => [
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -358,6 +358,20 @@ ...@@ -358,6 +358,20 @@
->middleware('permission:inventory.adjust'); ->middleware('permission:inventory.adjust');
Route::get('/inventory/adjustments/wizard', \App\Livewire\Inventory\StockAdjustmentWizard::class)->name('inventory.adjustments.wizard') Route::get('/inventory/adjustments/wizard', \App\Livewire\Inventory\StockAdjustmentWizard::class)->name('inventory.adjustments.wizard')
->middleware('permission:inventory.adjust'); ->middleware('permission:inventory.adjust');
Route::get('/inventory/kits', \App\Livewire\Inventory\KitList::class)->name('inventory.kits')
->middleware('permission:inventory.manage');
Route::get('/inventory/kits/create', \App\Livewire\Inventory\KitForm::class)->name('inventory.kits.create')
->middleware('permission:inventory.manage');
Route::get('/inventory/kits/{kit}/edit', \App\Livewire\Inventory\KitForm::class)->name('inventory.kits.edit')
->middleware('permission:inventory.manage');
Route::get('/inventory/stock-counts', \App\Livewire\Inventory\StockCountList::class)->name('inventory.stock-counts')
->middleware('permission:inventory.manage');
Route::get('/inventory/stock-counts/create', \App\Livewire\Inventory\StockCountForm::class)->name('inventory.stock-counts.create')
->middleware('permission:inventory.manage');
Route::get('/inventory/stock-counts/{stockCount}/edit', \App\Livewire\Inventory\StockCountForm::class)->name('inventory.stock-counts.edit')
->middleware('permission:inventory.manage');
Route::get('/inventory/purchase-orders', \App\Livewire\Inventory\PurchaseOrderList::class)->name('inventory.purchase-orders')
->middleware('permission:inventory.manage');
// Settings // Settings
Route::get('/settings', \App\Livewire\Settings\AcademySettings::class)->name('settings.academy') Route::get('/settings', \App\Livewire\Settings\AcademySettings::class)->name('settings.academy')
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment