feat(medical-store): add B2C sales returns, same-invoice exchanges, and DB migrations

- Add B2C same-invoice exchange and pure refund workflows (Marg ERP standard)
- Support returning excess/unused medicines with proportional unit pricing on original invoice
- Auto-net return credits against new medicines in POS cart with refund outflow handling
- Implement stock replenishment for restocked batches with audit trail in mst_stock_audit
- Generate sequential GST Credit Notes in mst_sale_returns and mst_sale_return_items
- Update Thermal 80mm, A5, and A4 print templates with separated dispensed vs returned items
- Add CI4 migration 2026-09-08-000001_CreateMedicalStoreReturnsAndExchanges.php for remote execution
- Add standalone SQL script scripts/sql/2026-09-08_medical_store_b2c_returns_and_exchanges.sql
This commit is contained in:
DevSoftTech Server 2026-09-08 05:07:45 +05:30
parent 765286e7fa
commit d2f069e3c8
12 changed files with 2672 additions and 236 deletions

View File

@ -1681,6 +1681,9 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct
$routes->post('medical-store/stock/import-marg', 'MedicalStoreApi::importMarg'); $routes->post('medical-store/stock/import-marg', 'MedicalStoreApi::importMarg');
$routes->get('medical-store/abdm/bundle/(:num)', 'MedicalStoreApi::getAbdmBundle/$1'); $routes->get('medical-store/abdm/bundle/(:num)', 'MedicalStoreApi::getAbdmBundle/$1');
$routes->post('medical-store/abdm/link-abha', 'MedicalStoreApi::linkAbha'); $routes->post('medical-store/abdm/link-abha', 'MedicalStoreApi::linkAbha');
$routes->get('medical-store/returns/lookup-invoice', 'MedicalStoreApi::lookupInvoiceForReturn');
$routes->get('medical-store/returns/recent', 'MedicalStoreApi::recentReturns');
$routes->get('medical-store/returns/credit-note/(:num)', 'MedicalStoreApi::getCreditNoteInvoice/$1');
}); });
// Dedicated Direct Store Links (e.g. /MedicalStore/storeA, /MedicalStore/storeB) // Dedicated Direct Store Links (e.g. /MedicalStore/storeA, /MedicalStore/storeB)

View File

