From d8f75b3a29223d49f649739742f45c22a3a0b1a6 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Thu, 2 Apr 2026 10:28:38 +0530 Subject: [PATCH] feat(stock): add modular hospital stock management with dashboard, masters, indents, purchase and reports --- app/Config/AuthGroups.php | 40 + app/Config/Routes.php | 23 + app/Controllers/Setting/HospitalStock.php | 1211 +++++++++++++++++ ...04-02-000034_CreateHospitalStockModule.php | 305 +++++ ...04-02-000035_EnhanceHospitalStockItems.php | 69 + app/Models/HospitalStockCategoryModel.php | 14 + app/Models/HospitalStockItemModel.php | 33 + app/Models/HospitalStockSupplierModel.php | 22 + app/Views/Setting/Admin/index.php | 6 + app/Views/Setting/Stock/dashboard.php | 98 ++ app/Views/Setting/Stock/indents.php | 138 ++ app/Views/Setting/Stock/index.php | 84 ++ app/Views/Setting/Stock/masters.php | 259 ++++ app/Views/Setting/Stock/purchase.php | 124 ++ app/Views/Setting/Stock/report_print.php | 56 + app/Views/Setting/Stock/reports.php | 83 ++ app/Views/partials/sidebar.php | 6 + 17 files changed, 2571 insertions(+) create mode 100644 app/Controllers/Setting/HospitalStock.php create mode 100644 app/Database/Migrations/2026-04-02-000034_CreateHospitalStockModule.php create mode 100644 app/Database/Migrations/2026-04-02-000035_EnhanceHospitalStockItems.php create mode 100644 app/Models/HospitalStockCategoryModel.php create mode 100644 app/Models/HospitalStockItemModel.php create mode 100644 app/Models/HospitalStockSupplierModel.php create mode 100644 app/Views/Setting/Stock/dashboard.php create mode 100644 app/Views/Setting/Stock/indents.php create mode 100644 app/Views/Setting/Stock/index.php create mode 100644 app/Views/Setting/Stock/masters.php create mode 100644 app/Views/Setting/Stock/purchase.php create mode 100644 app/Views/Setting/Stock/report_print.php create mode 100644 app/Views/Setting/Stock/reports.php diff --git a/app/Config/AuthGroups.php b/app/Config/AuthGroups.php index e12c8b3..0022c97 100644 --- a/app/Config/AuthGroups.php +++ b/app/Config/AuthGroups.php @@ -53,6 +53,18 @@ class AuthGroups extends ShieldAuthGroups 'title' => 'Developer', 'description' => 'Site programmers.', ], + 'stock_manager' => [ + 'title' => 'Stock Manager', + 'description' => 'Manages stock masters, approvals, and procurement.', + ], + 'department_head' => [ + 'title' => 'Department Head', + 'description' => 'Creates and tracks departmental stock indents.', + ], + 'storekeeper' => [ + 'title' => 'Storekeeper', + 'description' => 'Handles item issuance and stock receiving operations.', + ], 'user' => [ 'title' => 'User', 'description' => 'General users of the site. Often customers.', @@ -129,6 +141,13 @@ class AuthGroups extends ShieldAuthGroups 'template.diagnosis_print' => 'Can access diagnosis print templates', 'template.document_print' => 'Can access document print templates', 'template.ipd_document' => 'Can access IPD document master templates', + 'hospital_stock.access' => 'Can access hospital stock module', + 'hospital_stock.master.manage' => 'Can manage hospital stock masters (category/item/supplier)', + 'hospital_stock.indent.create' => 'Can create stock indents', + 'hospital_stock.indent.approve' => 'Can approve stock indents', + 'hospital_stock.issue' => 'Can issue stock against approved indents', + 'hospital_stock.purchase.manage' => 'Can create/receive purchase orders', + 'hospital_stock.report.view' => 'Can view stock reports and alerts', ]; /** @@ -150,6 +169,7 @@ class AuthGroups extends ShieldAuthGroups 'doctor_work.*', 'pharmacy.*', 'template.*', + 'hospital_stock.*', ], 'admin' => [ 'admin.access', @@ -163,6 +183,7 @@ class AuthGroups extends ShieldAuthGroups 'doctor_work.*', 'pharmacy.*', 'template.*', + 'hospital_stock.*', ], 'developer' => [ 'admin.access', @@ -176,6 +197,25 @@ class AuthGroups extends ShieldAuthGroups 'doctor_work.*', 'pharmacy.*', 'template.*', + 'hospital_stock.*', + ], + 'stock_manager' => [ + 'hospital_stock.access', + 'hospital_stock.master.manage', + 'hospital_stock.indent.approve', + 'hospital_stock.purchase.manage', + 'hospital_stock.report.view', + ], + 'department_head' => [ + 'hospital_stock.access', + 'hospital_stock.indent.create', + 'hospital_stock.report.view', + ], + 'storekeeper' => [ + 'hospital_stock.access', + 'hospital_stock.issue', + 'hospital_stock.purchase.manage', + 'hospital_stock.report.view', ], 'user' => [], 'beta' => [ diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 04342a1..30e1037 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -961,6 +961,29 @@ $routes->group('setting', static function($routes) { $routes->get('admin/bed-assignments', 'Setting\\BedManagement::assignmentHistory'); $routes->post('admin/bed-assignments/save', 'Setting\\BedManagement::saveAssignment'); $routes->post('admin/bed-assignments/delete', 'Setting\\BedManagement::deleteAssignment'); + $routes->get('admin/hospital-stock', 'Setting\\HospitalStock::index'); + $routes->get('admin/hospital-stock/dashboard', 'Setting\\HospitalStock::dashboard'); + $routes->get('admin/hospital-stock/masters', 'Setting\\HospitalStock::masters'); + $routes->get('admin/hospital-stock/indents', 'Setting\\HospitalStock::indents'); + $routes->get('admin/hospital-stock/purchase', 'Setting\\HospitalStock::purchase'); + $routes->get('admin/hospital-stock/reports', 'Setting\\HospitalStock::reports'); + $routes->post('admin/hospital-stock/category/save', 'Setting\\HospitalStock::saveCategory'); + $routes->post('admin/hospital-stock/category/update', 'Setting\\HospitalStock::updateCategory'); + $routes->post('admin/hospital-stock/category/delete', 'Setting\\HospitalStock::deleteCategory'); + $routes->post('admin/hospital-stock/item/save', 'Setting\\HospitalStock::saveItem'); + $routes->post('admin/hospital-stock/item/update', 'Setting\\HospitalStock::updateItem'); + $routes->post('admin/hospital-stock/item/delete', 'Setting\\HospitalStock::deleteItem'); + $routes->post('admin/hospital-stock/supplier/save', 'Setting\\HospitalStock::saveSupplier'); + $routes->post('admin/hospital-stock/supplier/update', 'Setting\\HospitalStock::updateSupplier'); + $routes->post('admin/hospital-stock/supplier/delete', 'Setting\\HospitalStock::deleteSupplier'); + $routes->post('admin/hospital-stock/indent/create', 'Setting\\HospitalStock::createIndent'); + $routes->post('admin/hospital-stock/indent/approve', 'Setting\\HospitalStock::approveIndent'); + $routes->post('admin/hospital-stock/indent/issue', 'Setting\\HospitalStock::issueIndent'); + $routes->post('admin/hospital-stock/po/create', 'Setting\\HospitalStock::createPurchaseOrder'); + $routes->post('admin/hospital-stock/po/receive', 'Setting\\HospitalStock::receivePurchaseOrder'); + $routes->get('admin/hospital-stock/report/department-consumption', 'Setting\\HospitalStock::reportDepartmentConsumption'); + $routes->get('admin/hospital-stock/report/monthly-issue', 'Setting\\HospitalStock::reportMonthlyIssue'); + $routes->get('admin/hospital-stock/report/near-expiry', 'Setting\\HospitalStock::reportNearExpiry'); $routes->get('template', 'Setting\\Template::index'); $routes->get('charges', 'Setting\\Charges::index'); }); diff --git a/app/Controllers/Setting/HospitalStock.php b/app/Controllers/Setting/HospitalStock.php new file mode 100644 index 0000000..5b1f40d --- /dev/null +++ b/app/Controllers/Setting/HospitalStock.php @@ -0,0 +1,1211 @@ +hasAnyPermission([ + self::PERM_ACCESS, + self::PERM_MASTER_MANAGE, + self::PERM_INDENT_CREATE, + self::PERM_INDENT_APPROVE, + self::PERM_ISSUE, + self::PERM_PURCHASE_MANAGE, + self::PERM_REPORT_VIEW, + ])) { + return '
Access denied.
'; + } + + if (! $this->isModuleReady()) { + return '
Hospital stock tables are missing. Please run php spark migrate.
'; + } + + return view('Setting/Stock/index', [ + 'canMasterManage' => $this->userCan(self::PERM_MASTER_MANAGE), + 'canIndentCreate' => $this->userCan(self::PERM_INDENT_CREATE), + 'canIndentApprove' => $this->userCan(self::PERM_INDENT_APPROVE), + 'canIssue' => $this->userCan(self::PERM_ISSUE), + 'canPurchaseManage' => $this->userCan(self::PERM_PURCHASE_MANAGE), + 'canReportView' => $this->userCan(self::PERM_REPORT_VIEW), + ]); + } + + public function dashboard(): string + { + if (! $this->userCan(self::PERM_REPORT_VIEW)) { + return '
Access denied.
'; + } + + $stats = $this->getStats(); + + $newRequests = $this->db->table('hsm_indents') + ->where('status', 'pending') + ->orderBy('id', 'DESC') + ->limit(20) + ->get() + ->getResultArray(); + + $nearExpiryFrequent = $this->db->query( + "SELECT i.item_code, i.name, i.current_stock, i.expiry_date, IFNULL(SUM(ii.qty),0) AS used_qty + FROM hsm_items i + LEFT JOIN hsm_issue_items ii ON ii.item_id = i.id + LEFT JOIN hsm_issues hs ON hs.id = ii.issue_id AND DATE(hs.created_at) >= DATE_SUB(CURDATE(), INTERVAL 90 DAY) + WHERE i.expiry_date IS NOT NULL + AND DATE(i.expiry_date) BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 90 DAY) + GROUP BY i.id, i.item_code, i.name, i.current_stock, i.expiry_date + HAVING used_qty > 0 + ORDER BY i.expiry_date ASC, used_qty DESC + LIMIT 25" + )->getResultArray(); + + $lowStockNoRequest = $this->db->query( + "SELECT i.item_code, i.name, i.current_stock, i.reorder_level + FROM hsm_items i + WHERE i.current_stock <= i.reorder_level + AND NOT EXISTS ( + SELECT 1 + FROM hsm_indent_items hi + JOIN hsm_indents h ON h.id = hi.indent_id + WHERE hi.item_id = i.id + AND h.status IN ('pending','approved','partial_issued') + ) + AND NOT EXISTS ( + SELECT 1 + FROM hsm_purchase_order_items pi + JOIN hsm_purchase_orders p ON p.id = pi.purchase_order_id + WHERE pi.item_id = i.id + AND p.status IN ('ordered','partial_received') + ) + ORDER BY i.current_stock ASC + LIMIT 25" + )->getResultArray(); + + $dailyUseStatus = $this->db->query( + "SELECT i.item_code, i.name, i.current_stock, + IFNULL(SUM(CASE WHEN DATE(hs.created_at)=CURDATE() THEN ii.qty ELSE 0 END),0) AS issued_today, + IFNULL(SUM(CASE WHEN DATE(hs.created_at)>=DATE_SUB(CURDATE(), INTERVAL 7 DAY) THEN ii.qty ELSE 0 END),0) AS issued_7d + FROM hsm_items i + LEFT JOIN hsm_issue_items ii ON ii.item_id = i.id + LEFT JOIN hsm_issues hs ON hs.id = ii.issue_id + WHERE IFNULL(i.is_daily_use,0) = 1 + GROUP BY i.id, i.item_code, i.name, i.current_stock + ORDER BY i.name ASC + LIMIT 30" + )->getResultArray(); + + return view('Setting/Stock/dashboard', [ + 'stats' => $stats, + 'newRequests' => $newRequests, + 'nearExpiryFrequent' => $nearExpiryFrequent, + 'lowStockNoRequest' => $lowStockNoRequest, + 'dailyUseStatus' => $dailyUseStatus, + ]); + } + + public function masters(): string + { + if (! $this->userCan(self::PERM_MASTER_MANAGE)) { + return '
Access denied.
'; + } + + $categories = (new HospitalStockCategoryModel())->orderBy('name', 'ASC')->findAll(); + $items = $this->db->table('hsm_items i') + ->select('i.*, c.name AS category_name') + ->join('hsm_categories c', 'c.id = i.category_id', 'left') + ->orderBy('i.id', 'DESC') + ->get() + ->getResultArray(); + $suppliers = (new HospitalStockSupplierModel())->orderBy('id', 'DESC')->findAll(); + + return view('Setting/Stock/masters', [ + 'categories' => $categories, + 'items' => $items, + 'suppliers' => $suppliers, + ]); + } + + public function indents(): string + { + if (! $this->hasAnyPermission([self::PERM_INDENT_CREATE, self::PERM_INDENT_APPROVE, self::PERM_ISSUE])) { + return '
Access denied.
'; + } + + $items = $this->db->table('hsm_items')->select('id, item_code, name')->where('status', 'active')->orderBy('name', 'ASC')->get()->getResultArray(); + $indents = $this->db->table('hsm_indents')->orderBy('id', 'DESC')->limit(100)->get()->getResultArray(); + + $departments = []; + if ($this->db->tableExists('hc_department')) { + $departments = $this->db->table('hc_department') + ->select('iId, vName') + ->orderBy('vName', 'ASC') + ->get() + ->getResultArray(); + } + + return view('Setting/Stock/indents', [ + 'items' => $items, + 'indents' => $indents, + 'departments' => $departments, + 'canIndentCreate' => $this->userCan(self::PERM_INDENT_CREATE), + 'canIndentApprove' => $this->userCan(self::PERM_INDENT_APPROVE), + 'canIssue' => $this->userCan(self::PERM_ISSUE), + ]); + } + + public function purchase(): string + { + if (! $this->userCan(self::PERM_PURCHASE_MANAGE)) { + return '
Access denied.
'; + } + + $items = $this->db->table('hsm_items')->select('id, item_code, name')->where('status', 'active')->orderBy('name', 'ASC')->get()->getResultArray(); + $suppliers = (new HospitalStockSupplierModel())->where('status', 'active')->orderBy('name', 'ASC')->findAll(); + $purchaseOrders = $this->db->table('hsm_purchase_orders p') + ->select('p.*, s.name AS supplier_name') + ->join('hsm_suppliers s', 's.id = p.supplier_id', 'left') + ->orderBy('p.id', 'DESC') + ->limit(100) + ->get() + ->getResultArray(); + + return view('Setting/Stock/purchase', [ + 'items' => $items, + 'suppliers' => $suppliers, + 'purchaseOrders' => $purchaseOrders, + ]); + } + + public function reports(): string + { + if (! $this->userCan(self::PERM_REPORT_VIEW)) { + return '
Access denied.
'; + } + + $alerts = $this->db->table('hsm_items i') + ->select('i.item_code, i.name, i.current_stock, i.min_stock_level, i.reorder_level, i.expiry_date') + ->groupStart() + ->where('i.current_stock <= i.min_stock_level', null, false) + ->orWhere('i.current_stock <= i.reorder_level', null, false) + ->groupEnd() + ->orderBy('i.current_stock', 'ASC') + ->limit(100) + ->get() + ->getResultArray(); + + return view('Setting/Stock/reports', [ + 'alerts' => $alerts, + ]); + } + + public function saveCategory() + { + if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $name = trim((string) $this->request->getPost('name')); + $status = trim((string) ($this->request->getPost('status') ?? 'active')); + + if ($name === '') { + return $this->jsonError('Category name is required.'); + } + + $exists = $this->db->query('SELECT id FROM hsm_categories WHERE LOWER(name) = ? LIMIT 1', [strtolower($name)])->getNumRows(); + if ($exists > 0) { + return $this->jsonError('Category already exists.'); + } + + $id = (new HospitalStockCategoryModel())->insert([ + 'name' => $name, + 'description' => $this->request->getPost('description'), + 'status' => in_array($status, ['active', 'inactive'], true) ? $status : 'active', + ]); + + $this->auditLog('category', 'create', 'hsm_categories', (int) $id, 'Created category: ' . $name, ['name' => $name]); + + return $this->jsonSuccess('Category saved successfully.', ['insertid' => $id]); + } + + public function updateCategory() + { + if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $id = (int) $this->request->getPost('id'); + $name = trim((string) $this->request->getPost('name')); + if ($id <= 0 || $name === '') { + return $this->jsonError('Invalid category data.'); + } + + $exists = $this->db->query('SELECT id FROM hsm_categories WHERE LOWER(name)=? AND id!=? LIMIT 1', [strtolower($name), $id])->getNumRows(); + if ($exists > 0) { + return $this->jsonError('Category already exists.'); + } + + (new HospitalStockCategoryModel())->update($id, [ + 'name' => $name, + 'description' => $this->request->getPost('description'), + 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active', + ]); + + $this->auditLog('category', 'update', 'hsm_categories', $id, 'Updated category: ' . $name); + + return $this->jsonSuccess('Category updated successfully.'); + } + + public function deleteCategory() + { + if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $id = (int) $this->request->getPost('id'); + if ($id <= 0) { + return $this->jsonError('Invalid category id.'); + } + + $itemCount = $this->db->table('hsm_items')->where('category_id', $id)->countAllResults(); + if ($itemCount > 0) { + return $this->jsonError('Cannot delete category with mapped items.'); + } + + (new HospitalStockCategoryModel())->delete($id); + $this->auditLog('category', 'delete', 'hsm_categories', $id, 'Deleted category id: ' . $id); + + return $this->jsonSuccess('Category deleted successfully.'); + } + + public function saveItem() + { + if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $itemCode = trim((string) $this->request->getPost('item_code')); + $name = trim((string) $this->request->getPost('name')); + $categoryId = (int) $this->request->getPost('category_id'); + + if ($itemCode === '' || $name === '' || $categoryId <= 0) { + return $this->jsonError('Item code, name and category are required.'); + } + + $exists = $this->db->table('hsm_items')->where('item_code', $itemCode)->countAllResults(); + if ($exists > 0) { + return $this->jsonError('Item code already exists.'); + } + + $model = new HospitalStockItemModel(); + $id = $model->insert([ + 'item_code' => $itemCode, + 'name' => $name, + 'category_id' => $categoryId, + 'item_type' => trim((string) ($this->request->getPost('item_type') ?? '')), + 'uom' => trim((string) ($this->request->getPost('uom') ?? 'Unit')), + 'purchase_uom' => trim((string) ($this->request->getPost('purchase_uom') ?? 'Unit')), + 'issue_uom' => trim((string) ($this->request->getPost('issue_uom') ?? 'Unit')), + 'issue_per_purchase' => (float) ($this->request->getPost('issue_per_purchase') ?? 1), + 'is_daily_use' => $this->request->getPost('is_daily_use') ? 1 : 0, + 'store_location' => trim((string) ($this->request->getPost('store_location') ?? '')), + 'barcode' => trim((string) ($this->request->getPost('barcode') ?? '')), + 'qr_code' => trim((string) ($this->request->getPost('qr_code') ?? '')), + 'current_stock' => (float) ($this->request->getPost('current_stock') ?? 0), + 'min_stock_level' => (float) ($this->request->getPost('min_stock_level') ?? 0), + 'reorder_level' => (float) ($this->request->getPost('reorder_level') ?? 0), + 'unit_cost' => (float) ($this->request->getPost('unit_cost') ?? 0), + 'expiry_date' => $this->request->getPost('expiry_date') ?: null, + 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active', + ]); + + $openingStock = (float) ($this->request->getPost('current_stock') ?? 0); + if ($openingStock > 0) { + $this->appendStockLedger((int) $id, 'adjustment_in', 'hsm_items', (int) $id, $openingStock, 0, 'Opening stock'); + } + + $this->auditLog('item', 'create', 'hsm_items', (int) $id, 'Created item: ' . $name, ['item_code' => $itemCode]); + + return $this->jsonSuccess('Item saved successfully.', ['insertid' => $id]); + } + + public function updateItem() + { + if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $id = (int) $this->request->getPost('id'); + $itemCode = trim((string) $this->request->getPost('item_code')); + $name = trim((string) $this->request->getPost('name')); + $categoryId = (int) $this->request->getPost('category_id'); + + if ($id <= 0 || $itemCode === '' || $name === '' || $categoryId <= 0) { + return $this->jsonError('Invalid item data.'); + } + + $exists = $this->db->table('hsm_items')->where('item_code', $itemCode)->where('id !=', $id)->countAllResults(); + if ($exists > 0) { + return $this->jsonError('Item code already exists.'); + } + + $current = $this->db->table('hsm_items')->where('id', $id)->get()->getRowArray(); + if (! $current) { + return $this->jsonError('Item not found.'); + } + + $newStock = (float) ($this->request->getPost('current_stock') ?? 0); + $oldStock = (float) ($current['current_stock'] ?? 0); + + (new HospitalStockItemModel())->update($id, [ + 'item_code' => $itemCode, + 'name' => $name, + 'category_id' => $categoryId, + 'item_type' => trim((string) ($this->request->getPost('item_type') ?? '')), + 'uom' => trim((string) ($this->request->getPost('uom') ?? 'Unit')), + 'purchase_uom' => trim((string) ($this->request->getPost('purchase_uom') ?? 'Unit')), + 'issue_uom' => trim((string) ($this->request->getPost('issue_uom') ?? 'Unit')), + 'issue_per_purchase' => (float) ($this->request->getPost('issue_per_purchase') ?? 1), + 'is_daily_use' => $this->request->getPost('is_daily_use') ? 1 : 0, + 'store_location' => trim((string) ($this->request->getPost('store_location') ?? '')), + 'barcode' => trim((string) ($this->request->getPost('barcode') ?? '')), + 'qr_code' => trim((string) ($this->request->getPost('qr_code') ?? '')), + 'current_stock' => $newStock, + 'min_stock_level' => (float) ($this->request->getPost('min_stock_level') ?? 0), + 'reorder_level' => (float) ($this->request->getPost('reorder_level') ?? 0), + 'unit_cost' => (float) ($this->request->getPost('unit_cost') ?? 0), + 'expiry_date' => $this->request->getPost('expiry_date') ?: null, + 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active', + ]); + + $diff = $newStock - $oldStock; + if ($diff > 0) { + $this->appendStockLedger($id, 'adjustment_in', 'hsm_items', $id, $diff, 0, 'Manual stock correction (+)'); + } elseif ($diff < 0) { + $this->appendStockLedger($id, 'adjustment_out', 'hsm_items', $id, 0, abs($diff), 'Manual stock correction (-)'); + } + + $this->auditLog('item', 'update', 'hsm_items', $id, 'Updated item: ' . $name, ['item_code' => $itemCode]); + + return $this->jsonSuccess('Item updated successfully.'); + } + + public function deleteItem() + { + if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $id = (int) $this->request->getPost('id'); + if ($id <= 0) { + return $this->jsonError('Invalid item id.'); + } + + $indentRows = $this->db->table('hsm_indent_items')->where('item_id', $id)->countAllResults(); + $poRows = $this->db->table('hsm_purchase_order_items')->where('item_id', $id)->countAllResults(); + $issueRows = $this->db->table('hsm_issue_items')->where('item_id', $id)->countAllResults(); + + if ($indentRows > 0 || $poRows > 0 || $issueRows > 0) { + return $this->jsonError('Cannot delete item linked with transactions.'); + } + + (new HospitalStockItemModel())->delete($id); + $this->auditLog('item', 'delete', 'hsm_items', $id, 'Deleted item id: ' . $id); + + return $this->jsonSuccess('Item deleted successfully.'); + } + + public function saveSupplier() + { + if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $name = trim((string) $this->request->getPost('name')); + if ($name === '') { + return $this->jsonError('Supplier name is required.'); + } + + $id = (new HospitalStockSupplierModel())->insert([ + 'name' => $name, + 'contact_person' => trim((string) ($this->request->getPost('contact_person') ?? '')), + 'phone' => trim((string) ($this->request->getPost('phone') ?? '')), + 'email' => trim((string) ($this->request->getPost('email') ?? '')), + 'address' => $this->request->getPost('address'), + 'gst_no' => trim((string) ($this->request->getPost('gst_no') ?? '')), + 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active', + ]); + + $this->auditLog('supplier', 'create', 'hsm_suppliers', (int) $id, 'Created supplier: ' . $name, ['name' => $name]); + + return $this->jsonSuccess('Supplier saved successfully.', ['insertid' => $id]); + } + + public function updateSupplier() + { + if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $id = (int) $this->request->getPost('id'); + $name = trim((string) $this->request->getPost('name')); + if ($id <= 0 || $name === '') { + return $this->jsonError('Invalid supplier data.'); + } + + (new HospitalStockSupplierModel())->update($id, [ + 'name' => $name, + 'contact_person' => trim((string) ($this->request->getPost('contact_person') ?? '')), + 'phone' => trim((string) ($this->request->getPost('phone') ?? '')), + 'email' => trim((string) ($this->request->getPost('email') ?? '')), + 'address' => $this->request->getPost('address'), + 'gst_no' => trim((string) ($this->request->getPost('gst_no') ?? '')), + 'status' => $this->request->getPost('status') === 'inactive' ? 'inactive' : 'active', + ]); + + $this->auditLog('supplier', 'update', 'hsm_suppliers', $id, 'Updated supplier: ' . $name); + + return $this->jsonSuccess('Supplier updated successfully.'); + } + + public function deleteSupplier() + { + if ($deny = $this->denyUnless(self::PERM_MASTER_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $id = (int) $this->request->getPost('id'); + if ($id <= 0) { + return $this->jsonError('Invalid supplier id.'); + } + + $poCount = $this->db->table('hsm_purchase_orders')->where('supplier_id', $id)->countAllResults(); + if ($poCount > 0) { + return $this->jsonError('Cannot delete supplier linked with purchase orders.'); + } + + (new HospitalStockSupplierModel())->delete($id); + $this->auditLog('supplier', 'delete', 'hsm_suppliers', $id, 'Deleted supplier id: ' . $id); + + return $this->jsonSuccess('Supplier deleted successfully.'); + } + + public function createIndent() + { + if ($deny = $this->denyUnless(self::PERM_INDENT_CREATE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $department = trim((string) $this->request->getPost('department_name')); + $itemIds = $this->request->getPost('item_id'); + $qtys = $this->request->getPost('qty'); + + if ($department === '') { + return $this->jsonError('Department name is required.'); + } + + if (! is_array($itemIds) || ! is_array($qtys) || count($itemIds) === 0) { + return $this->jsonError('At least one item is required for indent.'); + } + + $lines = []; + foreach ($itemIds as $k => $itemIdRaw) { + $itemId = (int) $itemIdRaw; + $qty = (float) ($qtys[$k] ?? 0); + if ($itemId > 0 && $qty > 0) { + $lines[] = ['item_id' => $itemId, 'qty' => $qty]; + } + } + + if ($lines === []) { + return $this->jsonError('No valid indent rows found.'); + } + + $this->db->transStart(); + + $indentCode = $this->nextCode('HIND'); + $userId = $this->currentUserId(); + + $this->db->table('hsm_indents')->insert([ + 'indent_code' => $indentCode, + 'department_name' => $department, + 'requested_by' => $userId, + 'remarks' => $this->request->getPost('remarks'), + 'status' => 'pending', + ]); + + $indentId = (int) $this->db->insertID(); + + foreach ($lines as $line) { + $this->db->table('hsm_indent_items')->insert([ + 'indent_id' => $indentId, + 'item_id' => $line['item_id'], + 'requested_qty' => $line['qty'], + 'approved_qty' => 0, + 'issued_qty' => 0, + ]); + } + + $this->auditLog('indent', 'create', 'hsm_indents', $indentId, 'Indent created: ' . $indentCode, ['department' => $department]); + + $this->db->transComplete(); + if (! $this->db->transStatus()) { + return $this->jsonError('Failed to create indent.'); + } + + return $this->jsonSuccess('Indent created successfully.', ['indent_id' => $indentId]); + } + + public function approveIndent() + { + if ($deny = $this->denyUnless(self::PERM_INDENT_APPROVE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $indentId = (int) $this->request->getPost('indent_id'); + if ($indentId <= 0) { + return $this->jsonError('Invalid indent ID.'); + } + + $indent = $this->db->table('hsm_indents')->where('id', $indentId)->get()->getRowArray(); + if (! $indent) { + return $this->jsonError('Indent not found.'); + } + + if (! in_array((string) $indent['status'], ['pending'], true)) { + return $this->jsonError('Only pending indents can be approved.'); + } + + $this->db->transStart(); + + $items = $this->db->table('hsm_indent_items')->where('indent_id', $indentId)->get()->getResultArray(); + foreach ($items as $row) { + $this->db->table('hsm_indent_items') + ->where('id', (int) $row['id']) + ->update(['approved_qty' => (float) ($row['requested_qty'] ?? 0)]); + } + + $this->db->table('hsm_indents')->where('id', $indentId)->update([ + 'status' => 'approved', + 'approved_by' => $this->currentUserId(), + 'approved_at' => date('Y-m-d H:i:s'), + ]); + + $this->auditLog('indent', 'approve', 'hsm_indents', $indentId, 'Indent approved: ' . ($indent['indent_code'] ?? '')); + + $this->db->transComplete(); + if (! $this->db->transStatus()) { + return $this->jsonError('Failed to approve indent.'); + } + + return $this->jsonSuccess('Indent approved successfully.'); + } + + public function issueIndent() + { + if ($deny = $this->denyUnless(self::PERM_ISSUE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $indentId = (int) $this->request->getPost('indent_id'); + if ($indentId <= 0) { + return $this->jsonError('Invalid indent ID.'); + } + + $indent = $this->db->table('hsm_indents')->where('id', $indentId)->get()->getRowArray(); + if (! $indent) { + return $this->jsonError('Indent not found.'); + } + + if (! in_array((string) $indent['status'], ['approved', 'partial_issued'], true)) { + return $this->jsonError('Indent must be approved before issue.'); + } + + $this->db->transStart(); + + $issueCode = $this->nextCode('HISS'); + $this->db->table('hsm_issues')->insert([ + 'issue_code' => $issueCode, + 'indent_id' => $indentId, + 'department_name' => (string) ($indent['department_name'] ?? ''), + 'issued_by' => $this->currentUserId(), + 'remarks' => $this->request->getPost('remarks'), + ]); + $issueId = (int) $this->db->insertID(); + + $rows = $this->db->table('hsm_indent_items')->where('indent_id', $indentId)->get()->getResultArray(); + $hasIssued = false; + $allFulfilled = true; + + foreach ($rows as $row) { + $itemId = (int) ($row['item_id'] ?? 0); + $approvedQty = (float) ($row['approved_qty'] ?? 0); + $issuedQty = (float) ($row['issued_qty'] ?? 0); + $remaining = $approvedQty - $issuedQty; + if ($itemId <= 0 || $remaining <= 0) { + continue; + } + + $item = $this->db->table('hsm_items')->where('id', $itemId)->get()->getRowArray(); + $stock = (float) ($item['current_stock'] ?? 0); + $issueNow = min($stock, $remaining); + + if ($issueNow <= 0) { + $allFulfilled = false; + continue; + } + + $updated = $this->db->table('hsm_items') + ->set('current_stock', 'current_stock - ' . $issueNow, false) + ->where('id', $itemId) + ->where('current_stock >=', $issueNow) + ->update(); + + if (! $updated || $this->db->affectedRows() <= 0) { + $allFulfilled = false; + continue; + } + + $this->db->table('hsm_indent_items') + ->set('issued_qty', 'issued_qty + ' . $issueNow, false) + ->where('id', (int) $row['id']) + ->update(); + + $this->db->table('hsm_issue_items')->insert([ + 'issue_id' => $issueId, + 'item_id' => $itemId, + 'qty' => $issueNow, + ]); + + $latest = $this->db->table('hsm_items')->select('current_stock')->where('id', $itemId)->get()->getRowArray(); + $balance = (float) ($latest['current_stock'] ?? 0); + + $this->appendStockLedger($itemId, 'issue', 'hsm_issues', $issueId, 0, $issueNow, 'Issued against indent: ' . ($indent['indent_code'] ?? '')); + + if ($balance < ($remaining - $issueNow)) { + $allFulfilled = false; + } + + $hasIssued = true; + } + + if (! $hasIssued) { + $this->db->transRollback(); + return $this->jsonError('No item could be issued (stock unavailable).'); + } + + $this->db->table('hsm_indents')->where('id', $indentId)->update([ + 'status' => $allFulfilled ? 'issued' : 'partial_issued', + ]); + + $this->auditLog('issue', 'create', 'hsm_issues', $issueId, 'Issue created: ' . $issueCode, ['indent_id' => $indentId]); + + $this->db->transComplete(); + if (! $this->db->transStatus()) { + return $this->jsonError('Failed to issue stock.'); + } + + return $this->jsonSuccess($allFulfilled ? 'Indent fully issued.' : 'Indent partially issued due to stock shortage.'); + } + + public function createPurchaseOrder() + { + if ($deny = $this->denyUnless(self::PERM_PURCHASE_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $supplierId = (int) $this->request->getPost('supplier_id'); + $itemIds = $this->request->getPost('item_id'); + $qtys = $this->request->getPost('qty'); + $rates = $this->request->getPost('unit_cost'); + + if ($supplierId <= 0) { + return $this->jsonError('Supplier is required.'); + } + + if (! is_array($itemIds) || ! is_array($qtys) || count($itemIds) === 0) { + return $this->jsonError('At least one purchase item is required.'); + } + + $lines = []; + foreach ($itemIds as $k => $itemIdRaw) { + $itemId = (int) $itemIdRaw; + $qty = (float) ($qtys[$k] ?? 0); + $rate = (float) ($rates[$k] ?? 0); + if ($itemId > 0 && $qty > 0) { + $lines[] = ['item_id' => $itemId, 'qty' => $qty, 'unit_cost' => $rate]; + } + } + + if ($lines === []) { + return $this->jsonError('No valid purchase lines found.'); + } + + $this->db->transStart(); + + $poCode = $this->nextCode('HPO'); + $this->db->table('hsm_purchase_orders')->insert([ + 'po_code' => $poCode, + 'supplier_id' => $supplierId, + 'order_date' => $this->request->getPost('order_date') ?: date('Y-m-d'), + 'expected_date' => $this->request->getPost('expected_date') ?: null, + 'ordered_by' => $this->currentUserId(), + 'status' => 'ordered', + 'remarks' => $this->request->getPost('remarks'), + ]); + + $poId = (int) $this->db->insertID(); + + foreach ($lines as $line) { + $this->db->table('hsm_purchase_order_items')->insert([ + 'purchase_order_id' => $poId, + 'item_id' => $line['item_id'], + 'ordered_qty' => $line['qty'], + 'received_qty' => 0, + 'unit_cost' => $line['unit_cost'], + ]); + } + + $this->auditLog('purchase', 'create', 'hsm_purchase_orders', $poId, 'PO created: ' . $poCode); + + $this->db->transComplete(); + if (! $this->db->transStatus()) { + return $this->jsonError('Failed to create purchase order.'); + } + + return $this->jsonSuccess('Purchase order created successfully.'); + } + + public function receivePurchaseOrder() + { + if ($deny = $this->denyUnless(self::PERM_PURCHASE_MANAGE)) { + return $deny; + } + + if (! $this->request->isAJAX()) { + return $this->response->setJSON(['update' => 0, 'error_text' => 'Invalid request']); + } + + $poId = (int) $this->request->getPost('purchase_order_id'); + if ($poId <= 0) { + return $this->jsonError('Invalid purchase order ID.'); + } + + $po = $this->db->table('hsm_purchase_orders')->where('id', $poId)->get()->getRowArray(); + if (! $po) { + return $this->jsonError('Purchase order not found.'); + } + + if (in_array((string) $po['status'], ['completed', 'cancelled'], true)) { + return $this->jsonError('This purchase order cannot be received.'); + } + + $this->db->transStart(); + + $rows = $this->db->table('hsm_purchase_order_items')->where('purchase_order_id', $poId)->get()->getResultArray(); + $allDone = true; + + foreach ($rows as $row) { + $itemId = (int) ($row['item_id'] ?? 0); + $ordered = (float) ($row['ordered_qty'] ?? 0); + $received = (float) ($row['received_qty'] ?? 0); + $remaining = $ordered - $received; + if ($itemId <= 0 || $remaining <= 0) { + continue; + } + + $this->db->table('hsm_items') + ->set('current_stock', 'current_stock + ' . $remaining, false) + ->where('id', $itemId) + ->update(); + + $this->db->table('hsm_purchase_order_items') + ->set('received_qty', 'received_qty + ' . $remaining, false) + ->where('id', (int) $row['id']) + ->update(); + + if ((float) ($row['unit_cost'] ?? 0) > 0) { + $this->db->table('hsm_items')->where('id', $itemId)->update([ + 'unit_cost' => (float) ($row['unit_cost'] ?? 0), + ]); + } + + $this->appendStockLedger($itemId, 'purchase', 'hsm_purchase_orders', $poId, $remaining, 0, 'PO receive: ' . ($po['po_code'] ?? '')); + } + + $recheck = $this->db->table('hsm_purchase_order_items')->where('purchase_order_id', $poId)->get()->getResultArray(); + foreach ($recheck as $line) { + if ((float) ($line['received_qty'] ?? 0) < (float) ($line['ordered_qty'] ?? 0)) { + $allDone = false; + break; + } + } + + $this->db->table('hsm_purchase_orders')->where('id', $poId)->update([ + 'status' => $allDone ? 'completed' : 'partial_received', + ]); + + $this->auditLog('purchase', 'receive', 'hsm_purchase_orders', $poId, 'PO stock received: ' . ($po['po_code'] ?? '')); + + $this->db->transComplete(); + if (! $this->db->transStatus()) { + return $this->jsonError('Failed to receive purchase order.'); + } + + return $this->jsonSuccess($allDone ? 'PO completed and stock updated.' : 'PO partially received.'); + } + + public function reportDepartmentConsumption(): string + { + if (! $this->userCan(self::PERM_REPORT_VIEW)) { + return 'Access denied.'; + } + + $from = trim((string) $this->request->getGet('from')); + $to = trim((string) $this->request->getGet('to')); + if ($from === '') { + $from = date('Y-m-01'); + } + if ($to === '') { + $to = date('Y-m-d'); + } + + $rows = $this->db->table('hsm_issue_items ii') + ->select('i.department_name, m.item_code, m.name AS item_name, SUM(ii.qty) AS total_qty', false) + ->join('hsm_issues i', 'i.id = ii.issue_id', 'inner') + ->join('hsm_items m', 'm.id = ii.item_id', 'inner') + ->where('DATE(i.created_at) >=', $from) + ->where('DATE(i.created_at) <=', $to) + ->groupBy('i.department_name, m.item_code, m.name') + ->orderBy('i.department_name', 'ASC') + ->orderBy('m.name', 'ASC') + ->get() + ->getResultArray(); + + return view('Setting/Stock/report_print', [ + 'title' => 'Department-wise Consumption Report', + 'subtitle' => 'Period: ' . $from . ' to ' . $to, + 'columns' => ['Department', 'Item Code', 'Item Name', 'Total Issued Qty'], + 'rows' => array_map(static fn (array $r): array => [ + (string) ($r['department_name'] ?? ''), + (string) ($r['item_code'] ?? ''), + (string) ($r['item_name'] ?? ''), + (string) ($r['total_qty'] ?? '0'), + ], $rows), + ]); + } + + public function reportMonthlyIssue(): string + { + if (! $this->userCan(self::PERM_REPORT_VIEW)) { + return 'Access denied.'; + } + + $year = (int) ($this->request->getGet('year') ?: date('Y')); + if ($year < 2000 || $year > 2100) { + $year = (int) date('Y'); + } + + $rows = $this->db->table('hsm_issue_items ii') + ->select('DATE_FORMAT(i.created_at, "%Y-%m") AS issue_month, SUM(ii.qty) AS total_qty, COUNT(DISTINCT i.id) AS issue_count', false) + ->join('hsm_issues i', 'i.id = ii.issue_id', 'inner') + ->where('YEAR(i.created_at)', $year) + ->groupBy('DATE_FORMAT(i.created_at, "%Y-%m")', false) + ->orderBy('issue_month', 'ASC') + ->get() + ->getResultArray(); + + return view('Setting/Stock/report_print', [ + 'title' => 'Monthly Issue Report', + 'subtitle' => 'Year: ' . $year, + 'columns' => ['Month', 'Issue Transactions', 'Total Issued Qty'], + 'rows' => array_map(static fn (array $r): array => [ + (string) ($r['issue_month'] ?? ''), + (string) ($r['issue_count'] ?? '0'), + (string) ($r['total_qty'] ?? '0'), + ], $rows), + ]); + } + + public function reportNearExpiry(): string + { + if (! $this->userCan(self::PERM_REPORT_VIEW)) { + return 'Access denied.'; + } + + $days = (int) ($this->request->getGet('days') ?: 60); + if ($days <= 0 || $days > 3650) { + $days = 60; + } + + $today = date('Y-m-d'); + $toDate = date('Y-m-d', strtotime('+' . $days . ' days')); + + $rows = $this->db->table('hsm_items i') + ->select('i.item_code, i.name, c.name AS category_name, i.current_stock, i.expiry_date, DATEDIFF(i.expiry_date, CURDATE()) AS days_left', false) + ->join('hsm_categories c', 'c.id = i.category_id', 'left') + ->where('i.current_stock >', 0) + ->where('i.expiry_date IS NOT NULL', null, false) + ->where('DATE(i.expiry_date) >=', $today) + ->where('DATE(i.expiry_date) <=', $toDate) + ->orderBy('i.expiry_date', 'ASC') + ->get() + ->getResultArray(); + + return view('Setting/Stock/report_print', [ + 'title' => 'Near Expiry Report', + 'subtitle' => 'Next ' . $days . ' day(s)', + 'columns' => ['Item Code', 'Item Name', 'Category', 'Current Stock', 'Expiry Date', 'Days Left'], + 'rows' => array_map(static fn (array $r): array => [ + (string) ($r['item_code'] ?? ''), + (string) ($r['name'] ?? ''), + (string) ($r['category_name'] ?? ''), + (string) ($r['current_stock'] ?? '0'), + (string) ($r['expiry_date'] ?? ''), + (string) ($r['days_left'] ?? ''), + ], $rows), + ]); + } + + private function appendStockLedger(int $itemId, string $txnType, string $refTable, int $refId, float $qtyIn, float $qtyOut, string $remarks = ''): void + { + $row = $this->db->table('hsm_items')->select('current_stock')->where('id', $itemId)->get()->getRowArray(); + $balance = (float) ($row['current_stock'] ?? 0); + + $this->db->table('hsm_stock_ledger')->insert([ + 'item_id' => $itemId, + 'txn_type' => $txnType, + 'ref_table' => $refTable, + 'ref_id' => $refId, + 'qty_in' => $qtyIn, + 'qty_out' => $qtyOut, + 'balance_after' => $balance, + 'remarks' => $remarks, + 'created_by' => $this->currentUserId(), + ]); + } + + private function getStats(): array + { + $totalItems = $this->db->table('hsm_items')->where('status', 'active')->countAllResults(); + $totalCategories = $this->db->table('hsm_categories')->where('status', 'active')->countAllResults(); + $totalSuppliers = $this->db->table('hsm_suppliers')->where('status', 'active')->countAllResults(); + $pendingIndents = $this->db->table('hsm_indents')->whereIn('status', ['pending', 'approved', 'partial_issued'])->countAllResults(); + $openPO = $this->db->table('hsm_purchase_orders')->whereIn('status', ['ordered', 'partial_received'])->countAllResults(); + + $stockValueRow = $this->db->table('hsm_items') + ->select('SUM(current_stock * unit_cost) AS stock_value', false) + ->get() + ->getRowArray(); + $stockValue = (float) ($stockValueRow['stock_value'] ?? 0); + + $lowStock = $this->db->table('hsm_items') + ->where('current_stock <= min_stock_level', null, false) + ->countAllResults(); + + return [ + 'total_items' => $totalItems, + 'total_categories' => $totalCategories, + 'total_suppliers' => $totalSuppliers, + 'pending_indents' => $pendingIndents, + 'open_po' => $openPO, + 'low_stock' => $lowStock, + 'stock_value' => $stockValue, + ]; + } + + private function nextCode(string $prefix): string + { + $date = date('Ymd'); + $like = $prefix . '-' . $date . '-'; + $tableMap = [ + 'HIND' => ['table' => 'hsm_indents', 'column' => 'indent_code'], + 'HISS' => ['table' => 'hsm_issues', 'column' => 'issue_code'], + 'HPO' => ['table' => 'hsm_purchase_orders', 'column' => 'po_code'], + ]; + + $map = $tableMap[$prefix] ?? null; + $seq = 1; + + if ($map !== null && $this->db->tableExists($map['table'])) { + $row = $this->db->table($map['table']) + ->select($map['column']) + ->like($map['column'], $like, 'after') + ->orderBy($map['column'], 'DESC') + ->get(1) + ->getRowArray(); + + $lastCode = (string) ($row[$map['column']] ?? ''); + if (preg_match('/-(\d{4})$/', $lastCode, $m) === 1) { + $seq = ((int) $m[1]) + 1; + } + } + + return sprintf('%s-%s-%04d', $prefix, $date, $seq); + } + + private function auditLog(string $module, string $action, string $entityTable, ?int $entityId, string $description, array $meta = []): void + { + $this->db->table('hsm_audit_log')->insert([ + 'module' => $module, + 'action' => $action, + 'entity_table' => $entityTable, + 'entity_id' => $entityId, + 'description' => $description, + 'meta_json' => $meta ? json_encode($meta) : null, + 'created_by' => $this->currentUserId(), + ]); + } + + private function currentUserId(): ?int + { + if (function_exists('auth')) { + $user = auth()->user(); + if ($user && isset($user->id)) { + return (int) $user->id; + } + } + + return null; + } + + private function denyUnless(string $permission) + { + if ($this->userCan($permission)) { + return null; + } + + return $this->response->setStatusCode(403)->setJSON([ + 'update' => 0, + 'error_text' => 'Access denied.', + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + private function hasAnyPermission(array $permissions): bool + { + foreach ($permissions as $permission) { + if ($this->userCan($permission)) { + return true; + } + } + + return false; + } + + private function userCan(string $permission): bool + { + if (! function_exists('auth')) { + return false; + } + + $user = auth()->user(); + if (! $user) { + return false; + } + + if (method_exists($user, 'can') && $user->can($permission)) { + return true; + } + + if (method_exists($user, 'inGroup') && $user->inGroup('superadmin', 'admin', 'developer')) { + return true; + } + + return false; + } + + private function jsonError(string $message) + { + return $this->response->setJSON([ + 'update' => 0, + 'error_text' => $message, + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ]); + } + + private function jsonSuccess(string $message, array $extra = []) + { + return $this->response->setJSON(array_merge([ + 'update' => 1, + 'showcontent' => $message, + 'csrfName' => csrf_token(), + 'csrfHash' => csrf_hash(), + ], $extra)); + } + + private function isModuleReady(): bool + { + return $this->db->tableExists('hsm_items') + && $this->db->tableExists('hsm_categories') + && $this->db->tableExists('hsm_indents') + && $this->db->tableExists('hsm_purchase_orders'); + } +} diff --git a/app/Database/Migrations/2026-04-02-000034_CreateHospitalStockModule.php b/app/Database/Migrations/2026-04-02-000034_CreateHospitalStockModule.php new file mode 100644 index 0000000..3324965 --- /dev/null +++ b/app/Database/Migrations/2026-04-02-000034_CreateHospitalStockModule.php @@ -0,0 +1,305 @@ +createCategoriesTable(); + $this->createSuppliersTable(); + $this->createItemsTable(); + $this->createIndentsTable(); + $this->createIndentItemsTable(); + $this->createIssuesTable(); + $this->createIssueItemsTable(); + $this->createPurchaseOrdersTable(); + $this->createPurchaseOrderItemsTable(); + $this->createStockLedgerTable(); + $this->createAuditLogTable(); + } + + public function down() + { + $this->forge->dropTable('hsm_audit_log', true); + $this->forge->dropTable('hsm_stock_ledger', true); + $this->forge->dropTable('hsm_purchase_order_items', true); + $this->forge->dropTable('hsm_purchase_orders', true); + $this->forge->dropTable('hsm_issue_items', true); + $this->forge->dropTable('hsm_issues', true); + $this->forge->dropTable('hsm_indent_items', true); + $this->forge->dropTable('hsm_indents', true); + $this->forge->dropTable('hsm_items', true); + $this->forge->dropTable('hsm_suppliers', true); + $this->forge->dropTable('hsm_categories', true); + } + + private function createCategoriesTable(): void + { + if ($this->hasTable('hsm_categories')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'name' => ['type' => 'VARCHAR', 'constraint' => 120], + 'description' => ['type' => 'TEXT', 'null' => true], + 'status' => ['type' => 'ENUM', 'constraint' => ['active', 'inactive'], 'default' => 'active'], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('name'); + $this->forge->createTable('hsm_categories', true); + } + + private function createSuppliersTable(): void + { + if ($this->hasTable('hsm_suppliers')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'name' => ['type' => 'VARCHAR', 'constraint' => 150], + 'contact_person' => ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true], + 'phone' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true], + 'email' => ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true], + 'address' => ['type' => 'TEXT', 'null' => true], + 'gst_no' => ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true], + 'status' => ['type' => 'ENUM', 'constraint' => ['active', 'inactive'], 'default' => 'active'], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('name'); + $this->forge->createTable('hsm_suppliers', true); + } + + private function createItemsTable(): void + { + if ($this->hasTable('hsm_items')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'item_code' => ['type' => 'VARCHAR', 'constraint' => 40], + 'name' => ['type' => 'VARCHAR', 'constraint' => 150], + 'category_id' => ['type' => 'INT', 'unsigned' => true], + 'uom' => ['type' => 'VARCHAR', 'constraint' => 20, 'default' => 'Unit'], + 'barcode' => ['type' => 'VARCHAR', 'constraint' => 80, 'null' => true], + 'qr_code' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'current_stock' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'min_stock_level' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'reorder_level' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'unit_cost' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'expiry_date' => ['type' => 'DATE', 'null' => true], + 'status' => ['type' => 'ENUM', 'constraint' => ['active', 'inactive'], 'default' => 'active'], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('item_code'); + $this->forge->addKey('name'); + $this->forge->addKey('category_id'); + $this->forge->createTable('hsm_items', true); + } + + private function createIndentsTable(): void + { + if ($this->hasTable('hsm_indents')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'indent_code' => ['type' => 'VARCHAR', 'constraint' => 40], + 'department_name' => ['type' => 'VARCHAR', 'constraint' => 120], + 'requested_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true], + 'approved_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true], + 'approved_at' => ['type' => 'DATETIME', 'null' => true], + 'remarks' => ['type' => 'TEXT', 'null' => true], + 'status' => ['type' => 'ENUM', 'constraint' => ['pending', 'approved', 'partial_issued', 'issued', 'rejected', 'cancelled'], 'default' => 'pending'], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('indent_code'); + $this->forge->addKey('status'); + $this->forge->createTable('hsm_indents', true); + } + + private function createIndentItemsTable(): void + { + if ($this->hasTable('hsm_indent_items')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'indent_id' => ['type' => 'INT', 'unsigned' => true], + 'item_id' => ['type' => 'INT', 'unsigned' => true], + 'requested_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'approved_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'issued_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('indent_id'); + $this->forge->addKey('item_id'); + $this->forge->createTable('hsm_indent_items', true); + } + + private function createIssuesTable(): void + { + if ($this->hasTable('hsm_issues')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'issue_code' => ['type' => 'VARCHAR', 'constraint' => 40], + 'indent_id' => ['type' => 'INT', 'unsigned' => true], + 'issued_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true], + 'department_name' => ['type' => 'VARCHAR', 'constraint' => 120], + 'remarks' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('issue_code'); + $this->forge->addKey('indent_id'); + $this->forge->createTable('hsm_issues', true); + } + + private function createIssueItemsTable(): void + { + if ($this->hasTable('hsm_issue_items')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'issue_id' => ['type' => 'INT', 'unsigned' => true], + 'item_id' => ['type' => 'INT', 'unsigned' => true], + 'qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('issue_id'); + $this->forge->addKey('item_id'); + $this->forge->createTable('hsm_issue_items', true); + } + + private function createPurchaseOrdersTable(): void + { + if ($this->hasTable('hsm_purchase_orders')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'po_code' => ['type' => 'VARCHAR', 'constraint' => 40], + 'supplier_id' => ['type' => 'INT', 'unsigned' => true], + 'order_date' => ['type' => 'DATE', 'null' => true], + 'expected_date' => ['type' => 'DATE', 'null' => true], + 'ordered_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true], + 'status' => ['type' => 'ENUM', 'constraint' => ['draft', 'ordered', 'partial_received', 'completed', 'cancelled'], 'default' => 'ordered'], + 'remarks' => ['type' => 'TEXT', 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('po_code'); + $this->forge->addKey('supplier_id'); + $this->forge->addKey('status'); + $this->forge->createTable('hsm_purchase_orders', true); + } + + private function createPurchaseOrderItemsTable(): void + { + if ($this->hasTable('hsm_purchase_order_items')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'purchase_order_id' => ['type' => 'INT', 'unsigned' => true], + 'item_id' => ['type' => 'INT', 'unsigned' => true], + 'ordered_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'received_qty' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'unit_cost' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'expiry_date' => ['type' => 'DATE', 'null' => true], + 'batch_no' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('purchase_order_id'); + $this->forge->addKey('item_id'); + $this->forge->createTable('hsm_purchase_order_items', true); + } + + private function createStockLedgerTable(): void + { + if ($this->hasTable('hsm_stock_ledger')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'item_id' => ['type' => 'INT', 'unsigned' => true], + 'txn_type' => ['type' => 'ENUM', 'constraint' => ['purchase', 'issue', 'adjustment_in', 'adjustment_out'], 'default' => 'adjustment_in'], + 'ref_table' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'ref_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true], + 'qty_in' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'qty_out' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'balance_after' => ['type' => 'DECIMAL', 'constraint' => '14,2', 'default' => 0], + 'remarks' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], + 'created_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('item_id'); + $this->forge->addKey('txn_type'); + $this->forge->addKey('created_at'); + $this->forge->createTable('hsm_stock_ledger', true); + } + + private function createAuditLogTable(): void + { + if ($this->hasTable('hsm_audit_log')) { + return; + } + + $this->forge->addField([ + 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true], + 'module' => ['type' => 'VARCHAR', 'constraint' => 60], + 'action' => ['type' => 'VARCHAR', 'constraint' => 60], + 'entity_table' => ['type' => 'VARCHAR', 'constraint' => 60], + 'entity_id' => ['type' => 'INT', 'unsigned' => true, 'null' => true], + 'description' => ['type' => 'TEXT', 'null' => true], + 'meta_json' => ['type' => 'LONGTEXT', 'null' => true], + 'created_by' => ['type' => 'INT', 'unsigned' => true, 'null' => true], + 'created_at' => ['type' => 'DATETIME', 'null' => true], + 'updated_at' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('module'); + $this->forge->addKey('entity_table'); + $this->forge->createTable('hsm_audit_log', true); + } + + private function hasTable(string $table): bool + { + $query = $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table)); + + return $query->getNumRows() > 0; + } +} \ No newline at end of file diff --git a/app/Database/Migrations/2026-04-02-000035_EnhanceHospitalStockItems.php b/app/Database/Migrations/2026-04-02-000035_EnhanceHospitalStockItems.php new file mode 100644 index 0000000..0afd003 --- /dev/null +++ b/app/Database/Migrations/2026-04-02-000035_EnhanceHospitalStockItems.php @@ -0,0 +1,69 @@ +db->tableExists('hsm_items')) { + return; + } + + $columns = $this->getColumns('hsm_items'); + $fields = []; + + if (! in_array('item_type', $columns, true)) { + $fields['item_type'] = ['type' => 'VARCHAR', 'constraint' => 60, 'null' => true, 'after' => 'category_id']; + } + if (! in_array('purchase_uom', $columns, true)) { + $fields['purchase_uom'] = ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true, 'after' => 'uom']; + } + if (! in_array('issue_uom', $columns, true)) { + $fields['issue_uom'] = ['type' => 'VARCHAR', 'constraint' => 30, 'null' => true, 'after' => 'purchase_uom']; + } + if (! in_array('issue_per_purchase', $columns, true)) { + $fields['issue_per_purchase'] = ['type' => 'DECIMAL', 'constraint' => '14,4', 'default' => 1, 'after' => 'issue_uom']; + } + if (! in_array('is_daily_use', $columns, true)) { + $fields['is_daily_use'] = ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0, 'after' => 'issue_per_purchase']; + } + if (! in_array('store_location', $columns, true)) { + $fields['store_location'] = ['type' => 'VARCHAR', 'constraint' => 120, 'null' => true, 'after' => 'is_daily_use']; + } + + if ($fields !== []) { + $this->forge->addColumn('hsm_items', $fields); + } + + // Backfill sensible defaults for unit conversion. + $this->db->query("UPDATE hsm_items SET purchase_uom = IFNULL(NULLIF(purchase_uom,''), IFNULL(NULLIF(uom,''), 'Unit'))"); + $this->db->query("UPDATE hsm_items SET issue_uom = IFNULL(NULLIF(issue_uom,''), IFNULL(NULLIF(uom,''), 'Unit'))"); + $this->db->query("UPDATE hsm_items SET issue_per_purchase = IFNULL(NULLIF(issue_per_purchase,0), 1)"); + } + + public function down() + { + if (! $this->db->tableExists('hsm_items')) { + return; + } + + foreach (['store_location', 'is_daily_use', 'issue_per_purchase', 'issue_uom', 'purchase_uom', 'item_type'] as $column) { + if (in_array($column, $this->getColumns('hsm_items'), true)) { + $this->forge->dropColumn('hsm_items', $column); + } + } + } + + /** + * @return string[] + */ + private function getColumns(string $table): array + { + $result = $this->db->query('SHOW COLUMNS FROM ' . $this->db->protectIdentifiers($table))->getResultArray(); + + return array_map(static fn (array $row): string => (string) ($row['Field'] ?? ''), $result); + } +} diff --git a/app/Models/HospitalStockCategoryModel.php b/app/Models/HospitalStockCategoryModel.php new file mode 100644 index 0000000..5b50926 --- /dev/null +++ b/app/Models/HospitalStockCategoryModel.php @@ -0,0 +1,14 @@ +Bed Management +
+ + + Hospital Stock + +

