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:
parent
765286e7fa
commit
d2f069e3c8
|
|
@ -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->get('medical-store/abdm/bundle/(:num)', 'MedicalStoreApi::getAbdmBundle/$1');
|
||||
$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)
|
||||
|
|
|
|||
|
|
@ -596,9 +596,10 @@ class MedicalStoreApi extends BaseController
|
|||
$json = $this->request->getJSON(true) ?: $this->request->getPost();
|
||||
$storeId = (int)($json['store_id'] ?? 1);
|
||||
$items = $json['items'] ?? [];
|
||||
$returnItems = $json['return_items'] ?? [];
|
||||
|
||||
if (empty($items)) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Cart is empty.']);
|
||||
if (empty($items) && empty($returnItems)) {
|
||||
return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Cart is empty. Please add medicines to sell or return.']);
|
||||
}
|
||||
|
||||
// Fetch Store Config
|
||||
|
|
@ -626,6 +627,7 @@ class MedicalStoreApi extends BaseController
|
|||
$today = date('Y-m-d');
|
||||
$preItems = [];
|
||||
|
||||
// 1. Process New Sale Items
|
||||
foreach ($items as $item) {
|
||||
$itemId = (int)$item['item_id'];
|
||||
$batchId = (int)$item['batch_id'];
|
||||
|
|
@ -848,17 +850,240 @@ class MedicalStoreApi extends BaseController
|
|||
->where('batch_id', $pi['batchId'])
|
||||
->set('current_qty', 'current_qty - ' . $pi['totalUnits'], false)
|
||||
->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);
|
||||
$roundOff = round($netRounded - $netBeforeRound, 2);
|
||||
|
||||
$paymentMode = trim($json['payment_mode'] ?? 'Cash'); // Cash, UPI, Card, Mixed, IPD_Credit
|
||||
$cashPaid = (float)($json['cash_paid'] ?? ($paymentMode === 'Cash' ? $netRounded : 0));
|
||||
$upiPaid = (float)($json['upi_paid'] ?? ($paymentMode === 'UPI' ? $netRounded : 0));
|
||||
$cardPaid = (float)($json['card_paid'] ?? ($paymentMode === 'Card' ? $netRounded : 0));
|
||||
$creditAmount = (float)($json['credit_amount'] ?? ($paymentMode === 'IPD_Credit' ? $netRounded : 0));
|
||||
$isExchangeBill = (!empty($validatedReturnItems) ? 1 : 0);
|
||||
$refundAmount = 0.00;
|
||||
$refundMode = null;
|
||||
$refundRefNo = null;
|
||||
|
||||
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'] ?? '');
|
||||
$upiRefNo = trim($json['upi_ref_no'] ?? '');
|
||||
$cardRefNo = trim($json['card_ref_no'] ?? '');
|
||||
|
|
@ -868,7 +1093,6 @@ class MedicalStoreApi extends BaseController
|
|||
$abhaId = trim((string)($json['abha_id'] ?? ''));
|
||||
$abhaAddress = trim((string)($json['abha_address'] ?? ''));
|
||||
|
||||
// If not in payload but patient_id exists, look up patient_master
|
||||
if (($abhaId === '' || $abhaAddress === '') && $patientId > 0) {
|
||||
$pRow = $this->db->table('patient_master')
|
||||
->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');
|
||||
$cleanInv = preg_replace('/[^A-Za-z0-9]/', '', $invoiceNo);
|
||||
$careContextRef = "PHARM-{$cleanPrefix}-{$cleanInv}";
|
||||
$careContextDisplay = "Pharmacy Dispensation - {$invoiceNo}";
|
||||
|
||||
// Build official ABDM FHIR R4 MedicationDispense Document Bundle
|
||||
$fhirBuilder = new \App\Libraries\FhirR4Builder();
|
||||
$patientData = [
|
||||
'id' => $patientId ?: 0,
|
||||
'uhid' => $json['uhid'] ?? '',
|
||||
'p_code' => $json['uhid'] ?? '',
|
||||
'name' => trim($json['patient_name'] ?? 'Patient'),
|
||||
'p_fname' => trim($json['patient_name'] ?? 'Patient'),
|
||||
'phone' => trim($json['patient_mobile'] ?? ''),
|
||||
'mphone1' => trim($json['patient_mobile'] ?? ''),
|
||||
'gender' => trim($json['gender'] ?? 'Male'),
|
||||
'age' => trim($json['age'] ?? '30'),
|
||||
'abha_id' => $abhaId,
|
||||
'abha_address' => $abhaAddress
|
||||
];
|
||||
// Build official ABDM FHIR R4 MedicationDispense Document Bundle (only for sale items if any)
|
||||
$fhirBundleJson = null;
|
||||
if (!empty($validatedItems)) {
|
||||
$fhirBuilder = new \App\Libraries\FhirR4Builder();
|
||||
$patientData = [
|
||||
'id' => $patientId ?: 0,
|
||||
'uhid' => $json['uhid'] ?? '',
|
||||
'p_code' => $json['uhid'] ?? '',
|
||||
'name' => trim($json['patient_name'] ?? 'Patient'),
|
||||
'p_fname' => trim($json['patient_name'] ?? 'Patient'),
|
||||
'phone' => trim($json['patient_mobile'] ?? ''),
|
||||
'mphone1' => trim($json['patient_mobile'] ?? ''),
|
||||
'gender' => trim($json['gender'] ?? 'Male'),
|
||||
'age' => trim($json['age'] ?? '30'),
|
||||
'abha_id' => $abhaId,
|
||||
'abha_address' => $abhaAddress
|
||||
];
|
||||
|
||||
$fhirBundle = $fhirBuilder->buildPharmacyDispenseBundle($patientData, $store, [
|
||||
'doctor_name' => trim($json['doctor_name'] ?? ''),
|
||||
'doctor_reg_no' => trim($json['doctor_reg_no'] ?? ''),
|
||||
'patient_name' => trim($json['patient_name'] ?? 'Patient'),
|
||||
'abha_id' => $abhaId,
|
||||
'abha_address' => $abhaAddress,
|
||||
'abdm_care_context_ref' => $careContextRef,
|
||||
'invoice_no' => $invoiceNo
|
||||
], $validatedItems);
|
||||
$fhirBundle = $fhirBuilder->buildPharmacyDispenseBundle($patientData, $store, [
|
||||
'doctor_name' => trim($json['doctor_name'] ?? ''),
|
||||
'doctor_reg_no' => trim($json['doctor_reg_no'] ?? ''),
|
||||
'patient_name' => trim($json['patient_name'] ?? 'Patient'),
|
||||
'abha_id' => $abhaId,
|
||||
'abha_address' => $abhaAddress,
|
||||
'abdm_care_context_ref' => $careContextRef,
|
||||
'invoice_no' => $invoiceNo
|
||||
], $validatedItems);
|
||||
|
||||
$fhirBundleJson = json_encode($fhirBundle, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
$fhirBundleJson = json_encode($fhirBundle, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
// Create Sale Record
|
||||
$saleData = [
|
||||
|
|
@ -936,15 +1163,20 @@ class MedicalStoreApi extends BaseController
|
|||
'ward_name' => trim($json['ward_name'] ?? ''),
|
||||
'bed_no' => trim($json['bed_no'] ?? ''),
|
||||
'gross_amount' => $grossAmount,
|
||||
'return_amount' => $grossReturnAmount,
|
||||
'is_exchange_bill' => $isExchangeBill,
|
||||
'discount_amount' => $totalDiscount,
|
||||
'bill_discount_type' => $wholeDiscType,
|
||||
'bill_discount_val' => $wholeDiscVal,
|
||||
'taxable_amount' => $taxableTotal,
|
||||
'cgst_amount' => $cgstTotal,
|
||||
'sgst_amount' => $sgstTotal,
|
||||
'igst_amount' => $igstTotal,
|
||||
'taxable_amount' => max(0, $taxableTotal - $returnTaxableTotal),
|
||||
'cgst_amount' => max(0, $cgstTotal - $returnCgstTotal),
|
||||
'sgst_amount' => max(0, $sgstTotal - $returnSgstTotal),
|
||||
'igst_amount' => max(0, $igstTotal - $returnIgstTotal),
|
||||
'round_off' => $roundOff,
|
||||
'net_amount' => $netRounded,
|
||||
'net_amount' => $finalNetPayable,
|
||||
'refund_amount' => $refundAmount,
|
||||
'refund_mode' => $refundMode,
|
||||
'refund_ref_no' => $refundRefNo,
|
||||
'payment_mode' => $paymentMode,
|
||||
'cash_paid' => $cashPaid,
|
||||
'upi_paid' => $upiPaid,
|
||||
|
|
@ -966,7 +1198,7 @@ class MedicalStoreApi extends BaseController
|
|||
'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'])) {
|
||||
$ipdId = (int)$json['ipd_id'];
|
||||
if ($this->db->tableExists('ipd_invoice_item')) {
|
||||
|
|
@ -975,8 +1207,8 @@ class MedicalStoreApi extends BaseController
|
|||
'item_name' => "Pharmacy Medicines ({$invoiceNo})",
|
||||
'item_desc' => "Medicines dispensed from {$store['store_name']}",
|
||||
'item_qty' => 1,
|
||||
'item_price' => $netRounded,
|
||||
'item_amount' => $netRounded,
|
||||
'item_price' => $finalNetPayable,
|
||||
'item_amount' => $finalNetPayable,
|
||||
'insert_date' => date('Y-m-d H:i:s'),
|
||||
]);
|
||||
$saleData['ipd_charge_id'] = $this->db->insertID();
|
||||
|
|
@ -986,8 +1218,8 @@ class MedicalStoreApi extends BaseController
|
|||
$this->db->table('mst_sales')->insert($saleData);
|
||||
$saleId = $this->db->insertID();
|
||||
|
||||
// Queue in hospital central ABDM Gateway Sync Outbox if table exists
|
||||
if ((!empty($abhaAddress) || !empty($patientId)) && $this->db->tableExists('abdm_sync_record')) {
|
||||
// Queue in central ABDM Gateway Sync Outbox if applicable
|
||||
if ($fhirBundleJson && (!empty($abhaAddress) || !empty($patientId)) && $this->db->tableExists('abdm_sync_record')) {
|
||||
$this->db->table('abdm_sync_record')->insert([
|
||||
'local_record_id' => 'mst_sale_' . $saleId,
|
||||
'local_patient_id' => $patientId ?: 0,
|
||||
|
|
@ -1010,6 +1242,7 @@ class MedicalStoreApi extends BaseController
|
|||
$this->db->table('mst_sales_items')->insert([
|
||||
'sale_id' => $saleId,
|
||||
'item_id' => $vItem['item_id'],
|
||||
'item_type' => 'SALE',
|
||||
'batch_id' => $vItem['batch_id'],
|
||||
'batch_no' => $vItem['batch_no'],
|
||||
'expiry_date' => $vItem['expiry_date'],
|
||||
|
|
@ -1035,28 +1268,129 @@ class MedicalStoreApi extends BaseController
|
|||
]);
|
||||
}
|
||||
|
||||
// Post Double-Entry Journal / Ledgers
|
||||
$salesHead = $this->db->table('mst_account_heads')->where('head_code', '3001')->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();
|
||||
// Insert Return Items
|
||||
foreach ($validatedReturnItems as $vri) {
|
||||
$this->db->table('mst_sales_items')->insert([
|
||||
'sale_id' => $saleId,
|
||||
'item_id' => $vri['item_id'],
|
||||
'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
|
||||
if ($cashPaid > 0) {
|
||||
$cashHead = $this->db->table('mst_account_heads')->where('head_code', '1001')->get()->getRowArray();
|
||||
// Standalone / Formal Credit Note Record if returns exist
|
||||
$creditNoteNo = null;
|
||||
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([
|
||||
'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,
|
||||
'reference_type' => 'sales_invoice', 'reference_id' => $saleId, 'narration' => "Cash received for bill $invoiceNo"
|
||||
]);
|
||||
}
|
||||
if ($upiPaid > 0 || $cardPaid > 0) {
|
||||
$bankHead = $this->db->table('mst_account_heads')->where('head_code', '1002')->get()->getRowArray();
|
||||
$refDetails = [];
|
||||
if (!empty($upiRefNo)) $refDetails[] = "UTR: {$upiRefNo}";
|
||||
if (!empty($cardRefNo)) $refDetails[] = "Card Auth: {$cardRefNo}";
|
||||
if (!empty($bankName)) $refDetails[] = "Bank: {$bankName}";
|
||||
if (($upiPaid + $cardPaid) > 0 && $bankHead) {
|
||||
$refDetails = array_filter([$bankName ? "Bank: $bankName" : null, $upiRefNo ? "UPI Ref: $upiRefNo" : null, $cardRefNo ? "Card Auth: $cardRefNo" : null]);
|
||||
$narrationExtra = !empty($refDetails) ? " (" . implode(', ', $refDetails) . ")" : "";
|
||||
$auditRef = !empty($upiRefNo) ? $upiRefNo : $cardRefNo;
|
||||
|
||||
|
|
@ -1071,12 +1405,28 @@ class MedicalStoreApi extends BaseController
|
|||
if ($creditAmount > 0) {
|
||||
$debtorHeadCode = ($paymentMode === 'IPD_Credit') ? '1005' : '1004';
|
||||
$debtorHead = $this->db->table('mst_account_heads')->where('head_code', $debtorHeadCode)->get()->getRowArray();
|
||||
$this->db->table('mst_ledger_entries')->insert([
|
||||
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'),
|
||||
'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 ($debtorHead) {
|
||||
$this->db->table('mst_ledger_entries')->insert([
|
||||
'store_id' => $storeId, 'voucher_no' => $invoiceNo, 'voucher_type' => 'SALE', 'voucher_date' => date('Y-m-d'),
|
||||
'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) {
|
||||
$this->db->table('mst_ledger_entries')->insert([
|
||||
'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
|
||||
if ($salesHead) {
|
||||
// Credit Sales & Output Tax for New Items
|
||||
if ($taxableTotal > 0 && $salesHead) {
|
||||
$this->db->table('mst_ledger_entries')->insert([
|
||||
'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,
|
||||
'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([
|
||||
'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"
|
||||
]);
|
||||
}
|
||||
if ($sgstTotal > 0 && $sgstHead) {
|
||||
if ($netSgst > 0 && $sgstHead) {
|
||||
$this->db->table('mst_ledger_entries')->insert([
|
||||
'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"
|
||||
]);
|
||||
}
|
||||
if ($igstTotal > 0 && $igstHead) {
|
||||
if ($netIgst > 0 && $igstHead) {
|
||||
$this->db->table('mst_ledger_entries')->insert([
|
||||
'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"
|
||||
]);
|
||||
}
|
||||
|
|
@ -1118,15 +1483,21 @@ class MedicalStoreApi extends BaseController
|
|||
$this->db->transComplete();
|
||||
|
||||
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([
|
||||
'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,
|
||||
'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' => [
|
||||
'abha_id' => $abhaId,
|
||||
'abha_address' => $abhaAddress,
|
||||
|
|
@ -1156,9 +1527,19 @@ class MedicalStoreApi extends BaseController
|
|||
->get()
|
||||
->getResultArray();
|
||||
|
||||
// Build HSN summary table for Indian GST invoice standard
|
||||
$hsnSummary = [];
|
||||
$saleItems = [];
|
||||
$returnItems = [];
|
||||
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';
|
||||
$rate = (string)$it['gst_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']);
|
||||
}
|
||||
|
||||
// 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
|
||||
$upiQrString = '';
|
||||
if (!empty($sale['upi_id'])) {
|
||||
|
|
@ -1189,7 +1576,10 @@ class MedicalStoreApi extends BaseController
|
|||
return $this->response->setJSON([
|
||||
'status' => 1,
|
||||
'sale' => $sale,
|
||||
'items' => $items,
|
||||
'items' => $saleItems,
|
||||
'return_items' => $returnItems,
|
||||
'all_items' => $items,
|
||||
'credit_note' => $creditNote,
|
||||
'hsn_summary' => array_values($hsnSummary),
|
||||
'upi_qr_string' => $upiQrString
|
||||
]);
|
||||
|
|
@ -1748,6 +2138,8 @@ class MedicalStoreApi extends BaseController
|
|||
->getResultArray();
|
||||
|
||||
$totalSales = 0;
|
||||
$totalReturns = 0;
|
||||
$totalRefunds = 0;
|
||||
$cashTotal = 0;
|
||||
$upiTotal = 0;
|
||||
$cardTotal = 0;
|
||||
|
|
@ -1757,8 +2149,23 @@ class MedicalStoreApi extends BaseController
|
|||
|
||||
foreach ($sales as $s) {
|
||||
$totalSales += (float)$s['net_amount'];
|
||||
$cashTotal += (float)$s['cash_paid'];
|
||||
$upiTotal += (float)$s['upi_paid'];
|
||||
$retAmt = (float)($s['return_amount'] ?? 0);
|
||||
$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'];
|
||||
$creditTotal += (float)$s['credit_amount'];
|
||||
$cgstTotal += (float)$s['cgst_amount'];
|
||||
|
|
@ -1772,6 +2179,8 @@ class MedicalStoreApi extends BaseController
|
|||
'summary' => [
|
||||
'total_invoices' => count($sales),
|
||||
'total_sales' => round($totalSales, 2),
|
||||
'total_returns' => round($totalReturns, 2),
|
||||
'total_refunds' => round($totalRefunds, 2),
|
||||
'cash_in_drawer' => round($cashTotal, 2),
|
||||
'upi_collection' => round($upiTotal, 2),
|
||||
'card_swipes' => round($cardTotal, 2),
|
||||
|
|
@ -2163,5 +2572,137 @@ class MedicalStoreApi extends BaseController
|
|||
'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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
25
scratch/check_jsx_syntax.js
Normal file
25
scratch/check_jsx_syntax.js
Normal 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.");
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<?php
|
||||
$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()) {
|
||||
echo $row['Field'] . " | " . $row['Type'] . "\n";
|
||||
print_r($row);
|
||||
}
|
||||
$db->close();
|
||||
|
|
|
|||
14
scratch/check_recent_sales.php
Normal file
14
scratch/check_recent_sales.php
Normal 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";
|
||||
}
|
||||
}
|
||||
7
scratch/inspect_sales_schema.php
Normal file
7
scratch/inspect_sales_schema.php
Normal 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();
|
||||
165
scratch/migrate_b2c_returns_and_exchanges.php
Normal file
165
scratch/migrate_b2c_returns_and_exchanges.php
Normal 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";
|
||||
172
scratch/test_b2c_exchange_sale.php
Normal file
172
scratch/test_b2c_exchange_sale.php
Normal 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";
|
||||
26
scratch/verify_sale_24.php
Normal file
26
scratch/verify_sale_24.php
Normal 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";
|
||||
}
|
||||
|
|
@ -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'
|
||||
);
|
||||
Loading…
Reference in New Issue
Block a user