feat(pharmacy): implement Purchase Orders (PO) and Inward Goods Received Notes (GRN) workflow

This commit is contained in:
DevSoftTech Server 2026-10-19 15:48:53 +05:30
parent 65a071928c
commit 8cbf55537f
17 changed files with 3373 additions and 9 deletions

View File

@ -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');

View File

@ -0,0 +1,973 @@
<?php
namespace App\Controllers;
use App\Controllers\BaseController;
use App\Models\MedicalModel;
use App\Models\PharmacyPurchaseOrderModel;
use App\Models\PharmacyPurchaseOrderItemModel;
use App\Models\PharmacyGrnModel;
use App\Models\PharmacyGrnItemModel;
use Mpdf\Mpdf;
class MedicalProcurement extends BaseController
{
protected $db;
protected $poModel;
protected $poItemModel;
protected $grnModel;
protected $grnItemModel;
public function __construct()
{
$this->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('<div class="alert alert-danger m-3">Access denied for Pharmacy Procurement module.</div>');
}
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'));
}
}

View File

@ -0,0 +1,408 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreatePharmacyGrnAndEnhancePoTables extends Migration
{
public function up()
{
$db = \Config\Database::connect();
// 1. Enhance mst_purchase_orders
if ($db->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);
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PharmacyGrnItemModel extends Model
{
protected $table = 'mst_grn_items';
protected $primaryKey = 'grn_item_id';
protected $returnType = 'array';
protected $useAutoIncrement = true;
protected $allowedFields = [
'grn_id',
'po_item_id',
'item_code',
'item_name',
'batch_no',
'expiry_date',
'packing',
'order_packs',
'received_packs',
'free_packs',
'rejected_packs',
'rejection_reason',
'unit_purchase_rate',
'mrp',
'selling_unit_rate',
'discount_percent',
'taxable_amount',
'cgst_percent',
'cgst_amount',
'sgst_percent',
'sgst_amount',
'igst_percent',
'igst_amount',
'hsn_code',
'total_amount',
'rack_no',
'shelf_no',
'cold_storage',
'purchase_item_id',
];
protected $useTimestamps = false;
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PharmacyGrnModel extends Model
{
protected $table = 'mst_grns';
protected $primaryKey = 'grn_id';
protected $returnType = 'array';
protected $useAutoIncrement = true;
protected $allowedFields = [
'grn_number',
'store_id',
'po_id',
'supplier_id',
'challan_no',
'supplier_invoice_no',
'invoice_date',
'grn_date',
'total_received_amount',
'total_tax_amount',
'net_amount',
'status',
'purchase_id',
'received_by',
'verified_by',
'verified_at',
'remarks',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
public function generateGrnNumber(): string
{
$prefix = 'GRN-' . date('ym');
$row = $this->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);
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PharmacyPurchaseOrderItemModel extends Model
{
protected $table = 'mst_purchase_order_items';
protected $primaryKey = 'po_item_id';
protected $returnType = 'array';
protected $useAutoIncrement = true;
protected $allowedFields = [
'po_id',
'item_id',
'item_name',
'generic_name',
'category',
'order_packs',
'units_per_pack',
'received_packs',
'remaining_packs',
'estimated_ptr',
'gst_rate',
'estimated_amount',
'status',
];
protected $useTimestamps = false;
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Models;
use CodeIgniter\Model;
class PharmacyPurchaseOrderModel extends Model
{
protected $table = 'mst_purchase_orders';
protected $primaryKey = 'po_id';
protected $returnType = 'array';
protected $useAutoIncrement = true;
protected $allowedFields = [
'store_id',
'supplier_id',
'po_number',
'po_date',
'expected_delivery_date',
'payment_terms',
'total_items',
'total_estimated_amount',
'tax_amount',
'grand_total',
'status',
'remarks',
'created_by',
'approved_by',
'approved_at',
'cancelled_by',
'cancelled_at',
'cancelled_reason',
];
protected $useTimestamps = true;
protected $createdField = 'created_at';
protected $updatedField = 'updated_at';
public function generatePoNumber(): string
{
$prefix = 'PO-' . date('ym');
$row = $this->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);
}
}

View File

@ -29,6 +29,12 @@
<a class="btn btn-outline-primary btn-sm" href="javascript:load_form_div('<?= base_url('Medical/main_store') ?>','medical-main','Store Main :Pharmacy');">
<i class="bi bi-display"></i> Store Main
</a>
<a class="btn btn-outline-info btn-sm" href="javascript:load_form_div('<?= base_url('Medical/purchase_orders') ?>','medical-main','Purchase Orders :Pharmacy');">
<i class="bi bi-file-earmark-text"></i> Purchase Orders (PO)
</a>
<a class="btn btn-outline-success btn-sm" href="javascript:load_form_div('<?= base_url('Medical/grn_list') ?>','medical-main','GRN Register :Pharmacy');">
<i class="bi bi-box-seam"></i> Inward GRN
</a>
<a class="btn btn-outline-primary btn-sm" href="javascript:load_form_div('<?= base_url('Medical/master') ?>','medical-main','Master :Pharmacy');">
<i class="bi bi-sliders"></i> Master
</a>

View File

@ -0,0 +1,177 @@
<div class="pagetitle d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0 text-success">
<i class="bi bi-card-checklist"></i> GRN Inspection Details
<span class="badge bg-light text-dark border ms-2 font-monospace"><?= esc($grn['grn_number']) ?></span>
</h4>
<small class="text-muted">Inward Goods Inspection &amp; Live Stock Posting</small>
</div>
<div class="d-flex gap-2">
<a href="javascript:load_form_div('<?= base_url('Medical/grn_list') ?>','medical-main','GRN Register :Pharmacy');" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to GRN List
</a>
<a href="<?= base_url('Medical/grn_print/' . $grn['grn_id']) ?>" target="_blank" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-printer"></i> Print Slip
</a>
</div>
</div>
<div class="card shadow-sm border-0 mb-3">
<div class="card-body p-3">
<div class="row g-3">
<div class="col-md-3">
<small class="text-muted d-block">Supplier / Vendor</small>
<strong><?= esc($grn['name_supplier']) ?></strong>
</div>
<div class="col-md-2">
<small class="text-muted d-block">Vendor Invoice #</small>
<strong><?= esc($grn['supplier_invoice_no']) ?></strong>
</div>
<div class="col-md-2">
<small class="text-muted d-block">Invoice Date</small>
<span><?= date('d-M-Y', strtotime($grn['invoice_date'])) ?></span>
</div>
<div class="col-md-2">
<small class="text-muted d-block">GRN Date</small>
<span><?= date('d-M-Y', strtotime($grn['grn_date'])) ?></span>
</div>
<div class="col-md-3">
<small class="text-muted d-block">Linked PO</small>
<?php if (!empty($grn['po_number'])): ?>
<span class="badge bg-light text-primary border font-monospace"><?= esc($grn['po_number']) ?></span>
<?php else: ?>
<span class="text-muted">Direct Intake</span>
<?php endif; ?>
</div>
</div>
<?php if (!empty($grn['remarks'])): ?>
<div class="alert alert-light border mt-3 mb-0 py-2">
<small><strong>Inspection Remarks:</strong> <?= esc($grn['remarks']) ?></small>
</div>
<?php endif; ?>
</div>
</div>
<div class="card shadow-sm border-0 mb-3">
<div class="card-header bg-light py-2 d-flex justify-content-between align-items-center">
<strong class="text-secondary"><i class="bi bi-capsule"></i> Verified Medicine Items</strong>
<span class="badge bg-<?= ($grn['status'] === 'posted_to_inventory') ? 'success' : 'info text-dark' ?> text-uppercase">
<?= str_replace('_', ' ', esc($grn['status'])) ?>
</span>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-sm table-striped align-middle mb-0">
<thead class="table-light">
<tr>
<th style="width: 40px;">#</th>
<th>Product Name</th>
<th>Batch No</th>
<th>Expiry Date</th>
<th class="text-center">Pack</th>
<th class="text-center">Rec. Packs</th>
<th class="text-center">Free</th>
<th class="text-center">Rejected</th>
<th class="text-end">Rate/Pack</th>
<th class="text-end">MRP</th>
<th class="text-center">GST %</th>
<th class="text-end">Line Total</th>
<th class="text-center">Live Stock ID</th>
</tr>
</thead>
<tbody>
<?php if (!empty($grnItems)): ?>
<?php foreach ($grnItems as $idx => $it): ?>
<tr>
<td><?= $idx + 1 ?></td>
<td><strong><?= esc($it['item_name']) ?></strong></td>
<td><span class="badge bg-light text-dark font-monospace border"><?= esc($it['batch_no']) ?></span></td>
<td><?= date('d-M-Y', strtotime($it['expiry_date'])) ?></td>
<td class="text-center"><?= (int)$it['packing'] ?>s</td>
<td class="text-center fw-bold"><?= (float)$it['received_packs'] ?></td>
<td class="text-center text-success"><?= (float)$it['free_packs'] ?></td>
<td class="text-center <?= ((float)$it['rejected_packs'] > 0) ? 'text-danger fw-bold' : 'text-muted' ?>">
<?= (float)$it['rejected_packs'] ?>
<?php if (!empty($it['rejection_reason'])): ?>
<small class="d-block text-danger">(<?= esc($it['rejection_reason']) ?>)</small>
<?php endif; ?>
</td>
<td class="text-end">₹<?= number_format((float)$it['unit_purchase_rate'], 2) ?></td>
<td class="text-end">₹<?= number_format((float)$it['mrp'], 2) ?></td>
<td class="text-center"><?= number_format((float)$it['cgst_percent'] * 2, 0) ?>%</td>
<td class="text-end fw-semibold">₹<?= number_format((float)$it['total_amount'], 2) ?></td>
<td class="text-center">
<?php if (!empty($it['purchase_item_id'])): ?>
<span class="badge bg-success font-monospace">Item #<?= (int)$it['purchase_item_id'] ?></span>
<?php else: ?>
<span class="text-muted">Pending Post</span>
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
<tfoot class="table-light">
<tr>
<th colspan="11" class="text-end">Taxable Amount:</th>
<th class="text-end">₹<?= number_format((float)$grn['total_received_amount'], 2) ?></th>
<th></th>
</tr>
<tr>
<th colspan="11" class="text-end">Total GST:</th>
<th class="text-end">₹<?= number_format((float)$grn['total_tax_amount'], 2) ?></th>
<th></th>
</tr>
<tr class="table-success">
<th colspan="11" class="text-end fs-6">Net Inward Bill Total:</th>
<th class="text-end fs-6 text-success fw-bold">₹<?= number_format((float)$grn['net_amount'], 2) ?></th>
<th></th>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="card p-3 border-0 bg-light">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div>
<?php if ($grn['status'] === 'posted_to_inventory'): ?>
<span class="text-success fw-bold">
<i class="bi bi-check-circle-fill"></i> Posted to Live Store Inventory (Purchase Bill Ref #<?= (int)$grn['purchase_id'] ?>)
</span>
<?php else: ?>
<span class="text-warning fw-bold">
<i class="bi bi-clock-history"></i> Verified by Pharmacist &bull; Ready to Post to Live Hospital Inventory
</span>
<?php endif; ?>
</div>
<div class="d-flex gap-2">
<?php if ($grn['status'] !== 'posted_to_inventory'): ?>
<button type="button" class="btn btn-success px-4" onclick="postGrnToLiveInventory(<?= (int)$grn['grn_id'] ?>)">
<i class="bi bi-box-arrow-in-down"></i> Post to Live Inventory
</button>
<?php else: ?>
<a href="javascript:load_form_div('<?= base_url('Medical/PurchaseInvoiceEdit/' . (int)$grn['purchase_id']) ?>','medical-main');" class="btn btn-outline-primary btn-sm">
<i class="bi bi-receipt"></i> View Purchase Invoice
</a>
<?php endif; ?>
</div>
</div>
</div>
<script>
function postGrnToLiveInventory(grnId) {
if (!confirm('Confirm posting verified items into live pharmacy store inventory? This will generate live batches and update available stock.')) return;
$.post('<?= base_url('Medical/grn_post_inventory/') ?>' + grnId, {}, function(data) {
alert(data.message || 'Done');
if (data.status) {
load_form_div('<?= base_url('Medical/grn_edit/') ?>' + grnId, 'medical-main', 'GRN Inspection Detail');
}
}, 'json').fail(function() {
alert('Error posting GRN.');
});
}
</script>

View File

@ -0,0 +1,462 @@
<?php
$poId = !empty($selectedPo) ? (int)$selectedPo['po_id'] : 0;
$supplierId = !empty($selectedPo) ? (int)$selectedPo['supplier_id'] : 0;
$nextGrnNumber = $nextGrnNumber ?? 'GRN-NEW';
?>
<div class="pagetitle d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0 text-success">
<i class="bi bi-box-arrow-in-down"></i> Create Goods Received Note (GRN)
<span class="badge bg-light text-dark border ms-2 font-monospace"><?= esc($nextGrnNumber) ?></span>
</h4>
<small class="text-muted">Inward Stock Physical Inspection &amp; Batch Entry</small>
</div>
<div>
<a href="javascript:load_form_div('<?= base_url('Medical/grn_list') ?>','medical-main','GRN Register :Pharmacy');" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to GRN List
</a>
</div>
</div>
<div class="card shadow-sm border-0 mb-3">
<div class="card-header bg-light py-2">
<strong class="text-secondary"><i class="bi bi-truck"></i> Inward Delivery Metadata</strong>
</div>
<div class="card-body p-3">
<form id="grn-create-form" onsubmit="event.preventDefault(); submitGrnForm();">
<?= csrf_field() ?>
<div class="row g-3">
<div class="col-md-3">
<label class="form-label form-label-sm fw-semibold">Linked Purchase Order (PO)</label>
<select name="po_id" id="grn_po_id" class="form-select form-select-sm" onchange="handlePoSelection(this.value);">
<option value="0">-- Direct Intake (No PO) --</option>
<?php if (!empty($approvedPos)): ?>
<?php foreach ($approvedPos as $p): ?>
<option value="<?= (int)$p['po_id'] ?>" <?= ($poId == $p['po_id']) ? 'selected' : '' ?>>
<?= esc($p['po_number']) ?> (<?= date('d/m/y', strtotime($p['po_date'])) ?>)
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<div class="col-md-3">
<label class="form-label form-label-sm fw-semibold">Supplier / Pharmaceutical Vendor <span class="text-danger">*</span></label>
<select name="supplier_id" id="grn_supplier_id" class="form-select form-select-sm" required>
<option value="">-- Select Supplier --</option>
<?php if (!empty($suppliers)): ?>
<?php foreach ($suppliers as $s): ?>
<option value="<?= (int)$s['sid'] ?>" <?= ($supplierId == $s['sid']) ? 'selected' : '' ?>>
<?= esc($s['name_supplier']) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold">Vendor Bill / Invoice # <span class="text-danger">*</span></label>
<input type="text" name="supplier_invoice_no" id="supplier_invoice_no" class="form-control form-control-sm" placeholder="e.g., INV-88421" required>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold">Bill Date</label>
<input type="date" name="invoice_date" id="invoice_date" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>" required>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold">GRN Received Date</label>
<input type="date" name="grn_date" id="grn_date" class="form-control form-control-sm" value="<?= date('Y-m-d') ?>" required>
</div>
<div class="col-md-3">
<label class="form-label form-label-sm fw-semibold">Delivery Challan / Gate Pass #</label>
<input type="text" name="challan_no" id="challan_no" class="form-control form-control-sm" placeholder="Optional Challan No.">
</div>
<div class="col-md-9">
<label class="form-label form-label-sm fw-semibold">Receiving Inspection Remarks</label>
<input type="text" name="remarks" id="grn_remarks" class="form-control form-control-sm" placeholder="Package integrity verified, cold-chain temperature logged, etc.">
</div>
</div>
<!-- Manual Drug Search (for direct items or extra items) -->
<div class="card border-success mt-3">
<div class="card-header bg-success text-white py-2 d-flex justify-content-between align-items-center">
<span><i class="bi bi-shield-check"></i> Physical Inspection &amp; Batch Intake Table</span>
<small class="text-white-50">Enter Batch, Expiry, Received Packs &amp; Pricing</small>
</div>
<div class="card-body p-3">
<div class="row g-2 align-items-end mb-3">
<div class="col-md-4">
<label class="form-label form-label-sm mb-1 fw-semibold">Add Medicine / Drug Master</label>
<input type="text" id="grn_drug_search" class="form-control form-control-sm" placeholder="Search medicine to add row..." autocomplete="off">
<input type="hidden" id="grn_sel_item_code" value="">
<input type="hidden" id="grn_sel_item_name" value="">
<input type="hidden" id="grn_sel_packing" value="10">
</div>
<div class="col-md-2">
<label class="form-label form-label-sm mb-1 fw-semibold">Batch No.</label>
<input type="text" id="grn_sel_batch" class="form-control form-control-sm" placeholder="e.g. BATCH-01">
</div>
<div class="col-md-2">
<label class="form-label form-label-sm mb-1 fw-semibold">Expiry Date</label>
<input type="date" id="grn_sel_expiry" class="form-control form-control-sm">
</div>
<div class="col-md-2">
<label class="form-label form-label-sm mb-1 fw-semibold">Received Packs</label>
<input type="number" id="grn_sel_rec_packs" class="form-control form-control-sm" value="10" min="1">
</div>
<div class="col-md-2">
<button type="button" class="btn btn-success btn-sm w-100" id="btn-add-grn-row" onclick="addCustomItemToGrn();" disabled>
<i class="bi bi-plus-lg"></i> Add Row
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-bordered table-sm align-middle mb-0" id="grn-items-table">
<thead class="table-light">
<tr>
<th style="width: 30px;">#</th>
<th>Medicine Name</th>
<th style="width: 110px;">Batch No <span class="text-danger">*</span></th>
<th style="width: 120px;">Expiry <span class="text-danger">*</span></th>
<th class="text-center" style="width: 70px;">Pack</th>
<th class="text-center" style="width: 80px;">Ordered</th>
<th class="text-center" style="width: 85px;">Rec. Packs <span class="text-danger">*</span></th>
<th class="text-center" style="width: 75px;">Free</th>
<th class="text-center" style="width: 75px;">Damaged</th>
<th class="text-end" style="width: 100px;">Rate/Pack</th>
<th class="text-end" style="width: 90px;">MRP</th>
<th class="text-center" style="width: 70px;">GST %</th>
<th class="text-end" style="width: 110px;">Line Total</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody id="grn-items-body">
<?php if (!empty($poItems)): ?>
<?php foreach ($poItems as $idx => $it): ?>
<tr>
<td class="row-num"><?= $idx + 1 ?></td>
<td>
<strong><?= esc($it['item_name']) ?></strong>
<input type="hidden" name="items[<?= $idx ?>][item_code]" value="<?= (int)$it['item_id'] ?>">
<input type="hidden" name="items[<?= $idx ?>][item_name]" value="<?= esc($it['item_name']) ?>">
<input type="hidden" name="items[<?= $idx ?>][po_item_id]" value="<?= (int)$it['po_item_id'] ?>">
</td>
<td>
<input type="text" name="items[<?= $idx ?>][batch_no]" class="form-control form-control-sm font-monospace line-batch" placeholder="Batch #" required>
</td>
<td>
<input type="date" name="items[<?= $idx ?>][expiry_date]" class="form-control form-control-sm line-expiry" required>
</td>
<td class="text-center">
<input type="number" name="items[<?= $idx ?>][packing]" class="form-control form-control-sm text-center line-pack" value="<?= (int)$it['units_per_pack'] ?>" min="1">
</td>
<td class="text-center text-muted">
<?= (float)$it['remaining_packs'] ?>
<input type="hidden" name="items[<?= $idx ?>][order_packs]" value="<?= (float)$it['remaining_packs'] ?>">
</td>
<td class="text-center">
<input type="number" name="items[<?= $idx ?>][received_packs]" class="form-control form-control-sm text-center line-rec" value="<?= (float)$it['remaining_packs'] ?>" min="0" step="1" onchange="recalculateGrnTotals();" required>
</td>
<td class="text-center">
<input type="number" name="items[<?= $idx ?>][free_packs]" class="form-control form-control-sm text-center line-free" value="0" min="0" step="1">
</td>
<td class="text-center">
<input type="number" name="items[<?= $idx ?>][rejected_packs]" class="form-control form-control-sm text-center line-rej" value="0" min="0" step="1">
</td>
<td class="text-end">
<input type="number" name="items[<?= $idx ?>][unit_purchase_rate]" class="form-control form-control-sm text-end line-rate" value="<?= number_format((float)$it['estimated_ptr'], 2, '.', '') ?>" min="0" step="0.01" onchange="recalculateGrnTotals();">
</td>
<td class="text-end">
<input type="number" name="items[<?= $idx ?>][mrp]" class="form-control form-control-sm text-end line-mrp" value="<?= number_format((float)$it['estimated_ptr'] * 1.3, 2, '.', '') ?>" min="0" step="0.01">
</td>
<td class="text-center">
<select name="items[<?= $idx ?>][cgst_percent]" class="form-select form-select-sm text-center line-gst" onchange="recalculateGrnTotals();">
<option value="6" <?= ((float)$it['gst_rate'] == 12) ? 'selected' : '' ?>>12%</option>
<option value="2.5" <?= ((float)$it['gst_rate'] == 5) ? 'selected' : '' ?>>5%</option>
<option value="9" <?= ((float)$it['gst_rate'] == 18) ? 'selected' : '' ?>>18%</option>
<option value="0" <?= ((float)$it['gst_rate'] == 0) ? 'selected' : '' ?>>0%</option>
</select>
<input type="hidden" name="items[<?= $idx ?>][sgst_percent]" class="line-sgst" value="6">
</td>
<td class="text-end fw-semibold line-total-disp">₹0.00</td>
<td class="text-center">
<button type="button" class="btn btn-outline-danger btn-sm py-0" onclick="removeGrnRow(this);">✖</button>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr id="grn-no-items">
<td colspan="14" class="text-center py-4 text-muted">
No items loaded. Select a PO above or use the Search box to add items.
</td>
</tr>
<?php endif; ?>
</tbody>
<tfoot class="table-light">
<tr>
<th colspan="12" class="text-end">Taxable Amount:</th>
<th class="text-end" id="grn-taxable-disp">₹0.00</th>
<th></th>
</tr>
<tr>
<th colspan="12" class="text-end">GST Tax Amount:</th>
<th class="text-end" id="grn-tax-disp">₹0.00</th>
<th></th>
</tr>
<tr class="table-success">
<th colspan="12" class="text-end fs-6">Net Inward Bill Total:</th>
<th class="text-end fs-6 text-success fw-bold" id="grn-net-disp">₹0.00</th>
<th></th>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="card p-3 mt-3 bg-light border-0">
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="post_to_inventory" id="post_to_inventory" value="1" checked>
<label class="form-check-label fw-bold text-dark" for="post_to_inventory">
Immediately Post Verified Quantities to Live Pharmacy Store Stock &amp; Purchase Ledgers
</label>
<small class="d-block text-muted">Calls stored procedure <code>p_stock_update_purchase_id</code> to make batches immediately sellable.</small>
</div>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" onclick="load_form_div('<?= base_url('Medical/grn_list') ?>','medical-main');">Cancel</button>
<button type="submit" class="btn btn-success px-4" id="btn-save-grn">
<i class="bi bi-check-all"></i> Save &amp; Complete Inward GRN
</button>
</div>
</div>
</div>
</form>
</div>
</div>
<script>
(function() {
if (window.jQuery && window.jQuery.ui && window.jQuery.ui.autocomplete) {
initGrnDrugAutocomplete();
} else {
var s = document.createElement('script');
s.src = '<?= base_url('assets/vendor/jquery-ui/jquery-ui.min.js') ?>';
s.onload = initGrnDrugAutocomplete;
document.body.appendChild(s);
}
recalculateGrnTotals();
})();
function initGrnDrugAutocomplete() {
if (!window.jQuery || !window.jQuery.ui) return;
var $ = window.jQuery;
$("#grn_drug_search").autocomplete({
source: function(request, response) {
$.getJSON("<?= base_url('Medical/get_drug') ?>", request, function(data) {
response(data || []);
});
},
minLength: 1,
select: function(event, ui) {
var item = ui.item || {};
document.getElementById('grn_sel_item_code').value = item.item_code || item.l_item_code || '';
document.getElementById('grn_sel_item_name').value = item.value || '';
document.getElementById('grn_sel_packing').value = item.l_packing || 10;
document.getElementById('btn-add-grn-row').disabled = false;
}
});
}
function handlePoSelection(poId) {
if (poId && parseInt(poId) > 0) {
load_form_div('<?= base_url('Medical/grn_new/') ?>' + poId, 'medical-main', 'Create GRN for PO');
}
}
function addCustomItemToGrn() {
var code = document.getElementById('grn_sel_item_code').value;
var name = document.getElementById('grn_sel_item_name').value;
var batch = document.getElementById('grn_sel_batch').value.trim();
var expiry = document.getElementById('grn_sel_expiry').value;
var packs = parseFloat(document.getElementById('grn_sel_rec_packs').value || 1);
var packing = parseInt(document.getElementById('grn_sel_packing').value || 10);
if (!code || !name) {
alert('Please search and select a medicine.');
return;
}
if (!batch) {
alert('Please enter a valid batch number for physical tracking.');
document.getElementById('grn_sel_batch').focus();
return;
}
if (!expiry) {
alert('Please enter medicine expiry date.');
document.getElementById('grn_sel_expiry').focus();
return;
}
var tbody = document.getElementById('grn-items-body');
var noRow = document.getElementById('grn-no-items');
if (noRow) noRow.remove();
var rowIdx = tbody.querySelectorAll('tr').length;
var tr = document.createElement('tr');
tr.innerHTML = `
<td class="row-num">${rowIdx + 1}</td>
<td>
<strong>${escapeHtml(name)}</strong>
<input type="hidden" name="items[${rowIdx}][item_code]" value="${code}">
<input type="hidden" name="items[${rowIdx}][item_name]" value="${escapeHtml(name)}">
<input type="hidden" name="items[${rowIdx}][po_item_id]" value="">
</td>
<td>
<input type="text" name="items[${rowIdx}][batch_no]" class="form-control form-control-sm font-monospace line-batch" value="${escapeHtml(batch)}" required>
</td>
<td>
<input type="date" name="items[${rowIdx}][expiry_date]" class="form-control form-control-sm line-expiry" value="${expiry}" required>
</td>
<td class="text-center">
<input type="number" name="items[${rowIdx}][packing]" class="form-control form-control-sm text-center line-pack" value="${packing}" min="1">
</td>
<td class="text-center text-muted">0</td>
<td class="text-center">
<input type="number" name="items[${rowIdx}][received_packs]" class="form-control form-control-sm text-center line-rec" value="${packs}" min="1" onchange="recalculateGrnTotals();" required>
</td>
<td class="text-center">
<input type="number" name="items[${rowIdx}][free_packs]" class="form-control form-control-sm text-center line-free" value="0" min="0">
</td>
<td class="text-center">
<input type="number" name="items[${rowIdx}][rejected_packs]" class="form-control form-control-sm text-center line-rej" value="0" min="0">
</td>
<td class="text-end">
<input type="number" name="items[${rowIdx}][unit_purchase_rate]" class="form-control form-control-sm text-end line-rate" value="100.00" min="0" step="0.01" onchange="recalculateGrnTotals();">
</td>
<td class="text-end">
<input type="number" name="items[${rowIdx}][mrp]" class="form-control form-control-sm text-end line-mrp" value="130.00" min="0" step="0.01">
</td>
<td class="text-center">
<select name="items[${rowIdx}][cgst_percent]" class="form-select form-select-sm text-center line-gst" onchange="recalculateGrnTotals();">
<option value="6" selected>12%</option>
<option value="2.5">5%</option>
<option value="9">18%</option>
<option value="0">0%</option>
</select>
<input type="hidden" name="items[${rowIdx}][sgst_percent]" class="line-sgst" value="6">
</td>
<td class="text-end fw-semibold line-total-disp">₹0.00</td>
<td class="text-center">
<button type="button" class="btn btn-outline-danger btn-sm py-0" onclick="removeGrnRow(this);">✖</button>
</td>
`;
tbody.appendChild(tr);
recalculateGrnTotals();
document.getElementById('grn_drug_search').value = '';
document.getElementById('grn_sel_batch').value = '';
document.getElementById('grn_sel_expiry').value = '';
document.getElementById('btn-add-grn-row').disabled = true;
document.getElementById('grn_drug_search').focus();
}
function removeGrnRow(btn) {
btn.closest('tr').remove();
var tbody = document.getElementById('grn-items-body');
var rows = tbody.querySelectorAll('tr');
if (rows.length === 0) {
tbody.innerHTML = '<tr id="grn-no-items"><td colspan="14" class="text-center py-4 text-muted">No items loaded.</td></tr>';
} else {
rows.forEach(function(r, idx) {
var numTd = r.querySelector('.row-num');
if (numTd) numTd.innerText = idx + 1;
});
}
recalculateGrnTotals();
}
function recalculateGrnTotals() {
var rows = document.querySelectorAll('#grn-items-body tr:not(#grn-no-items)');
var taxable = 0.0;
var totalTax = 0.0;
rows.forEach(function(r) {
var recPacks = parseFloat(r.querySelector('.line-rec')?.value || 0);
var rate = parseFloat(r.querySelector('.line-rate')?.value || 0);
var gstVal = parseFloat(r.querySelector('.line-gst')?.value || 0) * 2; // CGST + SGST
var lineSub = recPacks * rate;
var lineTax = lineSub * (gstVal / 100);
var lineTotal = lineSub + lineTax;
taxable += lineSub;
totalTax += lineTax;
var totalDisp = r.querySelector('.line-total-disp');
if (totalDisp) totalDisp.innerText = '₹' + lineTotal.toFixed(2);
});
document.getElementById('grn-taxable-disp').innerText = '₹' + taxable.toFixed(2);
document.getElementById('grn-tax-disp').innerText = '₹' + totalTax.toFixed(2);
document.getElementById('grn-net-disp').innerText = '₹' + (taxable + totalTax).toFixed(2);
}
function submitGrnForm() {
var rows = document.querySelectorAll('#grn-items-body tr:not(#grn-no-items)');
if (rows.length === 0) {
alert('Please add at least one received item line.');
return;
}
// Validate that all batch and expiry dates are populated
var missingBatch = false;
rows.forEach(function(r) {
var batch = r.querySelector('.line-batch')?.value.trim();
var exp = r.querySelector('.line-expiry')?.value.trim();
if (!batch || !exp) missingBatch = true;
});
if (missingBatch) {
alert('Please ensure every medicine row has a valid Batch No. and Expiration Date.');
return;
}
var form = document.getElementById('grn-create-form');
var formData = new FormData(form);
var saveBtn = document.getElementById('btn-save-grn');
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Processing Inward Stock...';
fetch('<?= base_url('Medical/grn_create') ?>', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData
})
.then(r => r.json())
.then(data => {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-all"></i> Save &amp; Complete Inward GRN';
if (data.status) {
alert(data.message);
load_form_div('<?= base_url('Medical/grn_list') ?>', 'medical-main', 'GRN Register :Pharmacy');
} else {
alert('Error: ' + (data.message || 'Failed to save GRN.'));
}
})
.catch(e => {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-all"></i> Save &amp; Complete Inward GRN';
alert('Server communication error.');
});
}
function escapeHtml(text) {
if (!text) return '';
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
</script>

View File

@ -0,0 +1,172 @@
<div class="pagetitle d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0 text-primary"><i class="bi bi-box-seam"></i> Goods Received Notes (GRN)</h4>
<small class="text-muted">Inward Stock Inspection &amp; Physical Receipt Verification</small>
</div>
<div class="d-flex gap-2">
<a href="javascript:load_form_div('<?= base_url('Medical/main_store') ?>','medical-main','Store Main :Pharmacy');" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Store Main
</a>
<a href="javascript:load_form_div('<?= base_url('Medical/purchase_orders') ?>','medical-main','Purchase Orders :Pharmacy');" class="btn btn-outline-primary btn-sm">
<i class="bi bi-file-earmark-text"></i> Purchase Orders
</a>
<a href="javascript:load_form_div('<?= base_url('Medical/grn_new') ?>','medical-main','Create GRN :Pharmacy');" class="btn btn-success btn-sm">
<i class="bi bi-plus-circle"></i> Create Inward GRN
</a>
</div>
</div>
<div class="card mb-3 shadow-sm border-0">
<div class="card-body p-3">
<form method="get" id="grn-filter-form" class="row g-2 align-items-center" onsubmit="event.preventDefault(); applyGrnFilter();">
<div class="col-md-3">
<input type="text" id="grn_search_text" class="form-control form-control-sm" placeholder="Search GRN #, Bill #, PO #..." value="<?= esc($search ?? '') ?>">
</div>
<div class="col-md-3">
<select id="grn_filter_supplier" class="form-select form-select-sm">
<option value="0">All Suppliers</option>
<?php if (!empty($suppliers)): ?>
<?php foreach ($suppliers as $s): ?>
<option value="<?= (int)$s['sid'] ?>" <?= ($supplierId == $s['sid']) ? 'selected' : '' ?>>
<?= esc($s['name_supplier']) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<div class="col-md-2">
<select id="grn_filter_status" class="form-select form-select-sm">
<option value="all">All Statuses</option>
<option value="draft" <?= ($status === 'draft') ? 'selected' : '' ?>>Draft</option>
<option value="verified" <?= ($status === 'verified') ? 'selected' : '' ?>>Verified</option>
<option value="posted_to_inventory" <?= ($status === 'posted_to_inventory') ? 'selected' : '' ?>>Posted to Live Stock</option>
<option value="cancelled" <?= ($status === 'cancelled') ? 'selected' : '' ?>>Cancelled</option>
</select>
</div>
<div class="col-md-2">
<input type="date" id="grn_filter_from" class="form-control form-control-sm" value="<?= esc($fromDate ?? '') ?>" title="From Date">
</div>
<div class="col-md-2 d-flex gap-1">
<button type="submit" class="btn btn-dark btn-sm w-100"><i class="bi bi-funnel"></i> Filter</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="resetGrnFilter()"><i class="bi bi-arrow-counterclockwise"></i></button>
</div>
</form>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped align-middle mb-0" id="grn-table">
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>GRN Number</th>
<th>Supplier / Vendor</th>
<th>Supplier Bill / Challan</th>
<th>GRN Date</th>
<th>Linked PO</th>
<th class="text-end">Net Amount</th>
<th class="text-center">Status</th>
<th class="text-end" style="min-width: 170px;">Actions</th>
</tr>
</thead>
<tbody>
<?php if (!empty($grnList)): ?>
<?php foreach ($grnList as $idx => $row): ?>
<?php
$statusBadge = 'bg-secondary';
if ($row['status'] === 'draft') $statusBadge = 'bg-warning text-dark';
elseif ($row['status'] === 'verified') $statusBadge = 'bg-info text-dark';
elseif ($row['status'] === 'posted_to_inventory') $statusBadge = 'bg-success';
elseif ($row['status'] === 'cancelled') $statusBadge = 'bg-danger';
?>
<tr>
<td><?= $idx + 1 ?></td>
<td>
<strong class="text-primary font-monospace"><?= esc($row['grn_number']) ?></strong>
</td>
<td>
<div class="fw-semibold"><?= esc($row['name_supplier'] ?: 'Unknown Vendor') ?></div>
</td>
<td>
<div><strong>Inv:</strong> <?= esc($row['supplier_invoice_no']) ?></div>
<?php if (!empty($row['challan_no'])): ?>
<small class="text-muted">Challan: <?= esc($row['challan_no']) ?></small>
<?php endif; ?>
</td>
<td><?= !empty($row['grn_date']) ? date('d-M-Y', strtotime($row['grn_date'])) : '-' ?></td>
<td>
<?php if (!empty($row['po_number'])): ?>
<span class="badge bg-light text-primary border"><?= esc($row['po_number']) ?></span>
<?php else: ?>
<span class="text-muted">Direct Intake</span>
<?php endif; ?>
</td>
<td class="text-end fw-bold">₹<?= number_format((float)$row['net_amount'], 2) ?></td>
<td class="text-center">
<span class="badge <?= $statusBadge ?> px-2 py-1 text-uppercase" style="font-size: 11px;">
<?= str_replace('_', ' ', esc($row['status'])) ?>
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="<?= base_url('Medical/grn_print/' . $row['grn_id']) ?>" target="_blank" class="btn btn-outline-secondary" title="Print GRN Slip">
<i class="bi bi-printer"></i>
</a>
<button type="button" class="btn btn-outline-primary" onclick="load_form_div('<?= base_url('Medical/grn_edit/' . $row['grn_id']) ?>','medical-main','GRN Inspection Detail');" title="View Inspection Details">
<i class="bi bi-eye"></i> View
</button>
<?php if ($row['status'] === 'verified'): ?>
<button type="button" class="btn btn-success" onclick="postGrnToStock(<?= (int)$row['grn_id'] ?>)" title="Post to Live Hospital Stock">
<i class="bi bi-box-arrow-in-down"></i> Post Stock
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="9" class="text-center py-4 text-muted">
<i class="bi bi-inbox fs-3 d-block mb-1"></i>
No Goods Received Notes found. Click <strong>Create Inward GRN</strong> to record incoming material.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
function applyGrnFilter() {
var search = document.getElementById('grn_search_text').value;
var supplier = document.getElementById('grn_filter_supplier').value;
var status = document.getElementById('grn_filter_status').value;
var from = document.getElementById('grn_filter_from').value;
var url = '<?= base_url('Medical/grn_list') ?>?q=' + encodeURIComponent(search)
+ '&supplier_id=' + encodeURIComponent(supplier)
+ '&status=' + encodeURIComponent(status)
+ '&from=' + encodeURIComponent(from);
load_form_div(url, 'medical-main', 'GRN Register :Pharmacy');
}
function resetGrnFilter() {
load_form_div('<?= base_url('Medical/grn_list') ?>', 'medical-main', 'GRN Register :Pharmacy');
}
function postGrnToStock(grnId) {
if (!confirm('Are you sure you want to verify and post this GRN into live pharmacy inventory? This will update physical stock quantities and supplier purchase ledgers.')) return;
$.post('<?= base_url('Medical/grn_post_inventory/') ?>' + grnId, {}, function(data) {
alert(data.message || 'Stock posted');
if (data.status) applyGrnFilter();
}, 'json').fail(function() {
alert('Error posting GRN to inventory.');
});
}
</script>

View File

@ -0,0 +1,133 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 10px; color: #222; }
.header { text-align: center; border-bottom: 2px solid #166534; padding-bottom: 8px; margin-bottom: 12px; }
.header h2 { margin: 0; color: #166534; text-transform: uppercase; font-size: 17px; }
.header p { margin: 2px 0 0; color: #555; font-size: 10px; }
.title-bar { background-color: #f0fdf4; padding: 6px 10px; margin-bottom: 12px; border-left: 4px solid #166534; }
.title-bar table { width: 100%; border-collapse: collapse; }
.meta-table { width: 100%; border-collapse: collapse; margin-bottom: 14px; font-size: 10px; }
.meta-table td { padding: 4px 6px; vertical-align: top; }
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.items-table th { background-color: #166534; color: #fff; padding: 5px 6px; text-align: left; font-size: 9px; text-transform: uppercase; border: 1px solid #166534; }
.items-table td { padding: 5px 6px; border: 1px solid #cbd5e1; font-size: 9px; }
.text-end { text-align: right; }
.text-center { text-align: center; }
.totals-table { width: 40%; margin-left: auto; border-collapse: collapse; margin-bottom: 20px; }
.totals-table td { padding: 4px 8px; border-bottom: 1px solid #cbd5e1; font-size: 10px; }
.grand-total { font-size: 12px; font-weight: bold; background-color: #f0fdf4; color: #166534; }
.signatures { width: 100%; margin-top: 30px; }
.signatures td { width: 33%; text-align: center; font-size: 10px; }
.sig-line { border-top: 1px solid #333; margin-top: 30px; padding-top: 4px; }
</style>
</head>
<body>
<div class="header">
<h2>Hospital Pharmacy Services</h2>
<p>Inward Goods Physical Inspection &amp; Receiving Quality Control</p>
</div>
<div class="title-bar">
<table>
<tr>
<td><strong style="font-size: 13px; color: #166534;">GOODS RECEIVED NOTE (GRN)</strong></td>
<td class="text-end"><strong>GRN NO:</strong> <span style="font-family: monospace; font-size: 12px; color: #166534;"><?= esc($grn['grn_number']) ?></span></td>
</tr>
</table>
</div>
<table class="meta-table">
<tr>
<td style="width: 50%; border: 1px solid #e2e8f0; padding: 6px;">
<strong style="color: #64748b; font-size: 8px; text-transform: uppercase;">SUPPLIER DETAILS</strong><br>
<strong style="font-size: 11px; color: #0f172a;"><?= esc($grn['name_supplier'] ?: 'Supplier') ?></strong><br>
<?php $loc = implode(', ', array_filter([$grn['city'] ?? '', $grn['state'] ?? ''])); if (!empty($loc)): ?><?= esc($loc) ?><br><?php endif; ?>
Phone: <?= esc($grn['contact_no'] ?: '-') ?><br>
<?php if (!empty($grn['gst_no'])): ?>GSTIN: <?= esc($grn['gst_no']) ?><?php endif; ?>
</td>
<td style="width: 50%; border: 1px solid #e2e8f0; padding: 6px;">
<strong style="color: #64748b; font-size: 8px; text-transform: uppercase;">DELIVERY METADATA</strong><br>
<strong>Supplier Bill #:</strong> <?= esc($grn['supplier_invoice_no']) ?> | <strong>Bill Date:</strong> <?= date('d-M-Y', strtotime($grn['invoice_date'])) ?><br>
<strong>GRN Receipt Date:</strong> <?= date('d-M-Y', strtotime($grn['grn_date'])) ?><br>
<strong>Linked PO #:</strong> <?= esc($grn['po_number'] ?: 'Direct Delivery') ?><br>
<strong>Status:</strong> <span style="text-transform: uppercase; font-weight: bold;"><?= esc($grn['status']) ?></span>
</td>
</tr>
</table>
<table class="items-table">
<thead>
<tr>
<th style="width: 25px;">#</th>
<th>Item Name</th>
<th style="width: 75px;">Batch No</th>
<th style="width: 65px;">Expiry</th>
<th class="text-center" style="width: 45px;">Pack</th>
<th class="text-center" style="width: 50px;">Ordered</th>
<th class="text-center" style="width: 50px;">Received</th>
<th class="text-center" style="width: 40px;">Free</th>
<th class="text-center" style="width: 45px;">Damaged</th>
<th class="text-end" style="width: 65px;">Rate/Pack</th>
<th class="text-end" style="width: 60px;">MRP</th>
<th class="text-end" style="width: 75px;">Line Total</th>
</tr>
</thead>
<tbody>
<?php if (!empty($items)): ?>
<?php foreach ($items as $idx => $it): ?>
<tr>
<td class="text-center"><?= $idx + 1 ?></td>
<td><strong><?= esc($it['item_name']) ?></strong></td>
<td><?= esc($it['batch_no']) ?></td>
<td><?= date('d-M-Y', strtotime($it['expiry_date'])) ?></td>
<td class="text-center"><?= (int)$it['packing'] ?>s</td>
<td class="text-center"><?= (float)$it['order_packs'] ?></td>
<td class="text-center"><strong><?= (float)$it['received_packs'] ?></strong></td>
<td class="text-center"><?= (float)$it['free_packs'] ?></td>
<td class="text-center <?= ((float)$it['rejected_packs'] > 0) ? 'style=\"color:red;\"' : '' ?>">
<?= (float)$it['rejected_packs'] ?>
</td>
<td class="text-end">₹<?= number_format((float)$it['unit_purchase_rate'], 2) ?></td>
<td class="text-end">₹<?= number_format((float)$it['mrp'], 2) ?></td>
<td class="text-end">₹<?= number_format((float)$it['total_amount'], 2) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<table class="totals-table">
<tr>
<td>Taxable Amount:</td>
<td class="text-end">₹<?= number_format((float)$grn['total_received_amount'], 2) ?></td>
</tr>
<tr>
<td>GST Tax:</td>
<td class="text-end">₹<?= number_format((float)$grn['total_tax_amount'], 2) ?></td>
</tr>
<tr class="grand-total">
<td>Net Payable Total:</td>
<td class="text-end">₹<?= number_format((float)$grn['net_amount'], 2) ?></td>
</tr>
</table>
<table class="signatures">
<tr>
<td>
<div class="sig-line">Physical Receiver (Store Assistant)</div>
</td>
<td>
<div class="sig-line">Inspected &amp; Quality Checked (Pharmacist)</div>
</td>
<td>
<div class="sig-line">Store In-Charge Approval</div>
</td>
</tr>
</table>
</body>
</html>

View File

@ -59,9 +59,19 @@
<div class="card-header">Inventory</div>
<div class="card-body pt-3">
<ul class="mb-0" style="list-style: disc;">
<li class="mb-3">
<a href="javascript:load_form_div('<?= base_url('Medical/purchase_orders') ?>','medical-main','Purchase Orders :Pharmacy');">
<i class="bi bi-file-earmark-text"></i> Purchase Order (PO)
</a>
</li>
<li class="mb-3">
<a href="javascript:load_form_div('<?= base_url('Medical/grn_list') ?>','medical-main','GRN Register :Pharmacy');">
<i class="bi bi-box-seam"></i> Goods Received Note (GRN)
</a>
</li>
<li class="mb-3">
<a href="javascript:load_form_div('<?= base_url('Medical/Purchase') ?>','medical-main','Purchase :Pharmacy');">
<i class="bi bi-hand-index-thumb"></i> Purchase
<i class="bi bi-hand-index-thumb"></i> Purchase Invoice
</a>
</li>
<li>

View File

@ -0,0 +1,464 @@
<?php
$isEdit = !empty($po);
$formAction = $isEdit ? base_url('Medical/po_create') : base_url('Medical/po_create'); // we handle create / update
$poNumber = $isEdit ? ($po['po_number'] ?? '') : ($nextPoNumber ?? 'PO-NEW');
$supplierId = (int)($po['supplier_id'] ?? 0);
$storeId = (int)($po['store_id'] ?? 1);
$poDate = !empty($po['po_date']) ? $po['po_date'] : date('Y-m-d');
$expDelivery = !empty($po['expected_delivery_date']) ? $po['expected_delivery_date'] : date('Y-m-d', strtotime('+7 days'));
$paymentTerms = $po['payment_terms'] ?? 'Net 30 Days';
$remarks = $po['remarks'] ?? '';
$status = $po['status'] ?? 'ordered';
?>
<div class="pagetitle d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0 text-primary">
<i class="bi bi-file-earmark-plus"></i> <?= $isEdit ? 'Edit Purchase Order' : 'New Purchase Order' ?>
<span class="badge bg-light text-dark border ms-2 font-monospace"><?= esc($poNumber) ?></span>
</h4>
<small class="text-muted">Procurement Requisition &amp; Supplier Order</small>
</div>
<div>
<a href="javascript:load_form_div('<?= base_url('Medical/purchase_orders') ?>','medical-main','Purchase Orders :Pharmacy');" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to PO List
</a>
</div>
</div>
<div class="card shadow-sm border-0 mb-3">
<div class="card-header bg-light py-2">
<strong class="text-secondary"><i class="bi bi-info-circle"></i> Purchase Order Details</strong>
</div>
<div class="card-body p-3">
<form id="po-create-form" onsubmit="event.preventDefault(); submitPoForm();">
<?= csrf_field() ?>
<div class="row g-3">
<div class="col-md-4">
<label class="form-label form-label-sm fw-semibold">Supplier / Pharmaceutical Vendor <span class="text-danger">*</span></label>
<select name="supplier_id" id="po_supplier_id" class="form-select form-select-sm" required onchange="handleSupplierChange(this.value);">
<option value="">-- Select Supplier --</option>
<?php if (!empty($suppliers)): ?>
<?php foreach ($suppliers as $s): ?>
<option value="<?= (int)$s['sid'] ?>" <?= ($supplierId == $s['sid']) ? 'selected' : '' ?>>
<?= esc($s['name_supplier']) ?> <?= !empty($s['contact_no']) ? '(' . esc($s['contact_no']) . ')' : '' ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold">PO Date</label>
<input type="date" name="po_date" id="po_date" class="form-control form-control-sm" value="<?= esc($poDate) ?>" required>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold">Expected Delivery</label>
<input type="date" name="expected_delivery_date" id="expected_delivery_date" class="form-control form-control-sm" value="<?= esc($expDelivery) ?>">
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold">Payment Terms</label>
<select name="payment_terms" id="payment_terms" class="form-select form-select-sm">
<option value="Net 30 Days" <?= ($paymentTerms === 'Net 30 Days') ? 'selected' : '' ?>>Net 30 Days</option>
<option value="Net 15 Days" <?= ($paymentTerms === 'Net 15 Days') ? 'selected' : '' ?>>Net 15 Days</option>
<option value="Net 45 Days" <?= ($paymentTerms === 'Net 45 Days') ? 'selected' : '' ?>>Net 45 Days</option>
<option value="Cash On Delivery (COD)" <?= ($paymentTerms === 'Cash On Delivery (COD)') ? 'selected' : '' ?>>Cash On Delivery (COD)</option>
<option value="Advance" <?= ($paymentTerms === 'Advance') ? 'selected' : '' ?>>Advance</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label form-label-sm fw-semibold">Initial Status</label>
<select name="status" id="po_status" class="form-select form-select-sm">
<option value="ordered" <?= ($status === 'ordered') ? 'selected' : '' ?>>Approved &amp; Issued</option>
<option value="draft" <?= ($status === 'draft') ? 'selected' : '' ?>>Draft</option>
</select>
</div>
<div class="col-md-12">
<label class="form-label form-label-sm fw-semibold">Notes / Special Instructions</label>
<input type="text" name="remarks" id="po_remarks" class="form-control form-control-sm" placeholder="e.g., Deliver to Main Pharmacy Warehouse before 5 PM" value="<?= esc($remarks) ?>">
</div>
</div>
<!-- Item Entry Panel -->
<div class="card border-primary mt-3">
<div class="card-header bg-primary text-white py-2 d-flex justify-content-between align-items-center">
<span><i class="bi bi-capsule"></i> Add Items to Purchase Order</span>
<button type="button" class="btn btn-warning btn-sm py-0" id="btn-reorder-fetch" onclick="fetchSupplierReorderItems();" style="display:none;">
<i class="bi bi-lightning-charge"></i> Auto-Add Low Stock Items for this Vendor
</button>
</div>
<div class="card-body p-3">
<div class="row g-2 align-items-end mb-3">
<div class="col-md-5">
<label class="form-label form-label-sm mb-1 fw-semibold">Search Medicine / Drug Master</label>
<input type="text" id="quick_drug_search" class="form-control form-control-sm" placeholder="Type medicine brand name or generic salt..." autocomplete="off">
<input type="hidden" id="selected_item_id" value="">
<input type="hidden" id="selected_item_name" value="">
<input type="hidden" id="selected_generic_name" value="">
<input type="hidden" id="selected_category" value="">
</div>
<div class="col-md-2">
<label class="form-label form-label-sm mb-1 fw-semibold">Pack Size (Units)</label>
<input type="number" id="quick_units_per_pack" class="form-control form-control-sm" value="10" min="1" step="1">
</div>
<div class="col-md-2">
<label class="form-label form-label-sm mb-1 fw-semibold">Order Packs</label>
<input type="number" id="quick_order_packs" class="form-control form-control-sm" value="10" min="1" step="1">
</div>
<div class="col-md-2">
<label class="form-label form-label-sm mb-1 fw-semibold">Est. Rate / Pack (₹)</label>
<input type="number" id="quick_ptr" class="form-control form-control-sm" value="0.00" min="0" step="0.01">
</div>
<div class="col-md-1">
<button type="button" class="btn btn-primary btn-sm w-100" id="btn-add-line" onclick="addItemToPoTable();" disabled>
<i class="bi bi-plus-lg"></i> Add
</button>
</div>
</div>
<div class="table-responsive">
<table class="table table-bordered table-sm align-middle mb-0" id="po-items-table">
<thead class="table-light">
<tr>
<th style="width: 40px;">#</th>
<th>Product Name</th>
<th>Generic / Salt</th>
<th>Formulation</th>
<th class="text-center" style="width: 100px;">Units/Pack</th>
<th class="text-center" style="width: 110px;">Order Packs</th>
<th class="text-end" style="width: 120px;">Est. Rate (₹)</th>
<th class="text-center" style="width: 90px;">GST %</th>
<th class="text-end" style="width: 130px;">Line Total (₹)</th>
<th style="width: 50px;"></th>
</tr>
</thead>
<tbody id="po-items-body">
<?php if (!empty($poItems)): ?>
<?php foreach ($poItems as $idx => $it): ?>
<tr data-item-id="<?= (int)$it['item_id'] ?>">
<td class="row-num"><?= $idx + 1 ?></td>
<td>
<strong><?= esc($it['item_name']) ?></strong>
<input type="hidden" name="items[<?= $idx ?>][item_id]" value="<?= (int)$it['item_id'] ?>">
<input type="hidden" name="items[<?= $idx ?>][item_name]" value="<?= esc($it['item_name']) ?>">
</td>
<td>
<?= esc($it['generic_name'] ?: '-') ?>
<input type="hidden" name="items[<?= $idx ?>][generic_name]" value="<?= esc($it['generic_name']) ?>">
</td>
<td>
<?= esc($it['category'] ?: 'Tablet') ?>
<input type="hidden" name="items[<?= $idx ?>][category]" value="<?= esc($it['category']) ?>">
</td>
<td class="text-center">
<input type="number" name="items[<?= $idx ?>][units_per_pack]" class="form-control form-control-sm text-center line-units" value="<?= (int)$it['units_per_pack'] ?>" min="1">
</td>
<td class="text-center">
<input type="number" name="items[<?= $idx ?>][order_packs]" class="form-control form-control-sm text-center line-packs" value="<?= (float)$it['order_packs'] ?>" min="1" onchange="recalculatePoTotals();">
</td>
<td class="text-end">
<input type="number" name="items[<?= $idx ?>][estimated_ptr]" class="form-control form-control-sm text-end line-rate" value="<?= number_format((float)$it['estimated_ptr'], 2, '.', '') ?>" min="0" step="0.01" onchange="recalculatePoTotals();">
</td>
<td class="text-center">
<select name="items[<?= $idx ?>][gst_rate]" class="form-select form-select-sm text-center line-gst" onchange="recalculatePoTotals();">
<option value="0" <?= ((float)$it['gst_rate'] == 0) ? 'selected' : '' ?>>0%</option>
<option value="5" <?= ((float)$it['gst_rate'] == 5) ? 'selected' : '' ?>>5%</option>
<option value="12" <?= ((float)$it['gst_rate'] == 12) ? 'selected' : '' ?>>12%</option>
<option value="18" <?= ((float)$it['gst_rate'] == 18) ? 'selected' : '' ?>>18%</option>
</select>
</td>
<td class="text-end fw-semibold line-total-display">
₹<?= number_format((float)$it['estimated_amount'], 2) ?>
</td>
<td class="text-center">
<button type="button" class="btn btn-outline-danger btn-sm py-0" onclick="removePoRow(this);">✖</button>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr id="no-items-row">
<td colspan="10" class="text-center py-4 text-muted">
No items added yet. Search above to add items or auto-load low stock items.
</td>
</tr>
<?php endif; ?>
</tbody>
<tfoot class="table-light">
<tr>
<th colspan="8" class="text-end">Estimated Subtotal:</th>
<th class="text-end" id="po-subtotal-disp">₹0.00</th>
<th></th>
</tr>
<tr>
<th colspan="8" class="text-end">Estimated GST Tax:</th>
<th class="text-end" id="po-tax-disp">₹0.00</th>
<th></th>
</tr>
<tr class="table-primary">
<th colspan="8" class="text-end fs-6">Grand Total Commitment:</th>
<th class="text-end fs-6 text-primary fw-bold" id="po-grand-total-disp">₹0.00</th>
<th></th>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
<div class="d-flex justify-content-end gap-2 mt-3">
<button type="button" class="btn btn-outline-secondary" onclick="load_form_div('<?= base_url('Medical/purchase_orders') ?>','medical-main');">Cancel</button>
<button type="submit" class="btn btn-primary px-4" id="btn-save-po">
<i class="bi bi-check-circle"></i> Save &amp; Generate Purchase Order
</button>
</div>
</form>
</div>
</div>
<script>
(function() {
// Ensure jQuery UI for drug autocomplete
if (window.jQuery && window.jQuery.ui && window.jQuery.ui.autocomplete) {
initPoDrugAutocomplete();
} else {
var s = document.createElement('script');
s.src = '<?= base_url('assets/vendor/jquery-ui/jquery-ui.min.js') ?>';
s.onload = initPoDrugAutocomplete;
document.body.appendChild(s);
}
recalculatePoTotals();
})();
function initPoDrugAutocomplete() {
if (!window.jQuery || !window.jQuery.ui) return;
var $ = window.jQuery;
$("#quick_drug_search").autocomplete({
source: function(request, response) {
$.getJSON("<?= base_url('Medical/get_drug') ?>", request, function(data) {
response(data || []);
});
},
minLength: 1,
select: function(event, ui) {
var item = ui.item || {};
document.getElementById('selected_item_id').value = item.item_code || item.l_item_code || '';
document.getElementById('selected_item_name').value = item.value || '';
document.getElementById('selected_generic_name').value = item.generic_name || '';
document.getElementById('selected_category').value = item.formulation || 'Tablet';
document.getElementById('quick_units_per_pack').value = item.l_packing || 10;
document.getElementById('quick_ptr').value = item.l_unit_rate ? (parseFloat(item.l_unit_rate) * (item.l_packing || 10)).toFixed(2) : '100.00';
document.getElementById('btn-add-line').disabled = false;
}
});
}
function handleSupplierChange(suppId) {
var reorderBtn = document.getElementById('btn-reorder-fetch');
if (suppId && parseInt(suppId) > 0) {
reorderBtn.style.display = 'inline-block';
} else {
reorderBtn.style.display = 'none';
}
}
function addItemToPoTable(prefill) {
var item = prefill || {
item_id: document.getElementById('selected_item_id').value,
item_name: document.getElementById('selected_item_name').value,
generic_name: document.getElementById('selected_generic_name').value,
category: document.getElementById('selected_category').value || 'Tablet',
units_per_pack: document.getElementById('quick_units_per_pack').value || 10,
order_packs: document.getElementById('quick_order_packs').value || 10,
estimated_ptr: document.getElementById('quick_ptr').value || 0,
gst_rate: 12
};
if (!item.item_id || !item.item_name) {
alert('Please search and select a medicine from master first.');
return;
}
var tbody = document.getElementById('po-items-body');
var noRow = document.getElementById('no-items-row');
if (noRow) noRow.remove();
var existingRow = tbody.querySelector('tr[data-item-id="' + item.item_id + '"]');
if (existingRow) {
var packsInput = existingRow.querySelector('.line-packs');
packsInput.value = parseFloat(packsInput.value || 0) + parseFloat(item.order_packs);
recalculatePoTotals();
resetQuickAddInputs();
return;
}
var rowIdx = tbody.querySelectorAll('tr').length;
var tr = document.createElement('tr');
tr.setAttribute('data-item-id', item.item_id);
tr.innerHTML = `
<td class="row-num">${rowIdx + 1}</td>
<td>
<strong>${escapeHtml(item.item_name)}</strong>
<input type="hidden" name="items[${rowIdx}][item_id]" value="${item.item_id}">
<input type="hidden" name="items[${rowIdx}][item_name]" value="${escapeHtml(item.item_name)}">
</td>
<td>
${escapeHtml(item.generic_name || '-')}
<input type="hidden" name="items[${rowIdx}][generic_name]" value="${escapeHtml(item.generic_name || '')}">
</td>
<td>
${escapeHtml(item.category || 'Tablet')}
<input type="hidden" name="items[${rowIdx}][category]" value="${escapeHtml(item.category || '')}">
</td>
<td class="text-center">
<input type="number" name="items[${rowIdx}][units_per_pack]" class="form-control form-control-sm text-center line-units" value="${item.units_per_pack}" min="1">
</td>
<td class="text-center">
<input type="number" name="items[${rowIdx}][order_packs]" class="form-control form-control-sm text-center line-packs" value="${item.order_packs}" min="1" onchange="recalculatePoTotals();">
</td>
<td class="text-end">
<input type="number" name="items[${rowIdx}][estimated_ptr]" class="form-control form-control-sm text-end line-rate" value="${parseFloat(item.estimated_ptr).toFixed(2)}" min="0" step="0.01" onchange="recalculatePoTotals();">
</td>
<td class="text-center">
<select name="items[${rowIdx}][gst_rate]" class="form-select form-select-sm text-center line-gst" onchange="recalculatePoTotals();">
<option value="0" ${item.gst_rate == 0 ? 'selected' : ''}>0%</option>
<option value="5" ${item.gst_rate == 5 ? 'selected' : ''}>5%</option>
<option value="12" ${item.gst_rate == 12 ? 'selected' : ''}>12%</option>
<option value="18" ${item.gst_rate == 18 ? 'selected' : ''}>18%</option>
</select>
</td>
<td class="text-end fw-semibold line-total-display">₹0.00</td>
<td class="text-center">
<button type="button" class="btn btn-outline-danger btn-sm py-0" onclick="removePoRow(this);">✖</button>
</td>
`;
tbody.appendChild(tr);
recalculatePoTotals();
resetQuickAddInputs();
}
function resetQuickAddInputs() {
document.getElementById('quick_drug_search').value = '';
document.getElementById('selected_item_id').value = '';
document.getElementById('selected_item_name').value = '';
document.getElementById('selected_generic_name').value = '';
document.getElementById('btn-add-line').disabled = true;
document.getElementById('quick_drug_search').focus();
}
function removePoRow(btn) {
btn.closest('tr').remove();
var tbody = document.getElementById('po-items-body');
var rows = tbody.querySelectorAll('tr');
if (rows.length === 0) {
tbody.innerHTML = '<tr id="no-items-row"><td colspan="10" class="text-center py-4 text-muted">No items added yet.</td></tr>';
} else {
rows.forEach(function(r, idx) {
var numTd = r.querySelector('.row-num');
if (numTd) numTd.innerText = idx + 1;
});
}
recalculatePoTotals();
}
function recalculatePoTotals() {
var rows = document.querySelectorAll('#po-items-body tr:not(#no-items-row)');
var subtotal = 0.0;
var totalTax = 0.0;
rows.forEach(function(r) {
var packs = parseFloat(r.querySelector('.line-packs')?.value || 0);
var rate = parseFloat(r.querySelector('.line-rate')?.value || 0);
var gst = parseFloat(r.querySelector('.line-gst')?.value || 0);
var lineSub = packs * rate;
var lineTax = lineSub * (gst / 100);
var lineTotal = lineSub + lineTax;
subtotal += lineSub;
totalTax += lineTax;
var totalDisp = r.querySelector('.line-total-display');
if (totalDisp) totalDisp.innerText = '₹' + lineTotal.toFixed(2);
});
document.getElementById('po-subtotal-disp').innerText = '₹' + subtotal.toFixed(2);
document.getElementById('po-tax-disp').innerText = '₹' + totalTax.toFixed(2);
document.getElementById('po-grand-total-disp').innerText = '₹' + (subtotal + totalTax).toFixed(2);
}
function fetchSupplierReorderItems() {
var suppId = document.getElementById('po_supplier_id').value;
if (!suppId) return;
fetch('<?= base_url('Medical/po_reorder_items/') ?>' + suppId)
.then(r => r.json())
.then(data => {
if (data.status && data.items && data.items.length > 0) {
var added = 0;
data.items.forEach(function(it) {
addItemToPoTable({
item_id: it.item_id,
item_name: it.item_name,
generic_name: it.generic_name,
category: it.category || 'Tablet',
units_per_pack: it.units_per_pack || 10,
order_packs: 10,
estimated_ptr: it.estimated_ptr || 50,
gst_rate: it.gst_rate || 12
});
added++;
});
alert('Added ' + added + ' low stock items to PO.');
} else {
alert('No low-stock items detected for this supplier.');
}
})
.catch(e => alert('Error fetching supplier items.'));
}
function submitPoForm() {
var form = document.getElementById('po-create-form');
var formData = new FormData(form);
var rows = document.querySelectorAll('#po-items-body tr:not(#no-items-row)');
if (rows.length === 0) {
alert('Please add at least one medicine line to the purchase order.');
return;
}
var saveBtn = document.getElementById('btn-save-po');
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Saving...';
fetch('<?= base_url('Medical/po_create') ?>', {
method: 'POST',
headers: { 'X-Requested-With': 'XMLHttpRequest' },
body: formData
})
.then(r => r.json())
.then(data => {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-circle"></i> Save &amp; Generate Purchase Order';
if (data.status) {
alert(data.message);
load_form_div('<?= base_url('Medical/purchase_orders') ?>', 'medical-main', 'Purchase Orders :Pharmacy');
} else {
alert('Error: ' + (data.message || 'Unable to save Purchase Order.'));
}
})
.catch(e => {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-check-circle"></i> Save &amp; Generate Purchase Order';
alert('Server communication error.');
});
}
function escapeHtml(text) {
if (!text) return '';
return text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
</script>

View File

@ -0,0 +1,191 @@
<div class="pagetitle d-flex justify-content-between align-items-center mb-3">
<div>
<h4 class="mb-0 text-primary"><i class="bi bi-file-earmark-text"></i> Pharmacy Purchase Orders (PO)</h4>
<small class="text-muted">Procurement &amp; Supplier Requisitions</small>
</div>
<div class="d-flex gap-2">
<a href="javascript:load_form_div('<?= base_url('Medical/main_store') ?>','medical-main','Store Main :Pharmacy');" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Store Main
</a>
<a href="javascript:load_form_div('<?= base_url('Medical/grn_list') ?>','medical-main','GRN Register :Pharmacy');" class="btn btn-outline-info btn-sm">
<i class="bi bi-box-seam"></i> GRN Register
</a>
<a href="javascript:load_form_div('<?= base_url('Medical/po_new') ?>','medical-main','New Purchase Order :Pharmacy');" class="btn btn-primary btn-sm">
<i class="bi bi-plus-circle"></i> Create New PO
</a>
</div>
</div>
<div class="card mb-3 shadow-sm border-0">
<div class="card-body p-3">
<form method="get" id="po-filter-form" class="row g-2 align-items-center" onsubmit="event.preventDefault(); applyPoFilter();">
<div class="col-md-3">
<input type="text" id="po_search_text" class="form-control form-control-sm" placeholder="Search PO # or Supplier..." value="<?= esc($search ?? '') ?>">
</div>
<div class="col-md-3">
<select id="po_filter_supplier" class="form-select form-select-sm">
<option value="0">All Suppliers</option>
<?php if (!empty($suppliers)): ?>
<?php foreach ($suppliers as $s): ?>
<option value="<?= (int)$s['sid'] ?>" <?= ($supplierId == $s['sid']) ? 'selected' : '' ?>>
<?= esc($s['name_supplier']) ?>
</option>
<?php endforeach; ?>
<?php endif; ?>
</select>
</div>
<div class="col-md-2">
<select id="po_filter_status" class="form-select form-select-sm">
<option value="all">All Statuses</option>
<option value="draft" <?= ($status === 'draft') ? 'selected' : '' ?>>Draft</option>
<option value="ordered" <?= ($status === 'ordered') ? 'selected' : '' ?>>Ordered / Approved</option>
<option value="partially_received" <?= ($status === 'partially_received') ? 'selected' : '' ?>>Partially Received</option>
<option value="received" <?= ($status === 'received') ? 'selected' : '' ?>>Received / Completed</option>
<option value="cancelled" <?= ($status === 'cancelled') ? 'selected' : '' ?>>Cancelled</option>
</select>
</div>
<div class="col-md-2">
<input type="date" id="po_filter_from" class="form-control form-control-sm" value="<?= esc($fromDate ?? '') ?>" title="From Date">
</div>
<div class="col-md-2 d-flex gap-1">
<button type="submit" class="btn btn-dark btn-sm w-100"><i class="bi bi-funnel"></i> Filter</button>
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="resetPoFilter()"><i class="bi bi-arrow-counterclockwise"></i></button>
</div>
</form>
</div>
</div>
<div class="card shadow-sm border-0">
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover table-striped align-middle mb-0" id="po-table">
<thead class="table-light">
<tr>
<th style="width: 50px;">#</th>
<th>PO Number</th>
<th>Supplier</th>
<th>PO Date</th>
<th>Delivery Due</th>
<th class="text-center">Items</th>
<th class="text-end">Total Amount</th>
<th class="text-center">Status</th>
<th class="text-end" style="min-width: 170px;">Actions</th>
</tr>
</thead>
<tbody>
<?php if (!empty($orders)): ?>
<?php foreach ($orders as $idx => $row): ?>
<?php
$statusBadge = 'bg-secondary';
if ($row['status'] === 'draft') $statusBadge = 'bg-warning text-dark';
elseif ($row['status'] === 'ordered' || $row['status'] === 'approved') $statusBadge = 'bg-primary';
elseif ($row['status'] === 'partially_received') $statusBadge = 'bg-info text-dark';
elseif ($row['status'] === 'received') $statusBadge = 'bg-success';
elseif ($row['status'] === 'cancelled') $statusBadge = 'bg-danger';
?>
<tr>
<td><?= $idx + 1 ?></td>
<td>
<strong class="text-primary font-monospace"><?= esc($row['po_number']) ?></strong>
</td>
<td>
<div class="fw-semibold"><?= esc($row['name_supplier'] ?: 'Unknown Vendor') ?></div>
<small class="text-muted"><?= esc($row['supplier_phone'] ?: '') ?></small>
</td>
<td><?= !empty($row['po_date']) ? date('d-M-Y', strtotime($row['po_date'])) : '-' ?></td>
<td><?= !empty($row['expected_delivery_date']) ? date('d-M-Y', strtotime($row['expected_delivery_date'])) : '<span class="text-muted">-</span>' ?></td>
<td class="text-center"><span class="badge bg-light text-dark border"><?= (int)$row['total_items'] ?></span></td>
<td class="text-end fw-bold">₹<?= number_format((float)$row['grand_total'] ?: (float)$row['total_estimated_amount'], 2) ?></td>
<td class="text-center">
<span class="badge <?= $statusBadge ?> px-2 py-1 text-uppercase" style="font-size: 11px;">
<?= str_replace('_', ' ', esc($row['status'])) ?>
</span>
</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<a href="<?= base_url('Medical/po_print/' . $row['po_id']) ?>" target="_blank" class="btn btn-outline-secondary" title="Print PO PDF">
<i class="bi bi-printer"></i>
</a>
<?php if ($row['status'] === 'draft'): ?>
<button type="button" class="btn btn-outline-primary" onclick="load_form_div('<?= base_url('Medical/po_edit/' . $row['po_id']) ?>','medical-main','Edit PO');" title="Edit Draft">
<i class="bi bi-pencil"></i>
</button>
<button type="button" class="btn btn-outline-success" onclick="updatePoStatus(<?= (int)$row['po_id'] ?>, 'ordered')" title="Approve &amp; Issue PO">
<i class="bi bi-check2-circle"></i>
</button>
<?php endif; ?>
<?php if ($row['status'] === 'ordered' || $row['status'] === 'partially_received'): ?>
<button type="button" class="btn btn-success" onclick="load_form_div('<?= base_url('Medical/grn_new/' . $row['po_id']) ?>','medical-main','Create GRN for PO');" title="Receive Goods (GRN)">
<i class="bi bi-box-arrow-in-down"></i> Receive
</button>
<?php endif; ?>
<?php if ($row['status'] !== 'cancelled' && $row['status'] !== 'received'): ?>
<button type="button" class="btn btn-outline-danger" onclick="cancelPo(<?= (int)$row['po_id'] ?>)" title="Cancel PO">
<i class="bi bi-x-circle"></i>
</button>
<?php endif; ?>
</div>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="9" class="text-center py-4 text-muted">
<i class="bi bi-inbox fs-3 d-block mb-1"></i>
No Purchase Orders found. Click <strong>Create New PO</strong> to draft your first procurement order.
</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
</div>
</div>
<script>
function applyPoFilter() {
var search = document.getElementById('po_search_text').value;
var supplier = document.getElementById('po_filter_supplier').value;
var status = document.getElementById('po_filter_status').value;
var from = document.getElementById('po_filter_from').value;
var url = '<?= base_url('Medical/purchase_orders') ?>?q=' + encodeURIComponent(search)
+ '&supplier_id=' + encodeURIComponent(supplier)
+ '&status=' + encodeURIComponent(status)
+ '&from=' + encodeURIComponent(from);
load_form_div(url, 'medical-main', 'Purchase Orders :Pharmacy');
}
function resetPoFilter() {
load_form_div('<?= base_url('Medical/purchase_orders') ?>', 'medical-main', 'Purchase Orders :Pharmacy');
}
function updatePoStatus(poId, newStatus) {
if (!confirm('Are you sure you want to approve & issue this purchase order to the vendor?')) return;
$.post('<?= base_url('Medical/po_status/') ?>' + poId, {
status: newStatus
}, function(data) {
alert(data.message || 'Updated');
if (data.status) applyPoFilter();
}, 'json').fail(function() {
alert('Error updating PO status.');
});
}
function cancelPo(poId) {
var reason = prompt('Please enter cancellation reason:');
if (reason === null) return;
$.post('<?= base_url('Medical/po_status/') ?>' + poId, {
status: 'cancelled',
reason: reason
}, function(data) {
alert(data.message || 'Cancelled');
if (data.status) applyPoFilter();
}, 'json').fail(function() {
alert('Error cancelling PO.');
});
}
</script>

View File

@ -0,0 +1,135 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
body { font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; font-size: 11px; color: #222; }
.header { text-align: center; border-bottom: 2px solid #1a365d; padding-bottom: 8px; margin-bottom: 12px; }
.header h2 { margin: 0; color: #1a365d; text-transform: uppercase; font-size: 18px; }
.header p { margin: 2px 0 0; color: #555; font-size: 10px; }
.title-bar { background-color: #f1f5f9; padding: 6px 10px; margin-bottom: 12px; border-left: 4px solid #1a365d; }
.title-bar table { width: 100%; border-collapse: collapse; }
.meta-table { width: 100%; border-collapse: collapse; margin-bottom: 14px; font-size: 11px; }
.meta-table td { padding: 4px 6px; vertical-align: top; }
.items-table { width: 100%; border-collapse: collapse; margin-bottom: 15px; }
.items-table th { background-color: #1a365d; color: #fff; padding: 6px 8px; text-align: left; font-size: 10px; text-transform: uppercase; border: 1px solid #1a365d; }
.items-table td { padding: 6px 8px; border: 1px solid #cbd5e1; font-size: 10px; }
.text-end { text-align: right; }
.text-center { text-align: center; }
.totals-table { width: 45%; margin-left: auto; border-collapse: collapse; margin-bottom: 20px; }
.totals-table td { padding: 4px 8px; border-bottom: 1px solid #cbd5e1; font-size: 11px; }
.grand-total { font-size: 13px; font-weight: bold; background-color: #f8fafc; color: #1a365d; }
.terms { border: 1px dashed #94a3b8; padding: 8px 12px; font-size: 9px; color: #475569; margin-bottom: 30px; }
.signatures { width: 100%; margin-top: 40px; }
.signatures td { width: 33%; text-align: center; font-size: 10px; }
.sig-line { border-top: 1px solid #333; margin-top: 35px; padding-top: 4px; }
</style>
</head>
<body>
<div class="header">
<h2>Hospital Pharmacy Services</h2>
<p>Central Medical Store &amp; Drug Supply Division | Drug Lic. 20B/21B</p>
</div>
<div class="title-bar">
<table>
<tr>
<td><strong style="font-size: 14px; color: #1a365d;">PURCHASE ORDER</strong></td>
<td class="text-end"><strong>PO NO:</strong> <span style="font-family: monospace; font-size: 13px; color: #1a365d;"><?= esc($po['po_number']) ?></span></td>
</tr>
</table>
</div>
<table class="meta-table">
<tr>
<td style="width: 50%; border: 1px solid #e2e8f0; padding: 8px;">
<strong style="color: #64748b; font-size: 9px; text-transform: uppercase;">VENDOR DETAILS</strong><br>
<strong style="font-size: 12px; color: #0f172a;"><?= esc($po['name_supplier'] ?: 'Supplier') ?></strong><br>
<?php $loc = implode(', ', array_filter([$po['city'] ?? '', $po['state'] ?? ''])); if (!empty($loc)): ?><?= esc($loc) ?><br><?php endif; ?>
Phone: <?= esc($po['contact_no'] ?: '-') ?><br>
<?php if (!empty($po['gst_no'])): ?>GSTIN: <?= esc($po['gst_no']) ?><br><?php endif; ?>
</td>
<td style="width: 50%; border: 1px solid #e2e8f0; padding: 8px;">
<strong style="color: #64748b; font-size: 9px; text-transform: uppercase;">ORDER METADATA</strong><br>
<strong>PO Date:</strong> <?= date('d-M-Y', strtotime($po['po_date'])) ?><br>
<strong>Expected Delivery:</strong> <?= !empty($po['expected_delivery_date']) ? date('d-M-Y', strtotime($po['expected_delivery_date'])) : 'Immediate' ?><br>
<strong>Payment Terms:</strong> <?= esc($po['payment_terms'] ?: 'Net 30 Days') ?><br>
<strong>Status:</strong> <span style="text-transform: uppercase; font-weight: bold;"><?= esc($po['status']) ?></span>
</td>
</tr>
</table>
<table class="items-table">
<thead>
<tr>
<th style="width: 30px;">#</th>
<th>Item / Brand Name</th>
<th>Salt Composition</th>
<th class="text-center" style="width: 70px;">Pack Size</th>
<th class="text-center" style="width: 70px;">Order Qty</th>
<th class="text-end" style="width: 80px;">Rate/Pack</th>
<th class="text-center" style="width: 50px;">GST %</th>
<th class="text-end" style="width: 90px;">Total (₹)</th>
</tr>
</thead>
<tbody>
<?php if (!empty($items)): ?>
<?php foreach ($items as $idx => $it): ?>
<tr>
<td class="text-center"><?= $idx + 1 ?></td>
<td><strong><?= esc($it['item_name']) ?></strong></td>
<td><?= esc($it['generic_name'] ?: '-') ?></td>
<td class="text-center"><?= (int)$it['units_per_pack'] ?>s</td>
<td class="text-center"><strong><?= (float)$it['order_packs'] ?></strong></td>
<td class="text-end">₹<?= number_format((float)$it['estimated_ptr'], 2) ?></td>
<td class="text-center"><?= (float)$it['gst_rate'] ?>%</td>
<td class="text-end">₹<?= number_format((float)$it['estimated_amount'], 2) ?></td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<table class="totals-table">
<tr>
<td>Estimated Subtotal:</td>
<td class="text-end">₹<?= number_format((float)$po['total_estimated_amount'], 2) ?></td>
</tr>
<tr>
<td>Estimated GST Tax:</td>
<td class="text-end">₹<?= number_format((float)$po['tax_amount'], 2) ?></td>
</tr>
<tr class="grand-total">
<td>Grand Total (INR):</td>
<td class="text-end">₹<?= number_format((float)$po['grand_total'] ?: ((float)$po['total_estimated_amount'] + (float)$po['tax_amount']), 2) ?></td>
</tr>
</table>
<div class="terms">
<strong>TERMS &amp; CONDITIONS:</strong>
<ol style="margin: 4px 0 0; padding-left: 15px;">
<li>All supplies must match the specified brand, packing, and manufacturer specifications.</li>
<li>Medicines must have at least 75% remaining shelf life from the date of manufacturing at the time of delivery.</li>
<li>Deliveries must be accompanied by an original Tax Invoice and Delivery Challan referencing this PO number.</li>
<li>Any broken, leaking, or damaged goods will be rejected at the time of Goods Received Note (GRN) verification.</li>
<li>Cold chain items (2-8°C) must be delivered with unbroken temperature logs / ice packs.</li>
</ol>
</div>
<table class="signatures">
<tr>
<td>
<div class="sig-line">Prepared By (Pharmacist)</div>
</td>
<td>
<div class="sig-line">Verified By (Store In-charge)</div>
</td>
<td>
<div class="sig-line">Authorized Signatory</div>
</td>
</tr>
</table>
</body>
</html>