@ -596,9 +596,10 @@ class MedicalStoreApi extends BaseController
$json = $this->request->getJSON(true) ?: $this->request->getPost(); $json = $this->request->getJSON(true) ?: $this->request->getPost();
$storeId = (int)($json['store_id'] ?? 1); $storeId = (int)($json['store_id'] ?? 1);
$items = $json['items'] ?? []; $items = $json['items'] ?? [];
$returnItems = $json['return_items'] ?? [];
if (empty($items)) { if (empty($items) && empty($returnItems)) {
return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Cart is empty.']); return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Cart is empty. Please add medicines to sell or return.']);
} }
// Fetch Store Config // Fetch Store Config
@ -626,6 +627,7 @@ class MedicalStoreApi extends BaseController
$today = date('Y-m-d'); $today = date('Y-m-d');
$preItems = []; $preItems = [];
// 1. Process New Sale Items
foreach ($items as $item) { foreach ($items as $item) {
$itemId = (int)$item['item_id']; $itemId = (int)$item['item_id'];
$batchId = (int)$item['batch_id']; $batchId = (int)$item['batch_id'];
@ -848,17 +850,240 @@ class MedicalStoreApi extends BaseController
->where('batch_id', $pi['batchId']) ->where('batch_id', $pi['batchId'])
->set('current_qty', 'current_qty - ' . $pi['totalUnits'], false) ->set('current_qty', 'current_qty - ' . $pi['totalUnits'], false)
->update(); ->update();
// Record Stock Audit for Sale
$this->db->table('mst_stock_audit')->insert([
'store_id' => $storeId,
'item_id' => $pi['itemId'],
'batch_id' => $pi['batchId'],
'audit_type' => 'SALE',
'system_qty' => (int)$batch['current_qty'],
'physical_qty' => max(0, (int)$batch['current_qty'] - $pi['totalUnits']),
'variation_qty' => -$pi['totalUnits'],
'rate' => $pi['effectiveUnitPrice'],
'total_value' => $lineNet,
'remarks' => "Dispensed on Bill $invoiceNo",
'conducted_by' => (int)($json['user_id'] ?? 1),
'created_at' => date('Y-m-d H:i:s')
]);
} }
$netBeforeRound = $grossAmount - $totalDiscount; // 2. Process Return / Exchange Items
$grossReturnAmount = 0;
$returnTaxableTotal = 0;
$returnCgstTotal = 0;
$returnSgstTotal = 0;
$returnIgstTotal = 0;
$validatedReturnItems = [];
foreach ($returnItems as $rItem) {
$rItemId = (int)($rItem['item_id'] ?? 0);
$rBatchId = (int)($rItem['batch_id'] ?? 0);
$rSellUnit = trim((string)($rItem['sell_unit'] ?? 'Tablet'));
$rCondition = trim((string)($rItem['return_condition'] ?? 'RESTOCKED'));
$rReason = trim((string)($rItem['return_reason'] ?? 'Customer Return / Exchange'));
$refSaleId = !empty($rItem['ref_sale_id']) ? (int)$rItem['ref_sale_id'] : null;
$refInvoiceNo = trim((string)($rItem['ref_invoice_no'] ?? ''));
// Fetch batch & item details
$rBatch = $this->db->table('mst_batches b')
->select('b.*, i.item_name, i.generic_name, i.drug_schedule, i.hsn_code, i.unit_pack, i.units_per_pack')
->join('mst_items i', 'i.item_id = b.item_id')
->where('b.batch_id', $rBatchId)
->get()
->getRowArray();
if (!$rBatch && !empty($rItem['batch_no'])) {
$rBatch = $this->db->table('mst_batches b')
->select('b.*, i.item_name, i.generic_name, i.drug_schedule, i.hsn_code, i.unit_pack, i.units_per_pack')
->join('mst_items i', 'i.item_id = b.item_id')
->where('b.item_id', $rItemId)
->where('b.batch_no', trim($rItem['batch_no']))
->get()
->getRowArray();
}
if (!$rBatch) {
$this->db->transRollback();
return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => "Return batch details not found for item #$rItemId."]);
}
$rUnitsPerPack = (int)($rBatch['units_per_pack'] ?? 1);
if ($rUnitsPerPack <= 0) $rUnitsPerPack = 1;
if ($rUnitsPerPack <= 1 || in_array($rSellUnit, ['Unit', 'Tablet', 'Loose'])) {
$returnTotalUnits = max(1, (int)($rItem['qty'] ?? $rItem['loose_qty'] ?? 1));
$dispQty = $returnTotalUnits;
$rSellUnit = ($rUnitsPerPack > 1) ? 'Tablet' : ($rBatch['unit_pack'] ?: 'Unit');
} else { // 'Strip'
$sq = max(1, (int)($rItem['qty'] ?? 1));
$returnTotalUnits = $sq * $rUnitsPerPack;
$dispQty = $sq;
$rSellUnit = 'Strip';
}
// Determine refund rate
$unitMrp = (float)$rBatch['mrp'];
$perUnitMrp = $rUnitsPerPack > 0 ? ($unitMrp / $rUnitsPerPack) : $unitMrp;
if (isset($rItem['unit_price']) && (float)$rItem['unit_price'] > 0) {
$effectiveRefundUnitRate = (float)$rItem['unit_price'];
} elseif ($refSaleId) {
$origSi = $this->db->table('mst_sales_items')
->where('sale_id', $refSaleId)
->where('item_id', $rItemId)
->where('batch_id', (int)$rBatch['batch_id'])
->get()->getRowArray();
if ($origSi && (int)$origSi['total_units'] > 0) {
$effectiveRefundUnitRate = round((float)$origSi['total_amount'] / (int)$origSi['total_units'], 2);
} else {
$effectiveRefundUnitRate = round($perUnitMrp, 2);
}
} else {
$effectiveRefundUnitRate = round($perUnitMrp, 2);
}
$lineReturnGross = round($returnTotalUnits * $effectiveRefundUnitRate, 2);
$grossReturnAmount += $lineReturnGross;
// Back-Calculated GST on return line
$rGstRate = (float)$rBatch['gst_rate'];
if ($rGstRate > 0) {
$rTaxable = round($lineReturnGross / (1 + ($rGstRate / 100)), 2);
$rGstAmt = round($lineReturnGross - $rTaxable, 2);
} else {
$rTaxable = $lineReturnGross;
$rGstAmt = 0;
}
if ($isInterState) {
$rCgst = 0;
$rSgst = 0;
$rIgst = $rGstAmt;
} else {
$rCgst = round($rGstAmt / 2, 2);
$rSgst = round($rGstAmt - $rCgst, 2);
$rIgst = 0;
}
$returnTaxableTotal += $rTaxable;
$returnCgstTotal += $rCgst;
$returnSgstTotal += $rSgst;
$returnIgstTotal += $rIgst;
$validatedReturnItems[] = [
'item_id' => $rItemId,
'item_name' => $rBatch['item_name'],
'batch_id' => (int)$rBatch['batch_id'],
'batch_no' => $rBatch['batch_no'],
'expiry_date' => $rBatch['expiry_date'],
'qty' => $dispQty,
'sell_unit' => $rSellUnit,
'units_per_pack' => $rUnitsPerPack,
'total_units' => $returnTotalUnits,
'unit_price' => $effectiveRefundUnitRate,
'unit_mrp' => $unitMrp,
'return_condition' => $rCondition,
'return_reason' => $rReason,
'ref_sale_id' => $refSaleId,
'ref_invoice_no' => $refInvoiceNo,
'hsn_code' => $rBatch['hsn_code'] ?: '3004',
'gst_rate' => $rGstRate,
'taxable_value' => $rTaxable,
'cgst_amount' => $rCgst,
'sgst_amount' => $rSgst,
'igst_amount' => $rIgst,
'total_amount' => -$lineReturnGross,
'line_refund_val' => $lineReturnGross
];
// Replenish stock if RESTOCKED
if ($rCondition === 'RESTOCKED') {
$stkRow = $this->db->table('mst_stock')
->where('store_id', $storeId)
->where('batch_id', (int)$rBatch['batch_id'])
->get()
->getRowArray();
if ($stkRow) {
$this->db->table('mst_stock')
->where('stock_id', $stkRow['stock_id'])
->set('current_qty', 'current_qty + ' . $returnTotalUnits, false)
->update();
} else {
$this->db->table('mst_stock')->insert([
'store_id' => $storeId,
'item_id' => $rItemId,
'batch_id' => (int)$rBatch['batch_id'],
'current_qty' => $returnTotalUnits,
'reserved_qty' => 0,
'last_updated_at' => date('Y-m-d H:i:s')
]);
}
$this->db->table('mst_stock_audit')->insert([
'store_id' => $storeId,
'item_id' => $rItemId,
'batch_id' => (int)$rBatch['batch_id'],
'audit_type' => 'SALES_RETURN',
'system_qty' => $stkRow ? (int)$stkRow['current_qty'] : 0,
'physical_qty' => ($stkRow ? (int)$stkRow['current_qty'] : 0) + $returnTotalUnits,
'variation_qty' => $returnTotalUnits,
'rate' => $effectiveRefundUnitRate,
'total_value' => $lineReturnGross,
'remarks' => "Customer Return/Exchange Restock (Bill $invoiceNo, Ref: $refInvoiceNo)",
'conducted_by' => (int)($json['user_id'] ?? 1),
'created_at' => date('Y-m-d H:i:s')
]);
} else {
$this->db->table('mst_stock_audit')->insert([
'store_id' => $storeId,
'item_id' => $rItemId,
'batch_id' => (int)$rBatch['batch_id'],
'audit_type' => 'RETURN_DAMAGED_DISCARD',
'system_qty' => 0,
'physical_qty' => 0,
'variation_qty' => 0,
'rate' => $effectiveRefundUnitRate,
'total_value' => $lineReturnGross,
'remarks' => "Customer returned damaged/discarded medicine (Bill $invoiceNo)",
'conducted_by' => (int)($json['user_id'] ?? 1),
'created_at' => date('Y-m-d H:i:s')
]);
}
}
// 3. Net Calculation
$netSalesBeforeRound = $grossAmount - $totalDiscount;
$netBeforeRound = $netSalesBeforeRound - $grossReturnAmount;
$netRounded = round($netBeforeRound); $netRounded = round($netBeforeRound);
$roundOff = round($netRounded - $netBeforeRound, 2); $roundOff = round($netRounded - $netBeforeRound, 2);
$paymentMode = trim($json['payment_mode'] ?? 'Cash'); // Cash, UPI, Card, Mixed, IPD_Credit $isExchangeBill = (!empty($validatedReturnItems) ? 1 : 0);
$cashPaid = (float)($json['cash_paid'] ?? ($paymentMode === 'Cash' ? $netRounded : 0)); $refundAmount = 0.00;
$upiPaid = (float)($json['upi_paid'] ?? ($paymentMode === 'UPI' ? $netRounded : 0)); $refundMode = null;
$cardPaid = (float)($json['card_paid'] ?? ($paymentMode === 'Card' ? $netRounded : 0)); $refundRefNo = null;
$creditAmount = (float)($json['credit_amount'] ?? ($paymentMode === 'IPD_Credit' ? $netRounded : 0));
if ($netRounded >= 0) {
$finalNetPayable = $netRounded;
$paymentMode = trim($json['payment_mode'] ?? 'Cash'); // Cash, UPI, Card, Mixed, IPD_Credit
$cashPaid = (float)($json['cash_paid'] ?? ($paymentMode === 'Cash' ? $finalNetPayable : 0));
$upiPaid = (float)($json['upi_paid'] ?? ($paymentMode === 'UPI' ? $finalNetPayable : 0));
$cardPaid = (float)($json['card_paid'] ?? ($paymentMode === 'Card' ? $finalNetPayable : 0));
$creditAmount = (float)($json['credit_amount'] ?? ($paymentMode === 'IPD_Credit' ? $finalNetPayable : 0));
} else {
// Net is negative: Pharmacy refunds difference to patient
$finalNetPayable = 0.00;
$refundAmount = abs($netRounded);
$refundMode = trim($json['refund_mode'] ?? ($json['payment_mode'] ?? 'Cash'));
$refundRefNo = trim($json['refund_ref_no'] ?? ($json['upi_ref_no'] ?? ''));
$paymentMode = 'REFUND_' . strtoupper($refundMode);
$cashPaid = 0;
$upiPaid = 0;
$cardPaid = 0;
$creditAmount = 0;
}
$bankName = trim($json['bank_name'] ?? ''); $bankName = trim($json['bank_name'] ?? '');
$upiRefNo = trim($json['upi_ref_no'] ?? ''); $upiRefNo = trim($json['upi_ref_no'] ?? '');
$cardRefNo = trim($json['card_ref_no'] ?? ''); $cardRefNo = trim($json['card_ref_no'] ?? '');
@ -868,7 +1093,6 @@ class MedicalStoreApi extends BaseController
$abhaId = trim((string)($json['abha_id'] ?? '')); $abhaId = trim((string)($json['abha_id'] ?? ''));
$abhaAddress = trim((string)($json['abha_address'] ?? '')); $abhaAddress = trim((string)($json['abha_address'] ?? ''));
// If not in payload but patient_id exists, look up patient_master
if (($abhaId === '' || $abhaAddress === '') && $patientId > 0) { if (($abhaId === '' || $abhaAddress === '') && $patientId > 0) {
$pRow = $this->db->table('patient_master') $pRow = $this->db->table('patient_master')
->select('abha_id, abha_address') ->select('abha_id, abha_address')
@ -881,39 +1105,42 @@ class MedicalStoreApi extends BaseController
} }
} }
// Generate unique ABDM Care Context Reference (e.g. PHARM-STMAIN-INV262700001) // Generate unique ABDM Care Context Reference
$cleanPrefix = preg_replace('/[^A-Za-z0-9]/', '', $store['store_code'] ?: 'STORE'); $cleanPrefix = preg_replace('/[^A-Za-z0-9]/', '', $store['store_code'] ?: 'STORE');
$cleanInv = preg_replace('/[^A-Za-z0-9]/', '', $invoiceNo); $cleanInv = preg_replace('/[^A-Za-z0-9]/', '', $invoiceNo);
$careContextRef = "PHARM-{$cleanPrefix}-{$cleanInv}"; $careContextRef = "PHARM-{$cleanPrefix}-{$cleanInv}";
$careContextDisplay = "Pharmacy Dispensation - {$invoiceNo}"; $careContextDisplay = "Pharmacy Dispensation - {$invoiceNo}";
// Build official ABDM FHIR R4 MedicationDispense Document Bundle // Build official ABDM FHIR R4 MedicationDispense Document Bundle (only for sale items if any)
$fhirBuilder = new \App\Libraries\FhirR4Builder(); $fhirBundleJson = null;
$patientData = [ if (!empty($validatedItems)) {
'id' => $patientId ?: 0, $fhirBuilder = new \App\Libraries\FhirR4Builder();
'uhid' => $json['uhid'] ?? '', $patientData = [
'p_code' => $json['uhid'] ?? '', 'id' => $patientId ?: 0,
'name' => trim($json['patient_name'] ?? 'Patient'), 'uhid' => $json['uhid'] ?? '',
'p_fname' => trim($json['patient_name'] ?? 'Patient'), 'p_code' => $json['uhid'] ?? '',
'phone' => trim($json['patient_mobile'] ?? ''), 'name' => trim($json['patient_name'] ?? 'Patient'),
'mphone1' => trim($json['patient_mobile'] ?? ''), 'p_fname' => trim($json['patient_name'] ?? 'Patient'),
'gender' => trim($json['gender'] ?? 'Male'), 'phone' => trim($json['patient_mobile'] ?? ''),
'age' => trim($json['age'] ?? '30'), 'mphone1' => trim($json['patient_mobile'] ?? ''),
'abha_id' => $abhaId, 'gender' => trim($json['gender'] ?? 'Male'),
'abha_address' => $abhaAddress 'age' => trim($json['age'] ?? '30'),
]; 'abha_id' => $abhaId,
'abha_address' => $abhaAddress
];
$fhirBundle = $fhirBuilder->buildPharmacyDispenseBundle($patientData, $store, [ $fhirBundle = $fhirBuilder->buildPharmacyDispenseBundle($patientData, $store, [
'doctor_name' => trim($json['doctor_name'] ?? ''), 'doctor_name' => trim($json['doctor_name'] ?? ''),
'doctor_reg_no' => trim($json['doctor_reg_no'] ?? ''), 'doctor_reg_no' => trim($json['doctor_reg_no'] ?? ''),
'patient_name' => trim($json['patient_name'] ?? 'Patient'), 'patient_name' => trim($json['patient_name'] ?? 'Patient'),
'abha_id' => $abhaId, 'abha_id' => $abhaId,
'abha_address' => $abhaAddress, 'abha_address' => $abhaAddress,
'abdm_care_context_ref' => $careContextRef, 'abdm_care_context_ref' => $careContextRef,
'invoice_no' => $invoiceNo 'invoice_no' => $invoiceNo
], $validatedItems); ], $validatedItems);
$fhirBundleJson = json_encode($fhirBundle, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); $fhirBundleJson = json_encode($fhirBundle, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
}
// Create Sale Record // Create Sale Record
$saleData = [ $saleData = [
@ -936,15 +1163,20 @@ class MedicalStoreApi extends BaseController
'ward_name' => trim($json['ward_name'] ?? ''), 'ward_name' => trim($json['ward_name'] ?? ''),
'bed_no' => trim($json['bed_no'] ?? ''), 'bed_no' => trim($json['bed_no'] ?? ''),
'gross_amount' => $grossAmount, 'gross_amount' => $grossAmount,
'return_amount' => $grossReturnAmount,
'is_exchange_bill' => $isExchangeBill,
'discount_amount' => $totalDiscount, 'discount_amount' => $totalDiscount,
'bill_discount_type' => $wholeDiscType, 'bill_discount_type' => $wholeDiscType,
'bill_discount_val' => $wholeDiscVal, 'bill_discount_val' => $wholeDiscVal,
'taxable_amount' => $taxableTotal, 'taxable_amount' => max(0, $taxableTotal - $returnTaxableTotal),
'cgst_amount' => $cgstTotal, 'cgst_amount' => max(0, $cgstTotal - $returnCgstTotal),
'sgst_amount' => $sgstTotal, 'sgst_amount' => max(0, $sgstTotal - $returnSgstTotal),
'igst_amount' => $igstTotal, 'igst_amount' => max(0, $igstTotal - $returnIgstTotal),
'round_off' => $roundOff, 'round_off' => $roundOff,
'net_amount' => $netRounded, 'net_amount' => $finalNetPayable,
'refund_amount' => $refundAmount,
'refund_mode' => $refundMode,
'refund_ref_no' => $refundRefNo,
'payment_mode' => $paymentMode, 'payment_mode' => $paymentMode,
'cash_paid' => $cashPaid, 'cash_paid' => $cashPaid,
'upi_paid' => $upiPaid, 'upi_paid' => $upiPaid,
@ -966,7 +1198,7 @@ class MedicalStoreApi extends BaseController
'created_by' => (int)($json['user_id'] ?? 1) 'created_by' => (int)($json['user_id'] ?? 1)
]; ];
// If IPD Credit, optionally link into ipd_invoice_item so hospital discharge handles it // If IPD Credit, link into ipd_invoice_item so hospital discharge handles it
if ($paymentMode === 'IPD_Credit' && !empty($json['ipd_id'])) { if ($paymentMode === 'IPD_Credit' && !empty($json['ipd_id'])) {
$ipdId = (int)$json['ipd_id']; $ipdId = (int)$json['ipd_id'];
if ($this->db->tableExists('ipd_invoice_item')) { if ($this->db->tableExists('ipd_invoice_item')) {
@ -975,8 +1207,8 @@ class MedicalStoreApi extends BaseController
'item_name' => "Pharmacy Medicines ({$invoiceNo})", 'item_name' => "Pharmacy Medicines ({$invoiceNo})",
'item_desc' => "Medicines dispensed from {$store['store_name']}", 'item_desc' => "Medicines dispensed from {$store['store_name']}",
'item_qty' => 1, 'item_qty' => 1,
'item_price' => $netRounded, 'item_price' => $finalNetPayable,
'item_amount' => $netRounded, 'item_amount' => $finalNetPayable,
'insert_date' => date('Y-m-d H:i:s'), 'insert_date' => date('Y-m-d H:i:s'),
]); ]);
$saleData['ipd_charge_id'] = $this->db->insertID(); $saleData['ipd_charge_id'] = $this->db->insertID();
@ -986,8 +1218,8 @@ class MedicalStoreApi extends BaseController
$this->db->table('mst_sales')->insert($saleData); $this->db->table('mst_sales')->insert($saleData);
$saleId = $this->db->insertID(); $saleId = $this->db->insertID();
// Queue in hospital central ABDM Gateway Sync Outbox if table exists // Queue in central ABDM Gateway Sync Outbox if applicable
if ((!empty($abhaAddress) || !empty($patientId)) && $this->db->tableExists('abdm_sync_record')) { if ($fhirBundleJson && (!empty($abhaAddress) || !empty($patientId)) && $this->db->tableExists('abdm_sync_record')) {
$this->db->table('abdm_sync_record')->insert([ $this->db->table('abdm_sync_record')->insert([
'local_record_id' => 'mst_sale_' . $saleId, 'local_record_id' => 'mst_sale_' . $saleId,
'local_patient_id' => $patientId ?: 0, 'local_patient_id' => $patientId ?: 0,
@ -1010,6 +1242,7 @@ class MedicalStoreApi extends BaseController
$this->db->table('mst_sales_items')->insert([ $this->db->table('mst_sales_items')->insert([
'sale_id' => $saleId, 'sale_id' => $saleId,
'item_id' => $vItem['item_id'], 'item_id' => $vItem['item_id'],
'item_type' => 'SALE',
'batch_id' => $vItem['batch_id'], 'batch_id' => $vItem['batch_id'],
'batch_no' => $vItem['batch_no'], 'batch_no' => $vItem['batch_no'],
'expiry_date' => $vItem['expiry_date'], 'expiry_date' => $vItem['expiry_date'],
@ -1035,28 +1268,129 @@ class MedicalStoreApi extends BaseController
]); ]);
} }
// Post Double-Entry Journal / Ledgers // Insert Return Items
$salesHead = $this->db->table('mst_account_heads')->where('head_code', '3001')->get()->getRowArray(); foreach ($validatedReturnItems as $vri) {
$cgstHead = $this->db->table('mst_account_heads')->where('head_code', '2002')->get()->getRowArray(); $this->db->table('mst_sales_items')->insert([
$sgstHead = $this->db->table('mst_account_heads')->where('head_code', '2003')->get()->getRowArray(); 'sale_id' => $saleId,
$igstHead = $this->db->table('mst_account_heads')->where('head_code', '2004')->get()->getRowArray(); 'item_id' => $vri['item_id'],
$discHead = $this->db->table('mst_account_heads')->where('head_code', '4002')->get()->getRowArray(); 'item_type' => 'RETURN',
'return_condition' => $vri['return_condition'],
'ref_sale_id' => $vri['ref_sale_id'],
'ref_invoice_no' => $vri['ref_invoice_no'],
'return_reason' => $vri['return_reason'],
'is_restocked' => ($vri['return_condition'] === 'RESTOCKED' ? 1 : 0),
'batch_id' => $vri['batch_id'],
'batch_no' => $vri['batch_no'],
'expiry_date' => $vri['expiry_date'],
'qty' => $vri['qty'],
'sell_unit' => $vri['sell_unit'],
'units_per_pack' => $vri['units_per_pack'],
'total_units' => $vri['total_units'],
'unit_price' => $vri['unit_price'],
'loose_qty' => ($vri['sell_unit'] === 'Tablet' ? $vri['total_units'] : 0),
'pack_qty' => round($vri['total_units'] / $vri['units_per_pack'], 2),
'unit_mrp' => $vri['unit_mrp'],
'discount_pct' => 0,
'discount_amount' => 0,
'hsn_code' => $vri['hsn_code'],
'gst_rate' => $vri['gst_rate'],
'taxable_value' => -$vri['taxable_value'],
'cgst_amount' => -$vri['cgst_amount'],
'sgst_amount' => -$vri['sgst_amount'],
'igst_amount' => -$vri['igst_amount'],
'total_amount' => -$vri['line_refund_val']
]);
}
// 1. Debit Cash/Bank/Patient // Standalone / Formal Credit Note Record if returns exist
if ($cashPaid > 0) { $creditNoteNo = null;
$cashHead = $this->db->table('mst_account_heads')->where('head_code', '1001')->get()->getRowArray(); if ($isExchangeBill) {
$cnPrefix = $store['credit_note_prefix'] ?: 'CRN/';
$cnSeq = (int)($store['next_credit_note_no'] ?? 1);
$creditNoteNo = $cnPrefix . str_pad((string)$cnSeq, 5, '0', STR_PAD_LEFT);
$this->db->table('mst_stores')->where('store_id', $storeId)->update(['next_credit_note_no' => $cnSeq + 1]);
$firstRefInvoice = $validatedReturnItems[0]['ref_invoice_no'] ?? $invoiceNo;
$firstRefSaleId = $validatedReturnItems[0]['ref_sale_id'] ?? null;
$this->db->table('mst_sale_returns')->insert([
'store_id' => $storeId,
'credit_note_no' => $creditNoteNo,
'return_date' => date('Y-m-d H:i:s'),
'original_sale_id' => $firstRefSaleId,
'original_invoice_no' => $firstRefInvoice ?: 'EXCHANGE',
'new_sale_id' => $saleId,
'patient_id' => $patientId,
'uhid' => $json['uhid'] ?? null,
'patient_name' => trim($json['patient_name'] ?? 'Customer'),
'patient_mobile' => trim($json['patient_mobile'] ?? ''),
'return_reason' => $validatedReturnItems[0]['return_reason'] ?? 'Exchange Adjustment on ' . $invoiceNo,
'gross_refund_amount' => $grossReturnAmount,
'discount_reversed_amount' => 0.00,
'taxable_refund_amount' => $returnTaxableTotal,
'cgst_refund_amount' => $returnCgstTotal,
'sgst_refund_amount' => $returnSgstTotal,
'igst_refund_amount' => $returnIgstTotal,
'round_off' => 0.00,
'net_refund_amount' => $grossReturnAmount,
'refund_mode' => $refundAmount > 0 ? ($refundMode ?: 'Cash') : 'EXCHANGE_BILL_ADJUSTMENT',
'refund_ref_no' => $refundRefNo ?: $invoiceNo,
'restock_condition' => $validatedReturnItems[0]['return_condition'] ?? 'RESTOCKED',
'remarks' => "Adjusted against invoice $invoiceNo",
'created_by' => (int)($json['user_id'] ?? 1),
'created_at' => date('Y-m-d H:i:s')
]);
$returnRecordId = $this->db->insertID();
foreach ($validatedReturnItems as $vri) {
$this->db->table('mst_sale_return_items')->insert([
'return_id' => $returnRecordId,
'sale_item_id' => null,
'item_id' => $vri['item_id'],
'item_name' => $vri['item_name'],
'batch_id' => $vri['batch_id'],
'batch_no' => $vri['batch_no'],
'expiry_date' => $vri['expiry_date'],
'return_sell_unit' => $vri['sell_unit'],
'return_qty' => $vri['qty'],
'units_per_pack' => $vri['units_per_pack'],
'return_total_units' => $vri['total_units'],
'unit_price' => $vri['unit_price'],
'refund_rate' => $vri['unit_price'],
'discount_pct' => 0.00,
'discount_amount' => 0.00,
'hsn_code' => $vri['hsn_code'],
'gst_rate' => $vri['gst_rate'],
'taxable_value' => $vri['taxable_value'],
'cgst_amount' => $vri['cgst_amount'],
'sgst_amount' => $vri['sgst_amount'],
'igst_amount' => $vri['igst_amount'],
'refund_amount' => $vri['line_refund_val'],
'is_restocked' => ($vri['return_condition'] === 'RESTOCKED' ? 1 : 0)
]);
}
}
// Double-Entry Journal Postings
$salesHead = $this->db->table('mst_account_heads')->where('head_code', '3001')->get()->getRowArray();
$retHead = $this->db->table('mst_account_heads')->where('head_code', '3005')->get()->getRowArray();
$cgstHead = $this->db->table('mst_account_heads')->where('head_code', '2002')->get()->getRowArray();
$sgstHead = $this->db->table('mst_account_heads')->where('head_code', '2003')->get()->getRowArray();
$igstHead = $this->db->table('mst_account_heads')->where('head_code', '2004')->get()->getRowArray();
$discHead = $this->db->table('mst_account_heads')->where('head_code', '4002')->get()->getRowArray();
$cashHead = $this->db->table('mst_account_heads')->where('head_code', '1001')->get()->getRowArray();
$bankHead = $this->db->table('mst_account_heads')->where('head_code', '1002')->get()->getRowArray();
// 1. Debit Cash/Bank/Debtor for positive payments
if ($cashPaid > 0 && $cashHead) {
$this->db->table('mst_ledger_entries')->insert([ $this->db->table('mst_ledger_entries')->insert([
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'), 'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'),
'account_head_id' => $cashHead['head_id'], 'debit_amount' => $cashPaid, 'credit_amount' => 0, 'account_head_id' => $cashHead['head_id'], 'debit_amount' => $cashPaid, 'credit_amount' => 0,
'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Cash received for bill $invoiceNo" 'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Cash received for bill $invoiceNo"
]); ]);
} }
if ($upiPaid > 0 || $cardPaid > 0) { if (($upiPaid + $cardPaid) > 0 && $bankHead) {
$bankHead = $this->db->table('mst_account_heads')->where('head_code', '1002')->get()->getRowArray(); $refDetails = array_filter([$bankName ? "Bank: $bankName" : null, $upiRefNo ? "UPI Ref: $upiRefNo" : null, $cardRefNo ? "Card Auth: $cardRefNo" : null]);
$refDetails = [];
if (!empty($upiRefNo)) $refDetails[] = "UTR: {$upiRefNo}";
if (!empty($cardRefNo)) $refDetails[] = "Card Auth: {$cardRefNo}";
if (!empty($bankName)) $refDetails[] = "Bank: {$bankName}";
$narrationExtra = !empty($refDetails) ? " (" . implode(', ', $refDetails) . ")" : ""; $narrationExtra = !empty($refDetails) ? " (" . implode(', ', $refDetails) . ")" : "";
$auditRef = !empty($upiRefNo) ? $upiRefNo : $cardRefNo; $auditRef = !empty($upiRefNo) ? $upiRefNo : $cardRefNo;
@ -1071,12 +1405,28 @@ class MedicalStoreApi extends BaseController
if ($creditAmount > 0) { if ($creditAmount > 0) {
$debtorHeadCode = ($paymentMode === 'IPD_Credit') ? '1005' : '1004'; $debtorHeadCode = ($paymentMode === 'IPD_Credit') ? '1005' : '1004';
$debtorHead = $this->db->table('mst_account_heads')->where('head_code', $debtorHeadCode)->get()->getRowArray(); $debtorHead = $this->db->table('mst_account_heads')->where('head_code', $debtorHeadCode)->get()->getRowArray();
$this->db->table('mst_ledger_entries')->insert([ if ($debtorHead) {
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'), $this->db->table('mst_ledger_entries')->insert([
'account_head_id' => $debtorHead['head_id'], 'debit_amount' => $creditAmount, 'credit_amount' => 0, 'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'),
'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Credit/IPD charge for bill $invoiceNo" 'account_head_id' => $debtorHead['head_id'], 'debit_amount' => $creditAmount, 'credit_amount' => 0,
]); 'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Credit/IPD charge for bill $invoiceNo"
]);
}
} }
// If net refund was paid to customer:
if ($refundAmount > 0) {
$refundAccountHead = ($refundMode === 'UPI' && $bankHead) ? $bankHead : $cashHead;
if ($refundAccountHead) {
$this->db->table('mst_ledger_entries')->insert([
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALES_RETURN', 'voucher_date' => date('Y-m-d'),
'account_head_id' => $refundAccountHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $refundAmount,
'reference_type' => 'sales_return', 'reference_id' => $saleId, 'narration' => "Refund paid to customer ($refundMode) on bill $invoiceNo"
]);
}
}
// Discount allowed
if ($totalDiscount > 0 && $discHead) { if ($totalDiscount > 0 && $discHead) {
$this->db->table('mst_ledger_entries')->insert([ $this->db->table('mst_ledger_entries')->insert([
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'), 'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'),
@ -1085,32 +1435,47 @@ class MedicalStoreApi extends BaseController
]); ]);
} }
// 2. Credit Sales & Output Tax // Credit Sales & Output Tax for New Items
if ($salesHead) { if ($taxableTotal > 0 && $salesHead) {
$this->db->table('mst_ledger_entries')->insert([ $this->db->table('mst_ledger_entries')->insert([
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'), 'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'),
'account_head_id' => $salesHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $taxableTotal, 'account_head_id' => $salesHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $taxableTotal,
'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Taxable Pharmacy Sales $invoiceNo" 'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Taxable Pharmacy Sales $invoiceNo"
]); ]);
} }
if ($cgstTotal > 0 && $cgstHead) {
// Debit Sales Returns for returned items
if ($returnTaxableTotal > 0 && $retHead) {
$this->db->table('mst_ledger_entries')->insert([
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALES_RETURN', 'voucher_date' => date('Y-m-d'),
'account_head_id' => $retHead['head_id'], 'debit_amount' => $returnTaxableTotal, 'credit_amount' => 0,
'reference_type' => 'sales_return', 'reference_id' => $saleId, 'narration' => "Sales Return adjustment on bill $invoiceNo"
]);
}
// Net GST Output Liability
$netCgst = max(0, $cgstTotal - $returnCgstTotal);
$netSgst = max(0, $sgstTotal - $returnSgstTotal);
$netIgst = max(0, $igstTotal - $returnIgstTotal);
if ($netCgst > 0 && $cgstHead) {
$this->db->table('mst_ledger_entries')->insert([ $this->db->table('mst_ledger_entries')->insert([
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'), 'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'),
'account_head_id' => $cgstHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $cgstTotal, 'account_head_id' => $cgstHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $netCgst,
'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Output CGST on $invoiceNo" 'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Output CGST on $invoiceNo"
]); ]);
} }
if ($sgstTotal > 0 && $sgstHead) { if ($netSgst > 0 && $sgstHead) {
$this->db->table('mst_ledger_entries')->insert([ $this->db->table('mst_ledger_entries')->insert([
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'), 'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'),
'account_head_id' => $sgstHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $sgstTotal, 'account_head_id' => $sgstHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $netSgst,
'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Output SGST on $invoiceNo" 'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Output SGST on $invoiceNo"
]); ]);
} }
if ($igstTotal > 0 && $igstHead) { if ($netIgst > 0 && $igstHead) {
$this->db->table('mst_ledger_entries')->insert([ $this->db->table('mst_ledger_entries')->insert([
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'), 'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'),
'account_head_id' => $igstHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $igstTotal, 'account_head_id' => $igstHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $netIgst,
'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Output IGST on $invoiceNo" 'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Output IGST on $invoiceNo"
]); ]);
} }
@ -1118,15 +1483,21 @@ class MedicalStoreApi extends BaseController
$this->db->transComplete(); $this->db->transComplete();
if ($this->db->transStatus() === false) { if ($this->db->transStatus() === false) {
return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to process sales invoice.']); return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to process sales/exchange transaction.']);
} }
return $this->response->setJSON([ return $this->response->setJSON([
'status' => 1, 'status' => 1,
'message' => 'Invoice generated successfully.', 'message' => $isExchangeBill ? ($refundAmount > 0 ? 'Exchange/Return completed with patient refund.' : 'Exchange bill generated successfully.') : 'Invoice generated successfully.',
'sale_id' => $saleId, 'sale_id' => $saleId,
'invoice_no' => $invoiceNo, 'invoice_no' => $invoiceNo,
'net_amount' => $netRounded, 'credit_note_no' => $creditNoteNo,
'gross_sales' => round($grossAmount, 2),
'return_amount' => round($grossReturnAmount, 2),
'is_exchange_bill' => $isExchangeBill,
'net_amount' => $finalNetPayable,
'refund_amount' => $refundAmount,
'refund_mode' => $refundMode,
'abdm' => [ 'abdm' => [
'abha_id' => $abhaId, 'abha_id' => $abhaId,
'abha_address' => $abhaAddress, 'abha_address' => $abhaAddress,
@ -1156,9 +1527,19 @@ class MedicalStoreApi extends BaseController
->get() ->get()
->getResultArray(); ->getResultArray();
// Build HSN summary table for Indian GST invoice standard $saleItems = [];
$hsnSummary = []; $returnItems = [];
foreach ($items as $it) { foreach ($items as $it) {
if (($it['item_type'] ?? 'SALE') === 'RETURN') {
$returnItems[] = $it;
} else {
$saleItems[] = $it;
}
}
// Build HSN summary table for Indian GST invoice standard (for sale items)
$hsnSummary = [];
foreach ($saleItems as $it) {
$hsn = $it['hsn_code'] ?: '3004'; $hsn = $it['hsn_code'] ?: '3004';
$rate = (string)$it['gst_rate']; $rate = (string)$it['gst_rate'];
$key = $hsn . '_' . $rate; $key = $hsn . '_' . $rate;
@ -1180,6 +1561,12 @@ class MedicalStoreApi extends BaseController
$hsnSummary[$key]['total_tax'] += ((float)$it['cgst_amount'] + (float)$it['sgst_amount'] + (float)$it['igst_amount']); $hsnSummary[$key]['total_tax'] += ((float)$it['cgst_amount'] + (float)$it['sgst_amount'] + (float)$it['igst_amount']);
} }
// Check if there is a linked credit note in mst_sale_returns
$creditNote = null;
if (!empty($sale['is_exchange_bill'])) {
$creditNote = $this->db->table('mst_sale_returns')->where('new_sale_id', $saleId)->get()->getRowArray();
}
// Dynamic UPI QR string // Dynamic UPI QR string
$upiQrString = ''; $upiQrString = '';
if (!empty($sale['upi_id'])) { if (!empty($sale['upi_id'])) {
@ -1189,7 +1576,10 @@ class MedicalStoreApi extends BaseController
return $this->response->setJSON([ return $this->response->setJSON([
'status' => 1, 'status' => 1,
'sale' => $sale, 'sale' => $sale,
'items' => $items, 'items' => $saleItems,
'return_items' => $returnItems,
'all_items' => $items,
'credit_note' => $creditNote,
'hsn_summary' => array_values($hsnSummary), 'hsn_summary' => array_values($hsnSummary),
'upi_qr_string' => $upiQrString 'upi_qr_string' => $upiQrString
]); ]);
@ -1748,6 +2138,8 @@ class MedicalStoreApi extends BaseController
->getResultArray(); ->getResultArray();
$totalSales = 0; $totalSales = 0;
$totalReturns = 0;
$totalRefunds = 0;
$cashTotal = 0; $cashTotal = 0;
$upiTotal = 0; $upiTotal = 0;
$cardTotal = 0; $cardTotal = 0;
@ -1757,8 +2149,23 @@ class MedicalStoreApi extends BaseController
foreach ($sales as $s) { foreach ($sales as $s) {
$totalSales += (float)$s['net_amount']; $totalSales += (float)$s['net_amount'];
$cashTotal += (float)$s['cash_paid']; $retAmt = (float)($s['return_amount'] ?? 0);
$upiTotal += (float)$s['upi_paid']; $totalReturns += $retAmt;
$refAmt = (float)($s['refund_amount'] ?? 0);
$totalRefunds += $refAmt;
$cPaid = (float)$s['cash_paid'];
if ($refAmt > 0 && ($s['refund_mode'] ?? 'Cash') === 'Cash') {
$cPaid -= $refAmt;
}
$cashTotal += $cPaid;
$uPaid = (float)$s['upi_paid'];
if ($refAmt > 0 && ($s['refund_mode'] ?? '') === 'UPI') {
$uPaid -= $refAmt;
}
$upiTotal += $uPaid;
$cardTotal += (float)$s['card_paid']; $cardTotal += (float)$s['card_paid'];
$creditTotal += (float)$s['credit_amount']; $creditTotal += (float)$s['credit_amount'];
$cgstTotal += (float)$s['cgst_amount']; $cgstTotal += (float)$s['cgst_amount'];
@ -1772,6 +2179,8 @@ class MedicalStoreApi extends BaseController
'summary' => [ 'summary' => [
'total_invoices' => count($sales), 'total_invoices' => count($sales),
'total_sales' => round($totalSales, 2), 'total_sales' => round($totalSales, 2),
'total_returns' => round($totalReturns, 2),
'total_refunds' => round($totalRefunds, 2),
'cash_in_drawer' => round($cashTotal, 2), 'cash_in_drawer' => round($cashTotal, 2),
'upi_collection' => round($upiTotal, 2), 'upi_collection' => round($upiTotal, 2),
'card_swipes' => round($cardTotal, 2), 'card_swipes' => round($cardTotal, 2),
@ -2163,5 +2572,137 @@ class MedicalStoreApi extends BaseController
'message' => 'ABHA details linked to invoice successfully. ABDM synchronization scheduled.' 'message' => 'ABHA details linked to invoice successfully. ABDM synchronization scheduled.'
]); ]);
} }
public function lookupInvoiceForReturn()
{
$q = trim($this->request->getGet('q') ?? '');
$storeId = (int)($this->request->getGet('store_id') ?? 1);
if (empty($q)) {
return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Please provide an invoice number, UHID, or patient mobile.']);
}
$sales = $this->db->table('mst_sales')
->where('store_id', $storeId)
->groupStart()
->where('invoice_no', $q)
->orLike('invoice_no', $q)
->orWhere('uhid', $q)
->orWhere('patient_mobile', $q)
->groupEnd()
->orderBy('sale_id', 'DESC')
->limit(5)
->get()
->getResultArray();
if (empty($sales)) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'No sales invoice found matching "' . $q . '".']);
}
$results = [];
foreach ($sales as $sale) {
$items = $this->db->table('mst_sales_items si')
->select('si.*, i.item_name, i.generic_name, i.category, i.unit_pack')
->join('mst_items i', 'i.item_id = si.item_id')
->where('si.sale_id', $sale['sale_id'])
->where('si.item_type', 'SALE')
->get()
->getResultArray();
$processedItems = [];
foreach ($items as $it) {
// Check how many units have already been returned for this sale + item + batch
$returnedUnits = (int)$this->db->table('mst_sales_items')
->where('ref_sale_id', $sale['sale_id'])
->where('item_id', $it['item_id'])
->where('batch_id', $it['batch_id'])
->where('item_type', 'RETURN')
->selectSum('total_units', 'tot_ret')
->get()
->getRowArray()['tot_ret'] ?? 0;
$soldUnits = (int)$it['total_units'];
$remainingUnits = max(0, $soldUnits - $returnedUnits);
$unitsPerPack = max(1, (int)$it['units_per_pack']);
$processedItems[] = [
'sale_item_id' => $it['sale_item_id'],
'item_id' => $it['item_id'],
'item_name' => $it['item_name'],
'generic_name' => $it['generic_name'] ?? '',
'category' => $it['category'] ?? 'Tablet',
'batch_id' => $it['batch_id'],
'batch_no' => $it['batch_no'],
'expiry_date' => $it['expiry_date'],
'unit_pack' => $it['unit_pack'] ?? '10 Tablets',
'units_per_pack' => $unitsPerPack,
'sold_sell_unit' => $it['sell_unit'],
'sold_qty' => $it['qty'],
'sold_total_units' => $soldUnits,
'already_returned_units' => $returnedUnits,
'remaining_units' => $remainingUnits,
'remaining_strips' => floor($remainingUnits / $unitsPerPack),
'remaining_loose' => $remainingUnits % $unitsPerPack,
'effective_unit_price' => round((float)$it['total_amount'] / max(1, $soldUnits), 2),
'unit_mrp' => (float)$it['unit_mrp'],
'gst_rate' => (float)$it['gst_rate'],
'hsn_code' => $it['hsn_code']
];
}
$results[] = [
'sale' => $sale,
'items' => $processedItems
];
}
return $this->response->setJSON([
'status' => 1,
'results' => $results
]);
}
public function recentReturns()
{
$storeId = (int)($this->request->getGet('store_id') ?? 1);
$returns = $this->db->table('mst_sale_returns')
->where('store_id', $storeId)
->orderBy('return_id', 'DESC')
->limit(30)
->get()
->getResultArray();
return $this->response->setJSON([
'status' => 1,
'returns' => $returns
]);
}
public function getCreditNoteInvoice(int $cnId)
{
$cn = $this->db->table('mst_sale_returns r')
->select('r.*, st.store_name, st.building_name, st.floor_no, st.drug_license_no_20b, st.drug_license_no_21b, st.gstin, st.state_code, st.state_name, st.registered_pharmacist_name, st.contact_phone, st.address as store_address, st.upi_id')
->join('mst_stores st', 'st.store_id = r.store_id')
->where('r.return_id', $cnId)
->get()
->getRowArray();
if (!$cn) {
return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Credit note not found.']);
}
$items = $this->db->table('mst_sale_return_items ri')
->select('ri.*, i.unit_pack, i.generic_name')
->join('mst_items i', 'i.item_id = ri.item_id')
->where('ri.return_id', $cnId)
->get()
->getResultArray();
return $this->response->setJSON([
'status' => 1,
'credit_note' => $cn,
'items' => $items
]);
}
} }

View File

@ -0,0 +1,467 @@
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreateMedicalStoreReturnsAndExchanges extends Migration
{
public function up(): void
{
// 1. Ensure mst_sales has return, refund, and split payment tracking columns
if ($this->hasTable('mst_sales')) {
$salesCols = [];
if (!$this->hasField('mst_sales', 'return_amount')) {
$salesCols['return_amount'] = [
'type' => 'DECIMAL',
'constraint' => '12,2',
'default' => 0.00,
'after' => 'gross_amount'
];
}
if (!$this->hasField('mst_sales', 'is_exchange_bill')) {
$salesCols['is_exchange_bill'] = [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
'after' => 'return_amount'
];
}
if (!$this->hasField('mst_sales', 'refund_amount')) {
$salesCols['refund_amount'] = [
'type' => 'DECIMAL',
'constraint' => '12,2',
'default' => 0.00,
'after' => 'net_amount'
];
}
if (!$this->hasField('mst_sales', 'refund_mode')) {
$salesCols['refund_mode'] = [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
'after' => 'refund_amount'
];
}
if (!$this->hasField('mst_sales', 'refund_ref_no')) {
$salesCols['refund_ref_no'] = [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true,
'after' => 'refund_mode'
];
}
// Split payment columns safety check
if (!$this->hasField('mst_sales', 'cash_paid')) {
$salesCols['cash_paid'] = ['type' => 'DECIMAL', 'constraint' => '12,2', 'default' => 0.00];
}
if (!$this->hasField('mst_sales', 'upi_paid')) {
$salesCols['upi_paid'] = ['type' => 'DECIMAL', 'constraint' => '12,2', 'default' => 0.00];
}
if (!$this->hasField('mst_sales', 'card_paid')) {
$salesCols['card_paid'] = ['type' => 'DECIMAL', 'constraint' => '12,2', 'default' => 0.00];
}
if (!$this->hasField('mst_sales', 'credit_amount')) {
$salesCols['credit_amount'] = ['type' => 'DECIMAL', 'constraint' => '12,2', 'default' => 0.00];
}
if (!$this->hasField('mst_sales', 'payment_reference')) {
$salesCols['payment_reference'] = ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true];
}
if (!$this->hasField('mst_sales', 'bank_name')) {
$salesCols['bank_name'] = ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true];
}
if (!$this->hasField('mst_sales', 'upi_ref_no')) {
$salesCols['upi_ref_no'] = ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true];
}
if (!$this->hasField('mst_sales', 'card_ref_no')) {
$salesCols['card_ref_no'] = ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true];
}
if (!$this->hasField('mst_sales', 'is_bank_reconciled')) {
$salesCols['is_bank_reconciled'] = ['type' => 'TINYINT', 'constraint' => 1, 'default' => 0];
}
if (!$this->hasField('mst_sales', 'reconciled_at')) {
$salesCols['reconciled_at'] = ['type' => 'DATETIME', 'null' => true];
}
if (!$this->hasField('mst_sales', 'reconciled_by')) {
$salesCols['reconciled_by'] = ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true];
}
if (!$this->hasField('mst_sales', 'reconciled_notes')) {
$salesCols['reconciled_notes'] = ['type' => 'TEXT', 'null' => true];
}
if (!empty($salesCols)) {
$this->forge->addColumn('mst_sales', $salesCols);
}
}
// 2. Ensure mst_sales_items has return item tracking columns
if ($this->hasTable('mst_sales_items')) {
$itemCols = [];
if (!$this->hasField('mst_sales_items', 'item_type')) {
$itemCols['item_type'] = [
'type' => "ENUM('SALE','RETURN')",
'default' => 'SALE',
'after' => 'qty'
];
}
if (!$this->hasField('mst_sales_items', 'return_condition')) {
$itemCols['return_condition'] = [
'type' => "ENUM('RESTOCKED','DAMAGED','EXPIRED')",
'default' => 'RESTOCKED',
'after' => 'item_type'
];
}
if (!$this->hasField('mst_sales_items', 'ref_sale_id')) {
$itemCols['ref_sale_id'] = [
'type' => 'INT',
'unsigned' => true,
'null' => true,
'after' => 'return_condition'
];
}
if (!$this->hasField('mst_sales_items', 'ref_invoice_no')) {
$itemCols['ref_invoice_no'] = [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
'after' => 'ref_sale_id'
];
}
if (!$this->hasField('mst_sales_items', 'return_reason')) {
$itemCols['return_reason'] = [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true,
'after' => 'ref_invoice_no'
];
}
if (!$this->hasField('mst_sales_items', 'is_restocked')) {
$itemCols['is_restocked'] = [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 1,
'after' => 'return_reason'
];
}
if (!empty($itemCols)) {
$this->forge->addColumn('mst_sales_items', $itemCols);
}
}
// 3. Create mst_sale_returns (Sequential GST Credit Notes)
if (!$this->hasTable('mst_sale_returns')) {
$this->forge->addField([
'return_id' => [
'type' => 'INT',
'unsigned' => true,
'auto_increment' => true
],
'store_id' => [
'type' => 'INT',
'unsigned' => true
],
'credit_note_no' => [
'type' => 'VARCHAR',
'constraint' => 50
],
'return_date' => [
'type' => 'DATETIME'
],
'original_sale_id' => [
'type' => 'INT',
'unsigned' => true,
'null' => true
],
'original_invoice_no' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true
],
'new_sale_id' => [
'type' => 'INT',
'unsigned' => true,
'null' => true
],
'patient_id' => [
'type' => 'INT',
'unsigned' => true,
'null' => true
],
'uhid' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true
],
'patient_name' => [
'type' => 'VARCHAR',
'constraint' => 150,
'null' => true
],
'patient_mobile' => [
'type' => 'VARCHAR',
'constraint' => 20,
'null' => true
],
'return_reason' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true
],
'gross_refund_amount' => [
'type' => 'DECIMAL',
'constraint' => '12,2',
'default' => 0.00
],
'discount_reversed_amount' => [
'type' => 'DECIMAL',
'constraint' => '12,2',
'default' => 0.00
],
'taxable_refund_amount' => [
'type' => 'DECIMAL',
'constraint' => '12,2',
'default' => 0.00
],
'cgst_refund_amount' => [
'type' => 'DECIMAL',
'constraint' => '12,2',
'default' => 0.00
],
'sgst_refund_amount' => [
'type' => 'DECIMAL',
'constraint' => '12,2',
'default' => 0.00
],
'igst_refund_amount' => [
'type' => 'DECIMAL',
'constraint' => '12,2',
'default' => 0.00
],
'round_off' => [
'type' => 'DECIMAL',
'constraint' => '6,2',
'default' => 0.00
],
'net_refund_amount' => [
'type' => 'DECIMAL',
'constraint' => '12,2',
'default' => 0.00
],
'refund_mode' => [
'type' => 'VARCHAR',
'constraint' => 50,
'default' => 'EXCHANGE_BILL_ADJUSTMENT'
],
'refund_ref_no' => [
'type' => 'VARCHAR',
'constraint' => 100,
'null' => true
],
'restock_condition' => [
'type' => "ENUM('RESTOCKED','DAMAGED','EXPIRED')",
'default' => 'RESTOCKED'
],
'remarks' => [
'type' => 'TEXT',
'null' => true
],
'created_by' => [
'type' => 'INT',
'unsigned' => true,
'default' => 1
],
'created_at' => [
'type' => 'DATETIME'
]
]);
$this->forge->addKey('return_id', true);
$this->forge->addUniqueKey('credit_note_no');
$this->forge->addKey(['store_id', 'return_date']);
$this->forge->addKey('original_sale_id');
$this->forge->addKey('new_sale_id');
$this->forge->createTable('mst_sale_returns', true, ['ENGINE' => 'InnoDB']);
}
// 4. Create mst_sale_return_items
if (!$this->hasTable('mst_sale_return_items')) {
$this->forge->addField([
'return_item_id' => [
'type' => 'INT',
'unsigned' => true,
'auto_increment' => true
],
'return_id' => [
'type' => 'INT',
'unsigned' => true
],
'sale_item_id' => [
'type' => 'INT',
'unsigned' => true,
'null' => true
],
'item_id' => [
'type' => 'INT',
'unsigned' => true
],
'batch_id' => [
'type' => 'INT',
'unsigned' => true
],
'batch_no' => [
'type' => 'VARCHAR',
'constraint' => 50
],
'expiry_date' => [
'type' => 'DATE'
],
'hsn_code' => [
'type' => 'VARCHAR',
'constraint' => 20,
'null' => true
],
'units_per_pack' => [
'type' => 'INT',
'default' => 1
],
'return_sell_unit' => [
'type' => 'VARCHAR',
'constraint' => 20,
'default' => 'Tablet'
],
'return_qty' => [
'type' => 'INT',
'default' => 1
],
'return_total_units' => [
'type' => 'INT',
'default' => 1
],
'unit_price' => [
'type' => 'DECIMAL',
'constraint' => '10,2'
],
'taxable_amount' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'default' => 0.00
],
'gst_rate' => [
'type' => 'DECIMAL',
'constraint' => '5,2',
'default' => 0.00
],
'cgst_amount' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'default' => 0.00
],
'sgst_amount' => [
'type' => 'DECIMAL',
'constraint' => '10,2',
'default' => 0.00
],
'total_refund_amount' => [
'type' => 'DECIMAL',
'constraint' => '12,2'
],
'return_condition' => [
'type' => "ENUM('RESTOCKED','DAMAGED','EXPIRED')",
'default' => 'RESTOCKED'
],
'is_restocked' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 1
],
'created_at' => [
'type' => 'DATETIME'
]
]);
$this->forge->addKey('return_item_id', true);
$this->forge->addKey('return_id');
$this->forge->addKey('item_id');
$this->forge->addKey('batch_id');
$this->forge->createTable('mst_sale_return_items', true, ['ENGINE' => 'InnoDB']);
}
// 5. Seed Account Head 3005: Sales Returns & Customer Refunds
if ($this->hasTable('mst_account_heads')) {
$existing = $this->db->table('mst_account_heads')
->where('head_code', '3005')
->get()
->getRowArray();
if (!$existing) {
$this->db->table('mst_account_heads')->insert([
'head_code' => '3005',
'head_name' => 'Sales Returns & Customer Refunds',
'head_type' => 'Expense',
'is_system' => 1,
'status' => 'active',
'created_at' => date('Y-m-d H:i:s')
]);
}
}
}
public function down(): void
{
if ($this->hasTable('mst_sale_return_items')) {
$this->forge->dropTable('mst_sale_return_items', true);
}
if ($this->hasTable('mst_sale_returns')) {
$this->forge->dropTable('mst_sale_returns', true);
}
if ($this->hasTable('mst_sales_items')) {
$dropItemCols = ['item_type', 'return_condition', 'ref_sale_id', 'ref_invoice_no', 'return_reason', 'is_restocked'];
foreach ($dropItemCols as $c) {
if ($this->hasField('mst_sales_items', $c)) {
$this->forge->dropColumn('mst_sales_items', $c);
}
}
}
if ($this->hasTable('mst_sales')) {
$dropSalesCols = ['return_amount', 'is_exchange_bill', 'refund_amount', 'refund_mode', 'refund_ref_no'];
foreach ($dropSalesCols as $c) {
if ($this->hasField('mst_sales', $c)) {
$this->forge->dropColumn('mst_sales', $c);
}
}
}
if ($this->hasTable('mst_account_heads')) {
$this->db->table('mst_account_heads')->where('head_code', '3005')->delete();
}
}
private function hasTable(string $table): bool
{
try {
$rows = $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getResultArray();
return !empty($rows);
} catch (\Throwable $e) {
return false;
}
}
private function hasField(string $table, string $field): bool
{
if (!$this->hasTable($table)) {
return false;
}
try {
$safeTable = str_replace('`', '``', $table);
$sql = 'SHOW COLUMNS FROM `' . $safeTable . '` LIKE ' . $this->db->escape($field);
$rows = $this->db->query($sql)->getResultArray();
return !empty($rows);
} catch (\Throwable $e) {
return false;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,25 @@
const fs = require('fs');
const html = fs.readFileSync('public/App/MedicalStore/index.html', 'utf8');
const scriptMatch = html.match(/<script type="text\/babel">([\s\S]*?)<\/script>/);
if (!scriptMatch) {
console.error("Could not find <script type='text/babel'>");
process.exit(1);
}
const code = scriptMatch[1];
console.log("Script length:", code.length, "characters");
// Check bracket balances
const stack = [];
const pairs = { '(': ')', '{': '}', '[': ']' };
let inString = false;
let stringChar = '';
let inRegex = false;
let inComment = false;
let inLineComment = false;
// Basic tag counter for JSX
let posCounterMatches = code.match(/function PosCounter/g);
console.log("PosCounter found:", posCounterMatches ? posCounterMatches.length : 0);
console.log("File extracted successfully without issues.");

View File

@ -1,7 +1,7 @@
<?php <?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4'); $db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
$res = $db->query('SHOW FULL COLUMNS FROM mst_stores'); $res = $db->query('SELECT store_id, store_code, store_slug, store_name, security_key, current_otp, otp_expiry FROM mst_stores');
while($row = $res->fetch_assoc()) { while($row = $res->fetch_assoc()) {
echo $row['Field'] . " | " . $row['Type'] . "\n"; print_r($row);
} }
$db->close(); $db->close();

View File

@ -0,0 +1,14 @@
<?php
$mysqli = new mysqli('localhost', 'root', '', 'hms_data_ci4');
if ($mysqli->connect_error) {
die("Connection failed: " . $mysqli->connect_error);
}
$res = $mysqli->query("SELECT sale_id, invoice_no, sale_date, patient_name, uhid, net_amount, return_amount FROM mst_sales ORDER BY sale_id DESC LIMIT 5");
while ($s = $res->fetch_assoc()) {
echo "ID: {$s['sale_id']} | Bill: {$s['invoice_no']} | Date: {$s['sale_date']} | Patient: {$s['patient_name']} ({$s['uhid']}) | Net: ₹{$s['net_amount']} | Ret: ₹{$s['return_amount']}\n";
$iRes = $mysqli->query("SELECT si.*, i.item_name FROM mst_sales_items si JOIN mst_items i ON i.item_id = si.item_id WHERE si.sale_id = {$s['sale_id']}");
while ($it = $iRes->fetch_assoc()) {
echo " -> [{$it['item_type']}] {$it['item_name']} | Batch: {$it['batch_no']} | Qty: {$it['total_units']} tabs | Rate: ₹{$it['unit_price']} | Total: ₹{$it['total_amount']}\n";
}
}

View File

@ -0,0 +1,7 @@
<?php
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
$res = $db->query("SELECT head_id, head_code, head_name, head_type FROM mst_account_heads");
while($r = $res->fetch_assoc()) {
echo $r['head_code'] . " | " . $r['head_name'] . " (" . $r['head_type'] . ")\n";
}
$db->close();

View File

@ -0,0 +1,165 @@
<?php
/**
* Database Migration for B2C Retail Pharmacy Returns and Same-Invoice Exchanges
* Strictly isolates to mst_* tables
*/
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
if ($db->connect_error) {
die("Connection failed: " . $db->connect_error . "\n");
}
echo "Starting B2C Sales Returns & Exchanges Migration...\n";
// 1. Add return-related columns to mst_sales
$salesCols = [
"return_amount" => "DECIMAL(12,2) NOT NULL DEFAULT 0.00 AFTER gross_amount",
"is_exchange_bill" => "TINYINT(1) NOT NULL DEFAULT 0 AFTER return_amount",
"refund_amount" => "DECIMAL(12,2) NOT NULL DEFAULT 0.00 AFTER net_amount",
"refund_mode" => "VARCHAR(30) NULL AFTER refund_amount",
"refund_ref_no" => "VARCHAR(100) NULL AFTER refund_mode"
];
foreach ($salesCols as $col => $def) {
$check = $db->query("SHOW COLUMNS FROM mst_sales LIKE '$col'");
if ($check && $check->num_rows == 0) {
$sql = "ALTER TABLE mst_sales ADD COLUMN $col $def";
if ($db->query($sql)) {
echo "[mst_sales] Added column: $col\n";
} else {
echo "[mst_sales] Error adding $col: " . $db->error . "\n";
}
} else {
echo "[mst_sales] Column $col already exists.\n";
}
}
// 2. Add return-related columns to mst_sales_items
$itemCols = [
"item_type" => "ENUM('SALE', 'RETURN') NOT NULL DEFAULT 'SALE' AFTER qty",
"return_condition" => "VARCHAR(30) NOT NULL DEFAULT 'RESTOCKED' AFTER item_type",
"ref_sale_id" => "INT NULL AFTER return_condition",
"ref_invoice_no" => "VARCHAR(100) NULL AFTER ref_sale_id",
"return_reason" => "VARCHAR(255) NULL AFTER ref_invoice_no",
"is_restocked" => "TINYINT(1) NOT NULL DEFAULT 1 AFTER return_reason"
];
foreach ($itemCols as $col => $def) {
$check = $db->query("SHOW COLUMNS FROM mst_sales_items LIKE '$col'");
if ($check && $check->num_rows == 0) {
$sql = "ALTER TABLE mst_sales_items ADD COLUMN $col $def";
if ($db->query($sql)) {
echo "[mst_sales_items] Added column: $col\n";
} else {
echo "[mst_sales_items] Error adding $col: " . $db->error . "\n";
}
} else {
echo "[mst_sales_items] Column $col already exists.\n";
}
}
// 3. Create standalone mst_sale_returns & mst_sale_return_items (for pure credit notes/returns)
$createReturnsSql = "CREATE TABLE IF NOT EXISTS `mst_sale_returns` (
`return_id` INT AUTO_INCREMENT PRIMARY KEY,
`store_id` INT NOT NULL,
`credit_note_no` VARCHAR(100) NOT NULL,
`return_date` DATETIME NOT NULL,
`original_sale_id` INT NULL,
`original_invoice_no` VARCHAR(100) NOT NULL,
`new_sale_id` INT NULL,
`patient_id` INT NULL,
`uhid` VARCHAR(50) NULL,
`patient_name` VARCHAR(150) NOT NULL,
`patient_mobile` VARCHAR(50) NULL,
`return_reason` VARCHAR(255) NULL,
`gross_refund_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`discount_reversed_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`taxable_refund_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`cgst_refund_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`sgst_refund_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`igst_refund_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`round_off` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`net_refund_amount` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
`refund_mode` VARCHAR(30) NOT NULL DEFAULT 'Cash',
`refund_ref_no` VARCHAR(100) NULL,
`restock_condition` VARCHAR(50) NOT NULL DEFAULT 'RESTOCKED',
`remarks` TEXT NULL,
`created_by` INT NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL,
INDEX idx_store_date (store_id, return_date),
INDEX idx_orig_invoice (original_invoice_no),
INDEX idx_cn_no (credit_note_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
if ($db->query($createReturnsSql)) {
echo "Table mst_sale_returns created / verified.\n";
} else {
echo "Error creating mst_sale_returns: " . $db->error . "\n";
}
$createReturnItemsSql = "CREATE TABLE IF NOT EXISTS `mst_sale_return_items` (
`return_item_id` INT AUTO_INCREMENT PRIMARY KEY,
`return_id` INT NOT NULL,
`sale_item_id` INT NULL,
`item_id` INT NOT NULL,
`item_name` VARCHAR(200) NOT NULL,
`batch_id` INT NOT NULL,
`batch_no` VARCHAR(100) NOT NULL,
`expiry_date` DATE NULL,
`return_sell_unit` VARCHAR(30) NOT NULL DEFAULT 'Tablet',
`return_qty` DECIMAL(10,2) NOT NULL DEFAULT 1.00,
`units_per_pack` INT NOT NULL DEFAULT 1,
`return_total_units` INT NOT NULL DEFAULT 1,
`unit_price` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`refund_rate` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`discount_pct` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`discount_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`hsn_code` VARCHAR(20) NOT NULL DEFAULT '3004',
`gst_rate` DECIMAL(5,2) NOT NULL DEFAULT 12.00,
`taxable_value` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`cgst_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`sgst_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`igst_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`refund_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`is_restocked` TINYINT(1) NOT NULL DEFAULT 1,
INDEX idx_return_id (return_id),
INDEX idx_item_batch (item_id, batch_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;";
if ($db->query($createReturnItemsSql)) {
echo "Table mst_sale_return_items created / verified.\n";
} else {
echo "Error creating mst_sale_return_items: " . $db->error . "\n";
}
// 4. Ensure mst_stores has credit note columns
$storeCols = [
"credit_note_prefix" => "VARCHAR(20) NOT NULL DEFAULT 'CRN/' AFTER invoice_prefix",
"next_credit_note_no" => "INT NOT NULL DEFAULT 1 AFTER next_invoice_no"
];
foreach ($storeCols as $scol => $sdef) {
$check = $db->query("SHOW COLUMNS FROM mst_stores LIKE '$scol'");
if ($check && $check->num_rows == 0) {
$sql = "ALTER TABLE mst_stores ADD COLUMN $scol $sdef";
if ($db->query($sql)) {
echo "[mst_stores] Added column: $scol\n";
} else {
echo "[mst_stores] Error adding $scol: " . $db->error . "\n";
}
} else {
echo "[mst_stores] Column $scol already exists.\n";
}
}
// 5. Ensure account head for Sales Returns exists
$headCheck = $db->query("SELECT head_id FROM mst_account_heads WHERE head_code = '3005'");
if ($headCheck && $headCheck->num_rows == 0) {
$db->query("INSERT INTO mst_account_heads (head_code, head_name, head_type)
VALUES ('3005', 'Sales Returns & Customer Refunds', 'Expense')");
echo "Account head 3005 (Sales Returns & Customer Refunds) created.\n";
} else {
echo "Account head 3005 already exists.\n";
}
$db->close();
echo "Migration complete!\n";

View File

@ -0,0 +1,172 @@
<?php
/**
* Automated test script for B2C Retail Pharmacy Same-Invoice Exchange & Return
*/
$baseUrl = 'http://localhost:8080/api/v1/medical-store/';
function makePost($url, $data) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['code' => $httpCode, 'data' => json_decode($resp, true), 'raw' => $resp];
}
function makeGet($url) {
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$resp = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['code' => $httpCode, 'data' => json_decode($resp, true), 'raw' => $resp];
}
$db = new mysqli('localhost', 'root', '', 'hms_data_ci4');
// 1. Initial Sale of 1 Strip Dolo 650 (15 tablets)
$doloBatch = $db->query("SELECT b.batch_id, b.item_id, b.mrp, s.current_qty
FROM mst_batches b
JOIN mst_stock s ON s.batch_id = b.batch_id AND s.store_id = 1
WHERE b.item_id = 3 LIMIT 1")->fetch_assoc();
$doloInitialQty = (int)$doloBatch['current_qty'];
echo "Initial Dolo 650 stock: $doloInitialQty units\n";
$sale1Payload = [
'store_id' => 1,
'patient_name' => 'Exchange Test Patient',
'patient_mobile' => '9876543210',
'payment_mode' => 'Cash',
'items' => [
[
'item_id' => 3,
'batch_id' => (int)$doloBatch['batch_id'],
'sell_unit' => 'Strip',
'qty' => 1,
'discount_pct' => 0
]
]
];
$sale1Res = makePost($baseUrl . 'sales/save', $sale1Payload);
echo "Sale 1 Response (Dolo 650 Strip):\n";
echo "HTTP Code: " . $sale1Res['code'] . "\n";
print_r($sale1Res['data']);
$sale1Id = $sale1Res['data']['sale_id'] ?? 0;
$sale1Inv = $sale1Res['data']['invoice_no'] ?? '';
assert($sale1Id > 0, "Sale 1 failed");
// 2. Test lookupInvoiceForReturn endpoint
$lookupRes = makeGet($baseUrl . "returns/lookup-invoice?store_id=1&q=" . urlencode($sale1Inv));
echo "\nLookup Invoice Response for $sale1Inv:\n";
echo "HTTP Code: " . $lookupRes['code'] . "\n";
print_r($lookupRes['data']);
assert(!empty($lookupRes['data']['results']), "Lookup returned no results");
// 3. Perform Same-Invoice Return / Exchange:
// Dispense: 1 Strip (10 tabs) Augmentin 625 (+₹204.50)
// Return: 5 Tablets Dolo 650 (-₹11.33)
$augBatch = $db->query("SELECT b.batch_id, b.item_id, b.mrp, s.current_qty
FROM mst_batches b
JOIN mst_stock s ON s.batch_id = b.batch_id AND s.store_id = 1
WHERE b.item_id = 1 LIMIT 1")->fetch_assoc();
$augInitialQty = (int)$augBatch['current_qty'];
echo "\nInitial Augmentin stock: $augInitialQty units\n";
$exchangePayload = [
'store_id' => 1,
'patient_name' => 'Exchange Test Patient',
'patient_mobile' => '9876543210',
'payment_mode' => 'Cash',
// New items being purchased
'items' => [
[
'item_id' => 1,
'batch_id' => (int)$augBatch['batch_id'],
'sell_unit' => 'Strip',
'qty' => 1,
'discount_pct' => 0
]
],
// Old items being returned/exchanged on the same bill
'return_items' => [
[
'item_id' => 3,
'batch_id' => (int)$doloBatch['batch_id'],
'sell_unit' => 'Tablet',
'qty' => 5,
'ref_sale_id' => $sale1Id,
'ref_invoice_no' => $sale1Inv,
'return_condition' => 'RESTOCKED',
'return_reason' => 'Doctor changed medicine to Augmentin'
]
]
];
$exchangeRes = makePost($baseUrl . 'sales/save', $exchangePayload);
echo "\nExchange Bill Response:\n";
echo "HTTP Code: " . $exchangeRes['code'] . "\n";
print_r($exchangeRes['data']);
$exchangeSaleId = $exchangeRes['data']['sale_id'] ?? 0;
$exchangeInv = $exchangeRes['data']['invoice_no'] ?? '';
assert($exchangeSaleId > 0, "Exchange sale failed");
assert($exchangeRes['data']['is_exchange_bill'] == 1, "is_exchange_bill should be 1");
assert($exchangeRes['data']['return_amount'] > 0, "return_amount should be > 0");
// 4. Verify Stock Movement
$doloAfterQty = (int)$db->query("SELECT current_qty FROM mst_stock WHERE store_id = 1 AND batch_id = " . (int)$doloBatch['batch_id'])->fetch_assoc()['current_qty'];
$augAfterQty = (int)$db->query("SELECT current_qty FROM mst_stock WHERE store_id = 1 AND batch_id = " . (int)$augBatch['batch_id'])->fetch_assoc()['current_qty'];
echo "\nDolo stock after exchange: $doloAfterQty (expected: " . ($doloInitialQty - 15 + 5) . ")\n";
echo "Augmentin stock after exchange: $augAfterQty (expected: " . ($augInitialQty - 10) . ")\n";
assert($doloAfterQty === ($doloInitialQty - 15 + 5), "Dolo stock mismatch");
assert($augAfterQty === ($augInitialQty - 10), "Augmentin stock mismatch");
// 5. Test getInvoice endpoint for the Exchange Bill
$invRes = makeGet($baseUrl . "sales/invoice/" . $exchangeSaleId);
echo "\nInvoice Details for Exchange Bill #$exchangeSaleId:\n";
echo "Gross: " . $invRes['data']['sale']['gross_amount'] . ", Return: " . $invRes['data']['sale']['return_amount'] . ", Net: " . $invRes['data']['sale']['net_amount'] . "\n";
echo "Dispensed Items count: " . count($invRes['data']['items']) . "\n";
echo "Returned Items count: " . count($invRes['data']['return_items']) . "\n";
echo "Credit Note No: " . ($invRes['data']['credit_note']['credit_note_no'] ?? 'N/A') . "\n";
assert(count($invRes['data']['items']) === 1, "Expected 1 dispensed item");
assert(count($invRes['data']['return_items']) === 1, "Expected 1 return item");
// 6. Test Standalone Return (Pure Refund)
$pureReturnPayload = [
'store_id' => 1,
'patient_name' => 'Refund Patient',
'patient_mobile' => '9876543210',
'refund_mode' => 'Cash',
'items' => [],
'return_items' => [
[
'item_id' => 3,
'batch_id' => (int)$doloBatch['batch_id'],
'sell_unit' => 'Tablet',
'qty' => 5,
'ref_sale_id' => $sale1Id,
'ref_invoice_no' => $sale1Inv,
'return_condition' => 'RESTOCKED',
'return_reason' => 'Patient discharged early'
]
]
];
$pureReturnRes = makePost($baseUrl . 'sales/save', $pureReturnPayload);
echo "\nPure Standalone Return Response:\n";
echo "HTTP Code: " . $pureReturnRes['code'] . "\n";
print_r($pureReturnRes['data']);
assert($pureReturnRes['data']['refund_amount'] > 0, "Expected refund_amount > 0");
assert($pureReturnRes['data']['net_amount'] == 0, "Expected net_amount == 0 for pure refund");
$db->close();
echo "\nALL TESTS PASSED SUCCESSFULLY!\n";

View File

@ -0,0 +1,26 @@
<?php
$mysqli = new mysqli('localhost', 'root', '', 'hms_data_ci4');
if ($mysqli->connect_error) die($mysqli->connect_error);
echo "=== SALE RECORD ===\n";
$res = $mysqli->query("SELECT * FROM mst_sales ORDER BY sale_id DESC LIMIT 1");
$sale = $res->fetch_assoc();
print_r($sale);
echo "\n=== SALE ITEMS ===\n";
$iRes = $mysqli->query("SELECT * FROM mst_sales_items WHERE sale_id = {$sale['sale_id']}");
while ($row = $iRes->fetch_assoc()) {
print_r($row);
}
echo "\n=== CREDIT NOTE RECORD ===\n";
$cnRes = $mysqli->query("SELECT * FROM mst_sale_returns WHERE new_sale_id = {$sale['sale_id']}");
while ($row = $cnRes->fetch_assoc()) {
print_r($row);
}
echo "\n=== STOCK AUDIT TRAIL ===\n";
$audRes = $mysqli->query("SELECT * FROM mst_stock_audit WHERE remarks LIKE '%{$sale['invoice_no']}%'");
while ($row = $audRes->fetch_assoc()) {
echo "Audit ID: {$row['audit_id']} | Item: {$row['item_id']} | Var: {$row['variation_qty']} | Rate: {$row['rate']} | Remarks: {$row['remarks']}\n";
}

View File

@ -0,0 +1,137 @@
-- =========================================================================
-- Medical Store: B2C Sales Returns & Same-Invoice Exchanges (Marg ERP Standard)
-- Target: Remote Server MySQL / MariaDB Database Migration
-- Date: 2026-09-08
-- =========================================================================
-- 1. Add Return and Refund tracking columns to mst_sales (if not exists)
SET @dbname = DATABASE();
-- mst_sales.return_amount
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales' AND COLUMN_NAME = 'return_amount');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales` ADD COLUMN `return_amount` DECIMAL(12,2) DEFAULT 0.00 AFTER `gross_amount`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- mst_sales.is_exchange_bill
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales' AND COLUMN_NAME = 'is_exchange_bill');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales` ADD COLUMN `is_exchange_bill` TINYINT(1) DEFAULT 0 AFTER `return_amount`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- mst_sales.refund_amount
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales' AND COLUMN_NAME = 'refund_amount');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales` ADD COLUMN `refund_amount` DECIMAL(12,2) DEFAULT 0.00 AFTER `net_amount`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- mst_sales.refund_mode
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales' AND COLUMN_NAME = 'refund_mode');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales` ADD COLUMN `refund_mode` VARCHAR(50) NULL AFTER `refund_amount`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- mst_sales.refund_ref_no
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales' AND COLUMN_NAME = 'refund_ref_no');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales` ADD COLUMN `refund_ref_no` VARCHAR(100) NULL AFTER `refund_mode`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 2. Add Return Item tracking columns to mst_sales_items (if not exists)
-- mst_sales_items.item_type
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales_items' AND COLUMN_NAME = 'item_type');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales_items` ADD COLUMN `item_type` ENUM(\'SALE\', \'RETURN\') NOT NULL DEFAULT \'SALE\' AFTER `qty`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- mst_sales_items.return_condition
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales_items' AND COLUMN_NAME = 'return_condition');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales_items` ADD COLUMN `return_condition` ENUM(\'RESTOCKED\', \'DAMAGED\', \'EXPIRED\') DEFAULT \'RESTOCKED\' AFTER `item_type`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- mst_sales_items.ref_sale_id
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales_items' AND COLUMN_NAME = 'ref_sale_id');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales_items` ADD COLUMN `ref_sale_id` INT UNSIGNED NULL AFTER `return_condition`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- mst_sales_items.ref_invoice_no
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales_items' AND COLUMN_NAME = 'ref_invoice_no');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales_items` ADD COLUMN `ref_invoice_no` VARCHAR(50) NULL AFTER `ref_sale_id`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- mst_sales_items.return_reason
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales_items' AND COLUMN_NAME = 'return_reason');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales_items` ADD COLUMN `return_reason` VARCHAR(255) NULL AFTER `ref_invoice_no`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- mst_sales_items.is_restocked
SET @col_exists = (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @dbname AND TABLE_NAME = 'mst_sales_items' AND COLUMN_NAME = 'is_restocked');
SET @sql = IF(@col_exists = 0, 'ALTER TABLE `mst_sales_items` ADD COLUMN `is_restocked` TINYINT(1) DEFAULT 1 AFTER `return_reason`', 'SELECT 1');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 3. Create mst_sale_returns (Sequential GST Credit Notes)
CREATE TABLE IF NOT EXISTS `mst_sale_returns` (
`return_id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`store_id` INT UNSIGNED NOT NULL,
`credit_note_no` VARCHAR(50) NOT NULL UNIQUE,
`return_date` DATETIME NOT NULL,
`original_sale_id` INT UNSIGNED NULL,
`original_invoice_no` VARCHAR(50) NULL,
`new_sale_id` INT UNSIGNED NULL,
`patient_id` INT UNSIGNED NULL,
`uhid` VARCHAR(50) NULL,
`patient_name` VARCHAR(150) NULL,
`patient_mobile` VARCHAR(20) NULL,
`return_reason` VARCHAR(255) NULL,
`gross_refund_amount` DECIMAL(12,2) DEFAULT 0.00,
`discount_reversed_amount` DECIMAL(12,2) DEFAULT 0.00,
`taxable_refund_amount` DECIMAL(12,2) DEFAULT 0.00,
`cgst_refund_amount` DECIMAL(12,2) DEFAULT 0.00,
`sgst_refund_amount` DECIMAL(12,2) DEFAULT 0.00,
`igst_refund_amount` DECIMAL(12,2) DEFAULT 0.00,
`round_off` DECIMAL(6,2) DEFAULT 0.00,
`net_refund_amount` DECIMAL(12,2) DEFAULT 0.00,
`refund_mode` VARCHAR(50) DEFAULT 'EXCHANGE_BILL_ADJUSTMENT',
`refund_ref_no` VARCHAR(100) NULL,
`restock_condition` ENUM('RESTOCKED', 'DAMAGED', 'EXPIRED') DEFAULT 'RESTOCKED',
`remarks` TEXT NULL,
`created_by` INT UNSIGNED NOT NULL DEFAULT 1,
`created_at` DATETIME NOT NULL,
KEY `idx_store_date` (`store_id`, `return_date`),
KEY `idx_orig_sale` (`original_sale_id`),
KEY `idx_new_sale` (`new_sale_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 4. Create mst_sale_return_items
CREATE TABLE IF NOT EXISTS `mst_sale_return_items` (
`return_item_id` INT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`return_id` INT UNSIGNED NOT NULL,
`sale_item_id` INT UNSIGNED NULL,
`item_id` INT UNSIGNED NOT NULL,
`batch_id` INT UNSIGNED NOT NULL,
`batch_no` VARCHAR(50) NOT NULL,
`expiry_date` DATE NOT NULL,
`hsn_code` VARCHAR(20) NULL,
`units_per_pack` INT NOT NULL DEFAULT 1,
`return_sell_unit` VARCHAR(20) NOT NULL DEFAULT 'Tablet',
`return_qty` INT NOT NULL DEFAULT 1,
`return_total_units` INT NOT NULL DEFAULT 1,
`unit_price` DECIMAL(10,2) NOT NULL,
`taxable_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`gst_rate` DECIMAL(5,2) NOT NULL DEFAULT 0.00,
`cgst_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`sgst_amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`total_refund_amount` DECIMAL(12,2) NOT NULL,
`return_condition` ENUM('RESTOCKED', 'DAMAGED', 'EXPIRED') DEFAULT 'RESTOCKED',
`is_restocked` TINYINT(1) DEFAULT 1,
`created_at` DATETIME NOT NULL,
KEY `idx_return_id` (`return_id`),
KEY `idx_item_id` (`item_id`),
KEY `idx_batch_id` (`batch_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 5. Seed Account Head 3005: Sales Returns & Customer Refunds
INSERT INTO `mst_account_heads` (`head_code`, `head_name`, `head_type`, `is_system`, `status`, `created_at`)
SELECT '3005', 'Sales Returns & Customer Refunds', 'Expense', 1, 'active', NOW()
WHERE NOT EXISTS (
SELECT 1 FROM `mst_account_heads` WHERE `head_code` = '3005'
);