diff --git a/app/Views/Setting/Stock/dashboard.php b/app/Views/Setting/Stock/dashboard.php new file mode 100644 index 0000000..4d2a9e4 --- /dev/null +++ b/app/Views/Setting/Stock/dashboard.php @@ -0,0 +1,98 @@ +
+
+
Inventory Dashboard
+ Focus: Expiry, Short Item, New Request +
+
+
+
Items
+
Low Stock
+
Pending Indents
+
Open PO
+
Stock Value
₹
+
+ +
+
+
New Request (Pending Indent)
+
+ + + + + + + + + + + +
IndentDepartmentDate
+
+
+
+
Regularly Used Items Near Expiry
+
+ + + + + + + + + + + + +
ItemUsed QtyStockExpiry
-
+
+
+
+
Short Item with No Replenishment Request
+
+ + + + + + + + + + + +
ItemStockReorder
-
+
+
+
+
Daily Use Item Status
+
+ + + + + + + + + + + + +
ItemToday7d UseStock
-
+
+
+
+
+
+ diff --git a/app/Views/Setting/Stock/indents.php b/app/Views/Setting/Stock/indents.php new file mode 100644 index 0000000..6990d37 --- /dev/null +++ b/app/Views/Setting/Stock/indents.php @@ -0,0 +1,138 @@ +
+
+
Department Indents & Issue
+ +
+
+
+ +
+
+ +
+ +
+
+
+
+
+ +
+
+ +
+
+
+
+
+ + + +
ItemQtyUOM
+
+ +
Requests
+ + + + + + + + + + + + + + +
Req NoDateDepartmentStatusRequested ByAction
+ + + + + + +
+
+
+ + diff --git a/app/Views/Setting/Stock/index.php b/app/Views/Setting/Stock/index.php new file mode 100644 index 0000000..745a810 --- /dev/null +++ b/app/Views/Setting/Stock/index.php @@ -0,0 +1,84 @@ +
+
+

