Commit 39f6a005 authored by Mahmoud Aglan's avatar Mahmoud Aglan

هىرثىفخقغ عحيشفث

parent 78d2ad89
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\AssetRegister;
use App\Modules\Inventory\Models\DepreciationEntry;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\DepreciationService;
class AssetController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'status' => trim((string) $request->get('status', '')),
'warehouse_id' => trim((string) $request->get('warehouse_id', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = AssetRegister::search($filters, 25, $page);
return $this->view('Inventory.Views.assets.index', [
'assets' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'statuses' => AssetRegister::getStatuses(),
'warehouses' => Warehouse::allActive(),
]);
}
public function show(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$asset = $db->selectOne(
"SELECT ar.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name
FROM `asset_register` ar
JOIN `inventory_items` i ON i.`id` = ar.`item_id`
JOIN `warehouses` w ON w.`id` = ar.`warehouse_id`
WHERE ar.`id` = ?",
[(int) $id]
);
if (!$asset) {
return $this->redirect('/inventory/assets')->withError('الأصل غير موجود');
}
$depreciationHistory = DepreciationEntry::getForAsset((int) $id);
return $this->view('Inventory.Views.assets.show', [
'asset' => $asset,
'depreciationHistory' => $depreciationHistory,
'methods' => AssetRegister::getDepreciationMethods(),
]);
}
public function dispose(Request $request, string $id): Response
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$asset = $db->selectOne("SELECT * FROM `asset_register` WHERE `id` = ?", [(int) $id]);
if (!$asset) {
return $this->redirect('/inventory/assets')->withError('الأصل غير موجود');
}
if ($asset['status'] !== 'active') {
return $this->redirect('/inventory/assets/' . $id)->withError('الأصل غير نشط — لا يمكن التصرف فيه');
}
$disposalValue = trim((string) $request->post('disposal_value', '0'));
$reason = trim((string) $request->post('disposal_reason', ''));
$db->update('asset_register', [
'status' => 'disposed',
'disposed_at' => date('Y-m-d'),
'disposal_value' => $disposalValue,
'disposal_reason' => $reason ?: null,
'updated_at' => date('Y-m-d H:i:s'),
'updated_by' => $employee ? (int) $employee->id : null,
], '`id` = ?', [(int) $id]);
return $this->redirect('/inventory/assets/' . $id)->withSuccess('تم تسجيل التصرف في الأصل');
}
public function runDepreciation(Request $request): Response
{
$month = trim((string) $request->post('period_month', date('Y-m')));
if (!preg_match('/^\d{4}-\d{2}$/', $month)) {
return $this->redirect('/inventory/assets')->withError('صيغة الشهر غير صحيحة');
}
$count = DepreciationService::runMonthlyDepreciation($month);
return $this->redirect('/inventory/assets')->withSuccess('تم تشغيل الإهلاك لشهر ' . $month . ' — ' . $count . ' أصل');
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\ItemCategory;
class CategoryController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'parent_id' => trim((string) $request->get('parent_id', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = ItemCategory::search($filters, 50, $page);
$tree = ItemCategory::getTree();
$roots = ItemCategory::getRoots();
return $this->view('Inventory.Views.categories.index', [
'categories' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'tree' => $tree,
'roots' => $roots,
]);
}
public function create(Request $request): Response
{
$roots = ItemCategory::getRoots();
return $this->view('Inventory.Views.categories.form', [
'category' => null,
'roots' => $roots,
]);
}
public function store(Request $request): Response
{
$data = $this->extractData($request);
$errors = $this->validate($data);
// Unique code
if ($data['code'] !== '') {
$existing = ItemCategory::query()->where('code', '=', $data['code'])->first();
if ($existing) {
$errors[] = 'كود التصنيف مستخدم بالفعل';
}
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/inventory/categories/create');
}
$category = ItemCategory::create($data);
return $this->redirect('/inventory/categories')->withSuccess('تم إضافة التصنيف بنجاح');
}
public function edit(Request $request, string $id): Response
{
$category = ItemCategory::find((int) $id);
if (!$category) {
return $this->redirect('/inventory/categories')->withError('التصنيف غير موجود');
}
$roots = ItemCategory::getRoots();
return $this->view('Inventory.Views.categories.form', [
'category' => $category,
'roots' => $roots,
]);
}
public function update(Request $request, string $id): Response
{
$category = ItemCategory::find((int) $id);
if (!$category) {
return $this->redirect('/inventory/categories')->withError('التصنيف غير موجود');
}
$data = $this->extractData($request);
$errors = $this->validate($data);
// Unique code (exclude current)
if ($data['code'] !== '') {
$existing = ItemCategory::query()
->where('code', '=', $data['code'])
->whereRaw('`id` != ?', [(int) $id])
->first();
if ($existing) {
$errors[] = 'كود التصنيف مستخدم بالفعل';
}
}
// Prevent self-parenting
if ($data['parent_id'] !== null && (int) $data['parent_id'] === (int) $id) {
$errors[] = 'لا يمكن أن يكون التصنيف أبًا لنفسه';
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/inventory/categories/' . $id . '/edit');
}
$category->update($data);
return $this->redirect('/inventory/categories')->withSuccess('تم تحديث التصنيف بنجاح');
}
private function extractData(Request $request): array
{
return [
'parent_id' => ((int) $request->post('parent_id', 0)) ?: null,
'code' => strtoupper(trim((string) $request->post('code', ''))),
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'description_ar' => trim((string) $request->post('description_ar', '')) ?: null,
'sort_order' => (int) $request->post('sort_order', 0),
'is_active' => (int) ($request->post('is_active', 1)),
];
}
private function validate(array $data): array
{
$errors = [];
if ($data['code'] === '') {
$errors[] = 'كود التصنيف مطلوب';
}
if ($data['name_ar'] === '' || mb_strlen($data['name_ar']) < 2) {
$errors[] = 'اسم التصنيف بالعربي مطلوب (حرفان على الأقل)';
}
return $errors;
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect($url);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\ItemCategory;
use App\Modules\Inventory\Models\ItemWarehouseStock;
use App\Modules\Inventory\Models\ItemBatch;
class InventoryReportController extends Controller
{
public function stockBalance(Request $request): Response
{
$warehouseId = (int) $request->get('warehouse_id', 0);
$categoryId = (int) $request->get('category_id', 0);
$db = App::getInstance()->db();
$where = 'i.`deleted_at` IS NULL';
$params = [];
if ($warehouseId > 0) {
$where .= ' AND iws.`warehouse_id` = ?';
$params[] = $warehouseId;
}
if ($categoryId > 0) {
$where .= ' AND i.`category_id` = ?';
$params[] = $categoryId;
}
$rows = $db->select(
"SELECT i.`id`, i.`sku`, i.`name_ar`, i.`unit_of_measure`, i.`cost_price`,
w.`name_ar` as warehouse_name, iws.`quantity`, iws.`min_level`, iws.`max_level`,
(i.`cost_price` * iws.`quantity`) as stock_value
FROM `item_warehouse_stock` iws
JOIN `inventory_items` i ON i.`id` = iws.`item_id`
JOIN `warehouses` w ON w.`id` = iws.`warehouse_id`
WHERE {$where}
ORDER BY i.`name_ar` ASC",
$params
);
$totalValue = array_reduce($rows, fn($carry, $r) => bcadd($carry, (string) ($r['stock_value'] ?? 0), 2), '0.00');
return $this->view('Inventory.Views.reports.stock_balance', [
'rows' => $rows,
'totalValue' => $totalValue,
'warehouses' => Warehouse::allActive(),
'categories' => ItemCategory::allActive(),
'warehouseId' => $warehouseId,
'categoryId' => $categoryId,
]);
}
public function movements(Request $request): Response
{
$db = App::getInstance()->db();
$warehouseId = (int) $request->get('warehouse_id', 0);
$dateFrom = trim((string) $request->get('date_from', date('Y-m-01')));
$dateTo = trim((string) $request->get('date_to', date('Y-m-d')));
$where = 'm.`movement_date` BETWEEN ? AND ?';
$params = [$dateFrom, $dateTo];
if ($warehouseId > 0) {
$where .= ' AND m.`warehouse_id` = ?';
$params[] = $warehouseId;
}
$summary = $db->select(
"SELECT m.`movement_type`, m.`direction`,
COUNT(*) as move_count,
SUM(m.`quantity`) as total_qty,
SUM(m.`total_cost`) as total_cost
FROM `stock_movements` m
WHERE {$where}
GROUP BY m.`movement_type`, m.`direction`
ORDER BY m.`direction`, m.`movement_type`",
$params
);
return $this->view('Inventory.Views.reports.movements', [
'summary' => $summary,
'warehouses' => Warehouse::allActive(),
'warehouseId' => $warehouseId,
'dateFrom' => $dateFrom,
'dateTo' => $dateTo,
]);
}
public function expiryAlert(Request $request): Response
{
$days = max(1, (int) $request->get('days', 30));
$nearExpiry = ItemBatch::getNearExpiry($days);
$expired = ItemBatch::getExpired();
return $this->view('Inventory.Views.reports.expiry_alert', [
'nearExpiry' => $nearExpiry,
'expired' => $expired,
'days' => $days,
]);
}
public function lowStock(Request $request): Response
{
$rows = ItemWarehouseStock::getLowStockItems();
return $this->view('Inventory.Views.reports.low_stock', [
'rows' => $rows,
]);
}
public function depreciationSchedule(Request $request): Response
{
$db = App::getInstance()->db();
$month = trim((string) $request->get('month', date('Y-m')));
$entries = $db->select(
"SELECT de.*, ar.`asset_tag`, ar.`purchase_cost`, ar.`salvage_value`, ar.`useful_life_months`,
i.`name_ar` as item_name, w.`name_ar` as warehouse_name
FROM `depreciation_entries` de
JOIN `asset_register` ar ON ar.`id` = de.`asset_id`
JOIN `inventory_items` i ON i.`id` = ar.`item_id`
JOIN `warehouses` w ON w.`id` = ar.`warehouse_id`
WHERE de.`period_month` = ?
ORDER BY ar.`asset_tag` ASC",
[$month]
);
$totalDep = array_reduce($entries, fn($c, $r) => bcadd($c, (string) ($r['depreciation_amount'] ?? 0), 2), '0.00');
return $this->view('Inventory.Views.reports.depreciation', [
'entries' => $entries,
'totalDep' => $totalDep,
'month' => $month,
]);
}
public function auditVariance(Request $request): Response
{
$db = App::getInstance()->db();
$auditId = (int) $request->get('audit_id', 0);
$audits = $db->select(
"SELECT a.`id`, a.`audit_number`, a.`audit_date`, w.`name_ar` as warehouse_name
FROM `stock_audits` a
JOIN `warehouses` w ON w.`id` = a.`warehouse_id`
WHERE a.`status` = 'approved'
ORDER BY a.`audit_date` DESC LIMIT 50"
);
$variances = [];
if ($auditId > 0) {
$variances = $db->select(
"SELECT sai.*, i.`name_ar` as item_name, i.`sku`, i.`cost_price`
FROM `stock_audit_items` sai
JOIN `inventory_items` i ON i.`id` = sai.`item_id`
WHERE sai.`audit_id` = ? AND sai.`variance` != 0
ORDER BY ABS(sai.`variance`) DESC",
[$auditId]
);
}
return $this->view('Inventory.Views.reports.audit_variance', [
'audits' => $audits,
'variances' => $variances,
'auditId' => $auditId,
]);
}
public function supplierHistory(Request $request): Response
{
$db = App::getInstance()->db();
$supplierId = (int) $request->get('supplier_id', 0);
$suppliers = $db->select(
"SELECT `id`, `name_ar`, `code` FROM `suppliers` WHERE `deleted_at` IS NULL ORDER BY `name_ar` ASC"
);
$orders = [];
if ($supplierId > 0) {
$orders = $db->select(
"SELECT po.*, w.`name_ar` as warehouse_name
FROM `purchase_orders` po
JOIN `warehouses` w ON w.`id` = po.`warehouse_id`
WHERE po.`supplier_id` = ?
ORDER BY po.`created_at` DESC LIMIT 50",
[$supplierId]
);
}
return $this->view('Inventory.Views.reports.supplier_history', [
'suppliers' => $suppliers,
'orders' => $orders,
'supplierId' => $supplierId,
]);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\InventoryItem;
use App\Modules\Inventory\Models\ItemCategory;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\ItemWarehouseStock;
use App\Modules\Inventory\Services\StockService;
class ItemController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'category_id' => trim((string) $request->get('category_id', '')),
'tracking_type' => trim((string) $request->get('tracking_type', '')),
'is_sellable' => $request->get('is_sellable', ''),
'warehouse_id' => trim((string) $request->get('warehouse_id', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = InventoryItem::search($filters, 25, $page);
return $this->view('Inventory.Views.items.index', [
'items' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'categories' => ItemCategory::allActive(),
'trackingTypes' => InventoryItem::getTrackingTypes(),
'warehouses' => Warehouse::allActive(),
]);
}
public function create(Request $request): Response
{
return $this->view('Inventory.Views.items.form', [
'item' => null,
'categories' => ItemCategory::allActive(),
'trackingTypes' => InventoryItem::getTrackingTypes(),
'units' => InventoryItem::getUnitsOfMeasure(),
]);
}
public function store(Request $request): Response
{
$data = $this->extractData($request);
$errors = $this->validate($data);
// Unique SKU
if ($data['sku'] !== '') {
$existing = InventoryItem::query()->where('sku', '=', $data['sku'])->first();
if ($existing) {
$errors[] = 'كود الصنف (SKU) مستخدم بالفعل';
}
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/inventory/items/create');
}
$item = InventoryItem::create($data);
return $this->redirect('/inventory/items/' . $item->id)->withSuccess('تم إضافة الصنف بنجاح');
}
public function show(Request $request, string $id): Response
{
$item = InventoryItem::find((int) $id);
if (!$item) {
return $this->redirect('/inventory/items')->withError('الصنف غير موجود');
}
$db = App::getInstance()->db();
// Category name
$category = $item->category_id ? ItemCategory::find((int) $item->category_id) : null;
// Stock per warehouse
$stockRecords = $db->select(
"SELECT iws.*, w.`name_ar` as warehouse_name, w.`code` as warehouse_code
FROM `item_warehouse_stock` iws
JOIN `warehouses` w ON w.`id` = iws.`warehouse_id`
WHERE iws.`item_id` = ?
ORDER BY w.`name_ar` ASC",
[(int) $id]
);
$totalStock = InventoryItem::getTotalStock((int) $id);
// Recent movements
$movements = StockService::getMovements((int) $id, null, 20);
return $this->view('Inventory.Views.items.show', [
'item' => $item,
'category' => $category,
'stockRecords' => $stockRecords,
'totalStock' => $totalStock,
'movements' => $movements,
'trackingTypes' => InventoryItem::getTrackingTypes(),
'units' => InventoryItem::getUnitsOfMeasure(),
]);
}
public function edit(Request $request, string $id): Response
{
$item = InventoryItem::find((int) $id);
if (!$item) {
return $this->redirect('/inventory/items')->withError('الصنف غير موجود');
}
return $this->view('Inventory.Views.items.form', [
'item' => $item,
'categories' => ItemCategory::allActive(),
'trackingTypes' => InventoryItem::getTrackingTypes(),
'units' => InventoryItem::getUnitsOfMeasure(),
]);
}
public function update(Request $request, string $id): Response
{
$item = InventoryItem::find((int) $id);
if (!$item) {
return $this->redirect('/inventory/items')->withError('الصنف غير موجود');
}
$data = $this->extractData($request);
$errors = $this->validate($data);
// Unique SKU (exclude current)
if ($data['sku'] !== '') {
$existing = InventoryItem::query()
->where('sku', '=', $data['sku'])
->whereRaw('`id` != ?', [(int) $id])
->first();
if ($existing) {
$errors[] = 'كود الصنف (SKU) مستخدم بالفعل';
}
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/inventory/items/' . $id . '/edit');
}
$item->update($data);
return $this->redirect('/inventory/items/' . $id)->withSuccess('تم تحديث الصنف بنجاح');
}
/**
* JSON search endpoint for AJAX item lookup (POS, transfers, etc.)
*/
public function searchJson(Request $request): Response
{
$q = trim((string) $request->get('q', ''));
if (mb_strlen($q) < 2) {
return $this->json(['results' => []]);
}
$db = App::getInstance()->db();
$search = '%' . $q . '%';
$rows = $db->select(
"SELECT `id`, `sku`, `name_ar`, `sale_price_member`, `sale_price_nonmember`, `unit_of_measure`, `tracking_type`
FROM `inventory_items`
WHERE (`name_ar` LIKE ? OR `sku` LIKE ? OR `barcode` LIKE ?)
AND `is_active` = 1 AND `deleted_at` IS NULL
ORDER BY `name_ar` ASC LIMIT 20",
[$search, $search, $search]
);
return $this->json(['results' => $rows]);
}
private function extractData(Request $request): array
{
return [
'category_id' => ((int) $request->post('category_id', 0)) ?: null,
'sku' => strtoupper(trim((string) $request->post('sku', ''))),
'barcode' => trim((string) $request->post('barcode', '')) ?: null,
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'description_ar' => trim((string) $request->post('description_ar', '')) ?: null,
'unit_of_measure' => trim((string) $request->post('unit_of_measure', 'piece')),
'tracking_type' => trim((string) $request->post('tracking_type', 'standard')),
'cost_price' => trim((string) $request->post('cost_price', '0.00')),
'sale_price_member' => trim((string) $request->post('sale_price_member', '0.00')),
'sale_price_nonmember' => trim((string) $request->post('sale_price_nonmember', '0.00')),
'sale_price_player' => trim((string) $request->post('sale_price_player', '')) ?: null,
'tax_rate' => trim((string) $request->post('tax_rate', '14.00')),
'is_sellable' => (int) ($request->post('is_sellable', 1)),
'is_active' => (int) ($request->post('is_active', 1)),
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
}
private function validate(array $data): array
{
$errors = [];
if ($data['sku'] === '') {
$errors[] = 'كود الصنف (SKU) مطلوب';
}
if ($data['name_ar'] === '' || mb_strlen($data['name_ar']) < 2) {
$errors[] = 'اسم الصنف بالعربي مطلوب (حرفان على الأقل)';
}
if (!array_key_exists($data['tracking_type'], InventoryItem::getTrackingTypes())) {
$errors[] = 'نوع التتبع غير صالح';
}
if (!array_key_exists($data['unit_of_measure'], InventoryItem::getUnitsOfMeasure())) {
$errors[] = 'وحدة القياس غير صالحة';
}
return $errors;
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect($url);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\Supplier;
use App\Modules\Inventory\Models\PurchaseOrder;
use App\Modules\Inventory\Models\PurchaseOrderItem;
use App\Modules\Inventory\Models\InventoryItem;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\PurchaseOrderService;
class PurchaseOrderController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->get('status', '')),
'supplier_id' => trim((string) $request->get('supplier_id', '')),
'warehouse_id' => trim((string) $request->get('warehouse_id', '')),
'date_from' => trim((string) $request->get('date_from', '')),
'date_to' => trim((string) $request->get('date_to', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = PurchaseOrder::search($filters, 25, $page);
return $this->view('Inventory.Views.purchase_orders.index', [
'orders' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'suppliers' => Supplier::allActive(),
'warehouses' => Warehouse::allActive(),
'statuses' => PurchaseOrder::getStatuses(),
]);
}
public function create(Request $request): Response
{
return $this->view('Inventory.Views.purchase_orders.form', [
'suppliers' => Supplier::allActive(),
'warehouses' => Warehouse::allActive(),
'items' => InventoryItem::allActive(),
]);
}
public function store(Request $request): Response
{
$header = [
'supplier_id' => (int) $request->post('supplier_id', 0),
'warehouse_id' => (int) $request->post('warehouse_id', 0),
'expected_delivery_date' => trim((string) $request->post('expected_delivery_date', '')) ?: null,
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
$itemIds = $request->post('item_ids', []);
$quantities = $request->post('quantities', []);
$unitPrices = $request->post('unit_prices', []);
$itemNotes = $request->post('item_notes', []);
if (!is_array($itemIds) || empty($itemIds)) {
return $this->redirect('/inventory/purchase-orders/create')->withError('يجب إضافة صنف واحد على الأقل');
}
$items = [];
foreach ($itemIds as $i => $itemId) {
$qty = $quantities[$i] ?? '0';
$price = $unitPrices[$i] ?? '0';
if ((int) $itemId > 0 && bccomp((string) $qty, '0', 3) > 0) {
$items[] = [
'item_id' => (int) $itemId,
'quantity_ordered' => (string) $qty,
'unit_price' => (string) $price,
'notes' => $itemNotes[$i] ?? null,
];
}
}
try {
$poId = PurchaseOrderService::createPO($header, $items);
return $this->redirect('/inventory/purchase-orders/' . $poId)->withSuccess('تم إنشاء أمر الشراء بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/purchase-orders/create')->withError($e->getMessage());
}
}
public function show(Request $request, string $id): Response
{
$po = PurchaseOrder::find((int) $id);
if (!$po) {
return $this->redirect('/inventory/purchase-orders')->withError('أمر الشراء غير موجود');
}
$items = PurchaseOrderItem::getForPO((int) $id);
$db = App::getInstance()->db();
$supplier = $db->selectOne("SELECT `name_ar`, `code`, `phone`, `email` FROM `suppliers` WHERE `id` = ?", [(int) $po->supplier_id]);
return $this->view('Inventory.Views.purchase_orders.show', [
'po' => $po,
'items' => $items,
'supplier' => $supplier,
'statuses' => PurchaseOrder::getStatuses(),
]);
}
public function submit(Request $request, string $id): Response
{
try {
PurchaseOrderService::submitPO((int) $id);
return $this->redirect('/inventory/purchase-orders/' . $id)->withSuccess('تم تقديم أمر الشراء للاعتماد');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/purchase-orders/' . $id)->withError($e->getMessage());
}
}
public function approve(Request $request, string $id): Response
{
try {
PurchaseOrderService::approvePO((int) $id);
return $this->redirect('/inventory/purchase-orders/' . $id)->withSuccess('تم اعتماد أمر الشراء');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/purchase-orders/' . $id)->withError($e->getMessage());
}
}
public function receiveForm(Request $request, string $id): Response
{
$po = PurchaseOrder::find((int) $id);
if (!$po) {
return $this->redirect('/inventory/purchase-orders')->withError('أمر الشراء غير موجود');
}
if (!in_array($po->status, ['approved', 'partially_received'], true)) {
return $this->redirect('/inventory/purchase-orders/' . $id)->withError('أمر الشراء ليس في حالة تسمح بالاستلام');
}
$items = PurchaseOrderItem::getForPO((int) $id);
return $this->view('Inventory.Views.purchase_orders.receive', [
'po' => $po,
'items' => $items,
]);
}
public function receive(Request $request, string $id): Response
{
$poItemIds = $request->post('po_item_ids', []);
$quantitiesRcvd = $request->post('quantities_received', []);
$batchNumbers = $request->post('batch_numbers', []);
$expiryDates = $request->post('expiry_dates', []);
if (!is_array($poItemIds) || empty($poItemIds)) {
return $this->redirect('/inventory/purchase-orders/' . $id . '/receive')->withError('لا توجد أصناف للاستلام');
}
$receivedItems = [];
foreach ($poItemIds as $i => $poItemId) {
$qty = $quantitiesRcvd[$i] ?? '0';
if ((int) $poItemId > 0 && bccomp((string) $qty, '0', 3) > 0) {
$receivedItems[] = [
'po_item_id' => (int) $poItemId,
'quantity_received' => (string) $qty,
'batch_number' => $batchNumbers[$i] ?? null,
'expiry_date' => $expiryDates[$i] ?? null,
];
}
}
try {
PurchaseOrderService::receivePO((int) $id, $receivedItems);
return $this->redirect('/inventory/purchase-orders/' . $id)->withSuccess('تم استلام الأصناف وتحديث الأرصدة بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/purchase-orders/' . $id . '/receive')->withError($e->getMessage());
}
}
public function cancel(Request $request, string $id): Response
{
try {
PurchaseOrderService::cancelPO((int) $id);
return $this->redirect('/inventory/purchase-orders/' . $id)->withSuccess('تم إلغاء أمر الشراء');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/purchase-orders/' . $id)->withError($e->getMessage());
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\StockAudit;
use App\Modules\Inventory\Models\StockAuditItem;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Models\ItemCategory;
use App\Modules\Inventory\Services\StockAuditService;
class StockAuditController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->get('status', '')),
'audit_type' => trim((string) $request->get('audit_type', '')),
'warehouse_id' => trim((string) $request->get('warehouse_id', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = StockAudit::search($filters, 25, $page);
return $this->view('Inventory.Views.audits.index', [
'audits' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'statuses' => StockAudit::getStatuses(),
'auditTypes' => StockAudit::getAuditTypes(),
'warehouses' => Warehouse::allActive(),
]);
}
public function create(Request $request): Response
{
return $this->view('Inventory.Views.audits.form', [
'auditTypes' => StockAudit::getAuditTypes(),
'warehouses' => Warehouse::allActive(),
'categories' => ItemCategory::allActive(),
]);
}
public function store(Request $request): Response
{
$warehouseId = (int) $request->post('warehouse_id', 0);
$auditType = trim((string) $request->post('audit_type', ''));
$categoryId = ((int) $request->post('category_id', 0)) ?: null;
$notes = trim((string) $request->post('notes', ''));
$errors = [];
if ($warehouseId <= 0) $errors[] = 'يجب اختيار المخزن';
if (!array_key_exists($auditType, StockAudit::getAuditTypes())) $errors[] = 'نوع الجرد غير صالح';
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect('/inventory/audits/create');
}
try {
$auditId = StockAuditService::createAudit($warehouseId, $auditType, $categoryId, $notes ?: null);
return $this->redirect('/inventory/audits/' . $auditId . '/count')->withSuccess('تم إنشاء الجرد — يمكنك البدء في العد');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/audits/create')->withError($e->getMessage());
}
}
public function show(Request $request, string $id): Response
{
$audit = StockAudit::find((int) $id);
if (!$audit) {
return $this->redirect('/inventory/audits')->withError('الجرد غير موجود');
}
$items = StockAuditItem::getForAudit((int) $id);
$db = App::getInstance()->db();
$warehouse = $db->selectOne("SELECT `name_ar`, `code` FROM `warehouses` WHERE `id` = ?", [(int) $audit->warehouse_id]);
return $this->view('Inventory.Views.audits.show', [
'audit' => $audit,
'items' => $items,
'warehouse' => $warehouse,
]);
}
public function count(Request $request, string $id): Response
{
$audit = StockAudit::find((int) $id);
if (!$audit) {
return $this->redirect('/inventory/audits')->withError('الجرد غير موجود');
}
if (!in_array($audit->status, ['in_progress', 'draft'], true)) {
return $this->redirect('/inventory/audits/' . $id)->withError('لا يمكن إدخال العد في هذه الحالة');
}
$items = StockAuditItem::getForAudit((int) $id);
return $this->view('Inventory.Views.audits.count', [
'audit' => $audit,
'items' => $items,
]);
}
public function saveCount(Request $request, string $id): Response
{
$audit = StockAudit::find((int) $id);
if (!$audit) {
return $this->redirect('/inventory/audits')->withError('الجرد غير موجود');
}
$physicalQtys = $request->post('physical_quantities', []);
$counts = [];
foreach ($physicalQtys as $auditItemId => $qty) {
if ($qty !== '' && $qty !== null) {
$counts[(int) $auditItemId] = (string) $qty;
}
}
if (empty($counts)) {
return $this->redirect('/inventory/audits/' . $id . '/count')->withError('يجب إدخال كمية واحدة على الأقل');
}
StockAuditService::saveCounts((int) $id, $counts);
return $this->redirect('/inventory/audits/' . $id)->withSuccess('تم حفظ العد — في انتظار الاعتماد');
}
public function approve(Request $request, string $id): Response
{
try {
StockAuditService::approveAudit((int) $id);
return $this->redirect('/inventory/audits/' . $id)->withSuccess('تم اعتماد الجرد وتنفيذ التسويات');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/audits/' . $id)->withError($e->getMessage());
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\StockMovement;
use App\Modules\Inventory\Models\InventoryItem;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\StockService;
use App\Modules\Inventory\Services\BatchService;
class StockMovementController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'warehouse_id' => trim((string) $request->get('warehouse_id', '')),
'movement_type' => trim((string) $request->get('movement_type', '')),
'direction' => trim((string) $request->get('direction', '')),
'date_from' => trim((string) $request->get('date_from', '')),
'date_to' => trim((string) $request->get('date_to', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = StockMovement::search($filters, 25, $page);
return $this->view('Inventory.Views.stock.movements', [
'movements' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'warehouses' => Warehouse::allActive(),
'movementTypes' => StockMovement::getMovementTypes(),
]);
}
public function create(Request $request): Response
{
return $this->view('Inventory.Views.stock.movement_form', [
'warehouses' => Warehouse::allActive(),
'items' => InventoryItem::allActive(),
]);
}
public function store(Request $request): Response
{
$itemId = (int) $request->post('item_id', 0);
$warehouseId = (int) $request->post('warehouse_id', 0);
$direction = trim((string) $request->post('direction', ''));
$moveType = trim((string) $request->post('movement_type', ''));
$quantity = trim((string) $request->post('quantity', ''));
$unitCost = trim((string) $request->post('unit_cost', ''));
$notes = trim((string) $request->post('notes', ''));
$moveDate = trim((string) $request->post('movement_date', date('Y-m-d')));
// Batch fields for expiry items
$batchNumber = trim((string) $request->post('batch_number', ''));
$expiryDate = trim((string) $request->post('expiry_date', ''));
$errors = [];
if ($itemId <= 0) $errors[] = 'يجب اختيار الصنف';
if ($warehouseId <= 0) $errors[] = 'يجب اختيار المخزن';
if (!in_array($direction, ['in', 'out'], true)) $errors[] = 'اتجاه الحركة غير صالح';
if (bccomp($quantity, '0', 3) <= 0) $errors[] = 'الكمية يجب أن تكون أكبر من صفر';
if (!empty($errors)) {
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect('/inventory/movements/create');
}
try {
$batchId = null;
// Check if the item is expiry-tracked and direction is IN — create a batch
$db = App::getInstance()->db();
$item = $db->selectOne(
"SELECT `tracking_type` FROM `inventory_items` WHERE `id` = ?",
[$itemId]
);
if ($item && $item['tracking_type'] === 'expiry' && $direction === 'in') {
if ($batchNumber === '' || $expiryDate === '') {
$session = App::getInstance()->session();
$session->flash('_alerts', [['type' => 'error', 'message' => 'رقم الدفعة وتاريخ الصلاحية مطلوبان للأصناف ذات تاريخ انتهاء']]);
$session->flash('_old_input', $request->all());
return $this->redirect('/inventory/movements/create');
}
$batchId = BatchService::createBatch([
'item_id' => $itemId,
'warehouse_id' => $warehouseId,
'batch_number' => $batchNumber,
'expiry_date' => $expiryDate,
'quantity' => $quantity,
'unit_cost' => $unitCost ?: null,
'received_date' => $moveDate,
]);
}
// For expiry OUT, use FEFO allocation
if ($item && $item['tracking_type'] === 'expiry' && $direction === 'out') {
$allocations = BatchService::allocateBatch($itemId, $warehouseId, $quantity);
foreach ($allocations as $alloc) {
StockService::moveStock([
'item_id' => $itemId,
'warehouse_id' => $warehouseId,
'movement_type' => $moveType,
'direction' => 'out',
'quantity' => $alloc['quantity'],
'unit_cost' => $unitCost ?: null,
'batch_id' => $alloc['batch_id'],
'notes' => $notes,
'movement_date' => $moveDate,
]);
}
return $this->redirect('/inventory/movements')->withSuccess('تم تسجيل الحركة بنجاح');
}
StockService::moveStock([
'item_id' => $itemId,
'warehouse_id' => $warehouseId,
'movement_type' => $moveType,
'direction' => $direction,
'quantity' => $quantity,
'unit_cost' => $unitCost ?: null,
'batch_id' => $batchId,
'notes' => $notes,
'movement_date' => $moveDate,
]);
return $this->redirect('/inventory/movements')->withSuccess('تم تسجيل الحركة بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/movements/create')->withError($e->getMessage());
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\StockTransfer;
use App\Modules\Inventory\Models\StockTransferItem;
use App\Modules\Inventory\Models\InventoryItem;
use App\Modules\Inventory\Models\Warehouse;
use App\Modules\Inventory\Services\StockTransferService;
class StockTransferController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'status' => trim((string) $request->get('status', '')),
'from_warehouse_id' => trim((string) $request->get('from_warehouse_id', '')),
'to_warehouse_id' => trim((string) $request->get('to_warehouse_id', '')),
'date_from' => trim((string) $request->get('date_from', '')),
'date_to' => trim((string) $request->get('date_to', '')),
];
$page = max(1, (int) $request->get('page', 1));
$result = StockTransfer::search($filters, 25, $page);
return $this->view('Inventory.Views.stock.transfers', [
'transfers' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'warehouses' => Warehouse::allActive(),
'statuses' => StockTransfer::getStatuses(),
]);
}
public function create(Request $request): Response
{
return $this->view('Inventory.Views.stock.transfer_form', [
'warehouses' => Warehouse::allActive(),
'items' => InventoryItem::allActive(),
]);
}
public function store(Request $request): Response
{
$fromWarehouse = (int) $request->post('from_warehouse_id', 0);
$toWarehouse = (int) $request->post('to_warehouse_id', 0);
$transferDate = trim((string) $request->post('transfer_date', date('Y-m-d')));
$notes = trim((string) $request->post('notes', ''));
$itemIds = $request->post('item_ids', []);
$quantities = $request->post('quantities', []);
if (!is_array($itemIds) || empty($itemIds)) {
return $this->redirect('/inventory/transfers/create')->withError('يجب إضافة صنف واحد على الأقل');
}
$items = [];
foreach ($itemIds as $i => $itemId) {
$qty = $quantities[$i] ?? '0';
if ((int) $itemId > 0 && bccomp((string) $qty, '0', 3) > 0) {
$items[] = [
'item_id' => (int) $itemId,
'quantity' => (string) $qty,
];
}
}
try {
$transferId = StockTransferService::createTransfer([
'from_warehouse_id' => $fromWarehouse,
'to_warehouse_id' => $toWarehouse,
'transfer_date' => $transferDate,
'notes' => $notes,
], $items);
return $this->redirect('/inventory/transfers/' . $transferId)->withSuccess('تم إنشاء طلب التحويل بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/transfers/create')->withError($e->getMessage());
}
}
public function show(Request $request, string $id): Response
{
$transfer = StockTransfer::find((int) $id);
if (!$transfer) {
return $this->redirect('/inventory/transfers')->withError('التحويل غير موجود');
}
$items = StockTransferItem::getForTransfer((int) $id);
$db = App::getInstance()->db();
$fromWarehouse = $db->selectOne("SELECT `name_ar`, `code` FROM `warehouses` WHERE `id` = ?", [(int) $transfer->from_warehouse_id]);
$toWarehouse = $db->selectOne("SELECT `name_ar`, `code` FROM `warehouses` WHERE `id` = ?", [(int) $transfer->to_warehouse_id]);
return $this->view('Inventory.Views.stock.transfer_show', [
'transfer' => $transfer,
'items' => $items,
'fromWarehouse' => $fromWarehouse,
'toWarehouse' => $toWarehouse,
'statuses' => StockTransfer::getStatuses(),
]);
}
public function approve(Request $request, string $id): Response
{
try {
StockTransferService::approve((int) $id);
return $this->redirect('/inventory/transfers/' . $id)->withSuccess('تمت الموافقة على التحويل');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/transfers/' . $id)->withError($e->getMessage());
}
}
public function receive(Request $request, string $id): Response
{
try {
StockTransferService::receive((int) $id);
return $this->redirect('/inventory/transfers/' . $id)->withSuccess('تم استلام التحويل وتحديث الأرصدة بنجاح');
} catch (\RuntimeException $e) {
return $this->redirect('/inventory/transfers/' . $id)->withError($e->getMessage());
}
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\Supplier;
class SupplierController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'is_active' => $request->get('is_active', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Supplier::search($filters, 25, $page);
return $this->view('Inventory.Views.suppliers.index', [
'suppliers' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
]);
}
public function create(Request $request): Response
{
return $this->view('Inventory.Views.suppliers.form', [
'supplier' => null,
]);
}
public function store(Request $request): Response
{
$data = $this->extractData($request);
$errors = $this->validateSupplier($data);
// Unique code
if ($data['code'] !== '') {
$existing = Supplier::query()->where('code', '=', $data['code'])->first();
if ($existing) {
$errors[] = 'كود المورد مستخدم بالفعل';
}
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/inventory/suppliers/create');
}
$supplier = Supplier::create($data);
return $this->redirect('/inventory/suppliers/' . $supplier->id)->withSuccess('تم إضافة المورد بنجاح');
}
public function show(Request $request, string $id): Response
{
$supplier = Supplier::find((int) $id);
if (!$supplier) {
return $this->redirect('/inventory/suppliers')->withError('المورد غير موجود');
}
$db = App::getInstance()->db();
$recentPOs = $db->select(
"SELECT * FROM `purchase_orders` WHERE `supplier_id` = ? ORDER BY `created_at` DESC LIMIT 10",
[(int) $id]
);
return $this->view('Inventory.Views.suppliers.show', [
'supplier' => $supplier,
'recentPOs' => $recentPOs,
]);
}
public function edit(Request $request, string $id): Response
{
$supplier = Supplier::find((int) $id);
if (!$supplier) {
return $this->redirect('/inventory/suppliers')->withError('المورد غير موجود');
}
return $this->view('Inventory.Views.suppliers.form', [
'supplier' => $supplier,
]);
}
public function update(Request $request, string $id): Response
{
$supplier = Supplier::find((int) $id);
if (!$supplier) {
return $this->redirect('/inventory/suppliers')->withError('المورد غير موجود');
}
$data = $this->extractData($request);
$errors = $this->validateSupplier($data);
// Unique code (exclude current)
if ($data['code'] !== '') {
$existing = Supplier::query()
->where('code', '=', $data['code'])
->whereRaw('`id` != ?', [(int) $id])
->first();
if ($existing) {
$errors[] = 'كود المورد مستخدم بالفعل';
}
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/inventory/suppliers/' . $id . '/edit');
}
$supplier->update($data);
return $this->redirect('/inventory/suppliers/' . $id)->withSuccess('تم تحديث بيانات المورد بنجاح');
}
private function extractData(Request $request): array
{
return [
'code' => strtoupper(trim((string) $request->post('code', ''))),
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'contact_person' => trim((string) $request->post('contact_person', '')) ?: null,
'phone' => trim((string) $request->post('phone', '')) ?: null,
'email' => trim((string) $request->post('email', '')) ?: null,
'address_ar' => trim((string) $request->post('address_ar', '')) ?: null,
'tax_number' => trim((string) $request->post('tax_number', '')) ?: null,
'payment_terms' => trim((string) $request->post('payment_terms', '')) ?: null,
'rating' => ((int) $request->post('rating', 0)) ?: null,
'is_active' => (int) ($request->post('is_active', 1)),
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
}
private function validateSupplier(array $data): array
{
$errors = [];
if ($data['code'] === '') {
$errors[] = 'كود المورد مطلوب';
}
if ($data['name_ar'] === '' || mb_strlen($data['name_ar']) < 2) {
$errors[] = 'اسم المورد بالعربي مطلوب (حرفان على الأقل)';
}
return $errors;
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect($url);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Controllers;
use App\Core\Controller;
use App\Core\Request;
use App\Core\Response;
use App\Core\App;
use App\Modules\Inventory\Models\Warehouse;
class WarehouseController extends Controller
{
public function index(Request $request): Response
{
$filters = [
'q' => trim((string) $request->get('q', '')),
'warehouse_type' => trim((string) $request->get('warehouse_type', '')),
'is_active' => $request->get('is_active', ''),
];
$page = max(1, (int) $request->get('page', 1));
$result = Warehouse::search($filters, 25, $page);
return $this->view('Inventory.Views.warehouses.index', [
'warehouses' => $result['data'],
'pagination' => $result['pagination'],
'filters' => $filters,
'warehouseTypes' => Warehouse::getTypes(),
]);
}
public function create(Request $request): Response
{
$db = App::getInstance()->db();
$employees = $db->select(
"SELECT `id`, `full_name_ar` FROM `employees` WHERE `is_active` = 1 ORDER BY `full_name_ar` ASC"
);
return $this->view('Inventory.Views.warehouses.form', [
'warehouse' => null,
'warehouseTypes' => Warehouse::getTypes(),
'employees' => $employees,
]);
}
public function store(Request $request): Response
{
$data = $this->extractData($request);
$errors = $this->validate($data);
// Unique code
if ($data['code'] !== '') {
$existing = Warehouse::query()->where('code', '=', $data['code'])->first();
if ($existing) {
$errors[] = 'كود المخزن مستخدم بالفعل';
}
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/inventory/warehouses/create');
}
$warehouse = Warehouse::create($data);
return $this->redirect('/inventory/warehouses/' . $warehouse->id)->withSuccess('تم إضافة المخزن بنجاح');
}
public function show(Request $request, string $id): Response
{
$warehouse = Warehouse::find((int) $id);
if (!$warehouse) {
return $this->redirect('/inventory/warehouses')->withError('المخزن غير موجود');
}
$db = App::getInstance()->db();
$keeper = $warehouse->keeper_employee_id
? $db->selectOne("SELECT `id`, `full_name_ar` FROM `employees` WHERE `id` = ?", [(int) $warehouse->keeper_employee_id])
: null;
// Stock summary in this warehouse
$stockSummary = $db->select(
"SELECT iws.*, i.`name_ar`, i.`sku`, i.`unit_of_measure`
FROM `item_warehouse_stock` iws
JOIN `inventory_items` i ON i.`id` = iws.`item_id`
WHERE iws.`warehouse_id` = ?
ORDER BY i.`name_ar` ASC",
[(int) $id]
);
return $this->view('Inventory.Views.warehouses.show', [
'warehouse' => $warehouse,
'keeper' => $keeper,
'stockSummary' => $stockSummary,
'warehouseTypes' => Warehouse::getTypes(),
]);
}
public function edit(Request $request, string $id): Response
{
$warehouse = Warehouse::find((int) $id);
if (!$warehouse) {
return $this->redirect('/inventory/warehouses')->withError('المخزن غير موجود');
}
$db = App::getInstance()->db();
$employees = $db->select(
"SELECT `id`, `full_name_ar` FROM `employees` WHERE `is_active` = 1 ORDER BY `full_name_ar` ASC"
);
return $this->view('Inventory.Views.warehouses.form', [
'warehouse' => $warehouse,
'warehouseTypes' => Warehouse::getTypes(),
'employees' => $employees,
]);
}
public function update(Request $request, string $id): Response
{
$warehouse = Warehouse::find((int) $id);
if (!$warehouse) {
return $this->redirect('/inventory/warehouses')->withError('المخزن غير موجود');
}
$data = $this->extractData($request);
$errors = $this->validate($data);
// Unique code (exclude current)
if ($data['code'] !== '') {
$existing = Warehouse::query()
->where('code', '=', $data['code'])
->whereRaw('`id` != ?', [(int) $id])
->first();
if ($existing) {
$errors[] = 'كود المخزن مستخدم بالفعل';
}
}
if (!empty($errors)) {
return $this->flashErrorsAndRedirect($errors, $request, '/inventory/warehouses/' . $id . '/edit');
}
$warehouse->update($data);
return $this->redirect('/inventory/warehouses/' . $id)->withSuccess('تم تحديث المخزن بنجاح');
}
private function extractData(Request $request): array
{
return [
'code' => strtoupper(trim((string) $request->post('code', ''))),
'name_ar' => trim((string) $request->post('name_ar', '')),
'name_en' => trim((string) $request->post('name_en', '')) ?: null,
'warehouse_type' => trim((string) $request->post('warehouse_type', 'general')),
'location_ar' => trim((string) $request->post('location_ar', '')) ?: null,
'keeper_employee_id' => ((int) $request->post('keeper_employee_id', 0)) ?: null,
'capacity_units' => ((int) $request->post('capacity_units', 0)) ?: null,
'phone' => trim((string) $request->post('phone', '')) ?: null,
'is_active' => (int) ($request->post('is_active', 1)),
'notes' => trim((string) $request->post('notes', '')) ?: null,
];
}
private function validate(array $data): array
{
$errors = [];
if ($data['code'] === '') {
$errors[] = 'كود المخزن مطلوب';
}
if ($data['name_ar'] === '' || mb_strlen($data['name_ar']) < 2) {
$errors[] = 'اسم المخزن بالعربي مطلوب (حرفان على الأقل)';
}
if (!array_key_exists($data['warehouse_type'], Warehouse::getTypes())) {
$errors[] = 'نوع المخزن غير صالح';
}
return $errors;
}
private function flashErrorsAndRedirect(array $errors, Request $request, string $url): Response
{
$session = App::getInstance()->session();
$session->flash('_alerts', array_map(fn($e) => ['type' => 'error', 'message' => $e], $errors));
$session->flash('_old_input', $request->all());
return $this->redirect($url);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class AssetRegister extends Model
{
protected static string $table = 'asset_register';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'item_id',
'warehouse_id',
'asset_tag',
'serial_number',
'purchase_date',
'purchase_cost',
'useful_life_months',
'salvage_value',
'depreciation_method',
'declining_rate',
'accumulated_depreciation',
'book_value',
'status',
'disposed_at',
'disposal_value',
'disposal_reason',
'condition_notes',
'last_depreciation_date',
];
public static function getStatuses(): array
{
return [
'active' => 'نشط',
'disposed' => 'تم التصرف',
'transferred' => 'محوّل',
'under_maintenance' => 'تحت الصيانة',
];
}
public static function getDepreciationMethods(): array
{
return [
'straight_line' => 'القسط الثابت',
'declining_balance' => 'القسط المتناقص',
];
}
public static function getActiveAssets(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT ar.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name
FROM `asset_register` ar
JOIN `inventory_items` i ON i.`id` = ar.`item_id`
JOIN `warehouses` w ON w.`id` = ar.`warehouse_id`
WHERE ar.`status` = 'active'
ORDER BY ar.`asset_tag` ASC"
);
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$where .= ' AND (ar.`asset_tag` LIKE ? OR i.`name_ar` LIKE ? OR ar.`serial_number` LIKE ?)';
$params[] = $search;
$params[] = $search;
$params[] = $search;
}
if (!empty($filters['status'])) {
$where .= ' AND ar.`status` = ?';
$params[] = $filters['status'];
}
if (!empty($filters['warehouse_id'])) {
$where .= ' AND ar.`warehouse_id` = ?';
$params[] = (int) $filters['warehouse_id'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `asset_register` ar JOIN `inventory_items` i ON i.`id` = ar.`item_id` WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT ar.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name
FROM `asset_register` ar
JOIN `inventory_items` i ON i.`id` = ar.`item_id`
JOIN `warehouses` w ON w.`id` = ar.`warehouse_id`
WHERE {$where}
ORDER BY ar.`asset_tag` ASC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
class DepreciationEntry extends Model
{
protected static string $table = 'depreciation_entries';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'asset_id',
'period_month',
'depreciation_amount',
'accumulated_total',
'book_value_after',
'method_used',
'is_manual',
'notes',
'created_by',
];
public static function getForAsset(int $assetId): array
{
return static::query()
->where('asset_id', '=', $assetId)
->orderBy('period_month', 'DESC')
->get();
}
public static function getForPeriod(string $periodMonth): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT de.*, ar.`asset_tag`, i.`name_ar` as item_name
FROM `depreciation_entries` de
JOIN `asset_register` ar ON ar.`id` = de.`asset_id`
JOIN `inventory_items` i ON i.`id` = ar.`item_id`
WHERE de.`period_month` = ?
ORDER BY ar.`asset_tag` ASC",
[$periodMonth]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
class InventoryItem extends Model
{
protected static string $table = 'inventory_items';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'category_id',
'sku',
'barcode',
'name_ar',
'name_en',
'description_ar',
'unit_of_measure',
'tracking_type',
'cost_price',
'sale_price_member',
'sale_price_nonmember',
'sale_price_player',
'tax_rate',
'is_sellable',
'is_active',
'image_document_id',
'notes',
];
/**
* Get all tracking types with Arabic labels.
*/
public static function getTrackingTypes(): array
{
return [
'standard' => 'قياسي',
'expiry' => 'بتاريخ صلاحية',
'asset' => 'أصل ثابت',
];
}
/**
* Get the Arabic label for a tracking type.
*/
public static function getTrackingTypeLabel(string $type): string
{
$types = self::getTrackingTypes();
return $types[$type] ?? $type;
}
/**
* Get all units of measure with Arabic labels.
*/
public static function getUnitsOfMeasure(): array
{
return [
'piece' => 'قطعة',
'kg' => 'كيلوجرام',
'liter' => 'لتر',
'box' => 'صندوق',
'pack' => 'عبوة',
'meter' => 'متر',
'set' => 'طقم',
];
}
/**
* Get the Arabic label for a unit of measure.
*/
public static function getUnitLabel(string $unit): string
{
$units = self::getUnitsOfMeasure();
return $units[$unit] ?? $unit;
}
/**
* Get all active items ordered by name_ar.
*/
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Get all sellable and active items ordered by name_ar.
*/
public static function allSellable(): array
{
return static::query()
->where('is_sellable', '=', 1)
->where('is_active', '=', 1)
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Search items with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
$join = '';
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$where .= ' AND (i.`name_ar` LIKE ? OR i.`sku` LIKE ? OR i.`barcode` LIKE ?)';
$params[] = $search;
$params[] = $search;
$params[] = $search;
}
if (!empty($filters['category_id'])) {
$where .= ' AND i.`category_id` = ?';
$params[] = (int) $filters['category_id'];
}
if (!empty($filters['tracking_type'])) {
$where .= ' AND i.`tracking_type` = ?';
$params[] = $filters['tracking_type'];
}
if (isset($filters['is_sellable']) && $filters['is_sellable'] !== '') {
$where .= ' AND i.`is_sellable` = ?';
$params[] = (int) $filters['is_sellable'];
}
if (!empty($filters['warehouse_id'])) {
$join = ' JOIN `item_warehouse_stock` iws ON iws.`item_id` = i.`id`';
$where .= ' AND iws.`warehouse_id` = ?';
$params[] = (int) $filters['warehouse_id'];
}
$where .= ' AND i.`deleted_at` IS NULL';
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `inventory_items` i{$join} WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT i.* FROM `inventory_items` i{$join} WHERE {$where} ORDER BY i.`name_ar` ASC LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => \App\Core\Pagination::paginate($total, $perPage, $page)];
}
/**
* Get the current stock quantity for an item in a specific warehouse.
*/
public static function getStockInWarehouse(int $itemId, int $warehouseId): float
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT `quantity` FROM `item_warehouse_stock` WHERE `item_id` = ? AND `warehouse_id` = ?",
[$itemId, $warehouseId]
);
return (float) ($row['quantity'] ?? 0);
}
/**
* Get the total stock quantity for an item across all warehouses.
*/
public static function getTotalStock(int $itemId): float
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT COALESCE(SUM(`quantity`), 0) as total FROM `item_warehouse_stock` WHERE `item_id` = ?",
[$itemId]
);
return (float) ($row['total'] ?? 0);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
class ItemBatch extends Model
{
protected static string $table = 'item_batches';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'item_id',
'warehouse_id',
'batch_number',
'expiry_date',
'quantity',
'initial_quantity',
'unit_cost',
'received_date',
'status',
'notes',
'created_by',
];
public static function getStatuses(): array
{
return [
'active' => 'نشط',
'expired' => 'منتهي',
'depleted' => 'مستنفد',
];
}
/**
* Get active batches for an item in a warehouse, ordered by expiry (FEFO).
*/
public static function getActiveBatches(int $itemId, int $warehouseId): array
{
return static::query()
->where('item_id', '=', $itemId)
->where('warehouse_id', '=', $warehouseId)
->where('status', '=', 'active')
->whereRaw('`quantity` > 0')
->orderBy('expiry_date', 'ASC')
->get();
}
/**
* Get near-expiry batches (within given days).
*/
public static function getNearExpiry(int $days = 30): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT b.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name
FROM `item_batches` b
JOIN `inventory_items` i ON i.`id` = b.`item_id`
JOIN `warehouses` w ON w.`id` = b.`warehouse_id`
WHERE b.`status` = 'active'
AND b.`quantity` > 0
AND b.`expiry_date` BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL ? DAY)
ORDER BY b.`expiry_date` ASC",
[$days]
);
}
/**
* Get already expired batches that are still active.
*/
public static function getExpired(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT b.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name
FROM `item_batches` b
JOIN `inventory_items` i ON i.`id` = b.`item_id`
JOIN `warehouses` w ON w.`id` = b.`warehouse_id`
WHERE b.`status` = 'active'
AND b.`quantity` > 0
AND b.`expiry_date` < CURDATE()
ORDER BY b.`expiry_date` ASC"
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
class ItemCategory extends Model
{
protected static string $table = 'item_categories';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'parent_id',
'code',
'name_ar',
'name_en',
'description_ar',
'sort_order',
'is_active',
];
/**
* Get all active categories ordered by sort_order.
*/
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('sort_order', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Get root categories (where parent_id IS NULL).
*/
public static function getRoots(): array
{
return static::query()
->whereRaw('`parent_id` IS NULL')
->orderBy('sort_order', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Get children of a given parent category.
*/
public static function getChildren(int $parentId): array
{
return static::query()
->where('parent_id', '=', $parentId)
->orderBy('sort_order', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Get full category tree (roots with nested 'children' key).
*/
public static function getTree(): array
{
$all = static::query()
->orderBy('sort_order', 'ASC')
->orderBy('name_ar', 'ASC')
->get();
$grouped = [];
foreach ($all as $cat) {
$parentId = $cat['parent_id'] ?? null;
$grouped[$parentId ?? 0][] = $cat;
}
$roots = $grouped[0] ?? [];
foreach ($roots as &$root) {
$root['children'] = $grouped[$root['id']] ?? [];
}
return $roots;
}
/**
* Search categories with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query();
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$query = $query->whereRaw('(`name_ar` LIKE ?)', [$search]);
}
if (!empty($filters['parent_id'])) {
$query = $query->where('parent_id', '=', (int) $filters['parent_id']);
}
$query = $query->orderBy('sort_order', 'ASC')->orderBy('name_ar', 'ASC');
return $query->paginate($perPage, $page);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
class ItemWarehouseStock extends Model
{
protected static string $table = 'item_warehouse_stock';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'item_id',
'warehouse_id',
'quantity',
'min_level',
'max_level',
'last_counted_at',
];
/**
* Get stock records for a specific item across all warehouses.
*/
public static function getForItem(int $itemId): array
{
return static::query()
->where('item_id', '=', $itemId)
->orderBy('warehouse_id', 'ASC')
->get();
}
/**
* Get all item stock records for a specific warehouse.
*/
public static function getForWarehouse(int $warehouseId): array
{
return static::query()
->where('warehouse_id', '=', $warehouseId)
->orderBy('item_id', 'ASC')
->get();
}
/**
* Get the quantity for a specific item in a specific warehouse.
*/
public static function getQuantity(int $itemId, int $warehouseId): float
{
$db = App::getInstance()->db();
$row = $db->selectOne(
"SELECT `quantity` FROM `item_warehouse_stock` WHERE `item_id` = ? AND `warehouse_id` = ?",
[$itemId, $warehouseId]
);
return (float) ($row['quantity'] ?? 0);
}
/**
* Upsert stock: adjust quantity by direction ('in' adds, 'out' subtracts).
* If no row exists for the item/warehouse pair, a new row is inserted.
*/
public static function upsertStock(int $itemId, int $warehouseId, string $quantityChange, string $direction): void
{
$db = App::getInstance()->db();
$existing = $db->selectOne(
"SELECT `id` FROM `item_warehouse_stock` WHERE `item_id` = ? AND `warehouse_id` = ?",
[$itemId, $warehouseId]
);
if ($existing) {
$operator = $direction === 'in' ? '+' : '-';
$db->statement(
"UPDATE `item_warehouse_stock` SET `quantity` = `quantity` {$operator} ?, `updated_at` = NOW() WHERE `item_id` = ? AND `warehouse_id` = ?",
[$quantityChange, $itemId, $warehouseId]
);
} else {
$qty = $direction === 'in' ? $quantityChange : '-' . $quantityChange;
$db->insert('item_warehouse_stock', [
'item_id' => $itemId,
'warehouse_id' => $warehouseId,
'quantity' => $qty,
'updated_at' => date('Y-m-d H:i:s'),
]);
}
}
/**
* Get items where current quantity is at or below the minimum level.
*/
public static function getLowStockItems(): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT iws.*, i.`name_ar`, i.`sku`, w.`name_ar` as warehouse_name
FROM `item_warehouse_stock` iws
JOIN `inventory_items` i ON i.`id` = iws.`item_id`
JOIN `warehouses` w ON w.`id` = iws.`warehouse_id`
WHERE iws.`quantity` <= iws.`min_level` AND iws.`min_level` IS NOT NULL
ORDER BY (iws.`quantity` - iws.`min_level`) ASC"
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class PurchaseOrder extends Model
{
protected static string $table = 'purchase_orders';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'po_number',
'supplier_id',
'warehouse_id',
'status',
'total_amount',
'tax_amount',
'grand_total',
'ordered_by',
'approved_by',
'approved_at',
'expected_delivery_date',
'received_at',
'notes',
];
/**
* Get all PO statuses with Arabic labels.
*/
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'submitted' => 'مُقدّم',
'approved' => 'معتمد',
'partially_received' => 'استلام جزئي',
'received' => 'تم الاستلام',
'cancelled' => 'ملغي',
];
}
/**
* Get the Arabic label for a status.
*/
public static function getStatusLabel(string $status): string
{
$statuses = self::getStatuses();
return $statuses[$status] ?? $status;
}
/**
* Get the color for a status.
*/
public static function getStatusColor(string $status): string
{
return match ($status) {
'draft' => '#6B7280',
'submitted' => '#D97706',
'approved' => '#0D7377',
'partially_received' => '#2563EB',
'received' => '#059669',
'cancelled' => '#DC2626',
default => '#6B7280',
};
}
/**
* Search purchase orders with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND po.`status` = ?';
$params[] = $filters['status'];
}
if (!empty($filters['supplier_id'])) {
$where .= ' AND po.`supplier_id` = ?';
$params[] = (int) $filters['supplier_id'];
}
if (!empty($filters['warehouse_id'])) {
$where .= ' AND po.`warehouse_id` = ?';
$params[] = (int) $filters['warehouse_id'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND po.`created_at` >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND po.`created_at` <= ?';
$params[] = $filters['date_to'] . ' 23:59:59';
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `purchase_orders` po WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT po.*, s.`name_ar` as supplier_name, w.`name_ar` as warehouse_name
FROM `purchase_orders` po
JOIN `suppliers` s ON s.`id` = po.`supplier_id`
JOIN `warehouses` w ON w.`id` = po.`warehouse_id`
WHERE {$where}
ORDER BY po.`created_at` DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
class PurchaseOrderItem extends Model
{
protected static string $table = 'purchase_order_items';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'po_id',
'item_id',
'quantity_ordered',
'quantity_received',
'unit_price',
'total_price',
'notes',
];
/**
* Get all items for a purchase order with item details.
*/
public static function getForPO(int $poId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT poi.*, i.`name_ar` as item_name, i.`sku`, i.`unit_of_measure`
FROM `purchase_order_items` poi
JOIN `inventory_items` i ON i.`id` = poi.`item_id`
WHERE poi.`po_id` = ?
ORDER BY i.`name_ar` ASC",
[$poId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class StockAudit extends Model
{
protected static string $table = 'stock_audits';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'audit_number',
'warehouse_id',
'audit_type',
'category_id',
'status',
'audit_date',
'started_at',
'completed_at',
'approved_by',
'approved_at',
'total_items',
'items_counted',
'variance_count',
'notes',
];
public static function getAuditTypes(): array
{
return [
'full' => 'جرد شامل',
'partial' => 'جرد جزئي',
'spot_check' => 'جرد عشوائي',
];
}
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'in_progress' => 'جارٍ',
'pending_approval' => 'في انتظار الاعتماد',
'approved' => 'معتمد',
'cancelled' => 'ملغي',
];
}
public static function getStatusLabel(string $status): string
{
$statuses = self::getStatuses();
return $statuses[$status] ?? $status;
}
public static function getStatusColor(string $status): string
{
return match ($status) {
'draft' => '#6B7280',
'in_progress' => '#2563EB',
'pending_approval' => '#D97706',
'approved' => '#059669',
'cancelled' => '#DC2626',
default => '#6B7280',
};
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND a.`status` = ?';
$params[] = $filters['status'];
}
if (!empty($filters['audit_type'])) {
$where .= ' AND a.`audit_type` = ?';
$params[] = $filters['audit_type'];
}
if (!empty($filters['warehouse_id'])) {
$where .= ' AND a.`warehouse_id` = ?';
$params[] = (int) $filters['warehouse_id'];
}
$countRow = $db->selectOne("SELECT COUNT(*) as cnt FROM `stock_audits` a WHERE {$where}", $params);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT a.*, w.`name_ar` as warehouse_name
FROM `stock_audits` a
JOIN `warehouses` w ON w.`id` = a.`warehouse_id`
WHERE {$where}
ORDER BY a.`created_at` DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
class StockAuditItem extends Model
{
protected static string $table = 'stock_audit_items';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'audit_id',
'item_id',
'batch_id',
'system_quantity',
'physical_quantity',
'variance',
'variance_cost',
'status',
'counted_by',
'counted_at',
'notes',
];
public static function getForAudit(int $auditId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT sai.*, i.`name_ar` as item_name, i.`sku`, i.`unit_of_measure`, i.`cost_price`
FROM `stock_audit_items` sai
JOIN `inventory_items` i ON i.`id` = sai.`item_id`
WHERE sai.`audit_id` = ?
ORDER BY i.`name_ar` ASC",
[$auditId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class StockMovement extends Model
{
protected static string $table = 'stock_movements';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'warehouse_id',
'item_id',
'movement_type',
'direction',
'quantity',
'unit_cost',
'total_cost',
'batch_id',
'reference_type',
'reference_id',
'transfer_id',
'notes',
'movement_date',
'created_by',
];
public static function getMovementTypes(): array
{
return [
'purchase_in' => 'شراء',
'transfer_in' => 'تحويل وارد',
'return_in' => 'مرتجع',
'adjustment_in' => 'تسوية (+)',
'opening_balance' => 'رصيد افتتاحي',
'sale_out' => 'بيع',
'transfer_out' => 'تحويل صادر',
'damage_out' => 'تالف',
'expired_out' => 'منتهي صلاحية',
'consumed_out' => 'مستهلك',
'adjustment_out' => 'تسوية (-)',
];
}
public static function getMovementTypeLabel(string $type): string
{
$types = self::getMovementTypes();
return $types[$type] ?? $type;
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['item_id'])) {
$where .= ' AND m.`item_id` = ?';
$params[] = (int) $filters['item_id'];
}
if (!empty($filters['warehouse_id'])) {
$where .= ' AND m.`warehouse_id` = ?';
$params[] = (int) $filters['warehouse_id'];
}
if (!empty($filters['movement_type'])) {
$where .= ' AND m.`movement_type` = ?';
$params[] = $filters['movement_type'];
}
if (!empty($filters['direction'])) {
$where .= ' AND m.`direction` = ?';
$params[] = $filters['direction'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND m.`movement_date` >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND m.`movement_date` <= ?';
$params[] = $filters['date_to'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `stock_movements` m WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT m.*, i.`name_ar` as item_name, i.`sku`, w.`name_ar` as warehouse_name
FROM `stock_movements` m
JOIN `inventory_items` i ON i.`id` = m.`item_id`
JOIN `warehouses` w ON w.`id` = m.`warehouse_id`
WHERE {$where}
ORDER BY m.`created_at` DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class StockTransfer extends Model
{
protected static string $table = 'stock_transfers';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'transfer_number',
'from_warehouse_id',
'to_warehouse_id',
'status',
'requested_by',
'approved_by',
'approved_at',
'received_by',
'received_at',
'transfer_date',
'notes',
];
public static function getStatuses(): array
{
return [
'draft' => 'مسودة',
'pending_approval' => 'في انتظار الموافقة',
'approved' => 'تمت الموافقة',
'in_transit' => 'قيد النقل',
'received' => 'تم الاستلام',
'cancelled' => 'ملغي',
];
}
public static function getStatusLabel(string $status): string
{
$statuses = self::getStatuses();
return $statuses[$status] ?? $status;
}
public static function getStatusColor(string $status): string
{
return match ($status) {
'draft' => '#6B7280',
'pending_approval' => '#D97706',
'approved' => '#0D7377',
'in_transit' => '#2563EB',
'received' => '#059669',
'cancelled' => '#DC2626',
default => '#6B7280',
};
}
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['status'])) {
$where .= ' AND t.`status` = ?';
$params[] = $filters['status'];
}
if (!empty($filters['from_warehouse_id'])) {
$where .= ' AND t.`from_warehouse_id` = ?';
$params[] = (int) $filters['from_warehouse_id'];
}
if (!empty($filters['to_warehouse_id'])) {
$where .= ' AND t.`to_warehouse_id` = ?';
$params[] = (int) $filters['to_warehouse_id'];
}
if (!empty($filters['date_from'])) {
$where .= ' AND t.`transfer_date` >= ?';
$params[] = $filters['date_from'];
}
if (!empty($filters['date_to'])) {
$where .= ' AND t.`transfer_date` <= ?';
$params[] = $filters['date_to'];
}
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `stock_transfers` t WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT t.*, fw.`name_ar` as from_warehouse_name, tw.`name_ar` as to_warehouse_name
FROM `stock_transfers` t
JOIN `warehouses` fw ON fw.`id` = t.`from_warehouse_id`
JOIN `warehouses` tw ON tw.`id` = t.`to_warehouse_id`
WHERE {$where}
ORDER BY t.`created_at` DESC
LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
class StockTransferItem extends Model
{
protected static string $table = 'stock_transfer_items';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'transfer_id',
'item_id',
'quantity',
'batch_id',
'notes',
];
/**
* Get all items for a transfer with item details.
*/
public static function getForTransfer(int $transferId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT sti.*, i.`name_ar` as item_name, i.`sku`, i.`unit_of_measure`
FROM `stock_transfer_items` sti
JOIN `inventory_items` i ON i.`id` = sti.`item_id`
WHERE sti.`transfer_id` = ?
ORDER BY i.`name_ar` ASC",
[$transferId]
);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
use App\Core\App;
use App\Core\Pagination;
class Supplier extends Model
{
protected static string $table = 'suppliers';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'code',
'name_ar',
'name_en',
'contact_person',
'phone',
'email',
'address_ar',
'tax_number',
'payment_terms',
'rating',
'is_active',
'notes',
];
/**
* Get all active suppliers ordered by name_ar.
*/
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Search suppliers with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$db = App::getInstance()->db();
$where = '1=1';
$params = [];
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$where .= ' AND (`name_ar` LIKE ? OR `code` LIKE ?)';
$params[] = $search;
$params[] = $search;
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$where .= ' AND `is_active` = ?';
$params[] = (int) $filters['is_active'];
}
$where .= ' AND `deleted_at` IS NULL';
$countRow = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `suppliers` WHERE {$where}",
$params
);
$total = (int) ($countRow['cnt'] ?? 0);
$offset = ($page - 1) * $perPage;
$rows = $db->select(
"SELECT * FROM `suppliers` WHERE {$where} ORDER BY `name_ar` ASC LIMIT {$perPage} OFFSET {$offset}",
$params
);
return ['data' => $rows, 'pagination' => Pagination::paginate($total, $perPage, $page)];
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Models;
use App\Core\Model;
class Warehouse extends Model
{
protected static string $table = 'warehouses';
protected static string $primaryKey = 'id';
protected static bool $timestamps = true;
protected static bool $softDelete = true;
protected static bool $autoTrackAuthor = true;
protected static array $fillable = [
'code',
'name_ar',
'name_en',
'warehouse_type',
'location_ar',
'keeper_employee_id',
'capacity_units',
'branch_id',
'phone',
'is_active',
'notes',
];
/**
* Get all warehouse types with Arabic labels.
*/
public static function getTypes(): array
{
return [
'general' => 'عام',
'sports_shop' => 'محل رياضي',
'cafeteria' => 'كافيتريا',
'gym' => 'جيم',
'medical' => 'طبي',
'maintenance' => 'صيانة',
];
}
/**
* Get the Arabic label for a warehouse type.
*/
public static function getTypeLabel(string $type): string
{
$types = self::getTypes();
return $types[$type] ?? $type;
}
/**
* Get all active warehouses ordered by name_ar.
*/
public static function allActive(): array
{
return static::query()
->where('is_active', '=', 1)
->orderBy('name_ar', 'ASC')
->get();
}
/**
* Search warehouses with filters and pagination.
*/
public static function search(array $filters, int $perPage = 25, int $page = 1): array
{
$query = static::query();
if (!empty($filters['q'])) {
$search = '%' . $filters['q'] . '%';
$query = $query->whereRaw('(`name_ar` LIKE ?)', [$search]);
}
if (!empty($filters['warehouse_type'])) {
$query = $query->where('warehouse_type', '=', $filters['warehouse_type']);
}
if (isset($filters['is_active']) && $filters['is_active'] !== '') {
$query = $query->where('is_active', '=', (int) $filters['is_active']);
}
$query = $query->orderBy('name_ar', 'ASC');
return $query->paginate($perPage, $page);
}
}
<?php
declare(strict_types=1);
return [
// Warehouses
['GET', '/inventory/warehouses', 'Inventory\Controllers\WarehouseController@index', ['auth'], 'warehouse.view'],
['GET', '/inventory/warehouses/create', 'Inventory\Controllers\WarehouseController@create', ['auth'], 'warehouse.manage'],
['POST', '/inventory/warehouses', 'Inventory\Controllers\WarehouseController@store', ['auth', 'csrf'], 'warehouse.manage'],
['GET', '/inventory/warehouses/{id:\d+}', 'Inventory\Controllers\WarehouseController@show', ['auth'], 'warehouse.view'],
['GET', '/inventory/warehouses/{id:\d+}/edit', 'Inventory\Controllers\WarehouseController@edit', ['auth'], 'warehouse.manage'],
['POST', '/inventory/warehouses/{id:\d+}', 'Inventory\Controllers\WarehouseController@update', ['auth', 'csrf'], 'warehouse.manage'],
// Categories
['GET', '/inventory/categories', 'Inventory\Controllers\CategoryController@index', ['auth'], 'inventory.view'],
['GET', '/inventory/categories/create', 'Inventory\Controllers\CategoryController@create', ['auth'], 'inventory.manage'],
['POST', '/inventory/categories', 'Inventory\Controllers\CategoryController@store', ['auth', 'csrf'], 'inventory.manage'],
['GET', '/inventory/categories/{id:\d+}/edit', 'Inventory\Controllers\CategoryController@edit', ['auth'], 'inventory.manage'],
['POST', '/inventory/categories/{id:\d+}', 'Inventory\Controllers\CategoryController@update', ['auth', 'csrf'], 'inventory.manage'],
// Items
['GET', '/inventory/items', 'Inventory\Controllers\ItemController@index', ['auth'], 'inventory.view'],
['GET', '/inventory/items/create', 'Inventory\Controllers\ItemController@create', ['auth'], 'inventory.manage'],
['POST', '/inventory/items', 'Inventory\Controllers\ItemController@store', ['auth', 'csrf'], 'inventory.manage'],
['GET', '/inventory/items/{id:\d+}', 'Inventory\Controllers\ItemController@show', ['auth'], 'inventory.view'],
['GET', '/inventory/items/{id:\d+}/edit', 'Inventory\Controllers\ItemController@edit', ['auth'], 'inventory.manage'],
['POST', '/inventory/items/{id:\d+}', 'Inventory\Controllers\ItemController@update', ['auth', 'csrf'], 'inventory.manage'],
['GET', '/inventory/items/search-json', 'Inventory\Controllers\ItemController@searchJson', ['auth'], 'inventory.view'],
// Stock Movements
['GET', '/inventory/movements', 'Inventory\Controllers\StockMovementController@index', ['auth'], 'stock.view'],
['GET', '/inventory/movements/create', 'Inventory\Controllers\StockMovementController@create', ['auth'], 'stock.move'],
['POST', '/inventory/movements', 'Inventory\Controllers\StockMovementController@store', ['auth', 'csrf'], 'stock.move'],
// Stock Transfers
['GET', '/inventory/transfers', 'Inventory\Controllers\StockTransferController@index', ['auth'], 'stock.view'],
['GET', '/inventory/transfers/create', 'Inventory\Controllers\StockTransferController@create', ['auth'], 'stock.move'],
['POST', '/inventory/transfers', 'Inventory\Controllers\StockTransferController@store', ['auth', 'csrf'], 'stock.move'],
['GET', '/inventory/transfers/{id:\d+}', 'Inventory\Controllers\StockTransferController@show', ['auth'], 'stock.view'],
['POST', '/inventory/transfers/{id:\d+}/approve', 'Inventory\Controllers\StockTransferController@approve',['auth', 'csrf'], 'stock.approve_transfer'],
['POST', '/inventory/transfers/{id:\d+}/receive', 'Inventory\Controllers\StockTransferController@receive',['auth', 'csrf'], 'stock.move'],
// Stock Audits
['GET', '/inventory/audits', 'Inventory\Controllers\StockAuditController@index', ['auth'], 'stock.audit'],
['GET', '/inventory/audits/create', 'Inventory\Controllers\StockAuditController@create', ['auth'], 'stock.audit'],
['POST', '/inventory/audits', 'Inventory\Controllers\StockAuditController@store', ['auth', 'csrf'], 'stock.audit'],
['GET', '/inventory/audits/{id:\d+}', 'Inventory\Controllers\StockAuditController@show', ['auth'], 'stock.audit'],
['GET', '/inventory/audits/{id:\d+}/count', 'Inventory\Controllers\StockAuditController@count', ['auth'], 'stock.audit'],
['POST', '/inventory/audits/{id:\d+}/count', 'Inventory\Controllers\StockAuditController@saveCount', ['auth', 'csrf'], 'stock.audit'],
['POST', '/inventory/audits/{id:\d+}/approve', 'Inventory\Controllers\StockAuditController@approve', ['auth', 'csrf'], 'stock.approve_adjustment'],
// Suppliers
['GET', '/inventory/suppliers', 'Inventory\Controllers\SupplierController@index', ['auth'], 'supplier.view'],
['GET', '/inventory/suppliers/create', 'Inventory\Controllers\SupplierController@create', ['auth'], 'supplier.manage'],
['POST', '/inventory/suppliers', 'Inventory\Controllers\SupplierController@store', ['auth', 'csrf'], 'supplier.manage'],
['GET', '/inventory/suppliers/{id:\d+}', 'Inventory\Controllers\SupplierController@show', ['auth'], 'supplier.view'],
['GET', '/inventory/suppliers/{id:\d+}/edit', 'Inventory\Controllers\SupplierController@edit', ['auth'], 'supplier.manage'],
['POST', '/inventory/suppliers/{id:\d+}', 'Inventory\Controllers\SupplierController@update', ['auth', 'csrf'], 'supplier.manage'],
// Purchase Orders
['GET', '/inventory/purchase-orders', 'Inventory\Controllers\PurchaseOrderController@index', ['auth'], 'purchase.view'],
['GET', '/inventory/purchase-orders/create', 'Inventory\Controllers\PurchaseOrderController@create', ['auth'], 'purchase.create'],
['POST', '/inventory/purchase-orders', 'Inventory\Controllers\PurchaseOrderController@store', ['auth', 'csrf'], 'purchase.create'],
['GET', '/inventory/purchase-orders/{id:\d+}', 'Inventory\Controllers\PurchaseOrderController@show', ['auth'], 'purchase.view'],
['POST', '/inventory/purchase-orders/{id:\d+}/submit', 'Inventory\Controllers\PurchaseOrderController@submit', ['auth', 'csrf'], 'purchase.create'],
['POST', '/inventory/purchase-orders/{id:\d+}/approve', 'Inventory\Controllers\PurchaseOrderController@approve',['auth', 'csrf'], 'purchase.approve'],
['GET', '/inventory/purchase-orders/{id:\d+}/receive', 'Inventory\Controllers\PurchaseOrderController@receiveForm', ['auth'], 'purchase.create'],
['POST', '/inventory/purchase-orders/{id:\d+}/receive', 'Inventory\Controllers\PurchaseOrderController@receive',['auth', 'csrf'], 'purchase.create'],
['POST', '/inventory/purchase-orders/{id:\d+}/cancel', 'Inventory\Controllers\PurchaseOrderController@cancel', ['auth', 'csrf'], 'purchase.approve'],
// Assets
['GET', '/inventory/assets', 'Inventory\Controllers\AssetController@index', ['auth'], 'asset.view'],
['GET', '/inventory/assets/{id:\d+}', 'Inventory\Controllers\AssetController@show', ['auth'], 'asset.view'],
['POST', '/inventory/assets/{id:\d+}/dispose', 'Inventory\Controllers\AssetController@dispose', ['auth', 'csrf'], 'asset.manage'],
['POST', '/inventory/assets/run-depreciation', 'Inventory\Controllers\AssetController@runDepreciation',['auth', 'csrf'], 'asset.manage'],
// Reports
['GET', '/inventory/reports/stock-balance', 'Inventory\Controllers\InventoryReportController@stockBalance', ['auth'], 'report.inventory'],
['GET', '/inventory/reports/movements', 'Inventory\Controllers\InventoryReportController@movements', ['auth'], 'report.inventory'],
['GET', '/inventory/reports/expiry-alert', 'Inventory\Controllers\InventoryReportController@expiryAlert', ['auth'], 'report.inventory'],
['GET', '/inventory/reports/low-stock', 'Inventory\Controllers\InventoryReportController@lowStock', ['auth'], 'report.inventory'],
['GET', '/inventory/reports/depreciation', 'Inventory\Controllers\InventoryReportController@depreciationSchedule',['auth'], 'report.inventory'],
['GET', '/inventory/reports/audit-variance', 'Inventory\Controllers\InventoryReportController@auditVariance', ['auth'], 'report.inventory'],
['GET', '/inventory/reports/supplier-history', 'Inventory\Controllers\InventoryReportController@supplierHistory', ['auth'], 'report.inventory'],
];
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Inventory\Models\ItemBatch;
/**
* FEFO (First Expiry First Out) batch allocation for expiry-tracked items.
*/
final class BatchService
{
/**
* Create a new batch when stock comes in (purchase, etc.).
*/
public static function createBatch(array $data): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$batchId = $db->insert('item_batches', [
'item_id' => (int) $data['item_id'],
'warehouse_id' => (int) $data['warehouse_id'],
'batch_number' => $data['batch_number'],
'expiry_date' => $data['expiry_date'],
'quantity' => $data['quantity'],
'initial_quantity' => $data['quantity'],
'unit_cost' => $data['unit_cost'] ?? null,
'received_date' => $data['received_date'] ?? date('Y-m-d'),
'status' => 'active',
'notes' => $data['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
Logger::info("Batch #{$batchId} created for item #{$data['item_id']}, qty: {$data['quantity']}, expiry: {$data['expiry_date']}");
return $batchId;
}
/**
* FEFO allocation: pick the earliest-expiring active batch(es) to fulfill a quantity.
* Returns array of ['batch_id' => int, 'quantity' => string] allocations.
*
* @throws \RuntimeException if insufficient batch quantity
*/
public static function allocateBatch(int $itemId, int $warehouseId, string $quantity): array
{
$batches = ItemBatch::getActiveBatches($itemId, $warehouseId);
$remaining = $quantity;
$allocations = [];
foreach ($batches as $batch) {
if (bccomp($remaining, '0', 3) <= 0) {
break;
}
$available = (string) $batch['quantity'];
$take = bccomp($available, $remaining, 3) >= 0 ? $remaining : $available;
$allocations[] = [
'batch_id' => (int) $batch['id'],
'quantity' => $take,
];
$remaining = bcsub($remaining, $take, 3);
}
if (bccomp($remaining, '0', 3) > 0) {
throw new \RuntimeException(
'كمية الدُفعات غير كافية — المطلوب: ' . $quantity . ' المتاح في الدفعات: ' . bcsub($quantity, $remaining, 3)
);
}
return $allocations;
}
/**
* Mark expired batches and dispatch alert events.
* Called by the daily cron job.
*/
public static function flagExpiredBatches(): int
{
$db = App::getInstance()->db();
$expired = ItemBatch::getExpired();
$count = 0;
foreach ($expired as $batch) {
$db->update('item_batches', [
'status' => 'expired',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $batch['id']]);
EventBus::dispatch('inventory.batch_expired', [
'batch_id' => (int) $batch['id'],
'item_id' => (int) $batch['item_id'],
'warehouse_id' => (int) $batch['warehouse_id'],
'expiry_date' => $batch['expiry_date'],
'quantity' => $batch['quantity'],
]);
$count++;
}
if ($count > 0) {
Logger::info("Flagged {$count} expired batch(es)");
}
return $count;
}
/**
* Get near-expiry batches and dispatch warnings.
* Called by the daily cron job.
*/
public static function alertNearExpiry(int $days = 30): int
{
$batches = ItemBatch::getNearExpiry($days);
foreach ($batches as $batch) {
EventBus::dispatch('inventory.expiry_warning', [
'batch_id' => (int) $batch['id'],
'item_id' => (int) $batch['item_id'],
'warehouse_id' => (int) $batch['warehouse_id'],
'expiry_date' => $batch['expiry_date'],
'days_left' => (int) ((strtotime($batch['expiry_date']) - time()) / 86400),
]);
}
return count($batches);
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class DepreciationService
{
/**
* Run monthly depreciation for all active assets.
*
* @param string $periodMonth YYYY-MM format
* @return int Number of assets depreciated
*/
public static function runMonthlyDepreciation(string $periodMonth): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$count = 0;
$assets = $db->select(
"SELECT * FROM `asset_register` WHERE `status` = 'active'"
);
foreach ($assets as $asset) {
// Skip if already depreciated for this period
$existing = $db->selectOne(
"SELECT `id` FROM `depreciation_entries` WHERE `asset_id` = ? AND `period_month` = ?",
[(int) $asset['id'], $periodMonth]
);
if ($existing) continue;
// Skip if book value already at salvage
$bookValue = (string) $asset['book_value'];
$salvageValue = (string) $asset['salvage_value'];
if (bccomp($bookValue, $salvageValue, 2) <= 0) continue;
// Calculate depreciation amount
$depAmount = self::calculateDepreciation($asset);
// Ensure book value doesn't go below salvage
$maxDep = bcsub($bookValue, $salvageValue, 2);
if (bccomp($depAmount, $maxDep, 2) > 0) {
$depAmount = $maxDep;
}
if (bccomp($depAmount, '0.00', 2) <= 0) continue;
$newAccumulated = bcadd((string) $asset['accumulated_depreciation'], $depAmount, 2);
$newBookValue = bcsub($bookValue, $depAmount, 2);
$db->beginTransaction();
try {
$db->insert('depreciation_entries', [
'asset_id' => (int) $asset['id'],
'period_month' => $periodMonth,
'depreciation_amount' => $depAmount,
'accumulated_total' => $newAccumulated,
'book_value_after' => $newBookValue,
'method_used' => $asset['depreciation_method'],
'is_manual' => 0,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
]);
$db->update('asset_register', [
'accumulated_depreciation' => $newAccumulated,
'book_value' => $newBookValue,
'last_depreciation_date' => $periodMonth . '-01',
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [(int) $asset['id']]);
$db->commit();
$count++;
} catch (\Throwable $e) {
$db->rollBack();
Logger::error("Depreciation failed for asset #{$asset['id']}: " . $e->getMessage());
}
}
if ($count > 0) {
EventBus::dispatch('inventory.depreciation_run', [
'period_month' => $periodMonth,
'assets_count' => $count,
]);
Logger::info("Monthly depreciation for {$periodMonth}: {$count} asset(s) processed");
}
return $count;
}
/**
* Calculate monthly depreciation for an asset.
*/
private static function calculateDepreciation(array $asset): string
{
$method = $asset['depreciation_method'] ?? 'straight_line';
if ($method === 'straight_line') {
// Monthly = (Purchase Cost - Salvage Value) / Useful Life in Months
$depreciableBase = bcsub((string) $asset['purchase_cost'], (string) $asset['salvage_value'], 2);
$months = max(1, (int) ($asset['useful_life_months'] ?? 60));
return bcdiv($depreciableBase, (string) $months, 2);
}
if ($method === 'declining_balance') {
// Monthly = Book Value × Annual Rate / 12
$rate = (float) ($asset['declining_rate'] ?? 20.00);
$annualDep = bcmul((string) $asset['book_value'], (string) ($rate / 100), 2);
return bcdiv($annualDep, '12', 2);
}
return '0.00';
}
}
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
final class InventoryNumberGenerator
{
/**
* Generate a transfer number: TRF-YYYY-NNNNNN
*/
public static function nextTransferNumber(): string
{
return self::generate('TRF', 'stock_transfers', 'transfer_number');
}
/**
* Generate a purchase order number: PO-YYYY-NNNNNN
*/
public static function nextPONumber(): string
{
return self::generate('PO', 'purchase_orders', 'po_number');
}
/**
* Generate an audit number: AUD-YYYY-NNNNNN
*/
public static function nextAuditNumber(): string
{
return self::generate('AUD', 'stock_audits', 'audit_number');
}
/**
* Generate an asset tag: AST-YYYY-NNNNNN
*/
public static function nextAssetTag(): string
{
return self::generate('AST', 'asset_register', 'asset_tag');
}
/**
* Core sequence generator: PREFIX-YYYY-NNNNNN
*/
private static function generate(string $prefix, string $table, string $column): string
{
$db = App::getInstance()->db();
$year = date('Y');
$pattern = $prefix . '-' . $year . '-';
$last = $db->selectOne(
"SELECT `{$column}` FROM `{$table}` WHERE `{$column}` LIKE ? ORDER BY `id` DESC LIMIT 1",
[$pattern . '%']
);
if ($last) {
$parts = explode('-', $last[$column]);
$seq = (int) end($parts) + 1;
} else {
$seq = 1;
}
return $pattern . str_pad((string) $seq, 6, '0', STR_PAD_LEFT);
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
final class StockAuditService
{
/**
* Create a new stock audit and populate its items.
*/
public static function createAudit(int $warehouseId, string $auditType, ?int $categoryId = null, ?string $notes = null): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$auditNumber = InventoryNumberGenerator::nextAuditNumber();
// Determine items to audit
$itemWhere = 'iws.`warehouse_id` = ?';
$itemParams = [$warehouseId];
if ($auditType === 'partial' && $categoryId) {
$itemWhere .= ' AND i.`category_id` = ?';
$itemParams[] = $categoryId;
}
$stockItems = $db->select(
"SELECT iws.`item_id`, iws.`quantity` as system_quantity
FROM `item_warehouse_stock` iws
JOIN `inventory_items` i ON i.`id` = iws.`item_id` AND i.`deleted_at` IS NULL
WHERE {$itemWhere}
ORDER BY i.`name_ar` ASC",
$itemParams
);
// For spot_check, only take a random subset (max 20 items)
if ($auditType === 'spot_check' && count($stockItems) > 20) {
shuffle($stockItems);
$stockItems = array_slice($stockItems, 0, 20);
}
$db->beginTransaction();
try {
$auditId = $db->insert('stock_audits', [
'audit_number' => $auditNumber,
'warehouse_id' => $warehouseId,
'audit_type' => $auditType,
'category_id' => $categoryId,
'status' => 'in_progress',
'audit_date' => date('Y-m-d'),
'started_at' => date('Y-m-d H:i:s'),
'total_items' => count($stockItems),
'items_counted' => 0,
'variance_count' => 0,
'notes' => $notes,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
foreach ($stockItems as $si) {
$db->insert('stock_audit_items', [
'audit_id' => $auditId,
'item_id' => (int) $si['item_id'],
'system_quantity' => (string) $si['system_quantity'],
'status' => 'pending',
]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Logger::info("Audit #{$auditId} ({$auditNumber}) created: {$auditType} for warehouse #{$warehouseId}, {$stockItems} items");
return $auditId;
}
/**
* Save physical counts for audit items.
*
* @param array $counts [audit_item_id => physical_quantity, ...]
*/
public static function saveCounts(int $auditId, array $counts): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$ts = date('Y-m-d H:i:s');
foreach ($counts as $auditItemId => $physicalQty) {
$item = $db->selectOne(
"SELECT * FROM `stock_audit_items` WHERE `id` = ? AND `audit_id` = ?",
[(int) $auditItemId, $auditId]
);
if (!$item) continue;
$systemQty = (string) $item['system_quantity'];
$physicalQty = (string) $physicalQty;
$variance = bcsub($physicalQty, $systemQty, 3);
// Get cost for variance calculation
$itemData = $db->selectOne("SELECT `cost_price` FROM `inventory_items` WHERE `id` = ?", [(int) $item['item_id']]);
$varianceCost = $itemData ? bcmul($variance, (string) ($itemData['cost_price'] ?? '0'), 2) : null;
$db->update('stock_audit_items', [
'physical_quantity' => $physicalQty,
'variance' => $variance,
'variance_cost' => $varianceCost,
'status' => 'counted',
'counted_by' => $employee ? (int) $employee->id : null,
'counted_at' => $ts,
], '`id` = ?', [(int) $auditItemId]);
}
// Update audit summary
$counted = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `stock_audit_items` WHERE `audit_id` = ? AND `status` = 'counted'",
[$auditId]
);
$variances = $db->selectOne(
"SELECT COUNT(*) as cnt FROM `stock_audit_items` WHERE `audit_id` = ? AND `status` = 'counted' AND `variance` != 0",
[$auditId]
);
$db->update('stock_audits', [
'items_counted' => (int) ($counted['cnt'] ?? 0),
'variance_count' => (int) ($variances['cnt'] ?? 0),
'status' => 'pending_approval',
'completed_at' => $ts,
'updated_at' => $ts,
], '`id` = ?', [$auditId]);
}
/**
* Approve an audit and create adjustment movements for variances.
*/
public static function approveAudit(int $auditId): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$audit = $db->selectOne("SELECT * FROM `stock_audits` WHERE `id` = ?", [$auditId]);
if (!$audit) {
throw new \RuntimeException('الجرد غير موجود');
}
if ($audit['status'] !== 'pending_approval') {
throw new \RuntimeException('الجرد ليس في حالة انتظار الاعتماد');
}
$warehouseId = (int) $audit['warehouse_id'];
// Get items with variances
$items = $db->select(
"SELECT * FROM `stock_audit_items` WHERE `audit_id` = ? AND `status` = 'counted' AND `variance` != 0",
[$auditId]
);
$db->beginTransaction();
try {
foreach ($items as $item) {
$variance = (string) $item['variance'];
$direction = bccomp($variance, '0', 3) > 0 ? 'in' : 'out';
$absVariance = ltrim($variance, '-');
StockService::moveStock([
'item_id' => (int) $item['item_id'],
'warehouse_id' => $warehouseId,
'movement_type' => $direction === 'in' ? 'adjustment_in' : 'adjustment_out',
'direction' => $direction,
'quantity' => $absVariance,
'reference_type' => 'stock_audits',
'reference_id' => $auditId,
'notes' => 'تسوية جرد — ' . $audit['audit_number'],
]);
$db->update('stock_audit_items', [
'status' => 'approved',
], '`id` = ?', [(int) $item['id']]);
}
$db->update('stock_audits', [
'status' => 'approved',
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$auditId]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
EventBus::dispatch('inventory.audit_completed', [
'audit_id' => $auditId,
'warehouse_id' => $warehouseId,
'variance_count' => count($items),
]);
Logger::info("Audit #{$auditId} approved — " . count($items) . " adjustment(s) created");
}
}
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Inventory\Services;
use App\Core\App;
use App\Core\EventBus;
use App\Core\Logger;
use App\Modules\Inventory\Models\StockTransfer;
use App\Modules\Inventory\Models\StockTransferItem;
final class StockTransferService
{
/**
* Create a new transfer with its items.
*
* @param array $header Keys: from_warehouse_id, to_warehouse_id, transfer_date, notes
* @param array $items Each: ['item_id' => int, 'quantity' => string, 'batch_id' => ?int, 'notes' => ?string]
* @return int transfer ID
*/
public static function createTransfer(array $header, array $items): int
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
if (empty($items)) {
throw new \RuntimeException('يجب إضافة صنف واحد على الأقل');
}
$fromId = (int) ($header['from_warehouse_id'] ?? 0);
$toId = (int) ($header['to_warehouse_id'] ?? 0);
if ($fromId <= 0 || $toId <= 0) {
throw new \RuntimeException('يجب تحديد المخزن المصدر والمخزن المستقبل');
}
if ($fromId === $toId) {
throw new \RuntimeException('لا يمكن التحويل لنفس المخزن');
}
$transferNumber = InventoryNumberGenerator::nextTransferNumber();
$db->beginTransaction();
try {
$transferId = $db->insert('stock_transfers', [
'transfer_number' => $transferNumber,
'from_warehouse_id' => $fromId,
'to_warehouse_id' => $toId,
'status' => 'pending_approval',
'requested_by' => $employee ? (int) $employee->id : null,
'transfer_date' => $header['transfer_date'] ?? date('Y-m-d'),
'notes' => $header['notes'] ?? null,
'created_by' => $employee ? (int) $employee->id : null,
'created_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
]);
foreach ($items as $item) {
$db->insert('stock_transfer_items', [
'transfer_id' => $transferId,
'item_id' => (int) $item['item_id'],
'quantity' => $item['quantity'],
'batch_id' => isset($item['batch_id']) ? (int) $item['batch_id'] : null,
'notes' => $item['notes'] ?? null,
]);
}
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
Logger::info("Transfer #{$transferId} ({$transferNumber}) created: warehouse #{$fromId} → #{$toId}");
return $transferId;
}
/**
* Approve a pending transfer.
*/
public static function approve(int $transferId): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$transfer = $db->selectOne("SELECT * FROM `stock_transfers` WHERE `id` = ?", [$transferId]);
if (!$transfer) {
throw new \RuntimeException('التحويل غير موجود');
}
if ($transfer['status'] !== 'pending_approval') {
throw new \RuntimeException('التحويل ليس في حالة انتظار الموافقة');
}
$db->update('stock_transfers', [
'status' => 'approved',
'approved_by' => $employee ? (int) $employee->id : null,
'approved_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$transferId]);
Logger::info("Transfer #{$transferId} approved");
}
/**
* Receive a transfer — executes all stock movements.
*/
public static function receive(int $transferId): void
{
$db = App::getInstance()->db();
$employee = App::getInstance()->currentEmployee();
$transfer = $db->selectOne("SELECT * FROM `stock_transfers` WHERE `id` = ?", [$transferId]);
if (!$transfer) {
throw new \RuntimeException('التحويل غير موجود');
}
if (!in_array($transfer['status'], ['approved', 'in_transit'], true)) {
throw new \RuntimeException('التحويل ليس في حالة تسمح بالاستلام');
}
$items = StockTransferItem::getForTransfer($transferId);
if (empty($items)) {
throw new \RuntimeException('التحويل لا يحتوي على أصناف');
}
$fromWarehouse = (int) $transfer['from_warehouse_id'];
$toWarehouse = (int) $transfer['to_warehouse_id'];
$db->beginTransaction();
try {
foreach ($items as $item) {
// OUT from source warehouse
StockService::moveStock([
'item_id' => (int) $item['item_id'],
'warehouse_id' => $fromWarehouse,
'movement_type' => 'transfer_out',
'direction' => 'out',
'quantity' => (string) $item['quantity'],
'batch_id' => $item['batch_id'] ? (int) $item['batch_id'] : null,
'reference_type' => 'stock_transfers',
'reference_id' => $transferId,
'transfer_id' => $transferId,
'notes' => 'تحويل صادر — ' . $transfer['transfer_number'],
]);
// IN to destination warehouse
StockService::moveStock([
'item_id' => (int) $item['item_id'],
'warehouse_id' => $toWarehouse,
'movement_type' => 'transfer_in',
'direction' => 'in',
'quantity' => (string) $item['quantity'],
'batch_id' => $item['batch_id'] ? (int) $item['batch_id'] : null,
'reference_type' => 'stock_transfers',
'reference_id' => $transferId,
'transfer_id' => $transferId,
'notes' => 'تحويل وارد — ' . $transfer['transfer_number'],
]);
}
$db->update('stock_transfers', [
'status' => 'received',
'received_by' => $employee ? (int) $employee->id : null,
'received_at' => date('Y-m-d H:i:s'),
'updated_at' => date('Y-m-d H:i:s'),
], '`id` = ?', [$transferId]);
$db->commit();
} catch (\Throwable $e) {
$db->rollBack();
throw $e;
}
EventBus::dispatch('inventory.transfer_completed', [
'transfer_id' => $transferId,
'from_warehouse_id' => $fromWarehouse,
'to_warehouse_id' => $toWarehouse,
]);
Logger::info("Transfer #{$transferId} received: all stock movements executed");
}
}
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>سجل الأصول<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<form method="POST" action="/inventory/assets/run-depreciation" style="display:inline-flex;align-items:center;gap:8px;" onsubmit="return confirm('هل أنت متأكد من تشغيل الإهلاك لهذا الشهر؟');">
<?= csrf_field() ?>
<input type="month" name="month" value="<?= e(date('Y-m')) ?>" class="form-input" style="width:160px;padding:6px 10px;font-size:13px;">
<button type="submit" class="btn" style="background:#D97706;color:#fff;border:none;">
<i data-lucide="calculator" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> تشغيل الإهلاك
</button>
</form>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<?php
$statusMap = [
'active' => ['bg' => '#ECFDF5', 'color' => '#059669', 'label' => 'نشط'],
'disposed' => ['bg' => '#FEE2E2', 'color' => '#DC2626', 'label' => 'مستبعد'],
'fully_depreciated' => ['bg' => '#FFF7ED', 'color' => '#D97706', 'label' => 'مهلك بالكامل'],
'inactive' => ['bg' => '#F3F4F6', 'color' => '#6B7280', 'label' => 'غير نشط'],
];
$depMethodLabels = [
'straight_line' => 'القسط الثابت',
'declining_balance' => 'القسط المتناقص',
];
?>
<!-- Search & Filters -->
<div class="card" style="margin-bottom:20px;padding:15px;">
<form method="GET" action="/inventory/assets" style="display:flex;gap:10px;flex-wrap:wrap;align-items:end;">
<div style="flex:1;min-width:200px;">
<label class="form-label" style="font-size:12px;">بحث</label>
<input type="text" name="q" value="<?= e($filters['q'] ?? '') ?>" placeholder="رقم الأصل، اسم الصنف..." class="form-input">
</div>
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">الحالة</label>
<select name="status" class="form-select">
<option value="">الكل</option>
<?php foreach ($statuses as $key => $label): ?>
<option value="<?= e($key) ?>" <?= ($filters['status'] ?? '') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div style="min-width:160px;">
<label class="form-label" style="font-size:12px;">المخزن</label>
<select name="warehouse_id" class="form-select">
<option value="">الكل</option>
<?php foreach ($warehouses as $wh): ?>
<option value="<?= (int) $wh['id'] ?>" <?= ($filters['warehouse_id'] ?? '') == $wh['id'] ? 'selected' : '' ?>><?= e($wh['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<button type="submit" class="btn btn-outline"><i data-lucide="search" style="width:16px;height:16px;vertical-align:middle;"></i> بحث</button>
<a href="/inventory/assets" class="btn btn-sm btn-outline" style="color:#6B7280;">مسح</a>
</form>
</div>
<!-- Assets Table -->
<?php if (!empty($assets)): ?>
<div class="card">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>رقم الأصل</th>
<th>الصنف</th>
<th>المخزن</th>
<th>تكلفة الشراء</th>
<th>القيمة الدفترية</th>
<th>طريقة الإهلاك</th>
<th>الحالة</th>
<th>الإجراءات</th>
</tr>
</thead>
<tbody>
<?php foreach ($assets as $asset): ?>
<?php
$asSt = $asset['status'] ?? 'active';
$asStInfo = $statusMap[$asSt] ?? $statusMap['active'];
$dm = $asset['depreciation_method'] ?? 'straight_line';
$dmLabel = $depMethodLabels[$dm] ?? $dm;
?>
<tr>
<td>
<a href="/inventory/assets/<?= (int) $asset['id'] ?>" style="color:#0D7377;font-weight:600;text-decoration:none;font-family:monospace;">
<?= e($asset['asset_tag'] ?? '') ?>
</a>
</td>
<td style="font-weight:600;"><?= e($asset['item_name'] ?? '') ?></td>
<td><?= e($asset['warehouse_name'] ?? '') ?></td>
<td style="font-weight:700;direction:ltr;text-align:left;"><?= money($asset['purchase_cost'] ?? 0) ?></td>
<td style="font-weight:700;direction:ltr;text-align:left;"><?= money($asset['book_value'] ?? 0) ?></td>
<td style="font-size:13px;"><?= e($dmLabel) ?></td>
<td>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:<?= $asStInfo['bg'] ?>;color:<?= $asStInfo['color'] ?>;">
<?= e($asStInfo['label']) ?>
</span>
</td>
<td>
<a href="/inventory/assets/<?= (int) $asset['id'] ?>" class="btn btn-sm btn-outline" style="font-size:12px;padding:4px 10px;">
<i data-lucide="eye" style="width:13px;height:13px;vertical-align:middle;"></i> عرض
</a>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
</div>
<?php if (isset($pagination) && ($pagination['last_page'] ?? 1) > 1): ?>
<div style="padding:15px;">
<?php $__template->include('Shared.Components.pagination', ['pagination' => $pagination]); ?>
</div>
<?php endif; ?>
<?php else: ?>
<div class="card" style="padding:60px 20px;text-align:center;">
<div style="margin-bottom:15px;">
<i data-lucide="landmark" style="width:48px;height:48px;color:#D1D5DB;"></i>
</div>
<h3 style="color:#6B7280;margin:0 0 8px;">لا توجد أصول مسجلة</h3>
<p style="color:#9CA3AF;font-size:14px;margin:0 0 20px;">
<?php if (!empty($filters['q']) || !empty($filters['status']) || !empty($filters['warehouse_id'])): ?>
لا توجد نتائج مطابقة لبحثك. جرب تغيير معايير البحث.
<?php else: ?>
يتم إنشاء الأصول تلقائيا عند استلام أصناف من نوع "أصل".
<?php endif; ?>
</p>
</div>
<?php endif; ?>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
});
</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>العد الفعلي — <?= e($audit->audit_number) ?><?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/inventory/audits/<?= (int) $audit->id ?>" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة للجرد</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/inventory/audits/<?= (int) $audit->id ?>/count" id="countForm">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="list-checks" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">العد الفعلي للأصناف</h3>
<div style="margin-right:auto;font-size:13px;color:#6B7280;">
رقم الجرد: <code style="font-size:12px;background:#F3F4F6;padding:2px 8px;border-radius:4px;"><?= e($audit->audit_number) ?></code>
</div>
</div>
<?php if (!empty($items)): ?>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>#</th>
<th>الصنف</th>
<th>SKU</th>
<th>الوحدة</th>
<th>الكمية بالنظام</th>
<th>الكمية الفعلية</th>
<th>الحالة</th>
</tr>
</thead>
<tbody>
<?php $rowNum = 0; ?>
<?php foreach ($items as $item): ?>
<?php
$rowNum++;
$sysQty = (float) ($item['system_quantity'] ?? 0);
$physQty = $item['physical_quantity'];
$hasVariance = $physQty !== null && (float) $physQty !== $sysQty;
$rowBg = $hasVariance ? '#FFF7ED' : '';
?>
<tr style="<?= $rowBg ? 'background:' . $rowBg . ';' : '' ?>">
<td style="color:#6B7280;font-size:13px;"><?= $rowNum ?></td>
<td style="font-weight:600;"><?= e($item['item_name'] ?? '') ?></td>
<td>
<code style="font-size:11px;background:#F3F4F6;padding:2px 6px;border-radius:4px;"><?= e($item['sku'] ?? '—') ?></code>
</td>
<td style="color:#6B7280;"><?= e($item['unit_of_measure'] ?? '—') ?></td>
<td style="font-weight:700;direction:ltr;text-align:left;color:#6B7280;"><?= number_format($sysQty, 2) ?></td>
<td style="width:150px;">
<input type="number"
name="physical_quantities[<?= (int) $item['id'] ?>]"
value="<?= e($physQty ?? '') ?>"
class="form-input physical-qty"
data-system="<?= $sysQty ?>"
step="0.01"
min="0"
placeholder="0.00"
style="direction:ltr;text-align:left;width:130px;">
</td>
<td>
<?php
$st = $item['status'] ?? 'pending';
if ($st === 'counted'):
?>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#ECFDF5;color:#059669;">تم العد</span>
<?php else: ?>
<span style="display:inline-block;padding:3px 10px;border-radius:10px;font-size:12px;font-weight:600;background:#F3F4F6;color:#6B7280;">معلق</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
</tbody>
</table>
</div>
<?php else: ?>
<div style="padding:40px 20px;text-align:center;color:#6B7280;">
<i data-lucide="package-x" style="width:36px;height:36px;color:#D1D5DB;margin-bottom:8px;"></i>
<p style="margin:0;">لا توجد أصناف في هذا الجرد</p>
</div>
<?php endif; ?>
</div>
<?php if (!empty($items)): ?>
<div style="display:flex;gap:10px;justify-content:flex-start;">
<button type="submit" class="btn btn-primary" style="padding:12px 30px;font-size:15px;">
<i data-lucide="save" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i>
حفظ العد
</button>
<a href="/inventory/audits/<?= (int) $audit->id ?>" class="btn btn-outline" style="padding:12px 30px;font-size:15px;">إلغاء</a>
</div>
<?php endif; ?>
</form>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
// Highlight rows with variance on input change
document.querySelectorAll('.physical-qty').forEach(function(input) {
input.addEventListener('input', function() {
var row = this.closest('tr');
var sysQty = parseFloat(this.dataset.system) || 0;
var physQty = parseFloat(this.value);
if (!isNaN(physQty) && physQty !== sysQty) {
row.style.background = '#FFF7ED';
} else {
row.style.background = '';
}
});
});
});
</script>
<?php $__template->endSection(); ?>
<?php $__template->layout('Layout.main'); ?>
<?php $__template->section('title'); ?>إنشاء جرد جديد<?php $__template->endSection(); ?>
<?php $__template->section('page_actions'); ?>
<a href="/inventory/audits" class="btn btn-outline"><i data-lucide="arrow-right" style="width:15px;height:15px;vertical-align:middle;margin-left:4px;"></i> العودة للقائمة</a>
<?php $__template->endSection(); ?>
<?php $__template->section('content'); ?>
<form method="POST" action="/inventory/audits" id="auditForm">
<?= csrf_field() ?>
<div class="card" style="margin-bottom:20px;">
<div style="padding:15px 20px;border-bottom:1px solid #E5E7EB;display:flex;align-items:center;gap:8px;">
<i data-lucide="clipboard-list" style="width:18px;height:18px;color:#0D7377;"></i>
<h3 style="margin:0;color:#0D7377;font-size:15px;">بيانات الجرد</h3>
</div>
<div style="padding:20px;">
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:20px;">
<div class="form-group">
<label class="form-label">المخزن <span style="color:#DC2626;">*</span></label>
<select name="warehouse_id" class="form-select" required>
<option value="">-- اختر المخزن --</option>
<?php foreach ($warehouses as $wh): ?>
<option value="<?= (int) $wh['id'] ?>" <?= (int) old('warehouse_id') === (int) $wh['id'] && old('warehouse_id') !== '' ? 'selected' : '' ?>><?= e($wh['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group">
<label class="form-label">نوع الجرد <span style="color:#DC2626;">*</span></label>
<select name="audit_type" id="auditType" class="form-select" required>
<?php foreach ($auditTypes as $key => $label): ?>
<option value="<?= e($key) ?>" <?= old('audit_type', 'full') === $key ? 'selected' : '' ?>><?= e($label) ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="form-group" id="categoryGroup" style="display:none;">
<label class="form-label">التصنيف</label>
<select name="category_id" class="form-select">
<option value="">-- كل التصنيفات --</option>
<?php foreach ($categories as $cat): ?>
<option value="<?= (int) $cat['id'] ?>" <?= (int) old('category_id') === (int) $cat['id'] && old('category_id') !== '' ? 'selected' : '' ?>><?= e($cat['name_ar']) ?></option>
<?php endforeach; ?>
</select>
</div>
</div>
<div class="form-group" style="margin-top:15px;">
<label class="form-label">ملاحظات</label>
<textarea name="notes" class="form-input" rows="3" placeholder="ملاحظات إضافية حول عملية الجرد..."><?= e(old('notes')) ?></textarea>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div style="display:flex;gap:10px;justify-content:flex-start;">
<button type="submit" class="btn btn-primary" style="padding:12px 30px;font-size:15px;">
<i data-lucide="check" style="width:16px;height:16px;vertical-align:middle;margin-left:4px;"></i>
إنشاء الجرد
</button>
<a href="/inventory/audits" class="btn btn-outline" style="padding:12px 30px;font-size:15px;">إلغاء</a>
</div>
</form>
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof lucide !== 'undefined') {
lucide.createIcons();
}
var auditTypeSelect = document.getElementById('auditType');
var categoryGroup = document.getElementById('categoryGroup');
function toggleCategory() {
categoryGroup.style.display = auditTypeSelect.value === 'partial' ? '' : 'none';
}
auditTypeSelect.addEventListener('change', toggleCategory);
toggleCategory();
});
</script>
<?php $__template->endSection(); ?>
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
...@@ -311,6 +311,7 @@ final class PaymentService ...@@ -311,6 +311,7 @@ final class PaymentService
'carnet_replacement' => 'بدل فاقد كارنيه', 'carnet_replacement' => 'بدل فاقد كارنيه',
'seasonal_fee' => 'رسوم عضوية موسمية', 'seasonal_fee' => 'رسوم عضوية موسمية',
'sports_conversion' => 'رسوم تحويل رياضي', 'sports_conversion' => 'رسوم تحويل رياضي',
'inventory_sale' => 'مبيعات مخزون',
'other' => 'أخرى', 'other' => 'أخرى',
default => $type, default => $type,
}; };
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
<?php
declare(strict_types=1);
namespace App\Modules\Sales\Models;
use App\Core\Model;
use App\Core\App;
class PackageItem extends Model
{
protected static string $table = 'package_items';
protected static string $primaryKey = 'id';
protected static bool $timestamps = false;
protected static bool $softDelete = false;
protected static bool $autoTrackAuthor = false;
protected static array $fillable = [
'package_id',
'item_id',
'quantity',
'sort_order',
];
public static function getForPackage(int $packageId): array
{
$db = App::getInstance()->db();
return $db->select(
"SELECT pi.*, i.`name_ar` as item_name, i.`sku`, i.`unit_of_measure`,
i.`sale_price_member`, i.`sale_price_nonmember`
FROM `package_items` pi
JOIN `inventory_items` i ON i.`id` = pi.`item_id`
WHERE pi.`package_id` = ?
ORDER BY pi.`sort_order` ASC, i.`name_ar` ASC",
[$packageId]
);
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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