feat(pharmacy): enhance PO creation with medicine master search, last PTR auto-fill, exact GST selection, and scheme/discount support

This commit is contained in:
DevSoftTech Server 2026-10-26 22:31:00 +05:30
parent 083a82dcb6
commit 5a13ce2b56
5 changed files with 604 additions and 93 deletions

View File

@ -133,6 +133,7 @@ $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/po_search_drug', 'MedicalProcurement::po_search_drug');
$routes->get('Medical/grn_list', 'MedicalProcurement::grn_list');
$routes->get('Medical/grn_new', 'MedicalProcurement::grn_new');

View File

@ -26,6 +26,28 @@ class MedicalProcurement extends BaseController
$this->grnModel = new PharmacyGrnModel();
$this->grnItemModel = new PharmacyGrnItemModel();
$this->ensureGrnSchema();
$this->ensurePoSchema();
}
private function ensurePoSchema(): void
{
try {
if (!$this->db->tableExists('mst_purchase_order_items')) {
return;
}
if (!$this->db->fieldExists('discount_percent', 'mst_purchase_order_items')) {
$this->db->query("ALTER TABLE `mst_purchase_order_items` ADD COLUMN `discount_percent` DECIMAL(5,2) DEFAULT 0.00 AFTER `estimated_ptr`");
}
if (!$this->db->fieldExists('sch_disc_per', 'mst_purchase_order_items')) {
$this->db->query("ALTER TABLE `mst_purchase_order_items` ADD COLUMN `sch_disc_per` DECIMAL(5,2) DEFAULT 0.00 AFTER `discount_percent`");
}
if (!$this->db->fieldExists('scheme_info', 'mst_purchase_order_items')) {
$this->db->query("ALTER TABLE `mst_purchase_order_items` ADD COLUMN `scheme_info` VARCHAR(100) DEFAULT '' AFTER `sch_disc_per`");
}
} catch (\Throwable $e) {
log_message('error', 'ensurePoSchema: ' . $e->getMessage());
}
}
private function ensureGrnSchema(): void
@ -215,6 +237,7 @@ class MedicalProcurement extends BaseController
return $deny;
}
$poId = (int)($this->request->getPost('po_id') ?? 0);
$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'));
@ -233,7 +256,6 @@ class MedicalProcurement extends BaseController
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;
@ -249,16 +271,23 @@ class MedicalProcurement extends BaseController
$unitsPerPack = (int)($row['units_per_pack'] ?? 10);
$estimatedPtr = (float)($row['estimated_ptr'] ?? 0);
$gstRate = (float)($row['gst_rate'] ?? 12.0);
$discountPercent = (float)($row['discount_percent'] ?? 0.0);
$schDiscPer = (float)($row['sch_disc_per'] ?? 0.0);
$schemeInfo = trim((string)($row['scheme_info'] ?? ''));
if ($itemId <= 0 || $itemName === '' || $orderPacks <= 0) {
continue;
}
$lineSubtotal = round($orderPacks * $estimatedPtr, 2);
$lineTax = round($lineSubtotal * ($gstRate / 100), 2);
$lineTotal = $lineSubtotal + $lineTax;
$lineGross = round($orderPacks * $estimatedPtr, 2);
$schAmt = ($schDiscPer > 0) ? round($lineGross * ($schDiscPer / 100), 2) : 0.0;
$afterSch = max(0.0, $lineGross - $schAmt);
$discAmt = ($discountPercent > 0) ? round($afterSch * ($discountPercent / 100), 2) : 0.0;
$taxable = max(0.0, $afterSch - $discAmt);
$lineTax = round($taxable * ($gstRate / 100), 2);
$lineTotal = $taxable + $lineTax;
$totalEstimatedAmount += $lineSubtotal;
$totalEstimatedAmount += $taxable;
$totalTaxAmount += $lineTax;
$validItems[] = [
@ -271,6 +300,9 @@ class MedicalProcurement extends BaseController
'received_packs' => 0,
'remaining_packs' => $orderPacks,
'estimated_ptr' => $estimatedPtr,
'discount_percent' => $discountPercent,
'sch_disc_per' => $schDiscPer,
'scheme_info' => $schemeInfo,
'gst_rate' => $gstRate,
'estimated_amount' => $lineTotal,
'status' => 'pending',
@ -283,35 +315,75 @@ class MedicalProcurement extends BaseController
$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 ($poId > 0) {
$existingPo = $this->poModel->find($poId);
if (!$existingPo) {
$this->db->transRollback();
return $this->response->setJSON(['status' => 0, 'message' => 'Existing purchase order not found.']);
}
if ($status === 'approved' || $status === 'ordered') {
$poHeader['approved_by'] = $userId;
$poHeader['approved_at'] = date('Y-m-d H:i:s');
}
$poNumber = $existingPo['po_number'];
$this->poModel->insert($poHeader);
$poId = (int)$this->db->insertID();
$poHeader = [
'store_id' => $storeId,
'supplier_id' => $supplierId,
'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,
'updated_at' => date('Y-m-d H:i:s'),
];
foreach ($validItems as &$item) {
$item['po_id'] = $poId;
$this->poItemModel->insert($item);
if (($status === 'approved' || $status === 'ordered') && empty($existingPo['approved_at'])) {
$poHeader['approved_by'] = $userId;
$poHeader['approved_at'] = date('Y-m-d H:i:s');
}
$this->poModel->update($poId, $poHeader);
$this->poItemModel->where('po_id', $poId)->delete();
foreach ($validItems as &$item) {
$item['po_id'] = $poId;
$this->poItemModel->insert($item);
}
} else {
$poNumber = $this->poModel->generatePoNumber();
$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();
@ -322,7 +394,7 @@ class MedicalProcurement extends BaseController
return $this->response->setJSON([
'status' => 1,
'message' => "Purchase Order {$poNumber} created successfully!",
'message' => "Purchase Order {$poNumber} saved successfully!",
'po_id' => $poId,
'po_number' => $poNumber,
]);
@ -460,15 +532,19 @@ class MedicalProcurement extends BaseController
// 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,
p.formulation AS category,
IFNULL(t.packing, IFNULL(p.packing, 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.CGST_per + t.SGST_per, IFNULL(p.CGST_per + p.SGST_per, 12)) AS gst_rate,
IFNULL(t.discount, 0) AS discount_percent,
IFNULL(t.sch_disc_per, 0) AS sch_disc_per,
IF(t.qty_free > 0, CONCAT('Free: ', CAST(t.qty_free AS UNSIGNED)), '') AS scheme_info,
(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
AND (p.is_continue = 1 OR p.is_continue IS NULL)
GROUP BY p.id
HAVING current_stock < 50
ORDER BY current_stock ASC
@ -483,6 +559,261 @@ class MedicalProcurement extends BaseController
]);
}
public function po_search_drug()
{
if ($deny = $this->ensurePharmacyAccess()) {
return $deny;
}
$term = trim((string)$this->request->getGet('term'));
$supplierId = (int)($this->request->getGet('supplier_id') ?? 0);
if ($term === '') {
return $this->response->setJSON([]);
}
if (!$this->db->tableExists('med_product_master')) {
return $this->response->setJSON([]);
}
$pFields = $this->db->getFieldNames('med_product_master') ?? [];
$hasGeneric = in_array('genericname', $pFields, true);
$hasCompany = in_array('company_name', $pFields, true);
$hasContinue = in_array('is_continue', $pFields, true);
$builder = $this->db->table('med_product_master p')
->select('p.id, p.item_name, p.formulation, '
. ($hasGeneric ? 'p.genericname' : "'' AS genericname") . ', '
. (in_array('packing', $pFields, true) ? 'p.packing' : "'10' AS packing") . ', '
. (in_array('CGST_per', $pFields, true) ? 'p.CGST_per' : '0 AS CGST_per') . ', '
. (in_array('SGST_per', $pFields, true) ? 'p.SGST_per' : '0 AS SGST_per') . ', '
. ($hasCompany ? 'p.company_name' : "'' AS company_name"));
if ($hasContinue) {
$builder->groupStart()
->where('p.is_continue', 1)
->orWhere('p.is_continue IS NULL')
->groupEnd();
}
$builder->groupStart()
->like('p.item_name', $term);
if ($hasGeneric) {
$builder->orLike('p.genericname', $term);
}
if ($hasCompany) {
$builder->orLike('p.company_name', $term);
}
$builder->groupEnd();
// Order: matches starting with term first, then alphabetical
$builder->orderBy("CASE WHEN p.item_name LIKE " . $this->db->escape($term . '%') . " THEN 1 ELSE 2 END", 'ASC', false)
->orderBy('p.item_name', 'ASC')
->limit(40);
$products = $builder->get()->getResultArray();
$hasPiItem = $this->db->tableExists('purchase_invoice_item');
$hasPi = $this->db->tableExists('purchase_invoice');
$hasGrnItem = $this->db->tableExists('mst_grn_items');
$hasGrn = $this->db->tableExists('mst_grns');
$result = [];
foreach ($products as $p) {
$itemId = (int)$p['id'];
$lastPur = null;
$isVendorMatch = false;
// 1. Check last purchase from selected supplier in purchase_invoice_item
if ($supplierId > 0 && $hasPiItem && $hasPi) {
$lastPur = $this->db->table('purchase_invoice_item pii')
->select('pii.purchase_price, pii.purchase_unit_rate, pii.packing, pii.CGST_per, pii.SGST_per, pii.discount, pii.sch_disc_per, pii.sch_disc_amt, pii.qty_free, pii.mrp, pi.sid, pi.supp_name, pi.date_of_invoice')
->join('purchase_invoice pi', 'pi.id = pii.purchase_id', 'left')
->where('pii.item_code', $itemId)
->where('pi.sid', $supplierId)
->orderBy('pii.id', 'DESC')
->get(1)
->getRowArray();
if ($lastPur) {
$isVendorMatch = true;
}
}
// 2. Check in mst_grn_items from selected supplier if still not found
if (!$lastPur && $supplierId > 0 && $hasGrnItem && $hasGrn) {
$lastGrn = $this->db->table('mst_grn_items gi')
->select('gi.unit_purchase_rate, gi.packing, gi.gross_amount, gi.cgst_percent, gi.sgst_percent, gi.igst_percent, gi.discount_percent, gi.sch_disc_per, gi.sch_disc_amt, gi.free_packs, gi.mrp, g.supplier_id AS sid, s.name_supplier AS supp_name, g.grn_date AS date_of_invoice')
->join('mst_grns g', 'g.grn_id = gi.grn_id', 'left')
->join('med_supplier s', 's.sid = g.supplier_id', 'left')
->where('gi.item_code', $itemId)
->where('g.supplier_id', $supplierId)
->orderBy('gi.grn_item_id', 'DESC')
->get(1)
->getRowArray();
if ($lastGrn) {
$isVendorMatch = true;
$pack = (int)($lastGrn['packing'] ?? 10);
$ratePerPack = (float)($lastGrn['unit_purchase_rate'] ?? 0) * ($pack > 0 ? $pack : 1);
$lastPur = [
'purchase_price' => $ratePerPack,
'purchase_unit_rate' => $lastGrn['unit_purchase_rate'],
'packing' => $pack,
'CGST_per' => $lastGrn['cgst_percent'],
'SGST_per' => $lastGrn['sgst_percent'],
'discount' => $lastGrn['discount_percent'],
'sch_disc_per' => $lastGrn['sch_disc_per'],
'sch_disc_amt' => $lastGrn['sch_disc_amt'],
'qty_free' => $lastGrn['free_packs'],
'mrp' => $lastGrn['mrp'],
'sid' => $lastGrn['sid'],
'supp_name' => $lastGrn['supp_name'],
'date_of_invoice' => $lastGrn['date_of_invoice'],
];
}
}
// 3. Fallback: check last purchase from ANY supplier in purchase_invoice_item
if (!$lastPur && $hasPiItem && $hasPi) {
$lastPur = $this->db->table('purchase_invoice_item pii')
->select('pii.purchase_price, pii.purchase_unit_rate, pii.packing, pii.CGST_per, pii.SGST_per, pii.discount, pii.sch_disc_per, pii.sch_disc_amt, pii.qty_free, pii.mrp, pi.sid, pi.supp_name, pi.date_of_invoice')
->join('purchase_invoice pi', 'pi.id = pii.purchase_id', 'left')
->where('pii.item_code', $itemId)
->orderBy('pii.id', 'DESC')
->get(1)
->getRowArray();
}
// 4. Fallback: check in mst_grn_items from ANY supplier
if (!$lastPur && $hasGrnItem && $hasGrn) {
$lastGrn = $this->db->table('mst_grn_items gi')
->select('gi.unit_purchase_rate, gi.packing, gi.gross_amount, gi.cgst_percent, gi.sgst_percent, gi.igst_percent, gi.discount_percent, gi.sch_disc_per, gi.sch_disc_amt, gi.free_packs, gi.mrp, g.supplier_id AS sid, s.name_supplier AS supp_name, g.grn_date AS date_of_invoice')
->join('mst_grns g', 'g.grn_id = gi.grn_id', 'left')
->join('med_supplier s', 's.sid = g.supplier_id', 'left')
->where('gi.item_code', $itemId)
->orderBy('gi.grn_item_id', 'DESC')
->get(1)
->getRowArray();
if ($lastGrn) {
$pack = (int)($lastGrn['packing'] ?? 10);
$ratePerPack = (float)($lastGrn['unit_purchase_rate'] ?? 0) * ($pack > 0 ? $pack : 1);
$lastPur = [
'purchase_price' => $ratePerPack,
'purchase_unit_rate' => $lastGrn['unit_purchase_rate'],
'packing' => $pack,
'CGST_per' => $lastGrn['cgst_percent'],
'SGST_per' => $lastGrn['sgst_percent'],
'discount' => $lastGrn['discount_percent'],
'sch_disc_per' => $lastGrn['sch_disc_per'],
'sch_disc_amt' => $lastGrn['sch_disc_amt'],
'qty_free' => $lastGrn['free_packs'],
'mrp' => $lastGrn['mrp'],
'sid' => $lastGrn['sid'],
'supp_name' => $lastGrn['supp_name'],
'date_of_invoice' => $lastGrn['date_of_invoice'],
];
}
}
// Determine packing
$packing = (int)($lastPur['packing'] ?? 0);
if ($packing <= 0) {
$packing = (int)preg_replace('/[^0-9]/', '', (string)($p['packing'] ?? ''));
}
if ($packing <= 0) {
$packing = 10;
}
// Determine Rate (estimated PTR per pack)
$rate = 0.0;
if ($lastPur && (float)($lastPur['purchase_price'] ?? 0) > 0) {
$rate = (float)$lastPur['purchase_price'];
} elseif ($lastPur && (float)($lastPur['purchase_unit_rate'] ?? 0) > 0) {
$rate = (float)$lastPur['purchase_unit_rate'] * $packing;
}
// Determine GST %
$gstRate = null;
if ($lastPur) {
$purGst = (float)($lastPur['CGST_per'] ?? 0) + (float)($lastPur['SGST_per'] ?? 0);
if ($purGst > 0) {
$gstRate = $purGst;
}
}
if ($gstRate === null) {
$masterGst = (float)($p['CGST_per'] ?? 0) + (float)($p['SGST_per'] ?? 0);
if ($masterGst > 0) {
$gstRate = $masterGst;
}
}
if ($gstRate === null) {
$gstRate = 12.0;
}
// Scheme & Discount
$tradeDisc = (float)($lastPur['discount'] ?? 0);
$schDiscPer = (float)($lastPur['sch_disc_per'] ?? 0);
$schDiscAmt = (float)($lastPur['sch_disc_amt'] ?? 0);
$freeQty = (float)($lastPur['qty_free'] ?? 0);
$schemeParts = [];
if ($tradeDisc > 0) {
$schemeParts[] = "Disc: " . rtrim(rtrim(number_format($tradeDisc, 2), '0'), '.') . "%";
}
if ($schDiscPer > 0) {
$schemeParts[] = "Sch Disc: " . rtrim(rtrim(number_format($schDiscPer, 2), '0'), '.') . "%";
}
if ($schDiscAmt > 0) {
$schemeParts[] = "Sch Amt: ₹" . number_format($schDiscAmt, 2);
}
if ($freeQty > 0) {
$schemeParts[] = "Free: " . (int)$freeQty;
}
$schemeSummary = !empty($schemeParts) ? implode(', ', $schemeParts) : '';
$schemeInfo = '';
if ($freeQty > 0) {
$schemeInfo = "Free: " . (int)$freeQty;
} elseif ($schDiscPer > 0) {
$schemeInfo = "Sch: " . rtrim(rtrim(number_format($schDiscPer, 2), '0'), '.') . "%";
}
// Clean Label for Autocomplete: Only Medicine Name, Formulation, and Generic Salt
$cleanLabel = trim($p['item_name']);
if (!empty($p['formulation'])) {
$cleanLabel .= ' [' . trim($p['formulation']) . ']';
}
if (!empty($p['genericname'])) {
$cleanLabel .= ' - ' . trim($p['genericname']);
}
$result[] = [
'label' => $cleanLabel,
'value' => $p['item_name'],
'item_code' => (int)$p['id'],
'generic_name' => (string)($p['genericname'] ?? ''),
'formulation' => (string)($p['formulation'] ?? 'Tablet'),
'packing' => $packing,
'estimated_ptr' => number_format($rate, 2, '.', ''),
'gst_rate' => (float)$gstRate,
'discount_percent' => number_format($tradeDisc, 2, '.', ''),
'sch_disc_per' => number_format($schDiscPer, 2, '.', ''),
'sch_disc_amt' => number_format($schDiscAmt, 2, '.', ''),
'qty_free' => (float)$freeQty,
'scheme_info' => $schemeInfo,
'scheme_summary' => $schemeSummary,
'has_last_purchase' => $lastPur ? 1 : 0,
'is_vendor_match' => $isVendorMatch ? 1 : 0,
'last_supplier_name' => (string)($lastPur['supp_name'] ?? ''),
'last_purchase_date' => !empty($lastPur['date_of_invoice']) ? date('d-M-Y', strtotime($lastPur['date_of_invoice'])) : '',
];
}
return $this->response->setJSON($result);
}
// =========================================================================
// 2. GOODS RECEIVED NOTE (GRN)
// =========================================================================

View File

@ -21,6 +21,9 @@ class PharmacyPurchaseOrderItemModel extends Model
'received_packs',
'remaining_packs',
'estimated_ptr',
'discount_percent',
'sch_disc_per',
'scheme_info',
'gst_rate',
'estimated_amount',
'status',

View File

@ -33,6 +33,9 @@
<div class="card-body p-3">
<form id="po-create-form" onsubmit="event.preventDefault(); submitPoForm();">
<?= csrf_field() ?>
<?php if ($isEdit): ?>
<input type="hidden" name="po_id" id="po_id" value="<?= (int)($po['po_id'] ?? 0) ?>">
<?php endif; ?>
<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>
@ -87,7 +90,7 @@
</button>
</div>
<div class="card-body p-3">
<div class="row g-2 align-items-end mb-3">
<div class="row g-2 align-items-end mb-2">
<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">
@ -95,18 +98,22 @@
<input type="hidden" id="selected_item_name" value="">
<input type="hidden" id="selected_generic_name" value="">
<input type="hidden" id="selected_category" value="">
<input type="hidden" id="selected_gst_rate" value="12">
<input type="hidden" id="selected_discount_percent" value="0">
<input type="hidden" id="selected_sch_disc_per" value="0">
<input type="hidden" id="selected_scheme_info" 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">
<input type="number" id="quick_units_per_pack" class="form-control form-control-sm text-center" 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">
<input type="number" id="quick_order_packs" class="form-control form-control-sm text-center" 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">
<input type="number" id="quick_ptr" class="form-control form-control-sm text-end" 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>
@ -115,25 +122,35 @@
</div>
</div>
<!-- Selected Drug History Pill -->
<div id="selected_drug_info_pill" class="mb-3" style="display: none;"></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 style="width: 35px;">#</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>
<th style="width: 85px;">Formulation</th>
<th class="text-center" style="width: 85px;">Units/Pack</th>
<th class="text-center" style="width: 95px;">Order Packs</th>
<th class="text-end" style="width: 105px;">Est. Rate (₹)</th>
<th class="text-center" style="width: 130px;">Scheme / Disc</th>
<th class="text-center" style="width: 95px;">GST %</th>
<th class="text-end" style="width: 115px;">Line Total (₹)</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody id="po-items-body">
<?php if (!empty($poItems)): ?>
<?php foreach ($poItems as $idx => $it): ?>
<?php
$itGst = (float)($it['gst_rate'] ?? 12);
$discPer = (float)($it['discount_percent'] ?? 0);
$schDiscPer = (float)($it['sch_disc_per'] ?? 0);
$schInfo = (string)($it['scheme_info'] ?? '');
?>
<tr data-item-id="<?= (int)$it['item_id'] ?>">
<td class="row-num"><?= $idx + 1 ?></td>
<td>
@ -150,20 +167,37 @@
<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">
<input type="number" name="items[<?= $idx ?>][units_per_pack]" class="form-control form-control-sm text-center line-units px-1" 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();">
<input type="number" name="items[<?= $idx ?>][order_packs]" class="form-control form-control-sm text-center line-packs px-1" 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();">
<input type="number" name="items[<?= $idx ?>][estimated_ptr]" class="form-control form-control-sm text-end line-rate px-1" 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>
<div class="d-flex flex-column gap-1">
<div class="input-group input-group-sm">
<span class="input-group-text py-0 px-1 bg-light text-muted" style="font-size:10px;">Disc%</span>
<input type="number" name="items[<?= $idx ?>][discount_percent]" class="form-control form-control-sm text-end line-disc px-1" value="<?= $discPer > 0 ? number_format($discPer, 2, '.', '') : '0' ?>" min="0" max="100" step="0.01" onchange="recalculatePoTotals();" style="font-size:11px;">
</div>
<div class="input-group input-group-sm">
<span class="input-group-text py-0 px-1 bg-light text-muted" style="font-size:10px;">Sch</span>
<input type="text" name="items[<?= $idx ?>][scheme_info]" class="form-control form-control-sm line-scheme px-1" value="<?= esc($schInfo) ?>" placeholder="e.g. 10+1" style="font-size:11px;">
</div>
<input type="hidden" name="items[<?= $idx ?>][sch_disc_per]" class="line-sch-per" value="<?= $schDiscPer ?>">
</div>
</td>
<td class="text-center">
<select name="items[<?= $idx ?>][gst_rate]" class="form-select form-select-sm text-center line-gst px-1" onchange="recalculatePoTotals();">
<option value="0" <?= ((float)$itGst == 0) ? 'selected' : '' ?>>0%</option>
<option value="5" <?= (abs((float)$itGst - 5) < 0.1) ? 'selected' : '' ?>>5%</option>
<option value="12" <?= (abs((float)$itGst - 12) < 0.1) ? 'selected' : '' ?>>12%</option>
<option value="18" <?= (abs((float)$itGst - 18) < 0.1) ? 'selected' : '' ?>>18%</option>
<option value="28" <?= (abs((float)$itGst - 28) < 0.1) ? 'selected' : '' ?>>28%</option>
<?php if (!in_array((float)$itGst, [0, 5, 12, 18, 28])): ?>
<option value="<?= (float)$itGst ?>" selected><?= (float)$itGst ?>%</option>
<?php endif; ?>
</select>
</td>
<td class="text-end fw-semibold line-total-display">
@ -176,7 +210,7 @@
<?php endforeach; ?>
<?php else: ?>
<tr id="no-items-row">
<td colspan="10" class="text-center py-4 text-muted">
<td colspan="11" class="text-center py-4 text-muted">
No items added yet. Search above to add items or auto-load low stock items.
</td>
</tr>
@ -184,18 +218,23 @@
</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 colspan="9" class="text-end">Estimated Subtotal (Taxable):</th>
<th class="text-end font-monospace" id="po-subtotal-disp">₹0.00</th>
<th></th>
</tr>
<tr id="po-disc-row" style="display:none;">
<th colspan="9" class="text-end text-success">Total Discount Saved:</th>
<th class="text-end text-success font-monospace" id="po-discount-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 colspan="9" class="text-end">Estimated GST Tax:</th>
<th class="text-end font-monospace" 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 colspan="9" class="text-end fs-6">Grand Total Commitment:</th>
<th class="text-end fs-6 text-primary fw-bold font-monospace" id="po-grand-total-disp">₹0.00</th>
<th></th>
</tr>
</tfoot>
@ -235,20 +274,55 @@ function initPoDrugAutocomplete() {
$("#quick_drug_search").autocomplete({
source: function(request, response) {
$.getJSON("<?= base_url('Medical/get_drug') ?>", request, function(data) {
var suppId = $('#po_supplier_id').val() || 0;
$.getJSON("<?= base_url('Medical/po_search_drug') ?>", {
term: request.term,
supplier_id: suppId
}, 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_id').value = item.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('quick_units_per_pack').value = item.packing || 10;
document.getElementById('quick_ptr').value = item.estimated_ptr || '0.00';
document.getElementById('selected_gst_rate').value = item.gst_rate !== undefined ? item.gst_rate : 12;
document.getElementById('selected_discount_percent').value = item.discount_percent || 0;
document.getElementById('selected_sch_disc_per').value = item.sch_disc_per || 0;
document.getElementById('selected_scheme_info').value = item.scheme_info || '';
document.getElementById('btn-add-line').disabled = false;
// Render Drug History / Scheme info pill
var pill = document.getElementById('selected_drug_info_pill');
if (pill) {
var h = '<div class="alert alert-light border py-1 px-3 d-flex flex-wrap align-items-center justify-content-between gap-2 shadow-sm" style="font-size:12px;">';
h += '<div>';
h += '<strong class="text-primary me-2"><i class="bi bi-capsule"></i> ' + escapeHtml(item.value) + '</strong>';
if (item.has_last_purchase) {
h += '<span class="badge bg-secondary me-1">Last Rate: ₹' + item.estimated_ptr + '</span>';
h += '<span class="badge bg-info text-dark me-1">GST: ' + item.gst_rate + '%</span>';
if (item.scheme_summary) {
h += '<span class="badge bg-success me-1"><i class="bi bi-tag-fill"></i> ' + escapeHtml(item.scheme_summary) + '</span>';
}
if (item.is_vendor_match) {
h += '<span class="badge bg-success-subtle text-success border border-success-subtle ms-1"><i class="bi bi-check-circle"></i> From selected vendor</span>';
} else if (item.last_supplier_name) {
h += '<span class="text-muted ms-1 small">(Last: ' + escapeHtml(item.last_supplier_name) + (item.last_purchase_date ? ' ' + item.last_purchase_date : '') + ')</span>';
}
} else {
h += '<span class="badge bg-light text-muted border">New Item &bull; Master GST: ' + item.gst_rate + '%</span>';
}
h += '</div>';
h += '<span class="text-muted fst-italic small">Auto-filled rate &amp; GST &bull; Click + Add</span>';
h += '</div>';
pill.innerHTML = h;
pill.style.display = 'block';
}
}
});
}
@ -271,7 +345,10 @@ function addItemToPoTable(prefill) {
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
gst_rate: document.getElementById('selected_gst_rate').value || 12,
discount_percent: document.getElementById('selected_discount_percent').value || 0,
sch_disc_per: document.getElementById('selected_sch_disc_per').value || 0,
scheme_info: document.getElementById('selected_scheme_info').value || ''
};
if (!item.item_id || !item.item_name) {
@ -296,6 +373,21 @@ function addItemToPoTable(prefill) {
var tr = document.createElement('tr');
tr.setAttribute('data-item-id', item.item_id);
var itemGst = parseFloat(item.gst_rate !== undefined ? item.gst_rate : 12);
var discVal = parseFloat(item.discount_percent || 0);
var schVal = parseFloat(item.sch_disc_per || 0);
var schInfo = item.scheme_info || '';
// Standard GST options
var standardGsts = [0, 5, 12, 18, 28];
var gstOptionsHtml = '';
standardGsts.forEach(function(g) {
gstOptionsHtml += `<option value="${g}" ${Math.abs(itemGst - g) < 0.1 ? 'selected' : ''}>${g}%</option>`;
});
if (!standardGsts.some(g => Math.abs(itemGst - g) < 0.1)) {
gstOptionsHtml += `<option value="${itemGst}" selected>${itemGst}%</option>`;
}
tr.innerHTML = `
<td class="row-num">${rowIdx + 1}</td>
<td>
@ -312,20 +404,30 @@ function addItemToPoTable(prefill) {
<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">
<input type="number" name="items[${rowIdx}][units_per_pack]" class="form-control form-control-sm text-center line-units px-1" 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();">
<input type="number" name="items[${rowIdx}][order_packs]" class="form-control form-control-sm text-center line-packs px-1" 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();">
<input type="number" name="items[${rowIdx}][estimated_ptr]" class="form-control form-control-sm text-end line-rate px-1" 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>
<div class="d-flex flex-column gap-1">
<div class="input-group input-group-sm">
<span class="input-group-text py-0 px-1 bg-light text-muted" style="font-size:10px;">Disc%</span>
<input type="number" name="items[${rowIdx}][discount_percent]" class="form-control form-control-sm text-end line-disc px-1" value="${discVal > 0 ? discVal.toFixed(2) : '0'}" min="0" max="100" step="0.01" onchange="recalculatePoTotals();" style="font-size:11px;">
</div>
<div class="input-group input-group-sm">
<span class="input-group-text py-0 px-1 bg-light text-muted" style="font-size:10px;">Sch</span>
<input type="text" name="items[${rowIdx}][scheme_info]" class="form-control form-control-sm line-scheme px-1" value="${escapeHtml(schInfo)}" placeholder="e.g. 10+1" style="font-size:11px;">
</div>
<input type="hidden" name="items[${rowIdx}][sch_disc_per]" class="line-sch-per" value="${schVal}">
</div>
</td>
<td class="text-center">
<select name="items[${rowIdx}][gst_rate]" class="form-select form-select-sm text-center line-gst px-1" onchange="recalculatePoTotals();">
${gstOptionsHtml}
</select>
</td>
<td class="text-end fw-semibold line-total-display">₹0.00</td>
@ -344,7 +446,19 @@ function resetQuickAddInputs() {
document.getElementById('selected_item_id').value = '';
document.getElementById('selected_item_name').value = '';
document.getElementById('selected_generic_name').value = '';
document.getElementById('selected_gst_rate').value = '12';
document.getElementById('selected_discount_percent').value = '0';
document.getElementById('selected_sch_disc_per').value = '0';
document.getElementById('selected_scheme_info').value = '';
document.getElementById('quick_ptr').value = '0.00';
document.getElementById('btn-add-line').disabled = true;
var pill = document.getElementById('selected_drug_info_pill');
if (pill) {
pill.innerHTML = '';
pill.style.display = 'none';
}
document.getElementById('quick_drug_search').focus();
}
@ -353,7 +467,7 @@ function removePoRow(btn) {
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>';
tbody.innerHTML = '<tr id="no-items-row"><td colspan="11" class="text-center py-4 text-muted">No items added yet. Search above to add items or auto-load low stock items.</td></tr>';
} else {
rows.forEach(function(r, idx) {
var numTd = r.querySelector('.row-num');
@ -365,28 +479,48 @@ function removePoRow(btn) {
function recalculatePoTotals() {
var rows = document.querySelectorAll('#po-items-body tr:not(#no-items-row)');
var subtotal = 0.0;
var totalGross = 0.0;
var totalDiscount = 0.0;
var totalTaxable = 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 discPer = parseFloat(r.querySelector('.line-disc')?.value || 0);
var schPer = parseFloat(r.querySelector('.line-sch-per')?.value || 0);
var gst = parseFloat(r.querySelector('.line-gst')?.value || 0);
var lineSub = packs * rate;
var lineTax = lineSub * (gst / 100);
var lineTotal = lineSub + lineTax;
var lineGross = packs * rate;
var schAmt = (schPer > 0) ? (lineGross * (schPer / 100)) : 0.0;
var afterSch = Math.max(0.0, lineGross - schAmt);
var tradeDiscAmt = (discPer > 0) ? (afterSch * (discPer / 100)) : 0.0;
var lineTaxable = Math.max(0.0, afterSch - tradeDiscAmt);
var lineTax = lineTaxable * (gst / 100);
var lineTotal = lineTaxable + lineTax;
subtotal += lineSub;
totalGross += lineGross;
totalDiscount += (schAmt + tradeDiscAmt);
totalTaxable += lineTaxable;
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-subtotal-disp').innerText = '₹' + totalTaxable.toFixed(2);
document.getElementById('po-tax-disp').innerText = '₹' + totalTax.toFixed(2);
document.getElementById('po-grand-total-disp').innerText = '₹' + (subtotal + totalTax).toFixed(2);
document.getElementById('po-grand-total-disp').innerText = '₹' + (totalTaxable + totalTax).toFixed(2);
var discRow = document.getElementById('po-disc-row');
if (discRow) {
if (totalDiscount > 0) {
discRow.style.display = '';
document.getElementById('po-discount-disp').innerText = '-₹' + totalDiscount.toFixed(2);
} else {
discRow.style.display = 'none';
}
}
}
function fetchSupplierReorderItems() {
@ -406,8 +540,11 @@ function fetchSupplierReorderItems() {
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
estimated_ptr: it.estimated_ptr || 0,
gst_rate: it.gst_rate !== undefined ? it.gst_rate : 12,
discount_percent: it.discount_percent || 0,
sch_disc_per: it.sch_disc_per || 0,
scheme_info: it.scheme_info || ''
});
added++;
});

View File

@ -102,19 +102,45 @@ if (!isset($branding) || !is_array($branding)) {
<table class="items-table">
<thead>
<tr>
<th style="width: 30px;">#</th>
<th style="width: 25px;">#</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>
<th class="text-center" style="width: 55px;">Pack Size</th>
<th class="text-center" style="width: 55px;">Order Qty</th>
<th class="text-end" style="width: 75px;">Rate/Pack</th>
<th class="text-center" style="width: 80px;">Scheme / Disc</th>
<th class="text-center" style="width: 48px;">GST %</th>
<th class="text-end" style="width: 85px;">Total (₹)</th>
</tr>
</thead>
<tbody>
<?php if (!empty($items)): ?>
<?php
$calcGross = 0.0;
$calcDiscount = 0.0;
if (!empty($items)):
?>
<?php foreach ($items as $idx => $it): ?>
<?php
$lineGross = round((float)$it['order_packs'] * (float)$it['estimated_ptr'], 2);
$schPer = (float)($it['sch_disc_per'] ?? 0);
$schAmt = ($schPer > 0) ? round($lineGross * ($schPer / 100), 2) : 0.0;
$afterSch = max(0.0, $lineGross - $schAmt);
$discPer = (float)($it['discount_percent'] ?? 0);
$discAmt = ($discPer > 0) ? round($afterSch * ($discPer / 100), 2) : 0.0;
$calcGross += $lineGross;
$calcDiscount += ($schAmt + $discAmt);
$discTags = [];
if ($discPer > 0) {
$discTags[] = $discPer . '% Disc';
}
if ($schPer > 0) {
$discTags[] = $schPer . '% Sch';
}
if (!empty($it['scheme_info'])) {
$discTags[] = esc($it['scheme_info']);
}
?>
<tr>
<td class="text-center"><?= $idx + 1 ?></td>
<td><strong><?= esc($it['item_name']) ?></strong></td>
@ -122,6 +148,9 @@ if (!isset($branding) || !is_array($branding)) {
<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" style="font-size: 9px;">
<?= !empty($discTags) ? implode('<br>', $discTags) : '<span style="color:#94a3b8;">-</span>' ?>
</td>
<td class="text-center"><?= (float)$it['gst_rate'] ?>%</td>
<td class="text-end">₹<?= number_format((float)$it['estimated_amount'], 2) ?></td>
</tr>
@ -131,8 +160,18 @@ if (!isset($branding) || !is_array($branding)) {
</table>
<table class="totals-table">
<?php if ($calcDiscount > 0.009): ?>
<tr>
<td>Estimated Subtotal:</td>
<td>Gross Amount:</td>
<td class="text-end">₹<?= number_format($calcGross, 2) ?></td>
</tr>
<tr>
<td style="color: #166534;">Total Schemes / Discounts:</td>
<td class="text-end" style="color: #166534;">-₹<?= number_format($calcDiscount, 2) ?></td>
</tr>
<?php endif; ?>
<tr>
<td>Taxable Subtotal:</td>
<td class="text-end">₹<?= number_format((float)$po['total_estimated_amount'], 2) ?></td>
</tr>
<tr>