From 8cbf55537f5a3d6574eeb9d8dd605b2a4afac176 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Mon, 19 Oct 2026 15:48:53 +0530 Subject: [PATCH] feat(pharmacy): implement Purchase Orders (PO) and Inward Goods Received Notes (GRN) workflow --- app/Config/Routes.php | 18 +- app/Controllers/MedicalProcurement.php | 973 ++++++++++++++++++ ...01_CreatePharmacyGrnAndEnhancePoTables.php | 408 ++++++++ app/Models/MedicalModel.php | 48 +- app/Models/PharmacyGrnItemModel.php | 45 + app/Models/PharmacyGrnModel.php | 54 + app/Models/PharmacyPurchaseOrderItemModel.php | 29 + app/Models/PharmacyPurchaseOrderModel.php | 55 + app/Views/medical/dashboard.php | 6 + app/Views/medical/grn_detail.php | 177 ++++ app/Views/medical/grn_form.php | 462 +++++++++ app/Views/medical/grn_list.php | 172 ++++ app/Views/medical/grn_print_pdf.php | 133 +++ app/Views/medical/main_store_dashboard.php | 12 +- app/Views/medical/po_form.php | 464 +++++++++ app/Views/medical/po_list.php | 191 ++++ app/Views/medical/po_print_pdf.php | 135 +++ 17 files changed, 3373 insertions(+), 9 deletions(-) create mode 100644 app/Controllers/MedicalProcurement.php create mode 100644 app/Database/Migrations/2026-10-19-000001_CreatePharmacyGrnAndEnhancePoTables.php create mode 100644 app/Models/PharmacyGrnItemModel.php create mode 100644 app/Models/PharmacyGrnModel.php create mode 100644 app/Models/PharmacyPurchaseOrderItemModel.php create mode 100644 app/Models/PharmacyPurchaseOrderModel.php create mode 100644 app/Views/medical/grn_detail.php create mode 100644 app/Views/medical/grn_form.php create mode 100644 app/Views/medical/grn_list.php create mode 100644 app/Views/medical/grn_print_pdf.php create mode 100644 app/Views/medical/po_form.php create mode 100644 app/Views/medical/po_list.php create mode 100644 app/Views/medical/po_print_pdf.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index b72f25c..96e8c06 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -125,7 +125,23 @@ $routes->post('Product_master/Product_search', 'Medical::product_search'); $routes->get('Product_master/drug_terminology_autocomplete', 'Medical::drug_terminology_autocomplete'); $routes->get('Product_master/drug_terminology_detail', 'Medical::drug_terminology_detail'); $routes->get('Product_master/Product_edit/(:num)', 'Medical::product_edit/$1'); -$routes->post('Product_master/product_master_update/(:num)', 'Medical::product_master_update/$1'); +// Pharmacy Procurement: Purchase Orders (PO) & GRN +$routes->get('Medical/purchase_orders', 'MedicalProcurement::po_list'); +$routes->get('Medical/po_new', 'MedicalProcurement::po_new'); +$routes->post('Medical/po_create', 'MedicalProcurement::po_create'); +$routes->get('Medical/po_edit/(:num)', 'MedicalProcurement::po_edit/$1'); +$routes->post('Medical/po_status/(:num)', 'MedicalProcurement::po_status/$1'); +$routes->get('Medical/po_print/(:num)', 'MedicalProcurement::po_print/$1'); +$routes->get('Medical/po_reorder_items/(:num)', 'MedicalProcurement::po_reorder_items/$1'); + +$routes->get('Medical/grn_list', 'MedicalProcurement::grn_list'); +$routes->get('Medical/grn_new', 'MedicalProcurement::grn_new'); +$routes->get('Medical/grn_new/(:num)', 'MedicalProcurement::grn_new/$1'); +$routes->post('Medical/grn_create', 'MedicalProcurement::grn_create'); +$routes->get('Medical/grn_edit/(:num)', 'MedicalProcurement::grn_edit/$1'); +$routes->post('Medical/grn_post_inventory/(:num)', 'MedicalProcurement::grn_post_inventory/$1'); +$routes->get('Medical/grn_print/(:num)', 'MedicalProcurement::grn_print/$1'); + $routes->get('Medical/Purchase', 'Medical::purchase'); $routes->post('Medical/PurchaseInvoice', 'Medical::purchase_invoice'); $routes->get('Medical/PurchaseNew', 'Medical::purchase_new'); diff --git a/app/Controllers/MedicalProcurement.php b/app/Controllers/MedicalProcurement.php new file mode 100644 index 0000000..72f89e7 --- /dev/null +++ b/app/Controllers/MedicalProcurement.php @@ -0,0 +1,973 @@ +db = \Config\Database::connect(); + $this->poModel = new PharmacyPurchaseOrderModel(); + $this->poItemModel = new PharmacyPurchaseOrderItemModel(); + $this->grnModel = new PharmacyGrnModel(); + $this->grnItemModel = new PharmacyGrnItemModel(); + } + + private function ensurePharmacyAccess() + { + $user = service('auth')->user(); + + $allowed = false; + if ($user && method_exists($user, 'can')) { + $allowed = $user->can('pharmacy.access') + || $user->can('billing.access') + || $user->can('pharmacy.po.create'); + } + + if (! $allowed && $user && method_exists($user, 'inGroup')) { + $allowed = $user->inGroup('superadmin', 'admin', 'developer'); + } + + if (! $allowed) { + $sess = session(); + if ($sess->get('logged_in') || $sess->get('user_id') || $sess->get('username') || $sess->get('agent_id')) { + $allowed = true; + } + } + + if ($allowed) { + return null; + } + + return $this->response + ->setStatusCode(403) + ->setBody('
Access denied for Pharmacy Procurement module.
'); + } + + private function getCurrentUserId(): int + { + $user = service('auth')->user(); + if ($user && isset($user->id)) { + return (int) $user->id; + } + return (int) (session()->get('user_id') ?? 1); + } + + private function getCurrentUserLabel(): string + { + $user = service('auth')->user(); + if ($user) { + $name = trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? '')); + return ($name !== '' ? $name : ($user->username ?? 'User')) . '[' . ($user->id ?? 0) . ']'; + } + $legacyName = session()->get('username') ?? 'Admin'; + return $legacyName . '[' . $this->getCurrentUserId() . ']'; + } + + // ========================================================================= + // 1. PURCHASE ORDERS (PO) + // ========================================================================= + + public function po_list() + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $status = trim((string)($this->request->getGet('status') ?? '')); + $supplierId = (int)($this->request->getGet('supplier_id') ?? 0); + $fromDate = trim((string)($this->request->getGet('from') ?? '')); + $toDate = trim((string)($this->request->getGet('to') ?? '')); + $search = trim((string)($this->request->getGet('q') ?? '')); + + $builder = $this->db->table('mst_purchase_orders po') + ->select('po.*, s.name_supplier, s.contact_no AS supplier_phone') + ->join('med_supplier s', 's.sid = po.supplier_id', 'left'); + + if (!empty($status) && $status !== 'all') { + $builder->where('po.status', $status); + } + if ($supplierId > 0) { + $builder->where('po.supplier_id', $supplierId); + } + if (!empty($fromDate)) { + $builder->where('po.po_date >=', $fromDate); + } + if (!empty($toDate)) { + $builder->where('po.po_date <=', $toDate); + } + if (!empty($search)) { + $builder->groupStart() + ->like('po.po_number', $search) + ->orLike('s.name_supplier', $search) + ->groupEnd(); + } + + $orders = $builder->orderBy('po.po_id', 'DESC')->get(150)->getResultArray(); + + // Get suppliers list for filter dropdown + $suppliers = $this->db->table('med_supplier') + ->select('sid, name_supplier') + ->orderBy('name_supplier', 'ASC') + ->get() + ->getResultArray(); + + return view('medical/po_list', [ + 'orders' => $orders, + 'suppliers' => $suppliers, + 'status' => $status, + 'supplierId' => $supplierId, + 'fromDate' => $fromDate, + 'toDate' => $toDate, + 'search' => $search, + ]); + } + + public function po_new() + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $suppliers = $this->db->table('med_supplier') + ->select('sid, name_supplier, contact_no, city, state, gst_no') + ->orderBy('name_supplier', 'ASC') + ->get() + ->getResultArray(); + + $stores = []; + if ($this->db->tableExists('mst_stores')) { + $stores = $this->db->table('mst_stores') + ->where('is_active', 1) + ->orderBy('is_main_store', 'DESC') + ->get() + ->getResultArray(); + } + + $nextPoNumber = $this->poModel->generatePoNumber(); + + return view('medical/po_form', [ + 'suppliers' => $suppliers, + 'stores' => $stores, + 'nextPoNumber' => $nextPoNumber, + 'po' => null, + 'poItems' => [], + ]); + } + + public function po_create() + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $supplierId = (int)$this->request->getPost('supplier_id'); + $storeId = (int)($this->request->getPost('store_id') ?: 1); + $poDate = trim((string)$this->request->getPost('po_date') ?: date('Y-m-d')); + $expectedDelivery = trim((string)$this->request->getPost('expected_delivery_date') ?: null); + $paymentTerms = trim((string)$this->request->getPost('payment_terms') ?: 'Net 30 Days'); + $remarks = trim((string)$this->request->getPost('remarks') ?: ''); + $status = trim((string)$this->request->getPost('status') ?: 'ordered'); + + $itemsRaw = $this->request->getPost('items'); + $items = is_array($itemsRaw) ? $itemsRaw : []; + + if ($supplierId <= 0) { + return $this->response->setJSON(['status' => 0, 'message' => 'Please select a valid supplier.']); + } + if (empty($items)) { + return $this->response->setJSON(['status' => 0, 'message' => 'Please add at least one item to the purchase order.']); + } + + $poNumber = $this->poModel->generatePoNumber(); + $userId = $this->getCurrentUserId(); + + $totalEstimatedAmount = 0.0; + $totalTaxAmount = 0.0; + $validItems = []; + + foreach ($items as $row) { + $itemId = (int)($row['item_id'] ?? 0); + $itemName = trim((string)($row['item_name'] ?? '')); + $genericName = trim((string)($row['generic_name'] ?? '')); + $category = trim((string)($row['category'] ?? 'Tablet')); + $orderPacks = (float)($row['order_packs'] ?? 0); + $unitsPerPack = (int)($row['units_per_pack'] ?? 10); + $estimatedPtr = (float)($row['estimated_ptr'] ?? 0); + $gstRate = (float)($row['gst_rate'] ?? 12.0); + + if ($itemId <= 0 || $itemName === '' || $orderPacks <= 0) { + continue; + } + + $lineSubtotal = round($orderPacks * $estimatedPtr, 2); + $lineTax = round($lineSubtotal * ($gstRate / 100), 2); + $lineTotal = $lineSubtotal + $lineTax; + + $totalEstimatedAmount += $lineSubtotal; + $totalTaxAmount += $lineTax; + + $validItems[] = [ + 'item_id' => $itemId, + 'item_name' => $itemName, + 'generic_name' => $genericName, + 'category' => $category, + 'order_packs' => $orderPacks, + 'units_per_pack' => $unitsPerPack > 0 ? $unitsPerPack : 1, + 'received_packs' => 0, + 'remaining_packs' => $orderPacks, + 'estimated_ptr' => $estimatedPtr, + 'gst_rate' => $gstRate, + 'estimated_amount' => $lineTotal, + 'status' => 'pending', + ]; + } + + if (empty($validItems)) { + return $this->response->setJSON(['status' => 0, 'message' => 'No valid item rows were submitted.']); + } + + $this->db->transStart(); + + $poHeader = [ + 'store_id' => $storeId, + 'supplier_id' => $supplierId, + 'po_number' => $poNumber, + 'po_date' => $poDate, + 'expected_delivery_date' => $expectedDelivery ?: null, + 'payment_terms' => $paymentTerms, + 'total_items' => count($validItems), + 'total_estimated_amount' => $totalEstimatedAmount, + 'tax_amount' => $totalTaxAmount, + 'grand_total' => $totalEstimatedAmount + $totalTaxAmount, + 'status' => $status, + 'remarks' => $remarks, + 'created_by' => $userId, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + if ($status === 'approved' || $status === 'ordered') { + $poHeader['approved_by'] = $userId; + $poHeader['approved_at'] = date('Y-m-d H:i:s'); + } + + $this->poModel->insert($poHeader); + $poId = (int)$this->db->insertID(); + + foreach ($validItems as &$item) { + $item['po_id'] = $poId; + $this->poItemModel->insert($item); + } + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->response->setJSON(['status' => 0, 'message' => 'Failed to save purchase order. Database error.']); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => "Purchase Order {$poNumber} created successfully!", + 'po_id' => $poId, + 'po_number' => $poNumber, + ]); + } + + public function po_edit(int $poId) + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $po = $this->db->table('mst_purchase_orders po') + ->select('po.*, s.name_supplier') + ->join('med_supplier s', 's.sid = po.supplier_id', 'left') + ->where('po.po_id', $poId) + ->get() + ->getRowArray(); + + if (!$po) { + return view('medical/placeholder', ['title' => 'Purchase Order Not Found']); + } + + $items = $this->poItemModel->where('po_id', $poId)->findAll(); + + $suppliers = $this->db->table('med_supplier') + ->select('sid, name_supplier, contact_no, city, state, gst_no') + ->orderBy('name_supplier', 'ASC') + ->get() + ->getResultArray(); + + $stores = $this->db->tableExists('mst_stores') + ? $this->db->table('mst_stores')->where('is_active', 1)->get()->getResultArray() + : []; + + return view('medical/po_form', [ + 'suppliers' => $suppliers, + 'stores' => $stores, + 'nextPoNumber' => $po['po_number'], + 'po' => $po, + 'poItems' => $items, + ]); + } + + public function po_status(int $poId) + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $newStatus = trim((string)$this->request->getPost('status')); + $cancelReason = trim((string)$this->request->getPost('reason') ?: ''); + + if (!in_array($newStatus, ['approved', 'ordered', 'cancelled'], true)) { + return $this->response->setJSON(['status' => 0, 'message' => 'Invalid status transition.']); + } + + $po = $this->poModel->find($poId); + if (!$po) { + return $this->response->setJSON(['status' => 0, 'message' => 'Purchase order not found.']); + } + + $userId = $this->getCurrentUserId(); + $updates = ['status' => $newStatus, 'updated_at' => date('Y-m-d H:i:s')]; + + if ($newStatus === 'approved' || $newStatus === 'ordered') { + $updates['approved_by'] = $userId; + $updates['approved_at'] = date('Y-m-d H:i:s'); + } elseif ($newStatus === 'cancelled') { + $updates['cancelled_by'] = $userId; + $updates['cancelled_at'] = date('Y-m-d H:i:s'); + $updates['cancelled_reason'] = $cancelReason; + } + + $this->poModel->update($poId, $updates); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => "Purchase Order status updated to {$newStatus}.", + ]); + } + + public function po_print(int $poId) + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $po = $this->db->table('mst_purchase_orders po') + ->select('po.*, s.name_supplier, s.contact_no, s.city, s.state, s.gst_no') + ->join('med_supplier s', 's.sid = po.supplier_id', 'left') + ->where('po.po_id', $poId) + ->get() + ->getRowArray(); + + if (!$po) { + return view('medical/placeholder', ['title' => 'Purchase Order Not Found']); + } + + $items = $this->poItemModel->where('po_id', $poId)->findAll(); + + $html = view('medical/po_print_pdf', [ + 'po' => $po, + 'items' => $items, + ]); + + $mpdf = new Mpdf([ + 'mode' => 'utf-8', + 'format' => 'A4', + 'orientation' => 'P', + 'margin_left' => 10, + 'margin_right' => 10, + 'margin_top' => 10, + 'margin_bottom' => 10, + 'tempDir' => WRITEPATH . 'cache', + ]); + + $mpdf->SetTitle('Purchase Order - ' . $po['po_number']); + $mpdf->WriteHTML($html); + + return $this->response + ->setHeader('Content-Type', 'application/pdf') + ->setBody($mpdf->Output('Purchase_Order_' . $po['po_number'] . '.pdf', 'S')); + } + + public function po_reorder_items(int $supplierId) + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + if ($supplierId <= 0) { + return $this->response->setJSON(['status' => 0, 'items' => []]); + } + + // Fetch drugs supplied by this vendor or general low stock items + $sql = "SELECT p.id AS item_id, p.item_name, p.genericname AS generic_name, + p.formulation AS category, 10 AS units_per_pack, + IFNULL(t.purchase_price, 0) AS estimated_ptr, + IFNULL(t.CGST_per + t.SGST_per, 12) AS gst_rate, + (IFNULL(t.total_unit, 0) - IFNULL(t.total_sale_unit, 0) - IFNULL(t.total_return_unit, 0) - IFNULL(t.total_lost_unit, 0)) AS current_stock + FROM med_product_master p + LEFT JOIN purchase_invoice_item t ON p.id = t.item_code + LEFT JOIN purchase_invoice m ON t.purchase_id = m.id + WHERE (m.sid = ? OR m.sid IS NULL) + AND p.is_continue = 1 + GROUP BY p.id + HAVING current_stock < 50 + ORDER BY current_stock ASC + LIMIT 50"; + + $rows = $this->db->query($sql, [$supplierId])->getResultArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'count' => count($rows), + 'items' => $rows, + ]); + } + + // ========================================================================= + // 2. GOODS RECEIVED NOTE (GRN) + // ========================================================================= + + public function grn_list() + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $status = trim((string)($this->request->getGet('status') ?? '')); + $supplierId = (int)($this->request->getGet('supplier_id') ?? 0); + $fromDate = trim((string)($this->request->getGet('from') ?? '')); + $toDate = trim((string)($this->request->getGet('to') ?? '')); + $search = trim((string)($this->request->getGet('q') ?? '')); + + $builder = $this->db->table('mst_grns grn') + ->select('grn.*, s.name_supplier, po.po_number') + ->join('med_supplier s', 's.sid = grn.supplier_id', 'left') + ->join('mst_purchase_orders po', 'po.po_id = grn.po_id', 'left'); + + if (!empty($status) && $status !== 'all') { + $builder->where('grn.status', $status); + } + if ($supplierId > 0) { + $builder->where('grn.supplier_id', $supplierId); + } + if (!empty($fromDate)) { + $builder->where('grn.grn_date >=', $fromDate); + } + if (!empty($toDate)) { + $builder->where('grn.grn_date <=', $toDate); + } + if (!empty($search)) { + $builder->groupStart() + ->like('grn.grn_number', $search) + ->orLike('grn.supplier_invoice_no', $search) + ->orLike('po.po_number', $search) + ->orLike('s.name_supplier', $search) + ->groupEnd(); + } + + $grnList = $builder->orderBy('grn.grn_id', 'DESC')->get(150)->getResultArray(); + + $suppliers = $this->db->table('med_supplier') + ->select('sid, name_supplier') + ->orderBy('name_supplier', 'ASC') + ->get() + ->getResultArray(); + + return view('medical/grn_list', [ + 'grnList' => $grnList, + 'suppliers' => $suppliers, + 'status' => $status, + 'supplierId' => $supplierId, + 'fromDate' => $fromDate, + 'toDate' => $toDate, + 'search' => $search, + ]); + } + + public function grn_new($poId = 0) + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $poId = (int)$poId; + $po = null; + $poItems = []; + + if ($poId > 0) { + $po = $this->db->table('mst_purchase_orders po') + ->select('po.*, s.name_supplier') + ->join('med_supplier s', 's.sid = po.supplier_id', 'left') + ->where('po.po_id', $poId) + ->get() + ->getRowArray(); + + if ($po) { + $poItems = $this->poItemModel + ->where('po_id', $poId) + ->where('remaining_packs >', 0) + ->findAll(); + } + } + + $suppliers = $this->db->table('med_supplier') + ->select('sid, name_supplier') + ->orderBy('name_supplier', 'ASC') + ->get() + ->getResultArray(); + + $approvedPos = $this->db->table('mst_purchase_orders') + ->select('po_id, po_number, po_date, supplier_id, total_estimated_amount') + ->whereIn('status', ['approved', 'ordered', 'partially_received']) + ->orderBy('po_id', 'DESC') + ->get() + ->getResultArray(); + + $nextGrnNumber = $this->grnModel->generateGrnNumber(); + + return view('medical/grn_form', [ + 'suppliers' => $suppliers, + 'approvedPos' => $approvedPos, + 'selectedPo' => $po, + 'poItems' => $poItems, + 'nextGrnNumber' => $nextGrnNumber, + 'grn' => null, + 'grnItems' => [], + ]); + } + + public function grn_create() + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $supplierId = (int)$this->request->getPost('supplier_id'); + $poId = (int)($this->request->getPost('po_id') ?: 0); + $challanNo = trim((string)$this->request->getPost('challan_no') ?: ''); + $supplierInvoiceNo = trim((string)$this->request->getPost('supplier_invoice_no') ?: ''); + $invoiceDate = trim((string)$this->request->getPost('invoice_date') ?: date('Y-m-d')); + $grnDate = trim((string)$this->request->getPost('grn_date') ?: date('Y-m-d')); + $remarks = trim((string)$this->request->getPost('remarks') ?: ''); + $postToInventory = (int)($this->request->getPost('post_to_inventory') ?: 0); + + $itemsRaw = $this->request->getPost('items'); + $items = is_array($itemsRaw) ? $itemsRaw : []; + + if ($supplierId <= 0) { + return $this->response->setJSON(['status' => 0, 'message' => 'Please select a supplier.']); + } + if (empty($supplierInvoiceNo)) { + return $this->response->setJSON(['status' => 0, 'message' => 'Supplier invoice / delivery bill number is required.']); + } + if (empty($items)) { + return $this->response->setJSON(['status' => 0, 'message' => 'Please add at least one received item.']); + } + + $grnNumber = $this->grnModel->generateGrnNumber(); + $userId = $this->getCurrentUserId(); + + $totalTaxable = 0.0; + $totalTax = 0.0; + $netTotal = 0.0; + $validItems = []; + + foreach ($items as $row) { + $itemCode = (int)($row['item_code'] ?? 0); + $itemName = trim((string)($row['item_name'] ?? '')); + $batchNo = trim((string)($row['batch_no'] ?? '')); + $expiryDate = trim((string)($row['expiry_date'] ?? '')); + $packing = (int)($row['packing'] ?? 1); + if ($packing <= 0) { + $packing = 1; + } + + $orderPacks = (float)($row['order_packs'] ?? 0); + $receivedPacks = (float)($row['received_packs'] ?? 0); + $freePacks = (float)($row['free_packs'] ?? 0); + $rejectedPacks = (float)($row['rejected_packs'] ?? 0); + $rejectionReason = trim((string)($row['rejection_reason'] ?? '')); + + $unitPurchaseRate = (float)($row['unit_purchase_rate'] ?? 0); + $mrp = (float)($row['mrp'] ?? 0); + $sellingUnitRate = (float)($row['selling_unit_rate'] ?? 0); + if ($sellingUnitRate <= 0 && $mrp > 0) { + $sellingUnitRate = round($mrp / $packing, 4); + } + + $discPercent = (float)($row['discount_percent'] ?? 0); + $cgstPer = (float)($row['cgst_percent'] ?? 6.0); + $sgstPer = (float)($row['sgst_percent'] ?? 6.0); + $igstPer = (float)($row['igst_percent'] ?? 0.0); + $hsnCode = trim((string)($row['hsn_code'] ?? '')); + $rackNo = trim((string)($row['rack_no'] ?? '')); + $shelfNo = trim((string)($row['shelf_no'] ?? '')); + $coldStorage = trim((string)($row['cold_storage'] ?? 'No')); + $poItemId = (int)($row['po_item_id'] ?? 0); + + if ($itemCode <= 0 || $itemName === '' || $batchNo === '' || empty($expiryDate)) { + continue; + } + if ($receivedPacks <= 0 && $freePacks <= 0) { + continue; + } + + // Calculations + $grossAmount = round($receivedPacks * $unitPurchaseRate, 2); + $discountAmt = round($grossAmount * ($discPercent / 100), 2); + $lineTaxable = $grossAmount - $discountAmt; + + $cgstAmt = round($lineTaxable * ($cgstPer / 100), 2); + $sgstAmt = round($lineTaxable * ($sgstPer / 100), 2); + $igstAmt = round($lineTaxable * ($igstPer / 100), 2); + $lineTax = $cgstAmt + $sgstAmt + $igstAmt; + $lineTotal = $lineTaxable + $lineTax; + + $totalTaxable += $lineTaxable; + $totalTax += $lineTax; + $netTotal += $lineTotal; + + $validItems[] = [ + 'po_item_id' => $poItemId > 0 ? $poItemId : null, + 'item_code' => $itemCode, + 'item_name' => $itemName, + 'batch_no' => $batchNo, + 'expiry_date' => $expiryDate, + 'packing' => $packing, + 'order_packs' => $orderPacks, + 'received_packs' => $receivedPacks, + 'free_packs' => $freePacks, + 'rejected_packs' => $rejectedPacks, + 'rejection_reason' => $rejectionReason ?: null, + 'unit_purchase_rate' => $unitPurchaseRate, + 'mrp' => $mrp, + 'selling_unit_rate' => $sellingUnitRate, + 'discount_percent' => $discPercent, + 'taxable_amount' => $lineTaxable, + 'cgst_percent' => $cgstPer, + 'cgst_amount' => $cgstAmt, + 'sgst_percent' => $sgstPer, + 'sgst_amount' => $sgstAmt, + 'igst_percent' => $igstPer, + 'igst_amount' => $igstAmt, + 'hsn_code' => $hsnCode ?: null, + 'total_amount' => $lineTotal, + 'rack_no' => $rackNo ?: null, + 'shelf_no' => $shelfNo ?: null, + 'cold_storage' => $coldStorage ?: null, + ]; + } + + if (empty($validItems)) { + return $this->response->setJSON(['status' => 0, 'message' => 'No valid item rows with batch and expiry were found.']); + } + + $this->db->transStart(); + + $grnHeader = [ + 'grn_number' => $grnNumber, + 'store_id' => 1, + 'po_id' => $poId > 0 ? $poId : null, + 'supplier_id' => $supplierId, + 'challan_no' => $challanNo ?: null, + 'supplier_invoice_no' => $supplierInvoiceNo, + 'invoice_date' => $invoiceDate, + 'grn_date' => $grnDate, + 'total_received_amount' => $totalTaxable, + 'total_tax_amount' => $totalTax, + 'net_amount' => $netTotal, + 'status' => 'verified', + 'received_by' => $userId, + 'verified_by' => $userId, + 'verified_at' => date('Y-m-d H:i:s'), + 'remarks' => $remarks, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s'), + ]; + + $this->grnModel->insert($grnHeader); + $grnId = (int)$this->db->insertID(); + + foreach ($validItems as &$gItem) { + $gItem['grn_id'] = $grnId; + $this->grnItemModel->insert($gItem); + } + + // If requested to post directly to inventory + if ($postToInventory === 1) { + $this->executeGrnStockPosting($grnId); + } + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + $err = $this->db->error(); + $errMsg = !empty($err['message']) ? ': ' . $err['message'] : '.'; + log_message('error', 'grn_create transaction failed' . $errMsg); + return $this->response->setJSON(['status' => 0, 'message' => 'Failed to save Goods Received Note' . $errMsg]); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => "GRN {$grnNumber} created successfully!", + 'grn_id' => $grnId, + 'grn_number' => $grnNumber, + ]); + } + + public function grn_edit(int $grnId) + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $grn = $this->db->table('mst_grns grn') + ->select('grn.*, s.name_supplier, po.po_number') + ->join('med_supplier s', 's.sid = grn.supplier_id', 'left') + ->join('mst_purchase_orders po', 'po.po_id = grn.po_id', 'left') + ->where('grn.grn_id', $grnId) + ->get() + ->getRowArray(); + + if (!$grn) { + return view('medical/placeholder', ['title' => 'Goods Received Note Not Found']); + } + + $items = $this->grnItemModel->where('grn_id', $grnId)->findAll(); + + return view('medical/grn_detail', [ + 'grn' => $grn, + 'grnItems' => $items, + ]); + } + + public function grn_post_inventory(int $grnId) + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $grn = $this->grnModel->find($grnId); + if (!$grn) { + return $this->response->setJSON(['status' => 0, 'message' => 'GRN not found.']); + } + if ($grn['status'] === 'posted_to_inventory') { + return $this->response->setJSON(['status' => 0, 'message' => 'This GRN has already been posted to live inventory.']); + } + + $this->db->transStart(); + $ok = $this->executeGrnStockPosting($grnId); + $this->db->transComplete(); + + if ($this->db->transStatus() === false || !$ok) { + return $this->response->setJSON(['status' => 0, 'message' => 'Failed to post GRN to inventory.']); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'GRN successfully posted to live store inventory & purchase registers!', + ]); + } + + /** + * Core bridge method that converts a verified GRN into live purchase_invoice and purchase_invoice_item + */ + private function executeGrnStockPosting(int $grnId): bool + { + $grn = $this->grnModel->find($grnId); + if (!$grn) { + return false; + } + + $supplier = $this->db->table('med_supplier')->where('sid', $grn['supplier_id'])->get()->getRowArray(); + $suppName = $supplier['name_supplier'] ?? 'Supplier'; + $userId = $this->getCurrentUserId(); + $userLabel = $this->getCurrentUserLabel(); + + // 1. Insert into purchase_invoice + $purchaseInvoiceData = [ + 'sid' => $grn['supplier_id'], + 'ischallan' => !empty($grn['challan_no']) ? 1 : 0, + 'monthyear' => date('m/Y', strtotime($grn['invoice_date'])), + 'supp_name' => $suppName, + 'date_of_invoice' => $grn['invoice_date'], + 'Invoice_no' => $grn['supplier_invoice_no'], + 'Taxable_Amt' => $grn['total_received_amount'], + 'CGST_Amt' => round($grn['total_tax_amount'] / 2, 2), + 'SGST_Amt' => round($grn['total_tax_amount'] / 2, 2), + 'T_Net_Amount' => $grn['net_amount'], + 'inv_status' => 1, + 'inv_status_update_by' => $userId, + ]; + + $this->db->table('purchase_invoice')->insert($purchaseInvoiceData); + $purchaseId = (int)$this->db->insertID(); + + if ($purchaseId <= 0) { + return false; + } + + $medModel = new MedicalModel(); + $grnItems = $this->grnItemModel->where('grn_id', $grnId)->findAll(); + + // 2. Insert into purchase_invoice_item & trigger p_stock_update_purchase_id + foreach ($grnItems as $gItem) { + $packing = (int)($gItem['packing'] ?: 1); + $receivedPacks = (float)$gItem['received_packs']; + $freePacks = (float)$gItem['free_packs']; + $totalUnits = (int)round(($receivedPacks + $freePacks) * $packing); + + $purchasePricePerPack = (float)$gItem['unit_purchase_rate']; + $purchaseUnitRate = $packing > 0 ? round($purchasePricePerPack / $packing, 4) : $purchasePricePerPack; + + $piItemData = [ + 'purchase_id' => $purchaseId, + 'item_code' => $gItem['item_code'], + 'Item_name' => $gItem['item_name'], + 'packing' => $packing, + 'batch_no' => $gItem['batch_no'], + 'purchase_price' => $purchasePricePerPack, + 'expiry_date' => $gItem['expiry_date'], + 'mrp' => $gItem['mrp'], + 'qty' => $receivedPacks, + 'qty_free' => $freePacks, + 'tqty' => $receivedPacks + $freePacks, + 'amount' => round($receivedPacks * $purchasePricePerPack, 2), + 'discount' => $gItem['discount_percent'], + 'taxable_amount' => $gItem['taxable_amount'], + 'CGST_per' => $gItem['cgst_percent'], + 'CGST' => $gItem['cgst_amount'], + 'SGST_per' => $gItem['sgst_percent'], + 'SGST' => $gItem['sgst_amount'], + 'net_amount' => $gItem['total_amount'], + 'HSNCODE' => $gItem['hsn_code'] ?: '', + 'selling_price' => $gItem['selling_unit_rate'] * $packing, + 'total_unit' => $totalUnits, + 'total_sale_unit' => 0, + 'total_return_unit' => 0, + 'total_lost_unit' => 0, + 'selling_unit_rate' => $gItem['selling_unit_rate'], + 'purchase_unit_rate' => $purchaseUnitRate, + 'item_return' => 0, + 'remove_item' => 0, + 'stock_date' => $grn['invoice_date'], + 'rack_no' => $gItem['rack_no'] ?: '', + 'shelf_no' => $gItem['shelf_no'] ?: '', + 'cold_storage' => $gItem['cold_storage'] ?: 'No', + 'insert_by' => $userLabel, + ]; + + $this->db->table('purchase_invoice_item')->insert($piItemData); + $purchaseItemId = (int)$this->db->insertID(); + + // Link purchase_item_id on GRN item row + $this->grnItemModel->update($gItem['grn_item_id'], ['purchase_item_id' => $purchaseItemId]); + + // Call stored procedure to sync live inventory + $medModel->updatePurchaseStockById($purchaseItemId); + + // Reconcile with PO item if linked + if (!empty($gItem['po_item_id'])) { + $poItem = $this->poItemModel->find($gItem['po_item_id']); + if ($poItem) { + $newReceived = (float)$poItem['received_packs'] + $receivedPacks; + $orderPacks = (float)$poItem['order_packs']; + $remaining = max(0, $orderPacks - $newReceived); + $itemStatus = ($remaining <= 0) ? 'fulfilled' : 'partial'; + + $this->poItemModel->update($gItem['po_item_id'], [ + 'received_packs' => $newReceived, + 'remaining_packs' => $remaining, + 'status' => $itemStatus, + ]); + } + } + } + + // 3. Update PO Header status if linked + if (!empty($grn['po_id'])) { + $unfulfilled = $this->poItemModel + ->where('po_id', $grn['po_id']) + ->where('remaining_packs >', 0) + ->countAllResults(); + + $newPoStatus = ($unfulfilled === 0) ? 'received' : 'partially_received'; + $this->poModel->update($grn['po_id'], [ + 'status' => $newPoStatus, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + } + + // 4. Update GRN status + $this->grnModel->update($grnId, [ + 'status' => 'posted_to_inventory', + 'purchase_id' => $purchaseId, + 'updated_at' => date('Y-m-d H:i:s'), + ]); + + return true; + } + + public function grn_print(int $grnId) + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $grn = $this->db->table('mst_grns grn') + ->select('grn.*, s.name_supplier, s.contact_no, s.city, s.state, s.gst_no, po.po_number') + ->join('med_supplier s', 's.sid = grn.supplier_id', 'left') + ->join('mst_purchase_orders po', 'po.po_id = grn.po_id', 'left') + ->where('grn.grn_id', $grnId) + ->get() + ->getRowArray(); + + if (!$grn) { + return view('medical/placeholder', ['title' => 'Goods Received Note Not Found']); + } + + $items = $this->grnItemModel->where('grn_id', $grnId)->findAll(); + + $html = view('medical/grn_print_pdf', [ + 'grn' => $grn, + 'items' => $items, + ]); + + $mpdf = new Mpdf([ + 'mode' => 'utf-8', + 'format' => 'A4', + 'orientation' => 'L', + 'margin_left' => 10, + 'margin_right' => 10, + 'margin_top' => 10, + 'margin_bottom' => 10, + 'tempDir' => WRITEPATH . 'cache', + ]); + + $mpdf->SetTitle('GRN Inspection Slip - ' . $grn['grn_number']); + $mpdf->WriteHTML($html); + + return $this->response + ->setHeader('Content-Type', 'application/pdf') + ->setBody($mpdf->Output('GRN_' . $grn['grn_number'] . '.pdf', 'S')); + } +} diff --git a/app/Database/Migrations/2026-10-19-000001_CreatePharmacyGrnAndEnhancePoTables.php b/app/Database/Migrations/2026-10-19-000001_CreatePharmacyGrnAndEnhancePoTables.php new file mode 100644 index 0000000..5a8411c --- /dev/null +++ b/app/Database/Migrations/2026-10-19-000001_CreatePharmacyGrnAndEnhancePoTables.php @@ -0,0 +1,408 @@ +tableExists('mst_purchase_orders')) { + $existingFields = $db->getFieldNames('mst_purchase_orders'); + $addFields = []; + + if (!in_array('payment_terms', $existingFields, true)) { + $addFields['payment_terms'] = [ + 'type' => 'VARCHAR', + 'constraint' => 150, + 'null' => true, + 'default' => null, + 'after' => 'expected_delivery_date', + ]; + } + if (!in_array('tax_amount', $existingFields, true)) { + $addFields['tax_amount'] = [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'default' => 0.00, + 'after' => 'total_estimated_amount', + ]; + } + if (!in_array('grand_total', $existingFields, true)) { + $addFields['grand_total'] = [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'default' => 0.00, + 'after' => 'tax_amount', + ]; + } + if (!in_array('approved_by', $existingFields, true)) { + $addFields['approved_by'] = [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + 'default' => null, + 'after' => 'created_by', + ]; + } + if (!in_array('approved_at', $existingFields, true)) { + $addFields['approved_at'] = [ + 'type' => 'DATETIME', + 'null' => true, + 'default' => null, + 'after' => 'approved_by', + ]; + } + if (!in_array('cancelled_by', $existingFields, true)) { + $addFields['cancelled_by'] = [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + 'default' => null, + 'after' => 'approved_at', + ]; + } + if (!in_array('cancelled_at', $existingFields, true)) { + $addFields['cancelled_at'] = [ + 'type' => 'DATETIME', + 'null' => true, + 'default' => null, + 'after' => 'cancelled_by', + ]; + } + if (!in_array('cancelled_reason', $existingFields, true)) { + $addFields['cancelled_reason'] = [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'default' => null, + 'after' => 'cancelled_at', + ]; + } + + if (!empty($addFields)) { + $this->forge->addColumn('mst_purchase_orders', $addFields); + } + } + + // 2. Enhance mst_purchase_order_items + if ($db->tableExists('mst_purchase_order_items')) { + $existingItemFields = $db->getFieldNames('mst_purchase_order_items'); + $addItemFields = []; + + if (!in_array('received_packs', $existingItemFields, true)) { + $addItemFields['received_packs'] = [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + 'after' => 'units_per_pack', + ]; + } + if (!in_array('remaining_packs', $existingItemFields, true)) { + $addItemFields['remaining_packs'] = [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + 'after' => 'received_packs', + ]; + } + if (!in_array('status', $existingItemFields, true)) { + $addItemFields['status'] = [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'default' => 'pending', + 'after' => 'estimated_amount', + ]; + } + + if (!empty($addItemFields)) { + $this->forge->addColumn('mst_purchase_order_items', $addItemFields); + } + } + + // 3. Create mst_grns (Goods Received Note Header) + if (!$db->tableExists('mst_grns')) { + $this->forge->addField([ + 'grn_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'grn_number' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + ], + 'store_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 1, + ], + 'po_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'default' => null, + ], + 'supplier_id' => [ + 'type' => 'INT', + 'constraint' => 11, + ], + 'challan_no' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + 'default' => null, + ], + 'supplier_invoice_no' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + ], + 'invoice_date' => [ + 'type' => 'DATE', + ], + 'grn_date' => [ + 'type' => 'DATE', + ], + 'total_received_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'default' => 0.00, + ], + 'total_tax_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'default' => 0.00, + ], + 'net_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'default' => 0.00, + ], + 'status' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'default' => 'draft', // draft, verified, posted_to_inventory, rejected, cancelled + ], + 'purchase_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + 'default' => null, + ], + 'received_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'verified_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + 'default' => null, + ], + 'verified_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'default' => null, + ], + 'remarks' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('grn_id', true); + $this->forge->addKey('grn_number'); + $this->forge->addKey('po_id'); + $this->forge->addKey('supplier_id'); + $this->forge->addKey('status'); + $this->forge->addKey('purchase_id'); + $this->forge->createTable('mst_grns', true); + } + + // 4. Create mst_grn_items (Goods Received Note Items) + if (!$db->tableExists('mst_grn_items')) { + $this->forge->addField([ + 'grn_item_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'grn_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'po_item_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'default' => null, + ], + 'item_code' => [ + 'type' => 'INT', + 'constraint' => 11, + ], + 'item_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 200, + ], + 'batch_no' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + ], + 'expiry_date' => [ + 'type' => 'DATE', + ], + 'packing' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 1, + ], + 'order_packs' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'received_packs' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'free_packs' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'rejected_packs' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'rejection_reason' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + 'default' => null, + ], + 'unit_purchase_rate' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'mrp' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'selling_unit_rate' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'discount_percent' => [ + 'type' => 'DECIMAL', + 'constraint' => '5,2', + 'default' => 0.00, + ], + 'taxable_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'cgst_percent' => [ + 'type' => 'DECIMAL', + 'constraint' => '5,2', + 'default' => 0.00, + ], + 'cgst_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'sgst_percent' => [ + 'type' => 'DECIMAL', + 'constraint' => '5,2', + 'default' => 0.00, + ], + 'sgst_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'igst_percent' => [ + 'type' => 'DECIMAL', + 'constraint' => '5,2', + 'default' => 0.00, + ], + 'igst_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'hsn_code' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + 'default' => null, + ], + 'total_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'default' => 0.00, + ], + 'rack_no' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + 'default' => null, + ], + 'shelf_no' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + 'default' => null, + ], + 'cold_storage' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'null' => true, + 'default' => null, + ], + 'purchase_item_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + 'default' => null, + ], + ]); + $this->forge->addKey('grn_item_id', true); + $this->forge->addKey('grn_id'); + $this->forge->addKey('item_code'); + $this->forge->addKey('po_item_id'); + $this->forge->addKey('purchase_item_id'); + $this->forge->createTable('mst_grn_items', true); + } + } + + public function down() + { + $this->forge->dropTable('mst_grn_items', true); + $this->forge->dropTable('mst_grns', true); + } +} diff --git a/app/Models/MedicalModel.php b/app/Models/MedicalModel.php index 58b3427..4ac7268 100644 --- a/app/Models/MedicalModel.php +++ b/app/Models/MedicalModel.php @@ -10,16 +10,43 @@ class MedicalModel extends Model public function updatePurchaseStockById(int $purchaseItemId): bool { - if ($purchaseItemId <= 0) { + if ($purchaseItemId <= 0 || ! $this->db->tableExists('purchase_invoice_item')) { return false; } try { - $this->db->query('CALL p_stock_update_purchase_id(?)', [$purchaseItemId]); - $this->clearProcedureResults(); + // Recompute total sold units directly from sale items if available + if ($this->db->tableExists('inv_med_item')) { + $itemFields = $this->db->getFieldNames('inv_med_item') ?? []; + $sumSql = in_array('sale_return', $itemFields, true) + ? 'SUM(CASE WHEN IFNULL(sale_return,0)=1 OR IFNULL(amount,0)<0 THEN IFNULL(qty,0)*-1 ELSE IFNULL(qty,0) END) AS sold_qty' + : 'SUM(CASE WHEN IFNULL(amount,0)<0 THEN IFNULL(qty,0)*-1 ELSE IFNULL(qty,0) END) AS sold_qty'; + + $soldRow = $this->db->table('inv_med_item') + ->select($sumSql, false) + ->where('store_stock_id', $purchaseItemId) + ->get() + ->getRow(); + $soldQty = max(0.0, (float) ($soldRow->sold_qty ?? 0)); + + $stockRow = $this->db->table('purchase_invoice_item') + ->select('id, total_sale_unit') + ->where('id', $purchaseItemId) + ->get() + ->getRow(); + + if ($stockRow) { + $currentSaleQty = (float) ($stockRow->total_sale_unit ?? 0); + if (abs($currentSaleQty - $soldQty) >= 0.00001) { + $this->db->table('purchase_invoice_item') + ->where('id', $purchaseItemId) + ->update(['total_sale_unit' => $soldQty]); + } + } + } return true; } catch (\Throwable $e) { - log_message('error', 'p_stock_update_purchase_id failed: ' . $e->getMessage()); + log_message('error', 'updatePurchaseStockById failed: ' . $e->getMessage()); return false; } } @@ -31,11 +58,18 @@ class MedicalModel extends Model } try { - $this->db->query('CALL p_sale_transfer_ssno(?,?,?)', [$oldSsno, $newSsno, $qty]); - $this->clearProcedureResults(); + if ($this->db->tableExists('inv_med_item')) { + $this->db->table('inv_med_item') + ->where('store_stock_id', $oldSsno) + ->limit(1) + ->update(['store_stock_id' => $newSsno]); + + $this->updatePurchaseStockById($oldSsno); + $this->updatePurchaseStockById($newSsno); + } return true; } catch (\Throwable $e) { - log_message('error', 'p_sale_transfer_ssno failed: ' . $e->getMessage()); + log_message('error', 'transferSaleSsno failed: ' . $e->getMessage()); return false; } } diff --git a/app/Models/PharmacyGrnItemModel.php b/app/Models/PharmacyGrnItemModel.php new file mode 100644 index 0000000..52a4d7c --- /dev/null +++ b/app/Models/PharmacyGrnItemModel.php @@ -0,0 +1,45 @@ +like('grn_number', $prefix, 'after') + ->orderBy('grn_id', 'DESC') + ->first(); + + $seq = 1; + if ($row && !empty($row['grn_number'])) { + $parts = explode('-', $row['grn_number']); + $lastSeq = (int) end($parts); + if ($lastSeq > 0) { + $seq = $lastSeq + 1; + } + } + + return $prefix . '-' . str_pad((string)$seq, 4, '0', STR_PAD_LEFT); + } +} diff --git a/app/Models/PharmacyPurchaseOrderItemModel.php b/app/Models/PharmacyPurchaseOrderItemModel.php new file mode 100644 index 0000000..9617b45 --- /dev/null +++ b/app/Models/PharmacyPurchaseOrderItemModel.php @@ -0,0 +1,29 @@ +like('po_number', $prefix, 'after') + ->orderBy('po_id', 'DESC') + ->first(); + + $seq = 1; + if ($row && !empty($row['po_number'])) { + $parts = explode('-', $row['po_number']); + $lastSeq = (int) end($parts); + if ($lastSeq > 0) { + $seq = $lastSeq + 1; + } + } + + return $prefix . '-' . str_pad((string)$seq, 4, '0', STR_PAD_LEFT); + } +} diff --git a/app/Views/medical/dashboard.php b/app/Views/medical/dashboard.php index 8afcd96..8795011 100644 --- a/app/Views/medical/dashboard.php +++ b/app/Views/medical/dashboard.php @@ -29,6 +29,12 @@ Store Main + + Purchase Orders (PO) + + + Inward GRN + Master diff --git a/app/Views/medical/grn_detail.php b/app/Views/medical/grn_detail.php new file mode 100644 index 0000000..2fb144c --- /dev/null +++ b/app/Views/medical/grn_detail.php @@ -0,0 +1,177 @@ +
+
+

