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
+ = esc($grn['grn_number']) ?>
+
+ Inward Goods Inspection & Live Stock Posting
+
+
+
+
+
+
+
+
+ Supplier / Vendor
+ = esc($grn['name_supplier']) ?>
+
+
+ Vendor Invoice #
+ = esc($grn['supplier_invoice_no']) ?>
+
+
+ Invoice Date
+ = date('d-M-Y', strtotime($grn['invoice_date'])) ?>
+
+
+ GRN Date
+ = date('d-M-Y', strtotime($grn['grn_date'])) ?>
+
+
+ Linked PO
+
+ = esc($grn['po_number']) ?>
+
+ Direct Intake
+
+
+
+
+
+
+ Inspection Remarks: = esc($grn['remarks']) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+ Product Name
+ Batch No
+ Expiry Date
+ Pack
+ Rec. Packs
+ Free
+ Rejected
+ Rate/Pack
+ MRP
+ GST %
+ Line Total
+ Live Stock ID
+
+
+
+
+ $it): ?>
+
+ = $idx + 1 ?>
+ = esc($it['item_name']) ?>
+ = esc($it['batch_no']) ?>
+ = date('d-M-Y', strtotime($it['expiry_date'])) ?>
+ = (int)$it['packing'] ?>s
+ = (float)$it['received_packs'] ?>
+ = (float)$it['free_packs'] ?>
+
+ = (float)$it['rejected_packs'] ?>
+
+ (= esc($it['rejection_reason']) ?>)
+
+
+ ₹= number_format((float)$it['unit_purchase_rate'], 2) ?>
+ ₹= number_format((float)$it['mrp'], 2) ?>
+ = number_format((float)$it['cgst_percent'] * 2, 0) ?>%
+ ₹= number_format((float)$it['total_amount'], 2) ?>
+
+
+ Item #= (int)$it['purchase_item_id'] ?>
+
+ Pending Post
+
+
+
+
+
+
+
+
+ Taxable Amount:
+ ₹= number_format((float)$grn['total_received_amount'], 2) ?>
+
+
+
+ Total GST:
+ ₹= number_format((float)$grn['total_tax_amount'], 2) ?>
+
+
+
+ Net Inward Bill Total:
+ ₹= number_format((float)$grn['net_amount'], 2) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Posted to Live Store Inventory (Purchase Bill Ref #= (int)$grn['purchase_id'] ?>)
+
+
+
+ Verified by Pharmacist • Ready to Post to Live Hospital Inventory
+
+
+
+
+
+
+
+
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)
+ = esc($nextGrnNumber) ?>
+
+ Inward Stock Physical Inspection & Batch Entry
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+ GRN Number
+ Supplier / Vendor
+ Supplier Bill / Challan
+ GRN Date
+ Linked PO
+ Net Amount
+ Status
+ Actions
+
+
+
+
+ $row): ?>
+
+
+ = $idx + 1 ?>
+
+ = esc($row['grn_number']) ?>
+
+
+ = esc($row['name_supplier'] ?: 'Unknown Vendor') ?>
+
+
+ Inv: = esc($row['supplier_invoice_no']) ?>
+
+ Challan: = esc($row['challan_no']) ?>
+
+
+ = !empty($row['grn_date']) ? date('d-M-Y', strtotime($row['grn_date'])) : '-' ?>
+
+
+ = esc($row['po_number']) ?>
+
+ Direct Intake
+
+
+ ₹= number_format((float)$row['net_amount'], 2) ?>
+
+
+ = str_replace('_', ' ', esc($row['status'])) ?>
+
+
+
+
+
+
+
+
+ View
+
+
+
+ Post Stock
+
+
+
+
+
+
+
+
+
+
+ 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ GOODS RECEIVED NOTE (GRN)
+ GRN NO: = esc($grn['grn_number']) ?>
+
+
+
+
+
+
+
+
+
+ #
+ Item Name
+ Batch No
+ Expiry
+ Pack
+ Ordered
+ Received
+ Free
+ Damaged
+ Rate/Pack
+ MRP
+ Line Total
+
+
+
+
+ $it): ?>
+
+ = $idx + 1 ?>
+ = esc($it['item_name']) ?>
+ = esc($it['batch_no']) ?>
+ = date('d-M-Y', strtotime($it['expiry_date'])) ?>
+ = (int)$it['packing'] ?>s
+ = (float)$it['order_packs'] ?>
+ = (float)$it['received_packs'] ?>
+ = (float)$it['free_packs'] ?>
+ ">
+ = (float)$it['rejected_packs'] ?>
+
+ ₹= number_format((float)$it['unit_purchase_rate'], 2) ?>
+ ₹= number_format((float)$it['mrp'], 2) ?>
+ ₹= number_format((float)$it['total_amount'], 2) ?>
+
+
+
+
+
+
+
+
+ Taxable Amount:
+ ₹= number_format((float)$grn['total_received_amount'], 2) ?>
+
+
+ GST Tax:
+ ₹= number_format((float)$grn['total_tax_amount'], 2) ?>
+
+
+ Net Payable Total:
+ ₹= number_format((float)$grn['net_amount'], 2) ?>
+
+
+
+
+
+
+ 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 @@
+
+
+ Purchase Order (PO)
+
+
+
+
+ Goods Received Note (GRN)
+
+
- Purchase
+ Purchase Invoice
diff --git a/app/Views/medical/po_form.php b/app/Views/medical/po_form.php
new file mode 100644
index 0000000..317e099
--- /dev/null
+++ b/app/Views/medical/po_form.php
@@ -0,0 +1,464 @@
+
+
+
+
+
+ = $isEdit ? 'Edit Purchase Order' : 'New Purchase Order' ?>
+ = esc($poNumber) ?>
+
+ Procurement Requisition & Supplier Order
+
+
+
+
+
+
+
diff --git a/app/Views/medical/po_list.php b/app/Views/medical/po_list.php
new file mode 100644
index 0000000..19bfa9c
--- /dev/null
+++ b/app/Views/medical/po_list.php
@@ -0,0 +1,191 @@
+
+
+
Pharmacy Purchase Orders (PO)
+ Procurement & Supplier Requisitions
+
+
+
+
+
+
+
+
+
+
+
+
+ #
+ PO Number
+ Supplier
+ PO Date
+ Delivery Due
+ Items
+ Total Amount
+ Status
+ Actions
+
+
+
+
+ $row): ?>
+
+
+ = $idx + 1 ?>
+
+ = esc($row['po_number']) ?>
+
+
+ = esc($row['name_supplier'] ?: 'Unknown Vendor') ?>
+ = esc($row['supplier_phone'] ?: '') ?>
+
+ = !empty($row['po_date']) ? date('d-M-Y', strtotime($row['po_date'])) : '-' ?>
+ = !empty($row['expected_delivery_date']) ? date('d-M-Y', strtotime($row['expected_delivery_date'])) : '- ' ?>
+ = (int)$row['total_items'] ?>
+ ₹= number_format((float)$row['grand_total'] ?: (float)$row['total_estimated_amount'], 2) ?>
+
+
+ = str_replace('_', ' ', esc($row['status'])) ?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Receive
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ No Purchase Orders found. Click Create New PO to draft your first procurement order.
+
+
+
+
+
+
+
+
+
+
diff --git a/app/Views/medical/po_print_pdf.php b/app/Views/medical/po_print_pdf.php
new file mode 100644
index 0000000..1ed4b82
--- /dev/null
+++ b/app/Views/medical/po_print_pdf.php
@@ -0,0 +1,135 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ PURCHASE ORDER
+ PO NO: = esc($po['po_number']) ?>
+
+
+
+
+
+
+
+
+
+ #
+ Item / Brand Name
+ Salt Composition
+ Pack Size
+ Order Qty
+ Rate/Pack
+ GST %
+ Total (₹)
+
+
+
+
+ $it): ?>
+
+ = $idx + 1 ?>
+ = esc($it['item_name']) ?>
+ = esc($it['generic_name'] ?: '-') ?>
+ = (int)$it['units_per_pack'] ?>s
+ = (float)$it['order_packs'] ?>
+ ₹= number_format((float)$it['estimated_ptr'], 2) ?>
+ = (float)$it['gst_rate'] ?>%
+ ₹= number_format((float)$it['estimated_amount'], 2) ?>
+
+
+
+
+
+
+
+
+ Estimated Subtotal:
+ ₹= number_format((float)$po['total_estimated_amount'], 2) ?>
+
+
+ Estimated GST Tax:
+ ₹= number_format((float)$po['tax_amount'], 2) ?>
+
+
+ Grand Total (INR):
+ ₹= number_format((float)$po['grand_total'] ?: ((float)$po['total_estimated_amount'] + (float)$po['tax_amount']), 2) ?>
+
+
+
+
+
TERMS & CONDITIONS:
+
+ All supplies must match the specified brand, packing, and manufacturer specifications.
+ Medicines must have at least 75% remaining shelf life from the date of manufacturing at the time of delivery.
+ Deliveries must be accompanied by an original Tax Invoice and Delivery Challan referencing this PO number.
+ Any broken, leaking, or damaged goods will be rejected at the time of Goods Received Note (GRN) verification.
+ Cold chain items (2-8°C) must be delivered with unbroken temperature logs / ice packs.
+
+
+
+
+
+
+ Prepared By (Pharmacist)
+
+
+ Verified By (Store In-charge)
+
+
+ Authorized Signatory
+
+
+
+
+
+