Hospital Stock Management

+ +
+ + + +
+
+
+
+
+ + \ No newline at end of file diff --git a/app/Views/Setting/Stock/masters.php b/app/Views/Setting/Stock/masters.php new file mode 100644 index 0000000..da19c64 --- /dev/null +++ b/app/Views/Setting/Stock/masters.php @@ -0,0 +1,259 @@ +
+
Item-wise Masters
+
+
+
+
+
Category
+
+ + + + + +
+ + +
+
+
+ + + + + + + + + + + +
CategoryStatusAction
+ + +
+
+ +
+
Item
+
+ + +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ + +
+
+
+ +
+ + + + + + + + + + + + + + +
CodeNameTypeUOMStockAction
+ + +
+
+ +
+
Supplier
+
+ + +
+
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + +
NamePhoneEmailAction
+ + +
+
+
+
+
+ + diff --git a/app/Views/Setting/Stock/purchase.php b/app/Views/Setting/Stock/purchase.php new file mode 100644 index 0000000..d237457 --- /dev/null +++ b/app/Views/Setting/Stock/purchase.php @@ -0,0 +1,124 @@ +
+
+
Purchase Orders & Receiving
+ +
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + + +
ItemQtyRateTax %
+
+ +
Purchase Orders
+ + + + + + + + + + + + + + +
PO NoDateSupplierStatusTotalAction
- + + + +
+
+
+ + diff --git a/app/Views/Setting/Stock/report_print.php b/app/Views/Setting/Stock/report_print.php new file mode 100644 index 0000000..f4f270c --- /dev/null +++ b/app/Views/Setting/Stock/report_print.php @@ -0,0 +1,56 @@ + + + + + <?= esc((string) ($title ?? 'Report')) ?> + + + +

+

+ + + + + + + + + + + + + + + + + + + +
+ +
No records found for selected criteria.
+ + + + + + + diff --git a/app/Views/Setting/Stock/reports.php b/app/Views/Setting/Stock/reports.php new file mode 100644 index 0000000..dbb5919 --- /dev/null +++ b/app/Views/Setting/Stock/reports.php @@ -0,0 +1,83 @@ +
+
Stock Reports & Alerts
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + + +
+
+ +
+ Reports open in print-ready format in a new tab. +
+ +
Current Alert Items (Low / Reorder)
+ + + + + + + + + + + + + + + + + + + + + + + +
Item CodeItem NameCurrent StockMin StockReorder LevelExpiry Date
+
+
+ + diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php index c42ba83..a9e4f4a 100644 --- a/app/Views/partials/sidebar.php +++ b/app/Views/partials/sidebar.php @@ -113,6 +113,12 @@ if (! $canDiagnosis && $user && method_exists($user, 'inGroup')) { Bed Status +