feat(medical-store): add supplier ledger, payment tracking, short items book, and purchase orders
This commit is contained in:
parent
62397c5a98
commit
e635e823e5
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,238 @@
|
|||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateSupplierLedgersAndPurchaseOrders extends Migration
|
||||
{
|
||||
public function up()
|
||||
{
|
||||
$db = \Config\Database::connect();
|
||||
|
||||
// 1. Table: mst_supplier_payments
|
||||
if (!$db->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);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user