From e635e823e5b01e166ad08a5cc584287f41a88c28 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Thu, 10 Sep 2026 04:49:00 +0530 Subject: [PATCH] feat(medical-store): add supplier ledger, payment tracking, short items book, and purchase orders --- app/Config/Routes.php | 13 + app/Controllers/Api/v1/MedicalStoreApi.php | 589 +++++- ...CreateSupplierLedgersAndPurchaseOrders.php | 238 +++ public/App/MedicalStore/index.html | 1585 ++++++++++++++++- 4 files changed, 2378 insertions(+), 47 deletions(-) create mode 100644 app/Database/Migrations/2026-09-10-000002_CreateSupplierLedgersAndPurchaseOrders.php diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 2ccdd8d..047c0bf 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -1675,8 +1675,21 @@ $routes->group('api/v1', ['namespace' => 'App\Controllers\Api\v1'], static funct $routes->get('medical-store/suppliers', 'MedicalStoreApi::suppliers'); $routes->post('medical-store/suppliers/save', 'MedicalStoreApi::saveSupplier'); $routes->post('medical-store/suppliers/delete', 'MedicalStoreApi::deleteSupplier'); + $routes->get('medical-store/suppliers/ledger-summary', 'MedicalStoreApi::getSupplierLedgerSummary'); + $routes->get('medical-store/suppliers/ledger/(:num)', 'MedicalStoreApi::getSupplierLedger/$1'); + $routes->post('medical-store/suppliers/record-payment', 'MedicalStoreApi::saveSupplierPayment'); + + // Purchase Orders & Inward Bill + $routes->get('medical-store/purchase-orders', 'MedicalStoreApi::getPurchaseOrders'); + $routes->get('medical-store/purchase-orders/(:num)', 'MedicalStoreApi::getPurchaseOrderDetail/$1'); + $routes->post('medical-store/purchase-orders/save', 'MedicalStoreApi::savePurchaseOrder'); + $routes->post('medical-store/purchase-orders/cancel', 'MedicalStoreApi::cancelPurchaseOrder'); $routes->post('medical-store/purchase/save', 'MedicalStoreApi::savePurchase'); $routes->post('medical-store/purchase/payment', 'MedicalStoreApi::saveSupplierPayment'); + + // Short Items / Deficiency & Reorder Book + $routes->get('medical-store/stock/short-items', 'MedicalStoreApi::getShortItems'); + $routes->post('medical-store/stock/flag-short-item', 'MedicalStoreApi::flagShortItem'); $routes->post('medical-store/transfer/request', 'MedicalStoreApi::requestTransfer'); $routes->post('medical-store/transfer/dispatch', 'MedicalStoreApi::dispatchTransfer'); $routes->post('medical-store/transfer/receive', 'MedicalStoreApi::receiveTransfer'); diff --git a/app/Controllers/Api/v1/MedicalStoreApi.php b/app/Controllers/Api/v1/MedicalStoreApi.php index c541425..1c1ff58 100644 --- a/app/Controllers/Api/v1/MedicalStoreApi.php +++ b/app/Controllers/Api/v1/MedicalStoreApi.php @@ -2245,6 +2245,17 @@ class MedicalStoreApi extends BaseController $this->db->table('mst_purchases')->insert($purchaseData); $purchaseId = $this->db->insertID(); + // If inward bill is created against a Purchase Order, mark PO as received + $poId = (int)($json['po_id'] ?? 0); + if ($poId > 0 && $this->db->tableExists('mst_purchase_orders')) { + $this->db->table('mst_purchase_orders') + ->where('po_id', $poId) + ->update([ + 'status' => 'received', + 'updated_at' => date('Y-m-d H:i:s') + ]); + } + foreach ($purchaseItems as $pi) { $pi['purchase_id'] = $purchaseId; $this->db->table('mst_purchase_items')->insert($pi); @@ -2304,41 +2315,383 @@ class MedicalStoreApi extends BaseController { $json = $this->request->getJSON(true) ?: $this->request->getPost(); $supplierId = (int)($json['supplier_id'] ?? 0); + $purchaseId = (int)($json['purchase_id'] ?? 0); $amount = (float)($json['amount'] ?? 0); - $paymentMode = trim($json['payment_mode'] ?? 'Bank'); // Cash, Bank, Cheque, NEFT + $paymentMode = trim($json['payment_mode'] ?? 'Bank'); // Cash, Bank, Cheque, NEFT, UPI $refNo = trim($json['reference_no'] ?? ''); + $bankName = trim($json['bank_name'] ?? ''); + $remarks = trim($json['remarks'] ?? ''); + $paymentDate = !empty($json['payment_date']) ? trim($json['payment_date']) : date('Y-m-d'); $storeId = (int)($json['store_id'] ?? 1); if ($supplierId <= 0 || $amount <= 0) { - return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Supplier and valid amount required.']); + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Supplier and valid payment amount are required.']); } - $vNo = 'PMT-' . time(); + $vNo = 'PMT-' . date('Ymd') . '-' . rand(1000, 9999); + + $this->db->transStart(); + + // 1. Insert into mst_supplier_payments + $this->db->table('mst_supplier_payments')->insert([ + 'store_id' => $storeId, + 'supplier_id' => $supplierId, + 'purchase_id' => $purchaseId > 0 ? $purchaseId : null, + 'payment_date' => $paymentDate, + 'amount' => $amount, + 'payment_mode' => $paymentMode, + 'reference_no' => $refNo, + 'bank_name' => $bankName, + 'voucher_no' => $vNo, + 'remarks' => $remarks, + 'created_at' => date('Y-m-d H:i:s') + ]); + $paymentId = $this->db->insertID(); + + // 2. Adjust invoice(s) paid_amount and payment_status + if ($purchaseId > 0) { + $inv = $this->db->table('mst_purchases')->where('purchase_id', $purchaseId)->get()->getRowArray(); + if ($inv) { + $newPaid = (float)$inv['paid_amount'] + $amount; + $newStatus = ($newPaid >= (float)$inv['net_amount']) ? 'paid' : 'partially_paid'; + $this->db->table('mst_purchases')->where('purchase_id', $purchaseId)->update([ + 'paid_amount' => $newPaid, + 'payment_status' => $newStatus + ]); + } + } else { + // On-account payment: FIFO allocation against unpaid / partially paid invoices + $remaining = $amount; + $unpaidInvoices = $this->db->table('mst_purchases') + ->where('supplier_id', $supplierId) + ->whereIn('payment_status', ['unpaid', 'partially_paid']) + ->orderBy('invoice_date', 'ASC') + ->orderBy('purchase_id', 'ASC') + ->get() + ->getResultArray(); + + foreach ($unpaidInvoices as $uInv) { + if ($remaining <= 0) break; + $dueOnInv = max(0, (float)$uInv['net_amount'] - (float)$uInv['paid_amount']); + if ($dueOnInv <= 0) continue; + + $alloc = min($remaining, $dueOnInv); + $newPaid = (float)$uInv['paid_amount'] + $alloc; + $newStatus = ($newPaid >= (float)$uInv['net_amount']) ? 'paid' : 'partially_paid'; + + $this->db->table('mst_purchases')->where('purchase_id', $uInv['purchase_id'])->update([ + 'paid_amount' => $newPaid, + 'payment_status' => $newStatus + ]); + + $remaining -= $alloc; + } + } + + // 3. Double-entry ledger in mst_ledger_entries: Dr Sundry Creditor, Cr Cash/Bank $creditorHead = $this->db->table('mst_account_heads')->where('head_code', '2001')->get()->getRowArray(); $sourceHeadCode = ($paymentMode === 'Cash') ? '1001' : '1002'; $sourceHead = $this->db->table('mst_account_heads')->where('head_code', $sourceHeadCode)->get()->getRowArray(); - $this->db->transStart(); + if ($creditorHead) { + $this->db->table('mst_ledger_entries')->insert([ + 'store_id' => $storeId, + 'voucher_no' => $vNo, + 'voucher_type' => 'PAYMENT', + 'voucher_date' => $paymentDate, + 'account_head_id' => $creditorHead['head_id'], + 'debit_amount' => $amount, + 'credit_amount' => 0, + 'reference_type' => 'supplier_payment', + 'reference_id' => $supplierId, + 'narration' => "Payment to Supplier #$supplierId via $paymentMode " . ($refNo ? "Ref: $refNo" : "") + ]); + } - // Dr Creditor (reducing liability) - $this->db->table('mst_ledger_entries')->insert([ - 'store_id' => $storeId, 'voucher_no' => $vNo, 'voucher_type' => 'PAYMENT', 'voucher_date' => date('Y-m-d'), - 'account_head_id' => $creditorHead['head_id'], 'debit_amount' => $amount, 'credit_amount' => 0, - 'reference_type' => 'supplier_payment', 'reference_id' => $supplierId, - 'narration' => "Payment made to Supplier #$supplierId via $paymentMode Ref: $refNo" - ]); - - // Cr Cash / Bank - $this->db->table('mst_ledger_entries')->insert([ - 'store_id' => $storeId, 'voucher_no' => $vNo, 'voucher_type' => 'PAYMENT', 'voucher_date' => date('Y-m-d'), - 'account_head_id' => $sourceHead['head_id'], 'debit_amount' => 0, 'credit_amount' => $amount, - 'reference_type' => 'supplier_payment', 'reference_id' => $supplierId, - 'narration' => "Payment outflow via $paymentMode to Supplier #$supplierId" - ]); + if ($sourceHead) { + $this->db->table('mst_ledger_entries')->insert([ + 'store_id' => $storeId, + 'voucher_no' => $vNo, + 'voucher_type' => 'PAYMENT', + 'voucher_date' => $paymentDate, + 'account_head_id' => $sourceHead['head_id'], + 'debit_amount' => 0, + 'credit_amount' => $amount, + 'reference_type' => 'supplier_payment', + 'reference_id' => $supplierId, + 'narration' => "Payment outflow via $paymentMode to Supplier #$supplierId" + ]); + } $this->db->transComplete(); - return $this->response->setJSON(['status' => 1, 'message' => 'Supplier payment recorded successfully.', 'voucher_no' => $vNo]); + if ($this->db->transStatus() === false) { + return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to record supplier payment.']); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Supplier payment recorded successfully.', + 'voucher_no' => $vNo, + 'payment_id' => $paymentId + ]); + } + + // ========================================================================= + // 5B. PURCHASE ORDERS (PO) + // ========================================================================= + + public function getPurchaseOrders() + { + $storeId = (int)($this->request->getGet('store_id') ?: 0); + $status = trim($this->request->getGet('status') ?: ''); + + $builder = $this->db->table('mst_purchase_orders po') + ->select('po.*, s.supplier_name, s.phone, s.contact_person, s.address, s.gstin') + ->join('mst_suppliers s', 's.supplier_id = po.supplier_id') + ->orderBy('po.po_date', 'DESC') + ->orderBy('po.po_id', 'DESC'); + + if ($storeId > 0) { + $builder->where('po.store_id', $storeId); + } + if (!empty($status) && $status !== 'all') { + $builder->where('po.status', $status); + } + + $orders = $builder->get()->getResultArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'purchase_orders' => $orders + ]); + } + + public function getPurchaseOrderDetail(int $poId) + { + $po = $this->db->table('mst_purchase_orders po') + ->select('po.*, s.supplier_name, s.contact_person, s.phone, s.email, s.address, s.gstin, s.dl_no_20b, s.dl_no_21b') + ->join('mst_suppliers s', 's.supplier_id = po.supplier_id') + ->where('po.po_id', $poId) + ->get() + ->getRowArray(); + + if (!$po) { + return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Purchase order not found.']); + } + + $items = $this->db->table('mst_purchase_order_items') + ->where('po_id', $poId) + ->orderBy('po_item_id', 'ASC') + ->get() + ->getResultArray(); + + return $this->response->setJSON([ + 'status' => 1, + 'purchase_order' => $po, + 'items' => $items + ]); + } + + public function savePurchaseOrder() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $storeId = (int)($json['store_id'] ?? 1); + $supplierId = (int)($json['supplier_id'] ?? 0); + $items = $json['items'] ?? []; + $expectedDate = !empty($json['expected_delivery_date']) ? trim($json['expected_delivery_date']) : date('Y-m-d', strtotime('+3 days')); + $remarks = trim($json['remarks'] ?? ''); + + if ($supplierId <= 0 || empty($items)) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Supplier and order items are required.']); + } + + $poNumber = 'PO-' . date('Ymd') . '-' . rand(1000, 9999); + + $totalEstAmount = 0; + $orderItems = []; + + foreach ($items as $it) { + $itemId = (int)($it['item_id'] ?? 0); + $itemName = trim($it['item_name'] ?? ''); + $orderPacks = max(1, (int)($it['order_packs'] ?? 1)); + $unitsPerPack = max(1, (int)($it['units_per_pack'] ?? 10)); + $estPtr = (float)($it['estimated_ptr'] ?? 0); + $gstRate = (float)($it['gst_rate'] ?? 12.00); + + $lineNet = $orderPacks * $estPtr; + $lineTotal = round($lineNet * (1 + ($gstRate / 100)), 2); + $totalEstAmount += $lineTotal; + + $orderItems[] = [ + 'item_id' => $itemId > 0 ? $itemId : null, + 'item_name' => $itemName, + 'generic_name' => trim($it['generic_name'] ?? ''), + 'category' => trim($it['category'] ?? 'Tablet'), + 'order_packs' => $orderPacks, + 'units_per_pack' => $unitsPerPack, + 'estimated_ptr' => $estPtr, + 'gst_rate' => $gstRate, + 'estimated_amount' => $lineTotal + ]; + } + + $this->db->transStart(); + + $this->db->table('mst_purchase_orders')->insert([ + 'store_id' => $storeId, + 'supplier_id' => $supplierId, + 'po_number' => $poNumber, + 'po_date' => date('Y-m-d'), + 'expected_delivery_date' => $expectedDate, + 'total_items' => count($orderItems), + 'total_estimated_amount' => round($totalEstAmount, 2), + 'status' => 'ordered', + 'remarks' => $remarks, + 'created_at' => date('Y-m-d H:i:s'), + 'updated_at' => date('Y-m-d H:i:s') + ]); + $poId = $this->db->insertID(); + + foreach ($orderItems as &$oi) { + $oi['po_id'] = $poId; + $this->db->table('mst_purchase_order_items')->insert($oi); + } + unset($oi); + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to create purchase order.']); + } + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Purchase order created successfully.', + 'po_id' => $poId, + 'po_number' => $poNumber, + 'total_estimated_amount' => round($totalEstAmount, 2) + ]); + } + + public function cancelPurchaseOrder() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $poId = (int)($json['po_id'] ?? 0); + if ($poId <= 0) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Valid PO ID required.']); + } + + $this->db->table('mst_purchase_orders') + ->where('po_id', $poId) + ->update([ + 'status' => 'cancelled', + 'updated_at' => date('Y-m-d H:i:s') + ]); + + return $this->response->setJSON(['status' => 1, 'message' => 'Purchase order marked as cancelled.']); + } + + // ========================================================================= + // 5C. SHORT ITEMS & DEFICIENCY BOOK + // ========================================================================= + + public function getShortItems() + { + $storeId = (int)($this->request->getGet('store_id') ?: 1); + + // Fetch items with current stock aggregated across batches for this store + $items = $this->db->table('mst_items i') + ->select('i.item_id, i.item_name, i.category, i.unit_pack, i.units_per_pack, i.min_reorder_level, i.drug_schedule, i.hsn_code, i.gst_rate, COALESCE(SUM(s.current_qty), 0) as current_qty') + ->join('mst_stock s', "s.item_id = i.item_id AND s.store_id = {$storeId}", 'left') + ->where('i.is_active', 1) + ->groupBy('i.item_id') + ->having('current_qty <= i.min_reorder_level OR current_qty = 0') + ->orderBy('current_qty', 'ASC') + ->orderBy('i.item_name', 'ASC') + ->get() + ->getResultArray(); + + $shortList = []; + foreach ($items as $it) { + $itemId = (int)$it['item_id']; + $currentQty = (int)$it['current_qty']; + $minReorder = max(1, (int)$it['min_reorder_level']); + $unitsPerPack = max(1, (int)$it['units_per_pack']); + + // Find last purchase details for supplier and PTR + $lastPurchase = $this->db->table('mst_purchase_items pi') + ->select('pi.ptr, pi.mrp, pi.gst_rate, p.supplier_id, p.received_date, s.supplier_name') + ->join('mst_purchases p', 'p.purchase_id = pi.purchase_id') + ->join('mst_suppliers s', 's.supplier_id = p.supplier_id') + ->where('pi.item_id', $itemId) + ->orderBy('p.received_date', 'DESC') + ->orderBy('p.purchase_id', 'DESC') + ->limit(1) + ->get() + ->getRowArray(); + + $lastPtr = (float)($lastPurchase['ptr'] ?? 0); + $lastMrp = (float)($lastPurchase['mrp'] ?? 0); + $lastSupplierId = (int)($lastPurchase['supplier_id'] ?? 0); + $lastSupplierName = $lastPurchase['supplier_name'] ?? 'Not Assigned'; + $gstRate = (float)($lastPurchase['gst_rate'] ?? $it['gst_rate'] ?? 12.00); + + // Suggested pack calculation: reorder up to (min_reorder_level * 2) units + $deficitUnits = max($unitsPerPack, ($minReorder * 2) - $currentQty); + $suggestedPacks = max(1, (int)ceil($deficitUnits / $unitsPerPack)); + + $status = ($currentQty <= 0) ? 'OUT_OF_STOCK' : 'LOW_STOCK'; + + $shortList[] = [ + 'item_id' => $itemId, + 'item_name' => $it['item_name'], + 'category' => $it['category'], + 'unit_pack' => $it['unit_pack'], + 'units_per_pack' => $unitsPerPack, + 'min_reorder_level' => $minReorder, + 'current_qty' => $currentQty, + 'current_packs' => round($currentQty / $unitsPerPack, 1), + 'suggested_packs' => $suggestedPacks, + 'status' => $status, + 'drug_schedule' => $it['drug_schedule'], + 'last_ptr' => $lastPtr, + 'last_mrp' => $lastMrp, + 'gst_rate' => $gstRate, + 'last_supplier_id' => $lastSupplierId, + 'last_supplier_name' => $lastSupplierName, + 'last_purchase_date' => $lastPurchase['received_date'] ?? null + ]; + } + + return $this->response->setJSON([ + 'status' => 1, + 'store_id' => $storeId, + 'count' => count($shortList), + 'short_items' => $shortList + ]); + } + + public function flagShortItem() + { + $json = $this->request->getJSON(true) ?: $this->request->getPost(); + $itemId = (int)($json['item_id'] ?? 0); + $minReorder = (int)($json['min_reorder_level'] ?? 10); + + if ($itemId <= 0) { + return $this->response->setStatusCode(400)->setJSON(['status' => 0, 'message' => 'Valid Item ID is required.']); + } + + $this->db->table('mst_items')->where('item_id', $itemId)->update([ + 'min_reorder_level' => $minReorder + ]); + + return $this->response->setJSON([ + 'status' => 1, + 'message' => 'Item reorder level updated and tracked in Short Book.', + 'item_id' => $itemId, + 'min_reorder_level' => $minReorder + ]); } // ========================================================================= @@ -2516,6 +2869,87 @@ class MedicalStoreApi extends BaseController // 7. INDIAN ACCOUNTING, LEDGERS & GST REPORTS // ========================================================================= + public function getSupplierLedgerSummary() + { + $suppliers = $this->db->table('mst_suppliers') + ->where('is_active', 1) + ->orderBy('supplier_name', 'ASC') + ->get() + ->getResultArray(); + + $result = []; + $totalAllPurchases = 0; + $totalAllPaid = 0; + $totalAllBalance = 0; + $totalPendingBills = 0; + + foreach ($suppliers as $sup) { + $supId = (int)$sup['supplier_id']; + + // Purchases total + $purchases = $this->db->table('mst_purchases') + ->select('SUM(net_amount) as total_purchases, SUM(paid_amount) as total_paid_in_invoices, COUNT(*) as invoice_count, MAX(invoice_date) as last_invoice_date') + ->where('supplier_id', $supId) + ->get() + ->getRowArray(); + + $totalPurchases = (float)($purchases['total_purchases'] ?? 0); + + // Payments total from mst_supplier_payments + $pmts = $this->db->table('mst_supplier_payments') + ->select('SUM(amount) as total_payments, COUNT(*) as payment_count, MAX(payment_date) as last_payment_date') + ->where('supplier_id', $supId) + ->get() + ->getRowArray(); + + $totalPayments = (float)($pmts['total_payments'] ?? 0); + $effectivePaid = max($totalPayments, (float)($purchases['total_paid_in_invoices'] ?? 0)); + $balance = max(0, $totalPurchases - $effectivePaid); + + // Pending invoices count + $pendingCount = $this->db->table('mst_purchases') + ->where('supplier_id', $supId) + ->whereIn('payment_status', ['unpaid', 'partially_paid']) + ->countAllResults(); + + $supData = [ + 'supplier_id' => $supId, + 'supplier_name' => $sup['supplier_name'], + 'contact_person' => $sup['contact_person'] ?? '', + 'phone' => $sup['phone'] ?? '', + 'email' => $sup['email'] ?? '', + 'gstin' => $sup['gstin'] ?? '', + 'city' => $sup['city'] ?? '', + 'total_purchases' => round($totalPurchases, 2), + 'total_paid' => round($effectivePaid, 2), + 'outstanding_balance' => round($balance, 2), + 'invoice_count' => (int)($purchases['invoice_count'] ?? 0), + 'pending_invoices' => $pendingCount, + 'last_purchase_date' => $purchases['last_invoice_date'] ?? null, + 'last_payment_date' => $pmts['last_payment_date'] ?? null, + ]; + + $totalAllPurchases += $totalPurchases; + $totalAllPaid += $effectivePaid; + $totalAllBalance += $balance; + $totalPendingBills += $pendingCount; + + $result[] = $supData; + } + + return $this->response->setJSON([ + 'status' => 1, + 'suppliers' => $result, + 'summary' => [ + 'total_purchases' => round($totalAllPurchases, 2), + 'total_paid' => round($totalAllPaid, 2), + 'outstanding_balance' => round($totalAllBalance, 2), + 'total_pending_bills' => $totalPendingBills, + 'total_suppliers' => count($result) + ] + ]); + } + public function getSupplierLedger(int $supplierId) { $supplier = $this->db->table('mst_suppliers')->where('supplier_id', $supplierId)->get()->getRowArray(); @@ -2523,35 +2957,110 @@ class MedicalStoreApi extends BaseController return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Supplier not found.']); } - // Ledger entries for this supplier - $entries = $this->db->table('mst_ledger_entries le') - ->select('le.*, ah.head_name') - ->join('mst_account_heads ah', 'ah.head_id = le.account_head_id') - ->groupStart() - ->where('le.reference_type', 'purchase_invoice') - ->orWhere('le.reference_type', 'supplier_payment') - ->groupEnd() - ->orderBy('le.voucher_date', 'ASC') - ->orderBy('le.entry_id', 'ASC') + // 1. All invoices + $invoices = $this->db->table('mst_purchases') + ->where('supplier_id', $supplierId) + ->orderBy('invoice_date', 'DESC') + ->orderBy('purchase_id', 'DESC') ->get() ->getResultArray(); - $runningBalance = 0; // Credit positive (Payable) - $statement = []; - foreach ($entries as $e) { - $debit = (float)$e['debit_amount']; - $credit = (float)$e['credit_amount']; - $runningBalance += ($credit - $debit); + $totalPurchases = 0; + $totalInvoicePaid = 0; + $pendingInvoices = []; + foreach ($invoices as &$inv) { + $net = (float)$inv['net_amount']; + $paid = (float)$inv['paid_amount']; + $pending = max(0, $net - $paid); + $inv['pending_amount'] = round($pending, 2); + $totalPurchases += $net; + $totalInvoicePaid += $paid; + if ($pending > 0 || in_array($inv['payment_status'], ['unpaid', 'partially_paid'])) { + $pendingInvoices[] = $inv; + } + } + unset($inv); - $e['balance'] = round($runningBalance, 2); - $statement[] = $e; + // 2. All payments + $payments = $this->db->table('mst_supplier_payments') + ->where('supplier_id', $supplierId) + ->orderBy('payment_date', 'DESC') + ->orderBy('payment_id', 'DESC') + ->get() + ->getResultArray(); + + $totalPaymentsMade = 0; + foreach ($payments as $pmt) { + $totalPaymentsMade += (float)$pmt['amount']; + } + $effectivePaid = max($totalPaymentsMade, $totalInvoicePaid); + $outstandingBalance = max(0, $totalPurchases - $effectivePaid); + + // 3. Chronological Statement (Dr / Cr) + // Purchases are Credit (increases liability), Payments are Debit (reduces liability) + $rawEvents = []; + foreach ($invoices as $inv) { + $rawEvents[] = [ + 'date' => $inv['invoice_date'], + 'sort_order' => 1, + 'type' => 'INVOICE', + 'voucher_no' => $inv['supplier_invoice_no'], + 'particulars' => "Purchase Bill #" . $inv['supplier_invoice_no'] . " (" . $inv['payment_status'] . ")", + 'debit' => 0, + 'credit' => (float)$inv['net_amount'], + 'invoice_id' => (int)$inv['purchase_id'], + 'payment_id' => null, + 'payment_mode'=> null, + 'ref_no' => null + ]; + } + + foreach ($payments as $pmt) { + $rawEvents[] = [ + 'date' => $pmt['payment_date'], + 'sort_order' => 2, + 'type' => 'PAYMENT', + 'voucher_no' => $pmt['voucher_no'] ?: ('PMT-' . $pmt['payment_id']), + 'particulars' => "Payment via " . $pmt['payment_mode'] . ($pmt['reference_no'] ? " (Ref: " . $pmt['reference_no'] . ")" : "") . ($pmt['bank_name'] ? " (" . $pmt['bank_name'] . ")" : ""), + 'debit' => (float)$pmt['amount'], + 'credit' => 0, + 'invoice_id' => (int)($pmt['purchase_id'] ?? 0), + 'payment_id' => (int)$pmt['payment_id'], + 'payment_mode'=> $pmt['payment_mode'], + 'ref_no' => $pmt['reference_no'] + ]; + } + + // Sort chronological (date ASC, invoices before payments on same date) + usort($rawEvents, function ($a, $b) { + $c = strcmp($a['date'], $b['date']); + if ($c !== 0) return $c; + return $a['sort_order'] - $b['sort_order']; + }); + + $runningBalance = 0; + $statement = []; + foreach ($rawEvents as $ev) { + $runningBalance += ($ev['credit'] - $ev['debit']); + $ev['balance'] = round($runningBalance, 2); + $statement[] = $ev; } return $this->response->setJSON([ 'status' => 1, 'supplier' => $supplier, - 'current_balance' => round($runningBalance, 2), - 'statement' => $statement + 'summary' => [ + 'total_purchases' => round($totalPurchases, 2), + 'total_paid' => round($effectivePaid, 2), + 'outstanding_balance' => round($outstandingBalance, 2), + 'total_invoices' => count($invoices), + 'pending_invoices_count' => count($pendingInvoices), + 'pending_amount' => round($outstandingBalance, 2) + ], + 'pending_invoices' => $pendingInvoices, + 'invoices' => $invoices, + 'payments' => $payments, + 'statement' => $statement ]); } diff --git a/app/Database/Migrations/2026-09-10-000002_CreateSupplierLedgersAndPurchaseOrders.php b/app/Database/Migrations/2026-09-10-000002_CreateSupplierLedgersAndPurchaseOrders.php new file mode 100644 index 0000000..f17799a --- /dev/null +++ b/app/Database/Migrations/2026-09-10-000002_CreateSupplierLedgersAndPurchaseOrders.php @@ -0,0 +1,238 @@ +tableExists('mst_supplier_payments')) { + $this->forge->addField([ + 'payment_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'store_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 1, + ], + 'supplier_id' => [ + 'type' => 'INT', + 'constraint' => 11, + ], + 'purchase_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + 'default' => null, + ], + 'payment_date' => [ + 'type' => 'DATE', + ], + 'amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'default' => 0.00, + ], + 'payment_mode' => [ + 'type' => 'VARCHAR', + 'constraint' => 50, + 'default' => 'Bank', + ], + 'reference_no' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + 'default' => null, + ], + 'bank_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + 'default' => null, + ], + 'voucher_no' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + 'default' => null, + ], + 'remarks' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('payment_id', true); + $this->forge->addKey('supplier_id'); + $this->forge->addKey('purchase_id'); + $this->forge->addKey('payment_date'); + $this->forge->createTable('mst_supplier_payments', true); + } + + // 2. Table: mst_purchase_orders + if (!$db->tableExists('mst_purchase_orders')) { + $this->forge->addField([ + 'po_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'store_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 1, + ], + 'supplier_id' => [ + 'type' => 'INT', + 'constraint' => 11, + ], + 'po_number' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + ], + 'po_date' => [ + 'type' => 'DATE', + ], + 'expected_delivery_date' => [ + 'type' => 'DATE', + 'null' => true, + ], + 'total_items' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'total_estimated_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'default' => 0.00, + ], + 'status' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'default' => 'ordered', // draft, ordered, received, cancelled + ], + 'remarks' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_by' => [ + 'type' => 'INT', + 'constraint' => 11, + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('po_id', true); + $this->forge->addKey('po_number'); + $this->forge->addKey('supplier_id'); + $this->forge->addKey('status'); + $this->forge->createTable('mst_purchase_orders', true); + } + + // 3. Table: mst_purchase_order_items + if (!$db->tableExists('mst_purchase_order_items')) { + $this->forge->addField([ + 'po_item_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'po_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + ], + 'item_id' => [ + 'type' => 'INT', + 'constraint' => 11, + ], + 'item_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 200, + ], + 'generic_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 250, + 'null' => true, + ], + 'category' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'order_packs' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 1, + ], + 'units_per_pack' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 10, + ], + 'estimated_ptr' => [ + 'type' => 'DECIMAL', + 'constraint' => '10,2', + 'default' => 0.00, + ], + 'gst_rate' => [ + 'type' => 'DECIMAL', + 'constraint' => '5,2', + 'default' => 12.00, + ], + 'estimated_amount' => [ + 'type' => 'DECIMAL', + 'constraint' => '12,2', + 'default' => 0.00, + ], + ]); + $this->forge->addKey('po_item_id', true); + $this->forge->addKey('po_id'); + $this->forge->addKey('item_id'); + $this->forge->createTable('mst_purchase_order_items', true); + } + + // 4. Ensure index on mst_purchases for payment_status and supplier_id + if ($db->tableExists('mst_purchases')) { + try { + $db->query("ALTER TABLE `mst_purchases` ADD INDEX `idx_mst_purchases_supp_status` (`supplier_id`, `payment_status`)"); + } catch (\Throwable $e) { + // Index may already exist + } + } + } + + public function down() + { + $this->forge->dropTable('mst_purchase_order_items', true); + $this->forge->dropTable('mst_purchase_orders', true); + $this->forge->dropTable('mst_supplier_payments', true); + } +} diff --git a/public/App/MedicalStore/index.html b/public/App/MedicalStore/index.html index fd87a52..9d2767b 100644 --- a/public/App/MedicalStore/index.html +++ b/public/App/MedicalStore/index.html @@ -4067,6 +4067,352 @@ function PurchaseView({ store, notify }) { { item_id: 0, item_name: '', generic_name: '', category: 'Tablet', batch_no: '', expiry_date: '', qty_packs: 1, free_qty_packs: 0, units_per_pack: 10, mrp: 0, ptr: 0, discount_pct: 0, gst_rate: 12, hsn_code: '3004' } ]); + // --- Supplier Ledger & Payments States --- + const [ledgerSummary, setLedgerSummary] = useState([]); + const [ledgerOverall, setLedgerOverall] = useState({ total_purchases: 0, total_paid: 0, outstanding_balance: 0, total_pending_bills: 0, total_suppliers: 0 }); + const [activeLedgerSupplierId, setActiveLedgerSupplierId] = useState(''); + const [activeLedgerDetail, setActiveLedgerDetail] = useState(null); + const [isLoadingLedger, setIsLoadingLedger] = useState(false); + const [showPaymentModal, setShowPaymentModal] = useState(false); + const [isRecordingPayment, setIsRecordingPayment] = useState(false); + const [paymentForm, setPaymentForm] = useState({ + supplier_id: '', + purchase_id: '', + amount: '', + payment_mode: 'Bank', + reference_no: '', + bank_name: '', + payment_date: new Date().toISOString().split('T')[0], + remarks: '' + }); + + // --- Short Items / Deficiency Reorder States --- + const [shortItems, setShortItems] = useState([]); + const [selectedShortItemIds, setSelectedShortItemIds] = useState([]); + const [shortFilter, setShortFilter] = useState('all'); // all, out_of_stock, low_stock + const [shortSearch, setShortSearch] = useState(''); + const [isLoadingShortItems, setIsLoadingShortItems] = useState(false); + const [showQuickReorderModal, setShowQuickReorderModal] = useState(false); + const [quickReorderForm, setQuickReorderForm] = useState({ item_id: '', item_name: '', min_reorder_level: 10 }); + const [isSavingShortItem, setIsSavingShortItem] = useState(false); + + // --- Purchase Orders States --- + const [purchaseOrders, setPurchaseOrders] = useState([]); + const [poStatusFilter, setPoStatusFilter] = useState('all'); + const [isLoadingPOs, setIsLoadingPOs] = useState(false); + const [showPoCreateModal, setShowPoCreateModal] = useState(false); + const [poCreateForm, setPoCreateForm] = useState({ + supplier_id: '', + expected_delivery_date: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + remarks: '', + items: [] + }); + const [isSavingPO, setIsSavingPO] = useState(false); + const [showPoViewModal, setShowPoViewModal] = useState(false); + const [activeViewPo, setActiveViewPo] = useState(null); + const [loadedPoId, setLoadedPoId] = useState(null); + const [loadedPoNumber, setLoadedPoNumber] = useState(''); + + // --- Data Fetching & Action Handlers --- + const fetchLedgerSummary = async () => { + setIsLoadingLedger(true); + try { + const res = await fetch(API_BASE + 'suppliers/ledger-summary'); + const data = await res.json(); + if (data.status) { + setLedgerSummary(data.suppliers || []); + if (data.summary) setLedgerOverall(data.summary); + } + } catch (e) { + console.error('fetchLedgerSummary error:', e); + } finally { + setIsLoadingLedger(false); + } + }; + + const fetchSupplierLedger = async (supplierId) => { + if (!supplierId) { + setActiveLedgerDetail(null); + return; + } + setIsLoadingLedger(true); + try { + const res = await fetch(API_BASE + 'suppliers/ledger/' + supplierId); + const data = await res.json(); + if (data.status) { + setActiveLedgerDetail(data); + } else { + notify(data.message || 'Failed to fetch supplier ledger.', 'error'); + } + } catch (e) { + notify('Error fetching ledger: ' + e.message, 'error'); + } finally { + setIsLoadingLedger(false); + } + }; + + const openPaymentModal = (supplierId, purchaseId = 0, defaultAmount = 0) => { + const sId = supplierId ? String(supplierId) : (activeLedgerSupplierId || ''); + setPaymentForm({ + supplier_id: sId, + purchase_id: purchaseId ? String(purchaseId) : '', + amount: defaultAmount > 0 ? defaultAmount : '', + payment_mode: 'Bank', + reference_no: '', + bank_name: '', + payment_date: new Date().toISOString().split('T')[0], + remarks: purchaseId ? ('Payment for invoice #' + purchaseId) : 'On-account settlement' + }); + setShowPaymentModal(true); + }; + + const handleRecordPayment = async (e) => { + if (e) e.preventDefault(); + const supId = Number(paymentForm.supplier_id); + const amt = parseFloat(paymentForm.amount); + if (!supId || isNaN(amt) || amt <= 0) { + notify('Please specify a supplier and valid payment amount.', 'error'); + return; + } + + setIsRecordingPayment(true); + try { + const res = await fetch(API_BASE + 'suppliers/record-payment', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + store_id: store.store_id, + supplier_id: supId, + purchase_id: Number(paymentForm.purchase_id) || 0, + amount: amt, + payment_mode: paymentForm.payment_mode, + reference_no: paymentForm.reference_no, + bank_name: paymentForm.bank_name, + payment_date: paymentForm.payment_date, + remarks: paymentForm.remarks + }) + }); + const data = await res.json(); + if (data.status) { + notify('Payment recorded! Voucher #' + data.voucher_no, 'success'); + setShowPaymentModal(false); + fetchLedgerSummary(); + if (activeLedgerSupplierId && String(activeLedgerSupplierId) === String(supId)) { + fetchSupplierLedger(supId); + } + } else { + notify(data.message || 'Failed to record payment.', 'error'); + } + } catch (err) { + notify('Payment error: ' + err.message, 'error'); + } finally { + setIsRecordingPayment(false); + } + }; + + const fetchShortItems = async () => { + setIsLoadingShortItems(true); + try { + const res = await fetch(API_BASE + 'stock/short-items?store_id=' + store.store_id); + const data = await res.json(); + if (data.status) { + setShortItems(data.short_items || []); + } + } catch (e) { + console.error('fetchShortItems error:', e); + } finally { + setIsLoadingShortItems(false); + } + }; + + const handleSaveShortItemFlag = async (e) => { + if (e) e.preventDefault(); + if (!quickReorderForm.item_id) { + notify('Please select an item to track in Short Book.', 'error'); + return; + } + setIsSavingShortItem(true); + try { + const res = await fetch(API_BASE + 'stock/flag-short-item', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + item_id: Number(quickReorderForm.item_id), + min_reorder_level: Number(quickReorderForm.min_reorder_level) || 10 + }) + }); + const data = await res.json(); + if (data.status) { + notify(data.message || 'Item updated in Short Book!', 'success'); + setShowQuickReorderModal(false); + fetchShortItems(); + } else { + notify(data.message || 'Failed to update reorder level.', 'error'); + } + } catch (err) { + notify('Error: ' + err.message, 'error'); + } finally { + setIsSavingShortItem(false); + } + }; + + const fetchPurchaseOrders = async () => { + setIsLoadingPOs(true); + try { + const res = await fetch(API_BASE + 'purchase-orders?store_id=' + store.store_id + '&status=' + poStatusFilter); + const data = await res.json(); + if (data.status) { + setPurchaseOrders(data.purchase_orders || []); + } + } catch (e) { + console.error('fetchPurchaseOrders error:', e); + } finally { + setIsLoadingPOs(false); + } + }; + + const openCreatePoFromShortItems = () => { + const selected = shortItems.filter(it => selectedShortItemIds.includes(it.item_id)); + if (selected.length === 0) { + notify('Please select at least one item from the short list.', 'warning'); + return; + } + + const defaultSupplier = selected.find(s => s.last_supplier_id > 0)?.last_supplier_id || (suppliers[0]?.supplier_id || ''); + + setPoCreateForm({ + supplier_id: defaultSupplier ? String(defaultSupplier) : '', + expected_delivery_date: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString().split('T')[0], + remarks: 'Replenishment purchase order from hospital deficiency book', + items: selected.map(it => ({ + item_id: it.item_id, + item_name: it.item_name, + generic_name: it.generic_name || '', + category: it.category || 'Tablet', + order_packs: it.suggested_packs || 1, + units_per_pack: it.units_per_pack || 10, + estimated_ptr: it.last_ptr || 0, + gst_rate: it.gst_rate || 12 + })) + }); + setShowPoCreateModal(true); + }; + + const handleSavePurchaseOrder = async (e) => { + if (e) e.preventDefault(); + const supId = Number(poCreateForm.supplier_id); + if (!supId || poCreateForm.items.length === 0) { + notify('Supplier and at least one line item are required for PO.', 'error'); + return; + } + + setIsSavingPO(true); + try { + const res = await fetch(API_BASE + 'purchase-orders/save', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + store_id: store.store_id, + supplier_id: supId, + expected_delivery_date: poCreateForm.expected_delivery_date, + remarks: poCreateForm.remarks, + items: poCreateForm.items + }) + }); + const data = await res.json(); + if (data.status) { + notify('Purchase Order ' + data.po_number + ' created successfully!', 'success'); + setShowPoCreateModal(false); + setSelectedShortItemIds([]); + fetchPurchaseOrders(); + setSubTab('purchase_orders'); + } else { + notify(data.message || 'Failed to create PO.', 'error'); + } + } catch (err) { + notify('PO error: ' + err.message, 'error'); + } finally { + setIsSavingPO(false); + } + }; + + const handleViewPo = async (poId) => { + try { + const res = await fetch(API_BASE + 'purchase-orders/' + poId); + const data = await res.json(); + if (data.status) { + setActiveViewPo(data); + setShowPoViewModal(true); + } else { + notify(data.message || 'PO not found', 'error'); + } + } catch (err) { + notify('Error loading PO: ' + err.message, 'error'); + } + }; + + const handleCancelPo = async (poId) => { + if (!confirm('Are you sure you want to cancel this Purchase Order?')) return; + try { + const res = await fetch(API_BASE + 'purchase-orders/cancel', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ po_id: poId }) + }); + const data = await res.json(); + if (data.status) { + notify(data.message || 'PO cancelled', 'success'); + fetchPurchaseOrders(); + } else { + notify(data.message || 'Failed to cancel PO', 'error'); + } + } catch (err) { + notify('Error: ' + err.message, 'error'); + } + }; + + const loadPoIntoInwardBill = async (poId) => { + try { + const res = await fetch(API_BASE + 'purchase-orders/' + poId); + const data = await res.json(); + if (data.status && data.purchase_order) { + const po = data.purchase_order; + const poItems = data.items || []; + setSelectedSupplierId(String(po.supplier_id)); + setLoadedPoId(po.po_id); + setLoadedPoNumber(po.po_number); + setInvoiceNo('INV-' + po.po_number); + + if (poItems.length > 0) { + setRows(poItems.map(it => ({ + item_id: it.item_id || 0, + item_name: it.item_name, + generic_name: it.generic_name || '', + category: it.category || 'Tablet', + batch_no: '', + expiry_date: '', + qty_packs: it.order_packs || 1, + free_qty_packs: 0, + units_per_pack: it.units_per_pack || 10, + mrp: it.estimated_ptr ? Math.round(Number(it.estimated_ptr) * 1.35) : 0, + ptr: Number(it.estimated_ptr) || 0, + discount_pct: 0, + gst_rate: Number(it.gst_rate) || 12, + hsn_code: '3004' + }))); + } + + setSubTab('inward'); + setShowPoViewModal(false); + notify('Loaded ' + poItems.length + ' items from ' + po.po_number + '. Verify batch & expiry to record inward bill.', 'info'); + } else { + notify('Could not load PO details.', 'error'); + } + } catch (err) { + notify('Error loading PO: ' + err.message, 'error'); + } + }; + useEffect(() => { fetchSuppliers(); fetchMasterSupportData(); @@ -4375,13 +4721,16 @@ function PurchaseView({ store, notify }) { supplier_id: Number(selectedSupplierId), supplier_invoice_no: invoiceNo, invoice_date: invoiceDate, + po_id: loadedPoId || undefined, items: itemsPayload }) }); const data = await res.json(); if (data.status) { - notify('Purchase inward recorded! Supplier Ledger & Stock updated.', 'success'); + notify('Purchase inward recorded! Stock & Supplier Ledger updated.' + (loadedPoNumber ? (' (PO ' + loadedPoNumber + ' marked as received)') : ''), 'success'); setInvoiceNo(''); + setLoadedPoId(null); + setLoadedPoNumber(''); setRows([{ item_id: 0, item_name: '', generic_name: '', category: 'Tablet', batch_no: '', expiry_date: '', qty_packs: 1, free_qty_packs: 0, units_per_pack: 10, mrp: 0, ptr: 0, discount_pct: 0, gst_rate: 12, hsn_code: '3004' }]); } else { notify(data.message || 'Failed to save purchase bill.', 'error'); @@ -4393,21 +4742,53 @@ function PurchaseView({ store, notify }) { return (
-
- - - + + +
{subTab === 'inward' && (
-
Distributor Purchase Inward (Stock Entry)
+
+
Distributor Purchase Inward (Stock Entry)
+
+ {loadedPoNumber ? ( +
+ + Linked PO: {loadedPoNumber} + +
+ ) : ( + + )} +
+
@@ -4813,6 +5194,665 @@ function PurchaseView({ store, notify }) {
)} + {/* ================================================================= */} + {/* SUB-TAB: SUPPLIER LEDGER & PAYMENTS */} + {/* ================================================================= */} + {subTab === 'ledger' && ( +
+ {/* KPI Cards Row */} +
+
+
+
+
+ Total Inward Purchases +

₹{Number(ledgerOverall.total_purchases || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}

+
+
+ +
+
+
+
+
+
+
+
+ Total Payments Settled +

₹{Number(ledgerOverall.total_paid || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}

+
+
+ +
+
+
+
+
+
+
+
+ Balance Outstanding +

₹{Number(ledgerOverall.outstanding_balance || 0).toLocaleString('en-IN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}

+
+
+ +
+
+
+
+
+
+
+
+ Pending Invoices +

{ledgerOverall.total_pending_bills || 0} Bills

+
+
+ +
+
+
+
+
+ + {/* Filter & Action Toolbar */} +
+
+ + + {activeLedgerSupplierId && ( + + )} +
+
+ + +
+
+ + {/* VIEW A: Summary Table of All Suppliers */} + {!activeLedgerSupplierId && ( +
+ + + + + + + + + + + + + + + {isLoadingLedger ? ( + + ) : ledgerSummary.length === 0 ? ( + + ) : ( + ledgerSummary.map((sup, idx) => ( + + + + + + + + + + + )) + )} + +
#Distributor / AgencyGSTIN & PhoneTotal PurchasesTotal PaidOutstanding DuePending BillsActions
Loading ledger summary...
No suppliers found.
{idx + 1} +
{sup.supplier_name}
+ {sup.contact_person && {sup.contact_person}} +
+
{sup.gstin || 'No GSTIN'}
+ {sup.phone && {sup.phone}} +
₹{Number(sup.total_purchases || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })}₹{Number(sup.total_paid || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })} + 0 ? 'bg-danger-subtle text-danger border border-danger-subtle fs-6' : 'bg-success-subtle text-success border border-success-subtle'} px-2.5 py-1.5`}> + ₹{Number(sup.outstanding_balance || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })} + + + {sup.pending_invoices > 0 ? ( + {sup.pending_invoices} Unpaid + ) : ( + All Settled + )} + +
+ + +
+
+
+ )} + + {/* VIEW B: Detailed Ledger Statement for Selected Supplier */} + {activeLedgerSupplierId && activeLedgerDetail && ( +
+ {/* Supplier Profile Banner */} +
+
+
+
+ + {activeLedgerDetail.supplier.supplier_name} +
+
+ GSTIN: {activeLedgerDetail.supplier.gstin || 'N/A'} + DL No: {activeLedgerDetail.supplier.dl_no_20b || activeLedgerDetail.supplier.dl_no_21b || 'N/A'} + Phone: {activeLedgerDetail.supplier.phone || 'N/A'} + Credit Days: {activeLedgerDetail.supplier.credit_days || 30} Days +
+
+
+ Net Outstanding Balance + 0 ? 'bg-danger text-white fs-5' : 'bg-success text-white fs-5'} px-3 py-2`}> + ₹{Number(activeLedgerDetail.summary.outstanding_balance || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })} + +
+
+
+ + {/* Section 1: Pending Unpaid / Partially Paid Invoices */} +
+
+
+ Pending Invoices ({activeLedgerDetail.pending_invoices?.length || 0}) +
+ Invoices awaiting full or partial payment +
+
+ + + + + + + + + + + + + + {(!activeLedgerDetail.pending_invoices || activeLedgerDetail.pending_invoices.length === 0) ? ( + + ) : ( + activeLedgerDetail.pending_invoices.map((inv) => ( + + + + + + + + + + )) + )} + +
Bill / Invoice NoInvoice DateBill AmountPaid AmountPending AmountStatusAction
All purchase bills for this supplier are fully paid.
{inv.supplier_invoice_no}{inv.invoice_date}₹{Number(inv.net_amount).toFixed(2)}₹{Number(inv.paid_amount).toFixed(2)}₹{Number(inv.pending_amount).toFixed(2)} + + {inv.payment_status === 'partially_paid' ? 'Partially Paid' : 'Unpaid'} + + + +
+
+
+ + {/* Section 2: Chronological Running Ledger Statement */} +
+
+
+ Complete Ledger Statement & Running Balance +
+ + Total Transactions: {activeLedgerDetail.statement?.length || 0} + +
+
+ + + + + + + + + + + + + + {(!activeLedgerDetail.statement || activeLedgerDetail.statement.length === 0) ? ( + + ) : ( + activeLedgerDetail.statement.map((row, idx) => ( + + + + + + + + + + )) + )} + + {activeLedgerDetail.statement?.length > 0 && ( + + + + + + + + + )} +
DateVoucher / RefTypeParticulars & DetailsDebit (₹ Payments)Credit (₹ Inward)Balance (₹ Payable)
No transactions recorded for this supplier.
{row.date}{row.voucher_no} + + {row.type === 'INVOICE' ? 'Purchase Bill' : 'Payment'} + + {row.particulars} + {row.debit > 0 ? ('₹' + Number(row.debit).toFixed(2)) : '-'} + + {row.credit > 0 ? ('₹' + Number(row.credit).toFixed(2)) : '-'} + + 0 ? 'text-danger' : 'text-success'}> + ₹{Number(row.balance).toFixed(2)} + +
Total Summary:₹{Number(activeLedgerDetail.summary.total_paid || 0).toFixed(2)}₹{Number(activeLedgerDetail.summary.total_purchases || 0).toFixed(2)}₹{Number(activeLedgerDetail.summary.outstanding_balance || 0).toFixed(2)}
+
+
+
+ )} +
+ )} + + {/* ================================================================= */} + {/* SUB-TAB: SHORT ITEMS / DEFICIENCY BOOK & PO GENERATOR */} + {/* ================================================================= */} + {subTab === 'short_items' && ( +
+
+
+
+ Short Items & Deficiency Reorder Book +
+ Real-time inventory shortage detection. Select items to generate consolidated Purchase Orders in 1-click. +
+
+ + +
+
+ + {/* Short Items Filter Bar */} +
+
+ + + +
+
+ + setShortSearch(e.target.value)} + /> + {shortSearch && ( + + )} +
+
+ + {/* Short Items Table */} + {(() => { + const filtered = shortItems.filter(it => { + if (shortFilter === 'out_of_stock' && it.status !== 'OUT_OF_STOCK') return false; + if (shortFilter === 'low_stock' && it.status !== 'LOW_STOCK') return false; + if (shortSearch.trim()) { + const q = shortSearch.toLowerCase(); + return (it.item_name && it.item_name.toLowerCase().includes(q)) || + (it.category && it.category.toLowerCase().includes(q)) || + (it.last_supplier_name && it.last_supplier_name.toLowerCase().includes(q)); + } + return true; + }); + + const allSelected = filtered.length > 0 && filtered.every(it => selectedShortItemIds.includes(it.item_id)); + + return ( +
+ + + + + + + + + + + + + + + + {isLoadingShortItems ? ( + + ) : filtered.length === 0 ? ( + + ) : ( + filtered.map((it) => { + const isSelected = selectedShortItemIds.includes(it.item_id); + return ( + + + + + + + + + + + + ); + }) + )} + +
+ { + if (e.target.checked) { + setSelectedShortItemIds(filtered.map(it => it.item_id)); + } else { + setSelectedShortItemIds([]); + } + }} + /> + Medicine / FormulationStock StatusCurrent StockMin ReorderSuggested OrderEstimated PTRLast SupplierAction
Checking stock levels...
No medicines currently below reorder levels. Inventory is well-stocked!
+ { + if (e.target.checked) { + setSelectedShortItemIds([...selectedShortItemIds, it.item_id]); + } else { + setSelectedShortItemIds(selectedShortItemIds.filter(id => id !== it.item_id)); + } + }} + /> + +
{it.item_name}
+
+ {it.category || 'Tablet'} + {it.unit_pack || (it.units_per_pack + ' Units/Pack')} + {it.drug_schedule && {it.drug_schedule}} +
+
+ {it.status === 'OUT_OF_STOCK' ? ( + + Out of Stock + + ) : ( + + Low Stock + + )} + + {it.current_qty} + ({it.current_packs} packs) + + {it.min_reorder_level} units + +
+ { + const val = Math.max(1, parseInt(e.target.value) || 1); + setShortItems(shortItems.map(si => si.item_id === it.item_id ? { ...si, suggested_packs: val } : si)); + }} + /> + Pks +
+
+ {it.last_ptr > 0 ? ('₹' + Number(it.last_ptr).toFixed(2)) : -} + +
+ {it.last_supplier_name} +
+ {it.last_purchase_date && Last: {it.last_purchase_date}} +
+ +
+
+ ); + })()} +
+ )} + + {/* ================================================================= */} + {/* SUB-TAB: PURCHASE ORDERS DIRECTORY */} + {/* ================================================================= */} + {subTab === 'purchase_orders' && ( +
+
+
+
+ Hospital Purchase Orders Directory +
+ Track order status, print official supplier order formats, and load directly into inward bills. +
+
+ + +
+
+ + {/* PO Status Filter Bar */} +
+ + + + +
+ + {/* PO Table */} +
+ + + + + + + + + + + + + + + {isLoadingPOs ? ( + + ) : purchaseOrders.length === 0 ? ( + + ) : ( + purchaseOrders.map((po) => ( + + + + + + + + + + + )) + )} + +
PO NumberPO DateExpected DeliverySupplier / DistributorItemsEst. Total AmountStatusActions
Loading purchase orders...
No purchase orders found. Click "New Purchase Order" or generate from Short Items.
{po.po_number}{po.po_date}{po.expected_delivery_date || -} +
{po.supplier_name}
+ {po.phone && {po.phone}} +
{po.total_items}₹{Number(po.total_estimated_amount || 0).toLocaleString('en-IN', { minimumFractionDigits: 2 })} + {po.status === 'ordered' && ( + + Ordered + + )} + {po.status === 'received' && ( + + Received + + )} + {po.status === 'cancelled' && ( + Cancelled + )} + +
+ + {po.status === 'ordered' && ( + <> + + + + )} +
+
+
+
+ )} + {/* Supplier Add / Edit Modal */} {showSupplierModal && (
@@ -5199,6 +6239,537 @@ function PurchaseView({ store, notify }) {
)} + + {/* ================================================================= */} + {/* MODAL: RECORD SUPPLIER PAYMENT */} + {/* ================================================================= */} + {showPaymentModal && ( +
+
+
+
+
+
+ +
+
+
Record Supplier Payment Voucher
+ Outflow accounting against invoice or on-account +
+
+ +
+ +
+
+
+
+
+ + +
+ +
+ + setPaymentForm({ ...paymentForm, amount: e.target.value })} + required + autoFocus + /> +
+ +
+ + +
+ +
+ + setPaymentForm({ ...paymentForm, payment_date: e.target.value })} + required + /> +
+ +
+ + setPaymentForm({ ...paymentForm, reference_no: e.target.value })} + /> +
+ +
+ + setPaymentForm({ ...paymentForm, bank_name: e.target.value })} + /> +
+ +
+ + setPaymentForm({ ...paymentForm, remarks: e.target.value })} + /> +
+
+
+
+ +
+ + +
+
+
+
+
+ )} + + {/* ================================================================= */} + {/* MODAL: CREATE PURCHASE ORDER */} + {/* ================================================================= */} + {showPoCreateModal && ( +
+
+
+
+
+
+ +
+
+
Generate New Purchase Order (PO)
+ Order requisition for pharmaceutical distributor +
+
+ +
+ +
+
+ {/* Header Info */} +
+
+
+ + +
+
+ + setPoCreateForm({ ...poCreateForm, expected_delivery_date: e.target.value })} + required + /> +
+
+ + setPoCreateForm({ ...poCreateForm, remarks: e.target.value })} + /> +
+
+
+ + {/* Order Items Table */} +
+
+
+ Ordered Items ({poCreateForm.items.length}) +
+ +
+ +
+ + + + + + + + + + + + + + + {poCreateForm.items.map((it, idx) => { + const lineTotal = Number(it.order_packs || 0) * Number(it.estimated_ptr || 0) * (1 + (Number(it.gst_rate || 0) / 100)); + return ( + + + + + + + + + + + ); + })} + + + + + + + + +
Medicine NameCategoryPacksUnits/PackEst. PTR (₹)GST%Est. Total (₹)
+ { + const items = [...poCreateForm.items]; + items[idx].item_name = e.target.value; + setPoCreateForm({ ...poCreateForm, items }); + }} + required + /> + + { + const items = [...poCreateForm.items]; + items[idx].category = e.target.value; + setPoCreateForm({ ...poCreateForm, items }); + }} + /> + + { + const items = [...poCreateForm.items]; + items[idx].order_packs = Math.max(1, parseInt(e.target.value) || 1); + setPoCreateForm({ ...poCreateForm, items }); + }} + required + /> + + { + const items = [...poCreateForm.items]; + items[idx].units_per_pack = Math.max(1, parseInt(e.target.value) || 1); + setPoCreateForm({ ...poCreateForm, items }); + }} + /> + + { + const items = [...poCreateForm.items]; + items[idx].estimated_ptr = parseFloat(e.target.value) || 0; + setPoCreateForm({ ...poCreateForm, items }); + }} + /> + + + + ₹{lineTotal.toFixed(2)} + + {poCreateForm.items.length > 1 && ( + + )} +
Estimated Total Amount: + ₹{poCreateForm.items.reduce((sum, it) => sum + (Number(it.order_packs || 0) * Number(it.estimated_ptr || 0) * (1 + (Number(it.gst_rate || 0) / 100))), 0).toFixed(2)} +
+
+
+
+ +
+ + +
+
+
+
+
+ )} + + {/* ================================================================= */} + {/* MODAL: VIEW & PRINT PURCHASE ORDER */} + {/* ================================================================= */} + {showPoViewModal && activeViewPo && ( +
+
+
+
+
+
+ +
+
+
Purchase Order Document: {activeViewPo.purchase_order.po_number}
+ Status: {activeViewPo.purchase_order.status} +
+
+ +
+ +
+ {/* Print Header */} +
+
+

{store.store_name}

+
{store.building_name} | {store.room_no}
+
Hospital Pharmacy & Medical Supplies Dept.
+
+
+
PURCHASE ORDER
+
{activeViewPo.purchase_order.po_number}
+
Date: {activeViewPo.purchase_order.po_date}
+
Delivery: {activeViewPo.purchase_order.expected_delivery_date || 'Prompt'}
+
+
+ + {/* Vendor Card */} +
+
+
+ Vendor / Supplier: +
{activeViewPo.purchase_order.supplier_name}
+ {activeViewPo.purchase_order.address &&
{activeViewPo.purchase_order.address}
} +
GSTIN: {activeViewPo.purchase_order.gstin || 'N/A'}
+
Phone: {activeViewPo.purchase_order.phone || 'N/A'}
+
+
+
+
+ Ship To / Delivery Destination: +
{store.store_name}
+
{store.building_name}, {store.floor_no}
+
Remarks: {activeViewPo.purchase_order.remarks || 'Supply per hospital contract terms'}
+
+
+
+ + {/* Items Table */} +
+ + + + + + + + + + + + + + + {activeViewPo.items?.map((it, idx) => ( + + + + + + + + + + + ))} + + + + + + + +
#Item Name & DescriptionCategoryOrdered PacksUnits/PackEst. PTRGST%Est. Amount
{idx + 1} +
{it.item_name}
+ {it.generic_name && {it.generic_name}} +
{it.category || 'Tablet'}{it.order_packs}{it.units_per_pack}₹{Number(it.estimated_ptr).toFixed(2)}{it.gst_rate}%₹{Number(it.estimated_amount).toFixed(2)}
Total Estimated Value:₹{Number(activeViewPo.purchase_order.total_estimated_amount).toFixed(2)}
+
+ + {/* Signatures */} +
+
+
+ Prepared By / Pharmacist +
+
+
+ Authorized Signatory +
+
+
+ +
+ +
+ {activeViewPo.purchase_order.status === 'ordered' && ( + + )} + +
+
+
+
+
+ )}
); }