From d2f069e3c8697fc5e6fc160f88a3fd0e3c55f736 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Tue, 8 Sep 2026 05:07:45 +0530 Subject: [PATCH] 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 --- app/Config/Routes.php | 3 + app/Controllers/Api/v1/MedicalStoreApi.php | 703 ++++++++-- ..._CreateMedicalStoreReturnsAndExchanges.php | 467 +++++++ public/App/MedicalStore/index.html | 1185 ++++++++++++++--- scratch/check_jsx_syntax.js | 25 + scratch/check_mst_stores_columns.php | 4 +- scratch/check_recent_sales.php | 14 + scratch/inspect_sales_schema.php | 7 + scratch/migrate_b2c_returns_and_exchanges.php | 165 +++ scratch/test_b2c_exchange_sale.php | 172 +++ scratch/verify_sale_24.php | 26 + ...edical_store_b2c_returns_and_exchanges.sql | 137 ++ 12 files changed, 2672 insertions(+), 236 deletions(-) create mode 100644 app/Database/Migrations/2026-09-08-000001_CreateMedicalStoreReturnsAndExchanges.php create mode 100644 scratch/check_jsx_syntax.js create mode 100644 scratch/check_recent_sales.php create mode 100644 scratch/inspect_sales_schema.php create mode 100644 scratch/migrate_b2c_returns_and_exchanges.php create mode 100644 scratch/test_b2c_exchange_sale.php create mode 100644 scratch/verify_sale_24.php create mode 100644 scripts/sql/2026-09-08_medical_store_b2c_returns_and_exchanges.sql diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 74d2c6a..fd587e7 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -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) diff --git a/app/Controllers/Api/v1/MedicalStoreApi.php b/app/Controllers/Api/v1/MedicalStoreApi.php index 771dcd0..bd0693b 100644 --- a/app/Controllers/Api/v1/MedicalStoreApi.php +++ b/app/Controllers/Api/v1/MedicalStoreApi.php @@ -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 + ]); + } } diff --git a/app/Database/Migrations/2026-09-08-000001_CreateMedicalStoreReturnsAndExchanges.php b/app/Database/Migrations/2026-09-08-000001_CreateMedicalStoreReturnsAndExchanges.php new file mode 100644 index 0000000..26f68d2 --- /dev/null +++ b/app/Database/Migrations/2026-09-08-000001_CreateMedicalStoreReturnsAndExchanges.php @@ -0,0 +1,467 @@ +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; + } + } +} diff --git a/public/App/MedicalStore/index.html b/public/App/MedicalStore/index.html index d4394ff..e1bdbf3 100644 --- a/public/App/MedicalStore/index.html +++ b/public/App/MedicalStore/index.html @@ -747,6 +747,153 @@ function PosCounter({ store, notify }) { const [printFormat, setPrintFormat] = useState('thermal'); // 'thermal', 'a5', 'a4' const [isProcessing, setIsProcessing] = useState(false); + // B2C Sales Return & Same-Invoice Exchange State + const [returnCart, setReturnCart] = useState([]); + const [showReturnModal, setShowReturnModal] = useState(false); + const [returnSearchQuery, setReturnSearchQuery] = useState(''); + const [returnSearchResults, setReturnSearchResults] = useState([]); + const [isSearchingReturn, setIsSearchingReturn] = useState(false); + const [selectedReturnInvoice, setSelectedReturnInvoice] = useState(null); + const [returnDraftItems, setReturnDraftItems] = useState({}); + const [refundMode, setRefundMode] = useState('Cash'); // 'Cash', 'UPI' + const [refundRefNo, setRefundRefNo] = useState(''); + const returnSearchInputRef = useRef(null); + + const openReturnModal = () => { + setShowReturnModal(true); + setTimeout(() => { + if (returnSearchInputRef.current) { + returnSearchInputRef.current.focus(); + returnSearchInputRef.current.select(); + } + }, 120); + }; + + const searchReturnInvoices = async (query = returnSearchQuery) => { + if (!query || query.trim().length < 2) { + notify('Please enter at least 2 characters to search bills.', 'error'); + return; + } + setIsSearchingReturn(true); + setSelectedReturnInvoice(null); + try { + const res = await fetch(API_BASE + `returns/lookup-invoice?store_id=${store.store_id}&q=${encodeURIComponent(query.trim())}`); + const data = await res.json(); + if (data.status) { + setReturnSearchResults(data.results || []); + if (data.results && data.results.length === 1) { + selectReturnInvoice(data.results[0]); + } else if (!data.results || data.results.length === 0) { + notify('No invoices found matching "' + query.trim() + '"', 'error'); + } + } else { + notify(data.message || 'Error looking up invoices', 'error'); + } + } catch (e) { + notify('Invoice search error: ' + e.message, 'error'); + } finally { + setIsSearchingReturn(false); + } + }; + + const selectReturnInvoice = (resObj) => { + setSelectedReturnInvoice(resObj); + const drafts = {}; + if (resObj && resObj.items) { + resObj.items.forEach(it => { + if (it.remaining_units > 0) { + drafts[it.sale_item_id] = { + return_qty: 0, + return_unit: (it.units_per_pack > 1 && it.remaining_strips > 0) ? 'Strip' : 'Tab', + condition: 'GOOD', + reason: 'EXCESS_UNUSED' + }; + } + }); + } + setReturnDraftItems(drafts); + }; + + const updateReturnDraft = (saleItemId, field, val) => { + setReturnDraftItems(prev => ({ + ...prev, + [saleItemId]: { + ...prev[saleItemId], + [field]: val + } + })); + }; + + const addSelectedReturnsToBill = () => { + if (!selectedReturnInvoice) return; + const newReturns = []; + let hasAnyQty = false; + + selectedReturnInvoice.items.forEach(it => { + const draft = returnDraftItems[it.sale_item_id]; + if (draft && Number(draft.return_qty) > 0) { + hasAnyQty = true; + const qty = Number(draft.return_qty); + const upp = Number(it.units_per_pack) || 1; + const totalUnits = draft.return_unit === 'Strip' ? (qty * upp) : qty; + + if (totalUnits > it.remaining_units) { + notify(`Cannot return more than available (${it.remaining_units} tabs) for ${it.item_name}!`, 'error'); + return; + } + + const lineTotal = Number((totalUnits * it.effective_unit_price).toFixed(2)); + const itemData = { + ref_sale_item_id: it.sale_item_id, + ref_sale_id: selectedReturnInvoice.sale.sale_id, + ref_invoice_no: selectedReturnInvoice.sale.invoice_no, + item_id: it.item_id, + item_name: it.item_name, + generic_name: it.generic_name, + batch_id: it.batch_id, + batch_no: it.batch_no, + expiry_date: it.expiry_date, + units_per_pack: upp, + return_qty: qty, + return_unit: draft.return_unit, + return_total_units: totalUnits, + unit_price: it.effective_unit_price, + unit_mrp: it.unit_mrp, + gst_rate: it.gst_rate, + condition: draft.condition || 'GOOD', + reason: draft.reason || 'EXCESS_UNUSED', + line_total: lineTotal + }; + + const existingIdx = returnCart.findIndex(rc => rc.ref_sale_item_id === it.sale_item_id); + if (existingIdx >= 0) { + returnCart[existingIdx] = itemData; + } else { + newReturns.push(itemData); + } + } + }); + + if (!hasAnyQty) { + notify('Please enter quantity > 0 for at least one item to return.', 'error'); + return; + } + + setReturnCart([...returnCart, ...newReturns]); + + // If patient context is currently blank, copy patient from original invoice + if (!selectedPatient && selectedReturnInvoice.sale) { + const s = selectedReturnInvoice.sale; + if (s.patient_name || s.uhid) { + setPatientQuery(s.patient_name + (s.uhid ? ` (${s.uhid})` : '')); + if (s.doctor_name) setDoctorName(s.doctor_name); + } + } + + setShowReturnModal(false); + notify('Added return medicines to current invoice! Adjustments reflected in totals.', 'success'); + }; + // Keyboard Navigation & Focus Refs const medicineSearchRef = useRef(null); const patientSearchRef = useRef(null); @@ -1187,7 +1334,7 @@ function PosCounter({ store, notify }) { setCart(cart.filter((_, i) => i !== idx)); }; - // Calculate totals with whole bill discount + // Calculate totals with whole bill discount and return items const grossTotal = cart.reduce((acc, it) => acc + getItemLineInfo(it).lineGross, 0); const itemDiscountTotal = cart.reduce((acc, it) => acc + getItemDiscount(it), 0); const balanceAfterItemDisc = Math.max(0, grossTotal - itemDiscountTotal); @@ -1204,9 +1351,24 @@ function PosCounter({ store, notify }) { } const discountTotal = itemDiscountTotal + wholeDiscountAmt; - const netBeforeRound = Math.max(0, grossTotal - discountTotal); - const netPayable = Math.round(netBeforeRound); - const roundOff = (netPayable - netBeforeRound).toFixed(2); + const netSalesBeforeReturn = Math.max(0, grossTotal - discountTotal); + + const returnTotal = returnCart.reduce((acc, it) => acc + (Number(it.line_total) || 0), 0); + const netDifference = netSalesBeforeReturn - returnTotal; + + let netPayable = 0; + let refundPayable = 0; + let roundOff = '0.00'; + + if (netDifference >= 0) { + netPayable = Math.round(netDifference); + roundOff = (netPayable - netDifference).toFixed(2); + refundPayable = 0; + } else { + refundPayable = Math.round(Math.abs(netDifference)); + roundOff = (refundPayable - Math.abs(netDifference)).toFixed(2); + netPayable = 0; + } const splitCashNum = Number(splitCash) || 0; const splitUpiNum = Number(splitUpi) || 0; @@ -1214,10 +1376,10 @@ function PosCounter({ store, notify }) { const totalSplitAllocated = splitCashNum + splitUpiNum + splitCardNum; const splitRemaining = Number((netPayable - totalSplitAllocated).toFixed(2)); - // Save and print sale + // Save and print sale or return/exchange bill const processSale = async () => { - if (cart.length === 0) { - notify('Cart is empty!', 'error'); + if (cart.length === 0 && returnCart.length === 0) { + notify('Cart has no items to bill or return!', 'error'); return; } @@ -1231,21 +1393,25 @@ function PosCounter({ store, notify }) { let finalCard = 0; let finalCredit = 0; - if (paymentMode === 'Cash') { - finalCash = netPayable; - } else if (paymentMode === 'UPI') { - finalUpi = netPayable; - } else if (paymentMode === 'Card') { - finalCard = netPayable; - } else if (paymentMode === 'IPD_Credit') { - finalCredit = netPayable; - } else if (paymentMode === 'Mixed') { - finalCash = splitCashNum; - finalUpi = splitUpiNum; - finalCard = splitCardNum; - if (Math.abs(splitRemaining) > 0.01) { - notify(`Split payment total (₹${totalSplitAllocated.toFixed(2)}) does not match Net Payable (₹${netPayable})! Difference: ₹${splitRemaining.toFixed(2)}`, 'error'); - return; + if (refundPayable > 0) { + // Patient receives a refund; no positive payment collected + } else { + if (paymentMode === 'Cash') { + finalCash = netPayable; + } else if (paymentMode === 'UPI') { + finalUpi = netPayable; + } else if (paymentMode === 'Card') { + finalCard = netPayable; + } else if (paymentMode === 'IPD_Credit') { + finalCredit = netPayable; + } else if (paymentMode === 'Mixed') { + finalCash = splitCashNum; + finalUpi = splitUpiNum; + finalCard = splitCardNum; + if (Math.abs(splitRemaining) > 0.01) { + notify(`Split payment total (₹${totalSplitAllocated.toFixed(2)}) does not match Net Payable (₹${netPayable})! Difference: ₹${splitRemaining.toFixed(2)}`, 'error'); + return; + } } } @@ -1266,11 +1432,14 @@ function PosCounter({ store, notify }) { gender: selectedPatient ? selectedPatient.gender : '', doctor_name: doctorName || (selectedPatient?.active_opd?.doctor_name || selectedPatient?.active_ipd?.doctor_name || 'Dr. Consultant'), doctor_reg_no: doctorRegNo, - payment_mode: paymentMode, + payment_mode: refundPayable > 0 ? (refundMode === 'Cash' ? 'Cash' : 'UPI') : paymentMode, cash_paid: finalCash, upi_paid: finalUpi, card_paid: finalCard, credit_amount: finalCredit, + refund_amount: refundPayable, + refund_mode: refundPayable > 0 ? refundMode : '', + refund_ref_no: refundPayable > 0 ? refundRefNo : '', payment_reference: paymentRef || upiRefNo || cardRefNo, bank_name: bankName, upi_ref_no: upiRefNo, @@ -1298,7 +1467,22 @@ function PosCounter({ store, notify }) { discount_pct: discPct, discount_amount: lineDisc }; - }) + }), + return_items: returnCart.map(rc => ({ + ref_sale_item_id: rc.ref_sale_item_id, + ref_sale_id: rc.ref_sale_id, + ref_invoice_no: rc.ref_invoice_no, + item_id: rc.item_id, + batch_id: rc.batch_id, + return_qty: rc.return_qty, + return_unit: rc.return_unit, + units_per_pack: rc.units_per_pack, + return_total_units: rc.return_total_units, + unit_price: rc.unit_price, + return_amount: rc.line_total, + condition: rc.condition || 'GOOD', + reason: rc.reason || 'EXCESS_UNUSED' + })) }; try { @@ -1309,9 +1493,13 @@ function PosCounter({ store, notify }) { }); const data = await res.json(); if (data.status) { - notify(`Invoice ${data.invoice_no} generated successfully! Amount: ₹${data.net_amount}`, 'success'); + const msg = data.refund_amount > 0 + ? `Return Bill ${data.invoice_no} processed! Refund: ₹${data.refund_amount} (${data.refund_mode})` + : `Invoice ${data.invoice_no} generated successfully! Net Amount: ₹${data.net_amount}`; + notify(msg, 'success'); fetchInvoiceDetails(data.sale_id); setCart([]); + setReturnCart([]); setWholeDiscountVal(0); setSplitCash(''); setSplitUpi(''); @@ -1320,9 +1508,10 @@ function PosCounter({ store, notify }) { setBankName(''); setCardRefNo(''); setPaymentRef(''); + setRefundRefNo(''); resetPatient(); } else { - notify(data.message || 'Failed to complete sale.', 'error'); + notify(data.message || 'Failed to complete transaction.', 'error'); } } catch (e) { notify('Sale error: ' + e.message, 'error'); @@ -1346,11 +1535,12 @@ function PosCounter({ store, notify }) { // Global Keyboard Shortcuts (Marg ERP / Desktop Pharmacy Standard) useEffect(() => { const handleGlobalKeyDown = (e) => { - // Dismiss ABDM Modal on Escape - if (showFhirModal) { + // Dismiss Modals on Escape + if (showFhirModal || showReturnModal) { if (e.key === 'Escape') { e.preventDefault(); setShowFhirModal(false); + setShowReturnModal(false); } return; } @@ -1372,6 +1562,13 @@ function PosCounter({ store, notify }) { return; } + // F6 or Alt+R: Jump to Sales Return & Same-Invoice Exchange Desk + if (e.key === 'F6' || (e.altKey && (e.key === 'r' || e.key === 'R'))) { + e.preventDefault(); + openReturnModal(); + return; + } + // F7: Jump to Whole Bill Discount if (e.key === 'F7') { e.preventDefault(); @@ -1396,7 +1593,7 @@ function PosCounter({ store, notify }) { // F9 or Ctrl+Enter or Alt+S: Complete Sale & Print Bill if (e.key === 'F9' || (e.ctrlKey && e.key === 'Enter') || (e.altKey && (e.key === 's' || e.key === 'S'))) { e.preventDefault(); - if (!isProcessing && cart.length > 0) { + if (!isProcessing && (cart.length > 0 || returnCart.length > 0)) { processSale(); } return; @@ -1405,7 +1602,7 @@ function PosCounter({ store, notify }) { window.addEventListener('keydown', handleGlobalKeyDown); return () => window.removeEventListener('keydown', handleGlobalKeyDown); - }, [showFhirModal, paymentMode, selectedPatient, isProcessing, cart, splitRemaining, splitCashNum, splitUpiNum, splitCardNum]); + }, [showFhirModal, showReturnModal, paymentMode, selectedPatient, isProcessing, cart, returnCart, splitRemaining, splitCashNum, splitUpiNum, splitCardNum, refundPayable, refundMode]); return (
@@ -1602,6 +1799,16 @@ function PosCounter({ store, notify }) { F8 Payment + F9 / Ctrl+↵ Bill & Print @@ -1700,13 +1907,31 @@ function PosCounter({ store, notify }) { {/* Billing Cart Table */}
-
-
Dispensing Cart ({cart.length} items)
- {cart.length > 0 && ( - - )} + {(cart.length > 0 || returnCart.length > 0) && ( + + )} +
@@ -1725,11 +1950,11 @@ function PosCounter({ store, notify }) { - {cart.length === 0 ? ( + {cart.length === 0 && returnCart.length === 0 ? ( - Scan barcode or search medicine to add items to bill. + Scan barcode or search medicine to add items, or click Add Return / Exch (Alt+R / F6). ) : ( @@ -1740,7 +1965,7 @@ function PosCounter({ store, notify }) { const lineNet = Math.max(0, lineGross - lineDisc).toFixed(2); const upp = info.upp; return ( - + {idx + 1}
{it.item_name}
@@ -1908,7 +2133,7 @@ function PosCounter({ store, notify }) { ) : it.sell_unit === 'Combo' ? ( ₹{it.unit_mrp} /str ) : ( - ₹{it.unit_mrp} {upp > 1 ? /str : ''} + ₹{it.unit_mrp} {upp > 1 ? /strip : ''} )}
{upp > 1 && ( @@ -1962,7 +2187,64 @@ function PosCounter({ store, notify }) { ); - }) + }).concat( + returnCart.map((rc, rIdx) => ( + + + RET + + +
+ + {rc.item_name} +
+ + Bill: {rc.ref_invoice_no} • {rc.reason ? rc.reason.replace(/_/g, ' ') : 'Return'} + + + + {rc.batch_no} +
Exp: {rc.expiry_date ? rc.expiry_date.substring(0,7) : ''}
+ + + + - {rc.return_qty} {rc.return_unit} + +
+ ({rc.return_total_units} Tabs • {rc.condition === 'GOOD' ? 'Restocking' : 'Discard'}) +
+ + +
+ ₹{rc.unit_price} /tab +
+ {rc.units_per_pack > 1 && ( + + MRP: ₹{rc.unit_mrp} + + )} + + + + {rc.condition === 'GOOD' ? 'Restock' : 'Scrap'} + + + {rc.gst_rate || 12}% + + -₹{Number(rc.line_total).toFixed(2)} + + + + + + )) + ) )} @@ -2101,24 +2383,76 @@ function PosCounter({ store, notify }) {
)} -
- Total Discounts: - -₹{discountTotal.toFixed(2)} -
+ {discountTotal > 0 && ( +
+ Total Discounts: + -₹{discountTotal.toFixed(2)} +
+ )} + + {returnTotal > 0 && ( +
+ Less Return Credit: + -₹{returnTotal.toFixed(2)} +
+ )} +
Round Off: ₹{roundOff}

-
- Net Payable: - ₹{netPayable.toLocaleString('en-IN')} -
+ {refundPayable > 0 ? ( +
+
+ REFUND TO PATIENT: + ₹{refundPayable.toLocaleString('en-IN')} +
+ + Return credit exceeds new items. Refund this net difference from counter cash drawer or UPI. + +
+ +
+ + +
+ {refundMode === 'UPI' && ( + setRefundRefNo(e.target.value)} + /> + )} +
+
+ ) : ( +
+ Net Payable: + ₹{netPayable.toLocaleString('en-IN')} +
+ )} - {/* Payment Mode Selection */} -
- + {/* Payment Mode Selection (Only when patient owes money) */} + {refundPayable <= 0 && ( +
+
+ {paymentMode === 'Mixed' && ( Partial / Split Mode )} @@ -2395,6 +2729,8 @@ function PosCounter({ store, notify }) {
)} +
+ )} {/* Print Format Selector */}
@@ -2425,14 +2761,18 @@ function PosCounter({ store, notify }) {
+ +
+ {/* Search Bar for Past Invoices */} +
+
+ +
+ + setReturnSearchQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + searchReturnInvoices(); + } + }} + /> + +
+
+
+ + {/* Search Results List (if multiple bills match) */} + {!selectedReturnInvoice && returnSearchResults.length > 1 && ( +
+
+ Matching Invoices ({returnSearchResults.length} found) - Select an invoice to process returns: +
+
+ + + + + + + + + + + + + + {returnSearchResults.map((resObj) => { + const returnableCount = (resObj.items || []).filter(it => it.remaining_units > 0).length; + return ( + + + + + + + + + + ); + })} + +
Bill NoDatePatientDoctorOriginal NetReturnable ItemsAction
{resObj.sale.invoice_no}{resObj.sale.sale_date} + {resObj.sale.patient_name} + {resObj.sale.uhid && ({resObj.sale.uhid})} + {resObj.sale.doctor_name || 'N/A'}₹{resObj.sale.net_amount} + 0 ? 'bg-success-subtle text-success' : 'bg-secondary'}`}> + {returnableCount} items eligible + + + +
+
+
+ )} + + {/* Selected Invoice Header Details */} + {selectedReturnInvoice && ( +
+
+
+ {selectedReturnInvoice.sale.invoice_no} + Date: {selectedReturnInvoice.sale.sale_date} + {selectedReturnInvoice.sale.uhid && ( + UHID: {selectedReturnInvoice.sale.uhid} + )} +
+ {returnSearchResults.length > 1 && ( + + )} +
+
+
+
Patient: {selectedReturnInvoice.sale.patient_name}
+
Phone: {selectedReturnInvoice.sale.patient_mobile || 'N/A'}
+
Doctor: {selectedReturnInvoice.sale.doctor_name || 'N/A'}
+
Original Net Paid: ₹{selectedReturnInvoice.sale.net_amount}
+
+
+ + {/* Returnable Items Grid */} +
+ + + + + + + + + + + + + + + + + {selectedReturnInvoice.items.map((it, idx) => { + const draft = returnDraftItems[it.sale_item_id] || { + return_qty: 0, + return_unit: (it.units_per_pack > 1 && it.remaining_strips > 0) ? 'Strip' : 'Tab', + condition: 'GOOD', + reason: 'EXCESS_UNUSED' + }; + const isEligible = it.remaining_units > 0; + const qty = Number(draft.return_qty) || 0; + const upp = Number(it.units_per_pack) || 1; + const totalReturnUnits = draft.return_unit === 'Strip' ? (qty * upp) : qty; + const lineCredit = Number((totalReturnUnits * it.effective_unit_price).toFixed(2)); + const isOverLimit = totalReturnUnits > it.remaining_units; + + return ( + 0 ? 'table-warning' : '')}> + + + + + + + + + + + + ); + })} + +
#Medicine NameBatch / ExpSold / ReturnedEligible UnitsReturn Qty & UnitConditionReasonCredit RateLine Credit
{idx + 1} + {it.item_name} + {it.generic_name &&
{it.generic_name}
} + {upp > 1 &&
1 Strip = {upp} Tabs
} +
+ {it.batch_no} +
Exp: {it.expiry_date ? it.expiry_date.substring(0, 7) : 'N/A'}
+
+
Sold: {it.total_units} tab
+ {Number(it.already_returned_units) > 0 && ( +
+ Returned: {it.already_returned_units} tab +
+ )} +
+ {isEligible ? ( + + {it.remaining_units} Tabs {upp > 1 ? `(${it.remaining_strips} Str + ${it.remaining_loose} Tab)` : ''} + + ) : ( + Fully Returned + )} + + {isEligible ? ( +
+ updateReturnDraft(it.sale_item_id, 'return_qty', e.target.value)} + /> + {upp > 1 && ( + + )} +
+ ) : ( + - + )} + {isOverLimit && ( + Max {it.remaining_units} tabs + )} +
+ {isEligible && qty > 0 ? ( + + ) : ( + - + )} + + {isEligible && qty > 0 ? ( + + ) : ( + - + )} + + ₹{it.effective_unit_price} /tab + {upp > 1 &&
₹{(it.effective_unit_price * upp).toFixed(2)}/str
} +
+ {qty > 0 ? ( + -₹{lineCredit.toFixed(2)} + ) : ( + ₹0.00 + )} +
+
+
+ )} + + {!selectedReturnInvoice && returnSearchResults.length === 0 && ( +
+ +
No Invoice Selected
+
Search by Bill Number, Patient UHID, Name, or Mobile to load returnable items.
+
+ )} +
+
+
+ {selectedReturnInvoice && ( +
+ {(() => { + let totalCredit = 0; + let totalItemsCount = 0; + selectedReturnInvoice.items.forEach(it => { + const draft = returnDraftItems[it.sale_item_id]; + if (draft && Number(draft.return_qty) > 0) { + const qty = Number(draft.return_qty); + const upp = Number(it.units_per_pack) || 1; + const tu = draft.return_unit === 'Strip' ? (qty * upp) : qty; + totalCredit += (tu * it.effective_unit_price); + totalItemsCount++; + } + }); + return ( +
+ Selected Items: {totalItemsCount} + + Total Return Credit: -₹{totalCredit.toFixed(2)} + +
+ ); + })()} +
+ )} +
+
+ + +
+
+ + + + )} ); diff --git a/scratch/check_jsx_syntax.js b/scratch/check_jsx_syntax.js new file mode 100644 index 0000000..72f58b2 --- /dev/null +++ b/scratch/check_jsx_syntax.js @@ -0,0 +1,25 @@ +const fs = require('fs'); +const html = fs.readFileSync('public/App/MedicalStore/index.html', 'utf8'); + +const scriptMatch = html.match(/