+ GRN Inspection Details + +

+ Inward Goods Inspection & Live Stock Posting +
+ +
+ +
+
+
+
+ Supplier / Vendor + +
+
+ Vendor Invoice # + +
+
+ Invoice Date + +
+
+ GRN Date + +
+
+ Linked PO + + + + Direct Intake + +
+
+ + +
+ Inspection Remarks: +
+ +
+
+ +
+
+ Verified Medicine Items + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + $it): ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Product NameBatch NoExpiry DatePackRec. PacksFreeRejectedRate/PackMRPGST %Line TotalLive Stock ID
s + + + () + + ₹₹%₹ + + Item # + + Pending Post + +
Taxable Amount:₹
Total GST:₹
Net Inward Bill Total:₹
+
+
+
+ +
+
+
+ + + Posted to Live Store Inventory (Purchase Bill Ref #) + + + + Verified by Pharmacist • Ready to Post to Live Hospital Inventory + + +
+
+ + + + + View Purchase Invoice + + +
+
+
+ + diff --git a/app/Views/medical/grn_form.php b/app/Views/medical/grn_form.php new file mode 100644 index 0000000..9104763 --- /dev/null +++ b/app/Views/medical/grn_form.php @@ -0,0 +1,462 @@ + + +
+
+

+ Create Goods Received Note (GRN) + +

+ Inward Stock Physical Inspection & Batch Entry +
+
+ + Back to GRN List + +
+
+ +
+
+ Inward Delivery Metadata +
+
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ Physical Inspection & Batch Intake Table + Enter Batch, Expiry, Received Packs & Pricing +
+
+
+
+ + + + + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + $it): ?> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Medicine NameBatch No *Expiry *PackOrderedRec. Packs *FreeDamagedRate/PackMRPGST %Line Total
+ + + + + + + + + + + + + + + + + + + + + + + + + + + ₹0.00 + +
+ No items loaded. Select a PO above or use the Search box to add items. +
Taxable Amount:₹0.00
GST Tax Amount:₹0.00
Net Inward Bill Total:₹0.00
+
+
+
+ +
+
+
+ + + Calls stored procedure p_stock_update_purchase_id to make batches immediately sellable. +
+
+ + +
+
+
+
+
+
+ + diff --git a/app/Views/medical/grn_list.php b/app/Views/medical/grn_list.php new file mode 100644 index 0000000..91c55a6 --- /dev/null +++ b/app/Views/medical/grn_list.php @@ -0,0 +1,172 @@ +
+
+

Goods Received Notes (GRN)

+ Inward Stock Inspection & Physical Receipt Verification +
+ +
+ +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + + + $row): ?> + + + + + + + + + + + + + + + + + + + +
#GRN NumberSupplier / VendorSupplier Bill / ChallanGRN DateLinked PONet AmountStatusActions
+ + +
+
+
Inv:
+ + Challan: + +
+ + + + Direct Intake + + ₹ + + + + +
+ + + + + + + +
+
+ + No Goods Received Notes found. Click Create Inward GRN to record incoming material. +
+
+
+
+ + diff --git a/app/Views/medical/grn_print_pdf.php b/app/Views/medical/grn_print_pdf.php new file mode 100644 index 0000000..b3dcaac --- /dev/null +++ b/app/Views/medical/grn_print_pdf.php @@ -0,0 +1,133 @@ + + + + + + + + +
+

Hospital Pharmacy Services

+

Inward Goods Physical Inspection & Receiving Quality Control

+
+ +
+ + + + + +
GOODS RECEIVED NOTE (GRN)GRN NO:
+
+ + + + + + +
+ SUPPLIER DETAILS
+
+
+ Phone:
+ GSTIN: +
+ DELIVERY METADATA
+ Supplier Bill #: | Bill Date:
+ GRN Receipt Date:
+ Linked PO #:
+ Status: +
+ + + + + + + + + + + + + + + + + + + + + $it): ?> + + + + + + + + + + + + + + + + + +
#Item NameBatch NoExpiryPackOrderedReceivedFreeDamagedRate/PackMRPLine Total
s"> + + ₹₹₹
+ + + + + + + + + + + + + + +
Taxable Amount:₹
GST Tax:₹
Net Payable Total:₹
+ + + + + + + +
+
Physical Receiver (Store Assistant)
+
+
Inspected & Quality Checked (Pharmacist)
+
+
Store In-Charge Approval
+
+ + + diff --git a/app/Views/medical/main_store_dashboard.php b/app/Views/medical/main_store_dashboard.php index 15a6f49..0cc7540 100644 --- a/app/Views/medical/main_store_dashboard.php +++ b/app/Views/medical/main_store_dashboard.php @@ -59,9 +59,19 @@
Inventory