diff --git a/WORKLOG_COPILOT.md b/WORKLOG_COPILOT.md
index 4d28dd0..94bdb7c 100644
--- a/WORKLOG_COPILOT.md
+++ b/WORKLOG_COPILOT.md
@@ -2,6 +2,38 @@
Date: 2026-02-08
+---
+
+## Storestock Module — 2026-04-19
+
+Ported Hospital Stock Management (Storestock) module from CI3 to CI4.
+
+### Files created
+- `app/Controllers/Storestock.php` — full port: index, indent_list, getIndentTable, new_indent, indent_create, indent_show, indent_items_list, add_item (type 1/2/3), get_drug, final_invoice, print_single_indent, store_stock, store_stock_result, get_product_stock, report_2, main_store
+- `app/Views/storestock/dashboard.php`
+- `app/Views/storestock/indent_list.php` — jQuery DataTable server-side, CI4 CSRF
+- `app/Views/storestock/new_indent.php` — location + employee indent creation
+- `app/Views/storestock/indent_edit.php` — add/update/delete items via AJAX, jQuery autocomplete for drug search
+- `app/Views/storestock/indent_item_list.php` — AJAX partial; qty edit, remove buttons
+- `app/Views/storestock/final_invoice.php` — read-only summary + print link
+- `app/Views/storestock/store_stock_search.php` — search form with schedule filter
+- `app/Views/storestock/stock_search_result.php` — result table + modal for batch detail
+- `app/Views/storestock/stock_item_history.php` — batch-wise purchase detail table
+- `app/Views/storestock/day_report.php` — date range stub
+- `app/Views/storestock/main_store_dashboard.php` — main store nav panel
+- `app/Views/storestock/store_print.php` — mPDF print template
+
+### Routes modified
+- `app/Config/Routes.php` — 17 Storestock routes added after `service('auth')->routes()`
+
+### Key patterns
+- Auth via `ensureStoreAccess()` — checks `hospital_stock.access` permission + group membership
+- `getIndentTable()` uses parameterised queries (no raw user-input interpolation)
+- `add_item()` calls stored procs `p_stock_update_purchase_id_store` + `p_update_med_GST_store`
+- indent_code generated as `S{ym}{padded_id}` after insert
+- Delete archives row to `inv_stock_item_delete` before deleting from `inv_stock_item`
+- `print_single_indent` uses mPDF (Mpdf\Mpdf), no debug toolbar
+
## Summary
Ported OPD Charges, IPD Charges, and Package modules from CI3 to CI4 with consistent NiceAdmin UI, AJAX modal flows, Simple-DataTables, insurance rate handling, and print/Excel exports.
diff --git a/app/Config/AuthGroups.php b/app/Config/AuthGroups.php
index 3340ccb..ee58324 100644
--- a/app/Config/AuthGroups.php
+++ b/app/Config/AuthGroups.php
@@ -73,6 +73,14 @@ class AuthGroups extends ShieldAuthGroups
'title' => 'Storekeeper',
'description' => 'Handles item issuance and stock receiving operations.',
],
+ 'billing_cashier' => [
+ 'title' => 'Billing Cashier',
+ 'description' => 'Submits billing cash statements to accounts.',
+ ],
+ 'accounts_officer' => [
+ 'title' => 'Accounts Officer',
+ 'description' => 'Accepts/verifies billing statements and audits bank records.',
+ ],
'user' => [
'title' => 'User',
'description' => 'General users of the site. Often customers.',
@@ -124,15 +132,13 @@ class AuthGroups extends ShieldAuthGroups
'reports.collection.view' => 'Can view collection reports',
'reports.insurance_credit.view' => 'Can view insurance credit reports',
'reports.nabh_audit.view' => 'Can view NABH audit report',
- 'finance.access' => 'Can access finance module',
- 'finance.vendor.manage' => 'Can manage finance vendors',
- 'finance.po.manage' => 'Can manage purchase orders',
- 'finance.grn.manage' => 'Can manage GRN entries',
- 'finance.invoice.manage' => 'Can manage vendor invoices',
- 'finance.cash.manage' => 'Can manage cash collection and disbursement',
- 'finance.doctor_payout.manage' => 'Can manage doctor payout workflow',
- 'finance.bank_deposit.manage' => 'Can manage bank deposit register',
- 'finance.compliance.view' => 'Can view finance compliance report',
+ 'finance.workflow.view' => 'Can access Accounts and Finance workflow dashboard',
+ 'finance.cash.billing.submit' => 'Can create billing cash entries and submit cash statements',
+ 'finance.cash.accounts.accept' => 'Can accept submitted billing cash statements',
+ 'finance.cash.accounts.verify' => 'Can verify accepted billing cash statements',
+ 'finance.bank.deposit.create' => 'Can create bank deposit register entries',
+ 'finance.bank.audit' => 'Can mark bank transaction audit status',
+ 'finance.bank.statement.update' => 'Can mark updated in bank statement status',
'diagnosis.access' => 'Can access diagnosis module',
'diagnosis.report.view' => 'Can view diagnosis reports',
'doctor_work.access' => 'Can access doctor work module',
@@ -269,6 +275,18 @@ class AuthGroups extends ShieldAuthGroups
'hospital_stock.purchase.manage',
'hospital_stock.report.view',
],
+ 'billing_cashier' => [
+ 'finance.workflow.view',
+ 'finance.cash.billing.submit',
+ 'finance.bank.deposit.create',
+ ],
+ 'accounts_officer' => [
+ 'finance.workflow.view',
+ 'finance.cash.accounts.accept',
+ 'finance.cash.accounts.verify',
+ 'finance.bank.audit',
+ 'finance.bank.statement.update',
+ ],
'user' => [],
'beta' => [
'beta.access',
diff --git a/app/Config/Routes.php b/app/Config/Routes.php
index db62ad3..8215930 100644
--- a/app/Config/Routes.php
+++ b/app/Config/Routes.php
@@ -262,6 +262,12 @@ $routes->get('Finance/scroll_items_table', 'Finance::scrollItemsTable');
$routes->get('Finance/bank_deposits', 'Finance::bankDeposits');
$routes->get('Finance/bank_deposits_table', 'Finance::bankDepositsTable');
$routes->get('Finance/bank_audit', 'Finance::bankAudit');
+$routes->get('Finance/payout/opd-consult', 'Finance::payoutOpdConsult');
+$routes->get('Finance/payout/opd-consult-summary', 'Finance::payoutOpdConsultSummary');
+$routes->get('Finance/payout/opd-consult-drafts-table', 'Finance::payoutOpdConsultDraftsTable');
+$routes->post('Finance/payout/opd-consult-draft-create', 'Finance::payoutOpdConsultDraftCreate');
+$routes->post('Finance/payout/opd-consult-draft-update', 'Finance::payoutOpdConsultDraftUpdate');
+$routes->post('Finance/payout/opd-consult-draft-delete', 'Finance::payoutOpdConsultDraftDelete');
$routes->get('Finance/bank_audit_direct_payments_table', 'Finance::bankAuditDirectPaymentsTable');
$routes->get('Finance/bank_statement_entries_table', 'Finance::bankStatementEntriesTable');
$routes->get('Finance/bank_settlement_entries_table', 'Finance::bankSettlementEntriesTable');
diff --git a/app/Controllers/Finance.php b/app/Controllers/Finance.php
index 4f7bc0e..c552b42 100644
--- a/app/Controllers/Finance.php
+++ b/app/Controllers/Finance.php
@@ -2,6 +2,7 @@
namespace App\Controllers;
+use App\Models\DoctorModel;
use App\Models\FinanceGrnModel;
use App\Models\FinanceCashTransactionModel;
use App\Models\FinanceBankDepositModel;
@@ -1800,6 +1801,384 @@ class Finance extends BaseController
return view('finance/index');
}
+ public function payoutOpdConsult()
+ {
+ if (! $this->canFinanceAny([
+ 'finance.workflow.view',
+ 'finance.cash.billing.submit',
+ 'finance.cash.accounts.accept',
+ 'finance.cash.accounts.verify',
+ 'finance.bank.deposit.create',
+ 'finance.bank.audit',
+ 'finance.bank.statement.update',
+ ])) {
+ return $this->response->setStatusCode(403)->setBody('Access denied');
+ }
+
+ $doctorModel = new DoctorModel();
+ $doctorRows = $doctorModel->getDoctors();
+ usort($doctorRows, static function ($left, $right): int {
+ $leftName = trim((string) (($left->p_title ?? '') . ' ' . ($left->p_fname ?? '')));
+ $rightName = trim((string) (($right->p_title ?? '') . ' ' . ($right->p_fname ?? '')));
+
+ return strcasecmp($leftName, $rightName);
+ });
+
+ $doctorOptions = [];
+ foreach ($doctorRows as $row) {
+ $isActive = property_exists($row, 'active') ? (int) ($row->active ?? 0) : 1;
+ if ($isActive !== 1) {
+ continue;
+ }
+
+ $doctorOptions[] = [
+ 'id' => (int) ($row->id ?? 0),
+ 'name' => trim((string) (($row->p_title ?? '') . ' ' . ($row->p_fname ?? ''))),
+ ];
+ }
+
+ return view('finance/payout_opd_consult', [
+ 'doctor_options' => $doctorOptions,
+ 'state_unit_options' => $this->fetchOpdStateUnitOptions(),
+ ]);
+ }
+
+ public function payoutOpdConsultSummary()
+ {
+ if (! $this->canFinanceAny([
+ 'finance.workflow.view',
+ 'finance.cash.billing.submit',
+ 'finance.cash.accounts.accept',
+ 'finance.cash.accounts.verify',
+ 'finance.bank.deposit.create',
+ 'finance.bank.audit',
+ 'finance.bank.statement.update',
+ ])) {
+ return $this->response->setStatusCode(403)->setBody('Access denied');
+ }
+
+ $fromDate = $this->normalizeDateInput((string) ($this->request->getGet('from_date') ?? ''));
+ $toDate = $this->normalizeDateInput((string) ($this->request->getGet('to_date') ?? ''));
+ $doctorId = (int) ($this->request->getGet('doctor_id') ?? 0);
+ $stateUnit = trim((string) ($this->request->getGet('state_unit') ?? ''));
+
+ if ($fromDate === '') {
+ $fromDate = date('Y-m-01');
+ }
+ if ($toDate === '') {
+ $toDate = date('Y-m-d');
+ }
+ if ($fromDate > $toDate) {
+ [$fromDate, $toDate] = [$toDate, $fromDate];
+ }
+
+ return view('finance/partials/payout_opd_consult_summary', $this->buildOpdConsultPayoutSummaryData($fromDate, $toDate, $doctorId, $stateUnit));
+ }
+
+ public function payoutOpdConsultDraftsTable()
+ {
+ if (! $this->canFinanceAny([
+ 'finance.workflow.view',
+ 'finance.cash.billing.submit',
+ 'finance.cash.accounts.accept',
+ 'finance.cash.accounts.verify',
+ 'finance.bank.deposit.create',
+ 'finance.bank.audit',
+ 'finance.bank.statement.update',
+ 'finance.doctor_payout.manage',
+ ])) {
+ return $this->response->setStatusCode(403)->setBody('Access denied');
+ }
+
+ $fromDate = $this->normalizeDateInput((string) ($this->request->getGet('from_date') ?? ''));
+ $toDate = $this->normalizeDateInput((string) ($this->request->getGet('to_date') ?? ''));
+ $doctorId = (int) ($this->request->getGet('doctor_id') ?? 0);
+ $stateUnit = trim((string) ($this->request->getGet('state_unit') ?? ''));
+
+ if ($fromDate === '') {
+ $fromDate = date('Y-m-01');
+ }
+ if ($toDate === '') {
+ $toDate = date('Y-m-d');
+ }
+ if ($fromDate > $toDate) {
+ [$fromDate, $toDate] = [$toDate, $fromDate];
+ }
+
+ $builder = $this->doctorPayoutModel
+ ->select('finance_doctor_payouts.*, finance_doctor_agreements.doctor_code, finance_doctor_agreements.doctor_name')
+ ->join('finance_doctor_agreements', 'finance_doctor_agreements.id = finance_doctor_payouts.doctor_id', 'left')
+ ->where('finance_doctor_payouts.payout_type', 'consultation')
+ ->where('finance_doctor_payouts.payout_date >=', $fromDate)
+ ->where('finance_doctor_payouts.payout_date <=', $toDate)
+ ->orderBy('finance_doctor_payouts.id', 'DESC');
+
+ if ($doctorId > 0) {
+ $agreementIds = $this->findDoctorAgreementIdsFromMaster($doctorId);
+ if (! empty($agreementIds)) {
+ $builder->whereIn('finance_doctor_payouts.doctor_id', $agreementIds);
+ } else {
+ $builder->where('1 = 0', null, false);
+ }
+ }
+
+ if ($stateUnit !== '') {
+ $builder->like('finance_doctor_payouts.case_reference', '-U' . $this->normalizeStateUnitToken($stateUnit), 'both');
+ }
+
+ return view('finance/partials/payout_opd_consult_drafts_table', [
+ 'rows' => $builder->findAll(30),
+ ]);
+ }
+
+ public function payoutOpdConsultDraftCreate()
+ {
+ if (! $this->canFinanceAny([
+ 'finance.workflow.view',
+ 'finance.cash.billing.submit',
+ 'finance.cash.accounts.accept',
+ 'finance.cash.accounts.verify',
+ 'finance.bank.deposit.create',
+ 'finance.bank.audit',
+ 'finance.bank.statement.update',
+ 'finance.doctor_payout.manage',
+ ])) {
+ return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
+ }
+
+ if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('finance_doctor_payouts')) {
+ return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Payout table not available.']);
+ }
+
+ $doctorId = (int) ($this->request->getPost('doctor_id') ?? 0);
+ $fromDate = $this->normalizeDateInput((string) ($this->request->getPost('from_date') ?? ''));
+ $toDate = $this->normalizeDateInput((string) ($this->request->getPost('to_date') ?? ''));
+ $stateUnit = trim((string) ($this->request->getPost('state_unit') ?? ''));
+ $baseAmount = (float) ($this->request->getPost('base_amount') ?? 0);
+ $doctorShare = (float) ($this->request->getPost('doctor_share') ?? 75);
+ $hospitalShare = (float) ($this->request->getPost('hospital_share') ?? 25);
+ $deductions = (float) ($this->request->getPost('deductions') ?? 0);
+ $adjustments = (float) ($this->request->getPost('adjustments') ?? 0);
+
+ if ($doctorId <= 0) {
+ return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Please select a doctor before creating payout draft.']);
+ }
+
+ if ($fromDate === '') {
+ $fromDate = date('Y-m-01');
+ }
+ if ($toDate === '') {
+ $toDate = date('Y-m-d');
+ }
+ if ($fromDate > $toDate) {
+ [$fromDate, $toDate] = [$toDate, $fromDate];
+ }
+
+ $summaryData = $this->buildOpdConsultPayoutSummaryData($fromDate, $toDate, $doctorId, $stateUnit);
+ $summary = (array) ($summaryData['summary'] ?? []);
+ $eligibleOpdIds = $this->fetchEligibleOpdIdsForPayout($fromDate, $toDate, $doctorId, $stateUnit);
+ $completedOpd = count($eligibleOpdIds);
+ if ($completedOpd <= 0) {
+ return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'No completed OPD found for selected filters.']);
+ }
+
+ if ($baseAmount <= 0) {
+ $baseAmount = (float) ($summary['total_received'] ?? 0);
+ }
+
+ $doctorGross = round(($baseAmount * $doctorShare) / 100, 2);
+ $hospitalGross = round(($baseAmount * $hospitalShare) / 100, 2);
+ $netPayable = round($doctorGross - $deductions + $adjustments, 2);
+ if ($netPayable < 0) {
+ $netPayable = 0.0;
+ }
+
+ $agreementId = $this->resolveOrCreateDoctorAgreementIdFromMaster($doctorId);
+ if ($agreementId <= 0) {
+ return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Unable to map selected doctor to payout agreement.']);
+ }
+
+ $rate = round($netPayable / max(1, $completedOpd), 2);
+ $caseReference = 'OPD-' . str_replace('-', '', $fromDate) . '-' . str_replace('-', '', $toDate) . '-D' . $doctorId;
+ if ($stateUnit !== '') {
+ $caseReference .= '-U' . $this->normalizeStateUnitToken($stateUnit);
+ }
+
+ $remarks = 'OPD payout draft from OPD Consult Payout | Completed=' . $completedOpd
+ . ', Base=' . number_format($baseAmount, 2, '.', '')
+ . ', Doc%=' . number_format($doctorShare, 2, '.', '')
+ . ', Hosp%=' . number_format($hospitalShare, 2, '.', '')
+ . ', Deductions=' . number_format($deductions, 2, '.', '')
+ . ', Adjustments=' . number_format($adjustments, 2, '.', '')
+ . ', DoctorGross=' . number_format($doctorGross, 2, '.', '')
+ . ', HospitalShare=' . number_format($hospitalGross, 2, '.', '')
+ . ', Net=' . number_format($netPayable, 2, '.', '');
+
+ $this->db->transStart();
+
+ $this->doctorPayoutModel->insert([
+ 'payout_date' => $toDate,
+ 'doctor_id' => $agreementId,
+ 'case_reference' => $caseReference,
+ 'payout_type' => 'consultation',
+ 'units' => $completedOpd,
+ 'rate' => $rate,
+ 'calculated_amount' => $netPayable,
+ 'approved_amount' => $netPayable,
+ 'status' => 'draft',
+ 'remarks' => $remarks,
+ 'hr_submitted_by' => $this->currentUserName(),
+ ]);
+
+ $insertId = (int) ($this->doctorPayoutModel->getInsertID() ?? 0);
+
+ if ($insertId > 0 && ! empty($eligibleOpdIds) && method_exists($this->db, 'tableExists') && $this->db->tableExists('opd_master')) {
+ $opdUpdate = [
+ 'payout_draft_id' => $insertId,
+ 'payout_calculated_at' => date('Y-m-d H:i:s'),
+ 'payout_calculated_by' => $this->currentUserName(),
+ ];
+ if ($this->tableHasField('opd_master', 'updated_at')) {
+ $opdUpdate['updated_at'] = date('Y-m-d H:i:s');
+ }
+
+ $this->db->table('opd_master')
+ ->whereIn('opd_id', $eligibleOpdIds)
+ ->update($opdUpdate);
+ }
+
+ $this->db->transComplete();
+
+ if (! $this->db->transStatus() || $insertId <= 0) {
+ return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to create payout draft.']);
+ }
+
+ return $this->response->setJSON([
+ 'status' => 1,
+ 'message' => 'Payout draft created successfully.',
+ 'payout_id' => $insertId,
+ 'case_reference' => $caseReference,
+ 'net_payable' => $netPayable,
+ ]);
+ }
+
+ public function payoutOpdConsultDraftUpdate()
+ {
+ if (! $this->canFinanceAny([
+ 'finance.workflow.view',
+ 'finance.cash.billing.submit',
+ 'finance.cash.accounts.accept',
+ 'finance.cash.accounts.verify',
+ 'finance.bank.deposit.create',
+ 'finance.bank.audit',
+ 'finance.bank.statement.update',
+ 'finance.doctor_payout.manage',
+ ])) {
+ return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
+ }
+
+ $payoutId = (int) ($this->request->getPost('payout_id') ?? 0);
+ $payoutDate = $this->normalizeDateInput((string) ($this->request->getPost('payout_date') ?? ''));
+ $approvedAmount = (float) ($this->request->getPost('approved_amount') ?? 0);
+ $remarks = trim((string) ($this->request->getPost('remarks') ?? ''));
+
+ if ($payoutId <= 0) {
+ return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid payout id.']);
+ }
+
+ $row = $this->doctorPayoutModel->find($payoutId);
+ if (! is_array($row)) {
+ return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Payout draft not found.']);
+ }
+
+ if ((string) ($row['status'] ?? 'draft') !== 'draft') {
+ return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Only draft entries can be edited.']);
+ }
+
+ if ($payoutDate === '') {
+ $payoutDate = (string) ($row['payout_date'] ?? date('Y-m-d'));
+ }
+ if ($approvedAmount < 0) {
+ $approvedAmount = 0;
+ }
+
+ $units = max(1, (int) ($row['units'] ?? 1));
+ $rate = round($approvedAmount / $units, 2);
+
+ $this->doctorPayoutModel->update($payoutId, [
+ 'payout_date' => $payoutDate,
+ 'approved_amount' => round($approvedAmount, 2),
+ 'rate' => $rate,
+ 'remarks' => $remarks !== '' ? $remarks : null,
+ ]);
+
+ return $this->response->setJSON([
+ 'status' => 1,
+ 'message' => 'Payout draft updated successfully.',
+ ]);
+ }
+
+ public function payoutOpdConsultDraftDelete()
+ {
+ if (! $this->canFinanceAny([
+ 'finance.workflow.view',
+ 'finance.cash.billing.submit',
+ 'finance.cash.accounts.accept',
+ 'finance.cash.accounts.verify',
+ 'finance.bank.deposit.create',
+ 'finance.bank.audit',
+ 'finance.bank.statement.update',
+ 'finance.doctor_payout.manage',
+ ])) {
+ return $this->response->setStatusCode(403)->setJSON(['status' => 0, 'message' => 'Access denied']);
+ }
+
+ $payoutId = (int) ($this->request->getPost('payout_id') ?? 0);
+ if ($payoutId <= 0) {
+ return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Invalid payout id.']);
+ }
+
+ $row = $this->doctorPayoutModel->find($payoutId);
+ if (! is_array($row)) {
+ return $this->response->setStatusCode(404)->setJSON(['status' => 0, 'message' => 'Payout draft not found.']);
+ }
+
+ if ((string) ($row['status'] ?? 'draft') !== 'draft') {
+ return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Only draft entries can be deleted.']);
+ }
+
+ $this->db->transStart();
+
+ if (method_exists($this->db, 'tableExists') && $this->db->tableExists('opd_master') && $this->tableHasField('opd_master', 'payout_draft_id')) {
+ $clearData = [
+ 'payout_draft_id' => null,
+ ];
+ if ($this->tableHasField('opd_master', 'payout_calculated_at')) {
+ $clearData['payout_calculated_at'] = null;
+ }
+ if ($this->tableHasField('opd_master', 'payout_calculated_by')) {
+ $clearData['payout_calculated_by'] = null;
+ }
+
+ $this->db->table('opd_master')
+ ->where('payout_draft_id', $payoutId)
+ ->update($clearData);
+ }
+
+ $this->doctorPayoutModel->delete($payoutId);
+
+ $this->db->transComplete();
+
+ if (! $this->db->transStatus()) {
+ return $this->response->setStatusCode(500)->setJSON(['status' => 0, 'message' => 'Failed to delete payout draft.']);
+ }
+
+ return $this->response->setJSON([
+ 'status' => 1,
+ 'message' => 'Payout draft deleted and linked OPD records unlocked for recalculation.',
+ ]);
+ }
+
public function phase2()
{
if (! $this->canFinance('finance.access')) {
@@ -3307,6 +3686,369 @@ class Finance extends BaseController
return $value > 0 ? $value : $default;
}
+ private function buildOpdConsultPayoutSummaryData(string $fromDate, string $toDate, int $doctorId = 0, string $stateUnit = ''): array
+ {
+ $db = \Config\Database::connect();
+ $stateUnitColumn = $this->resolveOpdStateUnitColumn();
+
+ $runningExpr = "(COALESCE(o.running_opd,0)=1 OR o.opd_fee_type=3 OR UPPER(COALESCE(o.opd_fee_desc,'')) LIKE '%RUNNING%')";
+ $newExpr = "(o.opd_fee_type=1 OR UPPER(COALESCE(o.opd_fee_desc,'')) LIKE '%NEW%')";
+ $emergencyExpr = "(UPPER(COALESCE(o.opd_fee_desc,'')) LIKE '%EMERG%')";
+ $routineExpr = "(NOT {$emergencyExpr})";
+ $orgCreditExpr = "(COALESCE(o.insurance_case_id,0) > 0 OR COALESCE(o.insurance_id,0) > 0 OR COALESCE(o.payment_status,0) = 3)";
+
+ $doctorName = 'All Doctors';
+ if ($doctorId > 0) {
+ $doctorRow = $db->table('doctor_master')->select('p_title, p_fname')->where('id', $doctorId)->get()->getRow();
+ if ($doctorRow) {
+ $doctorName = trim((string) (($doctorRow->p_title ?? '') . ' ' . ($doctorRow->p_fname ?? '')));
+ }
+ }
+
+ $opdBuilder = $db->table('opd_master o')
+ ->select('o.doc_id')
+ ->select("COALESCE(NULLIF(TRIM(o.doc_name), ''), CONCAT('Doctor #', o.doc_id)) AS doctor_name", false)
+ ->select('SUM(CASE WHEN o.opd_status IN (1,2) THEN 1 ELSE 0 END) AS completed_opd', false)
+ ->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$routineExpr} THEN 1 ELSE 0 END) AS routine_opd", false)
+ ->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$emergencyExpr} THEN 1 ELSE 0 END) AS emergency_opd", false)
+ ->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$runningExpr} THEN 1 ELSE 0 END) AS running_opd", false)
+ ->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$newExpr} THEN 1 ELSE 0 END) AS new_opd", false)
+ ->select('SUM(CASE WHEN o.opd_status IN (1,2) THEN COALESCE(o.opd_fee_amount,0) ELSE 0 END) AS gross_amount', false)
+ ->select("SUM(CASE WHEN o.opd_status IN (1,2) AND {$orgCreditExpr} THEN COALESCE(o.opd_fee_amount,0) ELSE 0 END) AS org_credit_amount", false)
+ ->where('o.apointment_date >=', $fromDate)
+ ->where('o.apointment_date <=', $toDate)
+ ->whereIn('o.opd_status', [1, 2]);
+
+ if ($this->tableHasField('opd_master', 'payout_draft_id')) {
+ $opdBuilder->groupStart()
+ ->where('o.payout_draft_id IS NULL', null, false)
+ ->orWhere('o.payout_draft_id', 0)
+ ->groupEnd();
+ }
+
+ if ($doctorId > 0) {
+ $opdBuilder->where('o.doc_id', $doctorId);
+ }
+ if ($stateUnit !== '' && $stateUnitColumn !== null) {
+ $opdBuilder->where('o.' . $stateUnitColumn, $stateUnit);
+ }
+
+ $doctorBreakdown = $opdBuilder
+ ->groupBy('o.doc_id, o.doc_name')
+ ->orderBy('doctor_name', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ $summary = [
+ 'completed_opd' => 0,
+ 'routine_opd' => 0,
+ 'emergency_opd' => 0,
+ 'running_opd' => 0,
+ 'new_opd' => 0,
+ 'calculated_opd' => 0,
+ 'gross_amount' => 0.0,
+ 'org_credit_amount' => 0.0,
+ 'doctor_count' => 0,
+ 'approved_consents' => 0,
+ 'payout_count' => 0,
+ 'payout_amount' => 0.0,
+ 'cash_received' => 0.0,
+ 'bank_received' => 0.0,
+ 'total_received' => 0.0,
+ 'credit_amount' => 0.0,
+ ];
+
+ foreach ($doctorBreakdown as &$row) {
+ $row['completed_opd'] = (int) ($row['completed_opd'] ?? 0);
+ $row['routine_opd'] = (int) ($row['routine_opd'] ?? 0);
+ $row['emergency_opd'] = (int) ($row['emergency_opd'] ?? 0);
+ $row['running_opd'] = (int) ($row['running_opd'] ?? 0);
+ $row['new_opd'] = (int) ($row['new_opd'] ?? 0);
+ $row['gross_amount'] = (float) ($row['gross_amount'] ?? 0);
+ $row['org_credit_amount'] = (float) ($row['org_credit_amount'] ?? 0);
+ $row['cash_received'] = 0.0;
+ $row['bank_received'] = 0.0;
+ $row['credit_amount'] = 0.0;
+ $row['payout_amount'] = 0.0;
+
+ $summary['completed_opd'] += $row['completed_opd'];
+ $summary['routine_opd'] += $row['routine_opd'];
+ $summary['emergency_opd'] += $row['emergency_opd'];
+ $summary['running_opd'] += $row['running_opd'];
+ $summary['new_opd'] += $row['new_opd'];
+ $summary['gross_amount'] += $row['gross_amount'];
+ $summary['org_credit_amount'] += $row['org_credit_amount'];
+ }
+ unset($row);
+
+ $summary['doctor_count'] = count($doctorBreakdown);
+
+ if ($this->tableHasField('opd_master', 'payout_draft_id')) {
+ $calcBuilder = $db->table('opd_master o')
+ ->select('COUNT(*) AS calculated_opd', false)
+ ->where('o.apointment_date >=', $fromDate)
+ ->where('o.apointment_date <=', $toDate)
+ ->whereIn('o.opd_status', [1, 2])
+ ->where('o.payout_draft_id >', 0);
+
+ if ($doctorId > 0) {
+ $calcBuilder->where('o.doc_id', $doctorId);
+ }
+ if ($stateUnit !== '' && $stateUnitColumn !== null) {
+ $calcBuilder->where('o.' . $stateUnitColumn, $stateUnit);
+ }
+
+ $calcRow = $calcBuilder->get()->getRowArray();
+ $summary['calculated_opd'] = (int) ($calcRow['calculated_opd'] ?? 0);
+ }
+
+ $collectionBuilder = $db->table('payment_history p')
+ ->select('o.doc_id')
+ ->select('SUM(CASE WHEN p.credit_debit = 0 THEN COALESCE(p.amount,0) ELSE 0 END) AS total_received', false)
+ ->select('SUM(CASE WHEN p.credit_debit = 0 AND p.payment_mode = 1 THEN COALESCE(p.amount,0) ELSE 0 END) AS cash_received', false)
+ ->select('SUM(CASE WHEN p.credit_debit = 0 AND p.payment_mode = 2 THEN COALESCE(p.amount,0) ELSE 0 END) AS bank_received', false)
+ ->join('opd_master o', 'p.payof_type = 1 AND o.opd_id = p.payof_id', 'inner')
+ ->where('DATE(p.payment_date) >=', $fromDate)
+ ->where('DATE(p.payment_date) <=', $toDate);
+
+ if ($doctorId > 0) {
+ $collectionBuilder->where('o.doc_id', $doctorId);
+ }
+ if ($stateUnit !== '' && $stateUnitColumn !== null) {
+ $collectionBuilder->where('o.' . $stateUnitColumn, $stateUnit);
+ }
+
+ $collectionRows = $collectionBuilder->groupBy('o.doc_id')->get()->getResultArray();
+ $collectionMap = [];
+ foreach ($collectionRows as $row) {
+ $docKey = (int) ($row['doc_id'] ?? 0);
+ $collectionMap[$docKey] = [
+ 'total_received' => (float) ($row['total_received'] ?? 0),
+ 'cash_received' => (float) ($row['cash_received'] ?? 0),
+ 'bank_received' => (float) ($row['bank_received'] ?? 0),
+ ];
+ }
+
+ foreach ($doctorBreakdown as &$row) {
+ $docKey = (int) ($row['doc_id'] ?? 0);
+ $received = $collectionMap[$docKey] ?? ['total_received' => 0.0, 'cash_received' => 0.0, 'bank_received' => 0.0];
+ $row['cash_received'] = $received['cash_received'];
+ $row['bank_received'] = $received['bank_received'];
+ $row['total_received'] = $received['total_received'];
+ $row['credit_amount'] = max(0, round($row['gross_amount'] - $row['total_received'], 2));
+
+ $summary['cash_received'] += $row['cash_received'];
+ $summary['bank_received'] += $row['bank_received'];
+ }
+ unset($row);
+
+ $summary['total_received'] = $summary['cash_received'] + $summary['bank_received'];
+ $summary['credit_amount'] = max(0, round($summary['gross_amount'] - $summary['total_received'], 2));
+
+ if ($db->tableExists('abdm_consent_records')) {
+ $consentBuilder = $db->table('abdm_consent_records acr')
+ ->select('COUNT(DISTINCT acr.id) AS approved_consents', false)
+ ->join('opd_master o', 'o.p_id = acr.patient_id', 'inner')
+ ->where('o.apointment_date >=', $fromDate)
+ ->where('o.apointment_date <=', $toDate)
+ ->whereIn('o.opd_status', [1, 2])
+ ->where('acr.consent_status', 'approved');
+
+ if ($doctorId > 0) {
+ $consentBuilder->where('o.doc_id', $doctorId);
+ }
+ if ($stateUnit !== '' && $stateUnitColumn !== null) {
+ $consentBuilder->where('o.' . $stateUnitColumn, $stateUnit);
+ }
+
+ $consentRow = $consentBuilder->get()->getRowArray();
+ $summary['approved_consents'] = (int) ($consentRow['approved_consents'] ?? 0);
+ }
+
+ if ($db->tableExists('finance_doctor_payouts')) {
+ $payoutBuilder = $db->table('finance_doctor_payouts p')
+ ->select('COUNT(*) AS payout_count, SUM(COALESCE(p.approved_amount, p.calculated_amount, 0)) AS payout_amount', false)
+ ->where('p.payout_date >=', $fromDate)
+ ->where('p.payout_date <=', $toDate);
+
+ if ($doctorId > 0) {
+ $agreementIds = $this->findDoctorAgreementIdsFromMaster($doctorId);
+ if (! empty($agreementIds)) {
+ $payoutBuilder->whereIn('p.doctor_id', $agreementIds);
+ } else {
+ $payoutBuilder->where('1 = 0', null, false);
+ }
+ }
+
+ $payoutRow = $payoutBuilder->get()->getRowArray();
+ $summary['payout_count'] = (int) ($payoutRow['payout_count'] ?? 0);
+ $summary['payout_amount'] = (float) ($payoutRow['payout_amount'] ?? 0);
+ }
+
+ foreach ($doctorBreakdown as &$row) {
+ $row['payout_amount'] = $summary['doctor_count'] === 1 ? $summary['payout_amount'] : 0.0;
+ }
+ unset($row);
+
+ return [
+ 'summary' => $summary,
+ 'doctor_breakdown' => $doctorBreakdown,
+ 'from_date' => $fromDate,
+ 'to_date' => $toDate,
+ 'doctor_name' => $doctorName,
+ 'state_unit' => $stateUnit,
+ 'state_unit_label' => $stateUnitColumn !== null ? ucwords(str_replace('_', ' ', $stateUnitColumn)) : 'State/Unit',
+ ];
+ }
+
+ private function fetchOpdStateUnitOptions(): array
+ {
+ $column = $this->resolveOpdStateUnitColumn();
+ if ($column === null || ! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('opd_master')) {
+ return [];
+ }
+
+ try {
+ $rows = $this->db->table('opd_master o')
+ ->select('TRIM(o.' . $column . ') AS value', false)
+ ->where('o.' . $column . ' IS NOT NULL', null, false)
+ ->where("TRIM(o." . $column . ") != ''", null, false)
+ ->groupBy('o.' . $column)
+ ->orderBy('value', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ return array_values(array_filter(array_map(static function (array $row): string {
+ return trim((string) ($row['value'] ?? ''));
+ }, $rows), static fn(string $value): bool => $value !== ''));
+ } catch (\Throwable $e) {
+ return [];
+ }
+ }
+
+ private function resolveOpdStateUnitColumn(): ?string
+ {
+ $candidates = ['unit_name', 'unit', 'state_name', 'state', 'branch_name', 'branch', 'center_name', 'location', 'city'];
+ foreach ($candidates as $column) {
+ if ($this->tableHasField('opd_master', $column)) {
+ return $column;
+ }
+ }
+
+ return null;
+ }
+
+ private function normalizeStateUnitToken(string $value): string
+ {
+ return preg_replace('/[^A-Za-z0-9]/', '', strtoupper(trim($value)));
+ }
+
+ private function findDoctorAgreementIdsFromMaster(int $doctorId): array
+ {
+ if ($doctorId <= 0) {
+ return [];
+ }
+
+ $ids = [];
+ $code = 'DM-' . $doctorId;
+ $row = $this->doctorAgreementModel->select('id')->where('doctor_code', $code)->first();
+ if (is_array($row) && (int) ($row['id'] ?? 0) > 0) {
+ $ids[] = (int) ($row['id'] ?? 0);
+ }
+
+ if (empty($ids) && method_exists($this->db, 'tableExists') && $this->db->tableExists('doctor_master')) {
+ $doctor = $this->db->table('doctor_master')->select('p_title, p_fname')->where('id', $doctorId)->get(1)->getRowArray();
+ if (is_array($doctor)) {
+ $doctorName = trim((string) (($doctor['p_title'] ?? '') . ' ' . ($doctor['p_fname'] ?? '')));
+ if ($doctorName !== '') {
+ $nameRows = $this->doctorAgreementModel->select('id')->like('doctor_name', $doctorName, 'both')->findAll();
+ foreach ($nameRows as $item) {
+ $id = (int) ($item['id'] ?? 0);
+ if ($id > 0) {
+ $ids[] = $id;
+ }
+ }
+ }
+ }
+ }
+
+ return array_values(array_unique(array_filter($ids, static fn(int $id): bool => $id > 0)));
+ }
+
+ private function fetchEligibleOpdIdsForPayout(string $fromDate, string $toDate, int $doctorId, string $stateUnit = ''): array
+ {
+ if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('opd_master')) {
+ return [];
+ }
+
+ $builder = $this->db->table('opd_master o')
+ ->select('o.opd_id')
+ ->where('o.apointment_date >=', $fromDate)
+ ->where('o.apointment_date <=', $toDate)
+ ->whereIn('o.opd_status', [1, 2]);
+
+ if ($doctorId > 0) {
+ $builder->where('o.doc_id', $doctorId);
+ }
+
+ $stateUnitColumn = $this->resolveOpdStateUnitColumn();
+ if ($stateUnit !== '' && $stateUnitColumn !== null) {
+ $builder->where('o.' . $stateUnitColumn, $stateUnit);
+ }
+
+ if ($this->tableHasField('opd_master', 'payout_draft_id')) {
+ $builder->groupStart()
+ ->where('o.payout_draft_id IS NULL', null, false)
+ ->orWhere('o.payout_draft_id', 0)
+ ->groupEnd();
+ }
+
+ $rows = $builder->get()->getResultArray();
+
+ return array_values(array_filter(array_map(static fn(array $row): int => (int) ($row['opd_id'] ?? 0), $rows), static fn(int $id): bool => $id > 0));
+ }
+
+ private function resolveOrCreateDoctorAgreementIdFromMaster(int $doctorId): int
+ {
+ if ($doctorId <= 0 || ! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('doctor_master')) {
+ return 0;
+ }
+
+ $doctorRow = $this->db->table('doctor_master')
+ ->select('id, p_title, p_fname')
+ ->where('id', $doctorId)
+ ->get(1)
+ ->getRowArray();
+ if (! is_array($doctorRow)) {
+ return 0;
+ }
+
+ $doctorName = trim((string) (($doctorRow['p_title'] ?? '') . ' ' . ($doctorRow['p_fname'] ?? '')));
+ if ($doctorName === '') {
+ $doctorName = 'Doctor #' . $doctorId;
+ }
+ $doctorCode = 'DM-' . $doctorId;
+
+ $existing = $this->doctorAgreementModel->where('doctor_code', $doctorCode)->first();
+ if (is_array($existing) && (int) ($existing['id'] ?? 0) > 0) {
+ return (int) $existing['id'];
+ }
+
+ $this->doctorAgreementModel->insert([
+ 'doctor_code' => $doctorCode,
+ 'doctor_name' => $doctorName,
+ 'specialization' => null,
+ 'consultation_rate' => 0,
+ 'surgery_rate' => 0,
+ 'agreement_start_date' => null,
+ 'agreement_end_date' => null,
+ 'status' => 1,
+ 'created_by' => $this->currentUserName(),
+ ]);
+
+ return (int) ($this->doctorAgreementModel->getInsertID() ?? 0);
+ }
+
private function normalizeDateInput(string $date): string
{
$date = trim($date);
diff --git a/app/Controllers/Patient.php b/app/Controllers/Patient.php
index 5da9f5c..3ca9a40 100644
--- a/app/Controllers/Patient.php
+++ b/app/Controllers/Patient.php
@@ -731,8 +731,14 @@ class Patient extends BaseController
return $this->response->setStatusCode(404)->setBody('Patient not found');
}
+ $opdFields = $this->db->getFieldNames('opd_master') ?? [];
+ $opdSelect = ['opd_id', 'opd_code', 'doc_name', 'apointment_date'];
+ if (in_array('queue_no', $opdFields, true)) {
+ $opdSelect[] = 'queue_no';
+ }
+
$opdList = $this->db->table('opd_master')
- ->select('opd_id, opd_code, doc_name, apointment_date, queue_no')
+ ->select(implode(', ', $opdSelect))
->where('p_id', $pno)
->orderBy('opd_id', 'DESC')
->get()
@@ -741,6 +747,7 @@ class Patient extends BaseController
$opdIds = array_column($opdList, 'opd_id');
$filesByOpd = [];
$rxByOpd = [];
+ $medicinesBySession = [];
if ($opdIds && $this->db->tableExists('file_upload_data')) {
$fields = $this->db->getFieldNames('file_upload_data') ?? [];
@@ -790,7 +797,7 @@ class Patient extends BaseController
if (in_array('opd_id', $rxFields, true)) {
$selectParts[] = 'opd_id';
}
- foreach (['date_opd_visit', 'queue_no', 'bp', 'diastolic', 'pulse', 'temp', 'spo2', 'complaints', 'diagnosis', 'investigation', 'advice'] as $col) {
+ foreach (['date_opd_visit', 'queue_no', 'bp', 'diastolic', 'pulse', 'temp', 'spo2', 'complaints', 'diagnosis', 'investigation', 'advice', 'next_visit', 'refer_to'] as $col) {
if (in_array($col, $rxFields, true)) {
$selectParts[] = $col;
}
@@ -814,6 +821,55 @@ class Patient extends BaseController
}
}
+ $medicineTable = $this->findExistingTable(['opd_prescrption_prescribed', 'opd_prescription_prescribed']);
+ $rxSessionIds = array_values(array_filter(array_map(static fn(array $row): int => (int) ($row['id'] ?? 0), $rxByOpd), static fn(int $id): bool => $id > 0));
+ if ($medicineTable !== null && $this->db->tableExists($medicineTable) && $rxSessionIds !== []) {
+ $medicineBuilder = $this->db->table($medicineTable . ' pt')
+ ->select('pt.id, pt.opd_pre_id, pt.med_type, pt.med_name, pt.dosage, pt.dosage_when, pt.dosage_freq, pt.dosage_where, pt.no_of_days, pt.qty, pt.remark');
+
+ if ($this->db->tableExists('opd_dose_shed')) {
+ $medicineBuilder->select('d.dose_show_sign AS dose_shed', false)
+ ->join('opd_dose_shed d', 'pt.dosage = d.dose_shed_id', 'left');
+ }
+ if ($this->db->tableExists('opd_dose_when')) {
+ $medicineBuilder->select('dw.dose_sign_desc AS dose_when_label', false)
+ ->join('opd_dose_when dw', 'pt.dosage_when = dw.dose_when_id', 'left');
+ }
+ if ($this->db->tableExists('opd_dose_frequency')) {
+ $medicineBuilder->select('df.dose_sign_desc AS dose_frequency_label', false)
+ ->join('opd_dose_frequency df', 'pt.dosage_freq = df.dose_freq_id', 'left');
+ }
+ if ($this->db->tableExists('opd_dose_where')) {
+ $medicineBuilder->select('d_on.dose_sign_desc AS dose_where_label', false)
+ ->join('opd_dose_where d_on', 'pt.dosage_where = d_on.dose_where_id', 'left');
+ }
+
+ $medicineRows = $medicineBuilder
+ ->whereIn('pt.opd_pre_id', $rxSessionIds)
+ ->orderBy('pt.id', 'ASC')
+ ->get()
+ ->getResultArray();
+
+ foreach ($medicineRows as $medicineRow) {
+ $sessionId = (int) ($medicineRow['opd_pre_id'] ?? 0);
+ if ($sessionId <= 0) {
+ continue;
+ }
+
+ $medicinesBySession[$sessionId][] = [
+ 'med_type' => trim((string) ($medicineRow['med_type'] ?? '')),
+ 'med_name' => trim((string) ($medicineRow['med_name'] ?? '')),
+ 'dose' => trim((string) ($medicineRow['dose_shed'] ?? '')),
+ 'timing' => trim((string) ($medicineRow['dose_when_label'] ?? '')),
+ 'frequency' => trim((string) ($medicineRow['dose_frequency_label'] ?? '')),
+ 'where' => trim((string) ($medicineRow['dose_where_label'] ?? '')),
+ 'days' => trim((string) ($medicineRow['no_of_days'] ?? '')),
+ 'qty' => trim((string) ($medicineRow['qty'] ?? '')),
+ 'remark' => trim((string) ($medicineRow['remark'] ?? '')),
+ ];
+ }
+ }
+
$opdGroups = [];
foreach ($opdList as $row) {
$opdId = (int) $row['opd_id'];
@@ -838,6 +894,9 @@ class Patient extends BaseController
'diagnosis' => (string) ($rx['diagnosis'] ?? ''),
'investigation' => (string) ($rx['investigation'] ?? ''),
'advice' => (string) ($rx['advice'] ?? ''),
+ 'next_visit' => (string) ($rx['next_visit'] ?? ''),
+ 'refer_to' => (string) ($rx['refer_to'] ?? ''),
+ 'medicines' => $medicinesBySession[$rxSessionId] ?? [],
'files' => $filesByOpd[$opdId] ?? [],
];
}
@@ -848,6 +907,17 @@ class Patient extends BaseController
]);
}
+ private function findExistingTable(array $candidates): ?string
+ {
+ foreach ($candidates as $table) {
+ if ($this->db->tableExists($table)) {
+ return $table;
+ }
+ }
+
+ return null;
+ }
+
public function save_profile_image(int $pid)
{
$patient = $this->db->table('patient_master')->where('id', $pid)->get()->getRow();
diff --git a/app/Controllers/Storestock.php b/app/Controllers/Storestock.php
new file mode 100644
index 0000000..a6df77b
--- /dev/null
+++ b/app/Controllers/Storestock.php
@@ -0,0 +1,950 @@
+db = \Config\Database::connect();
+ }
+
+ // -------------------------------------------------------------------------
+ // Auth helper
+ // -------------------------------------------------------------------------
+
+ private function ensureStoreAccess()
+ {
+ $user = service('auth')->user();
+
+ $allowed = false;
+ if ($user && method_exists($user, 'can')) {
+ $allowed = $user->can('hospital_stock.access') || $user->can('pharmacy.access');
+ }
+
+ if (! $allowed && $user && method_exists($user, 'inGroup')) {
+ $allowed = $user->inGroup(
+ 'superadmin', 'admin', 'developer',
+ 'stock_manager', 'stock_requester', 'stock_issuer', 'storekeeper'
+ );
+ }
+
+ if ($allowed) {
+ return null;
+ }
+
+ return $this->response
+ ->setStatusCode(403)
+ ->setBody('
Access denied for Hospital Stock module.
');
+ }
+
+ private function actorLabel(): string
+ {
+ $user = service('auth')->user();
+ if (! $user) {
+ return 'System[' . date('Y-m-d H:i:s') . ']';
+ }
+ $name = trim(($user->first_name ?? '') . ' ' . ($user->last_name ?? ''));
+ if ($name === '' || $name === ' ') {
+ $name = (string) ($user->username ?? ($user->email ?? 'User'));
+ }
+ $userId = (int) ($user->id ?? 0);
+ return $name . '[' . $userId . '][' . date('d-m-Y H:i:s') . ']';
+ }
+
+ private function safeScalar(string $sql, array $bindings = [], $default = 0)
+ {
+ try {
+ $row = $this->db->query($sql, $bindings)->getRowArray();
+ if (! is_array($row) || empty($row)) {
+ return $default;
+ }
+ $val = reset($row);
+ return $val !== null ? $val : $default;
+ } catch (\Throwable $e) {
+ return $default;
+ }
+ }
+
+ private function safeRows(string $sql, array $bindings = []): array
+ {
+ try {
+ return $this->db->query($sql, $bindings)->getResultArray();
+ } catch (\Throwable $e) {
+ return [];
+ }
+ }
+
+ private function buildDashboardInsights(): array
+ {
+ $todayIndents = (int) $this->safeScalar(
+ "SELECT COUNT(*) AS c FROM invoice_stock_master WHERE DATE(indent_date) = CURDATE()",
+ [],
+ 0
+ );
+
+ $monthIndents = (int) $this->safeScalar(
+ "SELECT COUNT(*) AS c FROM invoice_stock_master WHERE DATE_FORMAT(indent_date, '%Y-%m') = DATE_FORMAT(CURDATE(), '%Y-%m')",
+ [],
+ 0
+ );
+
+ $pendingPurchase = (int) $this->safeScalar(
+ "SELECT COUNT(*) AS c FROM purchase_invoice_stock WHERE IFNULL(inv_status, 0) = 0",
+ [],
+ 0
+ );
+
+ $lowStockCount = (int) $this->safeScalar(
+ "SELECT COUNT(*) AS c FROM (
+ SELECT p.id,
+ COALESCE(SUM(s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit), 0) AS cur_units,
+ COALESCE(MAX(p.re_order_qty), 0) AS re_order_qty,
+ COALESCE(MAX(NULLIF(s.packing, 0)), 1) AS packing
+ FROM med_store_product_master p
+ LEFT JOIN purchase_invoice_item_stock s
+ ON p.id = s.item_code
+ AND s.remove_item = 0
+ AND s.item_return = 0
+ WHERE IFNULL(p.is_continue, 1) = 1
+ GROUP BY p.id
+ HAVING cur_units <= (re_order_qty * packing)
+ ) x",
+ [],
+ 0
+ );
+
+ $nearExpiryCount = (int) $this->safeScalar(
+ "SELECT COUNT(*) AS c
+ FROM purchase_invoice_item_stock s
+ WHERE s.remove_item = 0
+ AND s.item_return = 0
+ AND (s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit) > 0
+ AND s.expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 60 DAY)",
+ [],
+ 0
+ );
+
+ $stockValue = (float) $this->safeScalar(
+ "SELECT ROUND(SUM(
+ (s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit)
+ * IFNULL(s.purchase_unit_rate, s.purchase_price)
+ ), 2) AS v
+ FROM purchase_invoice_item_stock s
+ WHERE s.remove_item = 0
+ AND s.item_return = 0
+ AND (s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit) > 0",
+ [],
+ 0
+ );
+
+ $lowStockItems = $this->safeRows(
+ "SELECT p.item_name,
+ COALESCE(MAX(p.re_order_qty), 0) AS re_order_qty,
+ COALESCE(MAX(NULLIF(s.packing, 0)), 1) AS packing,
+ ROUND(COALESCE(SUM(s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit), 0), 2) AS cur_units
+ FROM med_store_product_master p
+ LEFT JOIN purchase_invoice_item_stock s
+ ON p.id = s.item_code
+ AND s.remove_item = 0
+ AND s.item_return = 0
+ WHERE IFNULL(p.is_continue, 1) = 1
+ GROUP BY p.id, p.item_name
+ HAVING cur_units <= (re_order_qty * packing)
+ ORDER BY cur_units ASC
+ LIMIT 8"
+ );
+
+ $nearExpiryItems = $this->safeRows(
+ "SELECT s.item_name,
+ s.batch_no,
+ DATE_FORMAT(s.expiry_date, '%d-%m-%Y') AS expiry_str,
+ DATEDIFF(s.expiry_date, CURDATE()) AS days_left,
+ ROUND((s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit) / NULLIF(s.packing, 0), 2) AS cur_packs
+ FROM purchase_invoice_item_stock s
+ WHERE s.remove_item = 0
+ AND s.item_return = 0
+ AND (s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit) > 0
+ AND s.expiry_date BETWEEN CURDATE() AND DATE_ADD(CURDATE(), INTERVAL 60 DAY)
+ ORDER BY s.expiry_date ASC
+ LIMIT 8"
+ );
+
+ return [
+ 'stats' => [
+ 'today_indents' => $todayIndents,
+ 'month_indents' => $monthIndents,
+ 'pending_purchase' => $pendingPurchase,
+ 'low_stock_count' => $lowStockCount,
+ 'near_expiry_count' => $nearExpiryCount,
+ 'stock_value' => $stockValue,
+ ],
+ 'lists' => [
+ 'low_stock_items' => $lowStockItems,
+ 'near_expiry_items' => $nearExpiryItems,
+ ],
+ ];
+ }
+
+ // -------------------------------------------------------------------------
+ // Dashboard
+ // -------------------------------------------------------------------------
+
+ public function index()
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $data = $this->buildDashboardInsights();
+ return view('storestock/dashboard', $data);
+ }
+
+ // -------------------------------------------------------------------------
+ // Indent List
+ // -------------------------------------------------------------------------
+
+ public function indent_list()
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+ return view('storestock/indent_list');
+ }
+
+ // -------------------------------------------------------------------------
+ // Indent DataTable (server-side)
+ // -------------------------------------------------------------------------
+
+ public function getIndentTable()
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $columns = [
+ 0 => 'indent_code',
+ 1 => 'issued_name',
+ 2 => 'indent_date_str',
+ 3 => 'id',
+ ];
+
+ $draw = (int) ($this->request->getPost('draw') ?? $this->request->getGet('draw') ?? 1);
+ $start = (int) ($this->request->getPost('start') ?? $this->request->getGet('start') ?? 0);
+ $length = (int) ($this->request->getPost('length') ?? $this->request->getGet('length') ?? 10);
+
+ $orderColIdx = (int) ($this->request->getPost('order[0][column]') ?? 0);
+ $orderDir = $this->request->getPost('order[0][dir]') === 'asc' ? 'asc' : 'desc';
+ $orderCol = $columns[$orderColIdx] ?? 'id';
+
+ // Column search values
+ $searchIndentCode = (string) ($this->request->getPost('columns[0][search][value]') ?? '');
+ $searchIssuedName = (string) ($this->request->getPost('columns[1][search][value]') ?? '');
+ $searchIndentDate = (string) ($this->request->getPost('columns[2][search][value]') ?? '');
+
+ // Total records
+ $totalData = (int) $this->db->query("SELECT COUNT(*) AS no_rec FROM invoice_stock_master")->getRow()->no_rec;
+ $totalFiltered = $totalData;
+
+ // Build parameterised WHERE
+ $whereSQL = ' WHERE 1=1';
+ $whereParams = [];
+
+ if ($searchIndentCode !== '') {
+ $esc = $this->db->escapeLikeString($searchIndentCode);
+ $whereSQL .= " AND indent_code LIKE '%{$esc}%'";
+ }
+
+ if ($searchIssuedName !== '') {
+ $esc = $this->db->escapeLikeString($searchIssuedName);
+ $whereSQL .= " AND issued_name LIKE '%{$esc}%'";
+ }
+
+ if ($searchIndentDate !== '') {
+ // Date column — use parameterised bind
+ $whereSQL .= ' AND DATE(indent_date) = ?';
+ $whereParams[] = $searchIndentDate;
+ }
+
+ if ($searchIndentCode !== '' || $searchIssuedName !== '' || $searchIndentDate !== '') {
+ $countSql = "SELECT COUNT(*) AS no_rec FROM invoice_stock_master{$whereSQL}";
+ $totalFiltered = (int) $this->db->query($countSql, $whereParams)->getRow()->no_rec;
+ }
+
+ $orderClause = " ORDER BY {$orderCol} {$orderDir}, id desc";
+ $limitClause = ' LIMIT ' . $start . ', ' . $length;
+
+ $selectSQL = "SELECT m.id, m.indent_code, m.issued_name, m.indent_date,
+ DATE_FORMAT(m.indent_date,'%d-%m-%Y') AS indent_date_str
+ FROM invoice_stock_master m{$whereSQL}{$orderClause}{$limitClause}";
+
+ $rdata = $this->db->query($selectSQL, $whereParams)->getResultArray();
+
+ $rows = [];
+ foreach ($rdata as $aRow) {
+ $rows[] = [
+ $aRow['indent_code'],
+ $aRow['issued_name'],
+ $aRow['indent_date_str'],
+ $aRow['id'],
+ ];
+ }
+
+ return $this->response->setJSON([
+ 'draw' => $draw,
+ 'recordsTotal' => $totalData,
+ 'recordsFiltered' => $totalFiltered,
+ 'data' => $rows,
+ ]);
+ }
+
+ // -------------------------------------------------------------------------
+ // New Indent form
+ // -------------------------------------------------------------------------
+
+ public function new_indent()
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $data['location_master'] = $this->db->query("SELECT * FROM location_master ORDER BY loc_name")->getResult();
+ $data['employee_master'] = $this->db->query("SELECT * FROM employee_master ORDER BY emp_name")->getResult();
+
+ return view('storestock/new_indent', $data);
+ }
+
+ // -------------------------------------------------------------------------
+ // Create Indent
+ // -------------------------------------------------------------------------
+
+ public function indent_create(int $location_type)
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $date_indent = $this->request->getPost('date_indent');
+ $loc_id = (int) $this->request->getPost('loc_id');
+
+ // Convert dd/mm/yyyy → yyyy-mm-dd
+ $mysql_date = date('Y-m-d');
+ if ($date_indent) {
+ $parts = explode('/', trim($date_indent));
+ if (count($parts) === 3) {
+ $mysql_date = $parts[2] . '-' . $parts[1] . '-' . $parts[0];
+ } else {
+ $mysql_date = date('Y-m-d', strtotime($date_indent));
+ }
+ }
+
+ $issued_name = '';
+
+ if ($location_type == 1) {
+ $row = $this->db->query("SELECT * FROM location_master WHERE l_id = ?", [$loc_id])->getRow();
+ if ($row) {
+ $issued_name = $row->loc_name;
+ }
+ } elseif ($location_type == 2) {
+ $row = $this->db->query("SELECT * FROM employee_master WHERE emp_id = ?", [$loc_id])->getRow();
+ if ($row) {
+ $issued_name = $row->emp_name . ' [' . $row->emp_code . ']';
+ }
+ }
+
+ $this->db->table('invoice_stock_master')->insert([
+ 'indent_date' => $mysql_date,
+ 'location_type' => $location_type,
+ 'location_id' => $loc_id,
+ 'issued_name' => $issued_name,
+ ]);
+
+ $insert_id = $this->db->insertID();
+
+ // Generate indent_code like S{ym}{padded_id}
+ $ym = date('ym');
+ $indent_code = 'S' . $ym . str_pad($insert_id, 5, '0', STR_PAD_LEFT);
+ $this->db->table('invoice_stock_master')->where('id', $insert_id)->update(['indent_code' => $indent_code]);
+
+ return redirect()->to(site_url('Storestock/Indent_show/' . $insert_id));
+ }
+
+ // -------------------------------------------------------------------------
+ // Show / Edit Indent
+ // -------------------------------------------------------------------------
+
+ public function indent_show(int $indent_id)
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $data['invoice_stock_master'] = $this->db->query(
+ "SELECT * FROM invoice_stock_master WHERE id = ?",
+ [$indent_id]
+ )->getResult();
+
+ $data['inv_stock_item'] = $this->db->query(
+ "SELECT i.*, IFNULL(DATEDIFF(expiry, CURDATE()), 1000) AS no_day,
+ SUM(p.total_unit - p.total_sale_unit - p.total_return_unit - p.total_lost_unit) / p.packing AS cur_qty
+ FROM inv_stock_item i
+ JOIN purchase_invoice_item_stock p ON i.item_code = p.item_code AND p.remove_item = 0
+ WHERE i.indent_id = ?
+ GROUP BY i.id",
+ [$indent_id]
+ )->getResult();
+
+ $data['invoiceGtotal'] = $this->db->query(
+ "SELECT SUM(amount) AS Gtotal, SUM(tamount) AS tamt, SUM(CGST) AS TCGST, SUM(SGST) AS TSGST
+ FROM inv_stock_item WHERE indent_id = ?",
+ [$indent_id]
+ )->getResult();
+
+ $data['content'] = view('storestock/indent_item_list', $data);
+
+ return view('storestock/indent_edit', $data);
+ }
+
+ // -------------------------------------------------------------------------
+ // Indent Items List (AJAX partial refresh)
+ // -------------------------------------------------------------------------
+
+ public function indent_items_list(int $indent_id)
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $data['inv_stock_item'] = $this->db->query(
+ "SELECT * FROM inv_stock_item WHERE indent_id = ? ORDER BY id",
+ [$indent_id]
+ )->getResult();
+
+ $data['invoiceGtotal'] = $this->db->query(
+ "SELECT SUM(amount) AS Gtotal, SUM(tamount) AS tamt, SUM(CGST) AS TCGST, SUM(SGST) AS TSGST
+ FROM inv_stock_item WHERE indent_id = ?",
+ [$indent_id]
+ )->getResult();
+
+ $html = view('storestock/indent_item_list', $data);
+ return $this->response->setBody($html);
+ }
+
+ // -------------------------------------------------------------------------
+ // Add / Update / Delete item from indent
+ // -------------------------------------------------------------------------
+
+ public function add_item(int $type)
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $insert_id = 1;
+ $error_show = '';
+ $exist = 0;
+ $input_id = 0;
+ $inv_id = 0;
+
+ $user = service('auth')->user();
+ $user_id = $user ? (int) ($user->id ?? 0) : 0;
+ $user_name_info = $this->actorLabel();
+
+ $store_stock_id = (int) $this->request->getPost('l_ssno');
+
+ if ($type == 1) {
+ // ---- Add new item ----
+ $inv_id = (int) $this->request->getPost('inv_id');
+ $qty = (float) $this->request->getPost('qty');
+ $disc = (float) $this->request->getPost('disc');
+ $product_unit_rate = (float) $this->request->getPost('product_unit_rate');
+
+ $item = $this->db->query(
+ "SELECT p.item_name, t.batch_no, t.expiry_date,
+ (t.total_unit - t.total_sale_unit) AS S_qty,
+ t.mrp, t.selling_unit_rate, p.id AS master_product_id,
+ t.id AS purchase_item_id, p.formulation,
+ t.HSNCODE, t.CGST_per, t.SGST_per, t.packing
+ FROM med_store_product_master p
+ JOIN purchase_invoice_item_stock t ON p.id = t.item_code
+ WHERE t.id = ?",
+ [$store_stock_id]
+ )->getRow();
+
+ if (! $item) {
+ $insert_id = 0;
+ $error_show = 'Item not found in stock';
+ } else {
+ $item_exists = $this->db->query(
+ "SELECT * FROM inv_stock_item WHERE indent_id = ? AND store_stock_id = ?",
+ [$inv_id, $store_stock_id]
+ )->getResult();
+
+ $stock_qty = (float) $item->S_qty;
+ $amount_value = $qty * $product_unit_rate;
+ $disc_amount = $amount_value * $disc / 100;
+ $tamount_value = $amount_value - $disc_amount;
+
+ if ($qty > $stock_qty) {
+ $insert_id = 0;
+ $error_show = 'Stock Qty. is less than Required Qty : Current Qty :' . $stock_qty;
+ } elseif ($stock_qty == 0) {
+ $insert_id = 0;
+ $error_show = 'Stock is Empty';
+ }
+
+ if ($insert_id > 0) {
+ if (count($item_exists) > 0) {
+ $exist = count($item_exists);
+ $input_id = 0;
+ $error_show = 'Please Check Also Same Item List';
+ }
+
+ $this->db->table('inv_stock_item')->insert([
+ 'indent_id' => $inv_id,
+ 'item_code' => $item->master_product_id,
+ 'item_Name' => $item->item_name,
+ 'formulation' => $item->formulation,
+ 'qty' => $qty,
+ 'batch_no' => $item->batch_no,
+ 'expiry' => $item->expiry_date,
+ 'price' => $product_unit_rate,
+ 'price2' => $product_unit_rate,
+ 'mrp' => $item->mrp,
+ 'disc_per' => $disc,
+ 'disc_amount' => $disc_amount,
+ 'amount' => $amount_value,
+ 'tamount' => $tamount_value,
+ 'CGST_per' => $item->CGST_per,
+ 'SGST_per' => $item->SGST_per,
+ 'HSNCODE' => $item->HSNCODE,
+ 'store_stock_id' => $item->purchase_item_id,
+ 'update_by_id' => $user_id,
+ 'update_by_remark' => $user_name_info,
+ 'packing' => $item->packing,
+ ]);
+
+ $insert_id = $this->db->insertID();
+ }
+ }
+
+ } elseif ($type == 2) {
+ // ---- Update qty ----
+ $update_qty = (float) $this->request->getPost('u_qty');
+ $item_id = (int) $this->request->getPost('itemid');
+
+ $inv_item = $this->db->query(
+ "SELECT * FROM inv_stock_item WHERE id = ?",
+ [$item_id]
+ )->getRow();
+
+ if (! $inv_item) {
+ $insert_id = 0;
+ $error_show = 'Item not found';
+ } else {
+ $inv_id = (int) $inv_item->indent_id;
+ $store_stock_id = (int) $inv_item->store_stock_id;
+ $disc = (float) $inv_item->disc_per;
+ $old_qty = (float) $inv_item->qty;
+ $diff_qty = $update_qty - $old_qty;
+
+ if ($diff_qty > 0) {
+ $stock_item = $this->db->query(
+ "SELECT (t.total_unit - t.total_sale_unit) AS S_qty
+ FROM purchase_invoice_item_stock t WHERE t.id = ?",
+ [$store_stock_id]
+ )->getRow();
+
+ $stock_qty = $stock_item ? (float) $stock_item->S_qty : 0;
+
+ if ($diff_qty > $stock_qty) {
+ $insert_id = 0;
+ $error_show = 'Stock Qty. is less than Required Qty : Current Qty :' . $stock_qty;
+ }
+ }
+
+ if ($insert_id > 0) {
+ $item_rate = (float) $inv_item->price;
+ $amount_value = $update_qty * $item_rate;
+ $disc_amount = $amount_value * $disc / 100;
+ $tamount = $amount_value - $disc_amount;
+
+ $this->db->table('inv_stock_item')->where('id', $item_id)->update([
+ 'qty' => $update_qty,
+ 'disc_amount' => $disc_amount,
+ 'amount' => $amount_value,
+ 'tamount' => $tamount,
+ 'store_stock_id'=> $store_stock_id,
+ 'packing' => $inv_item->packing,
+ 'indent_id' => $inv_id,
+ ]);
+
+ $insert_id = $item_id;
+ }
+ }
+
+ } else {
+ // ---- Delete item ----
+ $item_id = (int) $this->request->getPost('itemid');
+ $inv_item = $this->db->query(
+ "SELECT * FROM inv_stock_item WHERE id = ?",
+ [$item_id]
+ )->getRowArray();
+
+ if ($inv_item) {
+ $inv_id = (int) $inv_item['indent_id'];
+
+ // Archive to delete table
+ $del_data = $inv_item;
+ $del_data['del_by'] = $user_name_info;
+ unset($del_data['id']);
+ $this->db->table('inv_stock_item_delete')->insert($del_data);
+
+ $this->db->table('inv_stock_item')->where('id', $item_id)->delete();
+ $insert_id = 1;
+ } else {
+ $insert_id = 0;
+ $error_show = 'Item not found';
+ }
+ }
+
+ $content = '';
+
+ if ($insert_id > 0) {
+ // Call stored procs
+ $this->db->query("CALL p_stock_update_purchase_id_store({$inv_id})");
+ $this->db->query("CALL p_update_med_GST_store({$inv_id})");
+
+ $data['inv_stock_item'] = $this->db->query(
+ "SELECT i.*, IFNULL(DATEDIFF(expiry, CURDATE()), 1000) AS no_day,
+ SUM(p.total_unit - p.total_sale_unit - p.total_return_unit - p.total_lost_unit) / p.packing AS cur_qty
+ FROM inv_stock_item i
+ JOIN purchase_invoice_item_stock p ON i.item_code = p.item_code AND p.remove_item = 0
+ WHERE i.indent_id = ?
+ GROUP BY i.id",
+ [$inv_id]
+ )->getResult();
+
+ $data['invoiceGtotal'] = $this->db->query(
+ "SELECT SUM(amount) AS Gtotal, SUM(tamount) AS tamt,
+ SUM(CGST) AS TCGST, SUM(SGST) AS TSGST, SUM(disc_amount) AS t_dis_amt
+ FROM inv_stock_item WHERE indent_id = ?",
+ [$inv_id]
+ )->getResult();
+
+ $content = view('storestock/indent_item_list', $data);
+ }
+
+ return $this->response->setJSON([
+ 'exist' => $exist,
+ 'insertid' => $insert_id,
+ 'content' => $content,
+ 'error' => $error_show,
+ 'input_id' => $input_id,
+ 'csrf_token_value' => csrf_hash(),
+ ]);
+ }
+
+ // -------------------------------------------------------------------------
+ // Drug autocomplete
+ // -------------------------------------------------------------------------
+
+ public function get_drug()
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $term = trim((string) $this->request->getGet('term'));
+ if ($term === '') {
+ return $this->response->setJSON([]);
+ }
+
+ $q = strtolower($term);
+ $esc = $this->db->escapeLikeString($q);
+
+ $sql = "SELECT p.item_name, p.formulation, t.batch_no,
+ t.expiry_date AS expiry_date_str, t.expiry_date,
+ (t.total_unit - t.total_sale_unit - t.total_lost_unit - t.total_return_unit) AS c_qty,
+ t.id, t.mrp, t.selling_unit_rate, p.id AS item_code, t.packing
+ FROM med_store_product_master p
+ JOIN purchase_invoice_item_stock t ON p.id = t.item_code
+ AND (t.total_unit - t.total_sale_unit - t.total_lost_unit - t.total_return_unit) > 0
+ AND (expiry_date > DATE_ADD(SYSDATE(), INTERVAL 1 DAY) OR p.exp_date_applicable = 0)
+ AND t.remove_item = 0 AND t.item_return = 0
+ WHERE p.item_name LIKE '{$esc}%'
+ ORDER BY p.item_name, t.id
+ LIMIT 100";
+
+ $rows = $this->db->query($sql)->getResultArray();
+ $row_set = [];
+
+ foreach ($rows as $row) {
+ $row_set[] = [
+ 'label' => htmlentities($row['item_name'] . ' ' . $row['formulation'])
+ . ' |B:' . htmlentities($row['batch_no'])
+ . ' |Pak:' . htmlentities($row['packing'])
+ . ' |Rs.' . htmlentities($row['mrp'])
+ . ' |Qty:' . htmlentities($row['c_qty']),
+ 'value' => htmlentities($row['item_name']),
+ 'l_item_code' => htmlentities($row['item_code']),
+ 'l_ss_no' => htmlentities($row['id']),
+ 'l_Batch' => htmlentities($row['batch_no']),
+ 'l_Expiry' => htmlentities($row['expiry_date_str']),
+ 'l_mrp' => htmlentities($row['mrp']),
+ 'l_unit_rate' => htmlentities($row['selling_unit_rate']),
+ 'l_c_qty' => htmlentities($row['c_qty']),
+ 'l_packing' => htmlentities($row['packing']),
+ ];
+ }
+
+ return $this->response->setJSON($row_set);
+ }
+
+ // -------------------------------------------------------------------------
+ // Final Invoice
+ // -------------------------------------------------------------------------
+
+ public function final_invoice(int $inv_id)
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ // Call stored procedure to recalculate GST
+ $this->db->query("CALL p_update_med_GST_store({$inv_id})");
+
+ $data['invoice_stock_master'] = $this->db->query(
+ "SELECT *, (discount_amount + item_discount_amount) AS inv_disc_total,
+ (CGST_Tamount + SGST_Tamount) AS TGST
+ FROM invoice_stock_master WHERE id = ?",
+ [$inv_id]
+ )->getResult();
+
+ $data['inv_items'] = $this->db->query(
+ "SELECT * FROM inv_stock_item WHERE indent_id = ?",
+ [$inv_id]
+ )->getResult();
+
+ $data['invoiceGtotal'] = $this->db->query(
+ "SELECT SUM(amount) AS Gtotal, SUM(tamount) AS tamt,
+ SUM(CGST) AS TCGST, SUM(SGST) AS TSGST, SUM(disc_amount) AS t_dis_amt
+ FROM inv_stock_item WHERE indent_id = ?",
+ [$inv_id]
+ )->getResult();
+
+ return view('storestock/final_invoice', $data);
+ }
+
+ // -------------------------------------------------------------------------
+ // Print single indent (mPDF)
+ // -------------------------------------------------------------------------
+
+ public function print_single_indent(int $inv_id)
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $this->db->query("CALL p_update_med_GST_store({$inv_id})");
+
+ $data['inv_items'] = $this->db->query(
+ "SELECT i.indent_id, i.id, i.item_Name, i.formulation,
+ i.batch_no, DATE_FORMAT(i.expiry, '%m-%y') AS expiry,
+ i.price, i.qty, (i.amount) AS amount,
+ i.HSNCODE, m.indent_code, m.id AS m_id,
+ DATE_FORMAT(m.indent_date, '%d-%m-%Y') AS str_inv_date,
+ (i.disc_amount) AS d_amt,
+ (i.CGST + i.SGST) AS gst,
+ (i.CGST_per + i.SGST_per) AS gst_per, i.sale_return
+ FROM inv_stock_item i
+ JOIN invoice_stock_master m ON i.indent_id = m.id
+ WHERE m.id = ?
+ ORDER BY i.sale_return, i.id",
+ [$inv_id]
+ )->getResult();
+
+ $data['invoice_stock_master'] = $this->db->query(
+ "SELECT *, DATE_FORMAT(indent_date,'%d-%m-%Y') AS str_inv_date,
+ (discount_amount + item_discount_amount) AS inv_disc_total,
+ (CGST_Tamount + SGST_Tamount) AS TGST
+ FROM invoice_stock_master WHERE id = ?",
+ [$inv_id]
+ )->getResult();
+
+ $html = view('storestock/store_print', $data);
+
+ $mpdf = new Mpdf([
+ 'margin_top' => 40,
+ 'margin_bottom' => 12,
+ 'margin_left' => 10,
+ 'margin_right' => 5,
+ ]);
+ $mpdf->showWatermarkText = false;
+ $mpdf->WriteHTML($html);
+
+ $filename = 'Indent-' . $inv_id . '-' . date('Ymdhis') . '.pdf';
+ $mpdf->Output($filename, 'I');
+ }
+
+ // -------------------------------------------------------------------------
+ // Store Stock Search
+ // -------------------------------------------------------------------------
+
+ public function store_stock()
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $data['supplier_data'] = $this->db->query("SELECT * FROM stock_supplier ORDER BY name_supplier")->getResult();
+ $data['med_formulation'] = $this->db->query("SELECT * FROM med_formulation ORDER BY formulation_length")->getResult();
+
+ return view('storestock/store_stock_search', $data);
+ }
+
+ // -------------------------------------------------------------------------
+ // Store Stock Result (POST)
+ // -------------------------------------------------------------------------
+
+ public function store_stock_result()
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $supplier_id = (int) $this->request->getPost('input_supplier');
+ $item_name = (string) $this->request->getPost('txtsearch');
+ $chk_reorder = (string) $this->request->getPost('chk_reorder');
+
+ $having = '';
+ if ($chk_reorder === 'on') {
+ $having = ' HAVING SUM(s.total_unit - s.total_lost_unit - s.total_sale_unit - s.total_return_unit) <= p.re_order_qty';
+ }
+
+ $where = ' s.remove_item = 0';
+ $whereParams = [];
+
+ if ($supplier_id > 0) {
+ $where .= ' AND m.sid = ?';
+ $whereParams[] = $supplier_id;
+ }
+
+ if ($item_name !== '') {
+ $esc = $this->db->escapeLikeString($item_name);
+ $where .= " AND p.item_name LIKE '%{$esc}%'";
+ }
+
+ $sql = "SELECT p.id, p.item_name,
+ IFNULL(s.item_code, 0) AS item_found,
+ IFNULL(s.packing, p.packing) AS packing,
+ SUM(s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit) AS C_Unit_Stock_Qty,
+ CONCAT(
+ TRUNCATE(SUM(s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit) / s.packing, 0),
+ ':', MOD(SUM(s.total_unit - s.total_sale_unit - s.total_lost_unit - s.total_return_unit), s.packing)
+ ) AS C_Pak_Qty,
+ SUM(s.total_unit) AS P_Unit_Qty,
+ SUM(s.total_unit) / s.packing AS Pak_qty,
+ SUM(s.total_sale_unit) AS sale_unit,
+ SUM(s.total_sale_unit) / s.packing AS C_Pak_Sale_Qty,
+ p.re_order_qty,
+ s.total_lost_unit
+ FROM med_store_product_master p
+ JOIN purchase_invoice_item_stock s ON p.id = s.item_code AND s.remove_item = 0 AND s.item_return = 0
+ JOIN purchase_invoice_stock m ON s.purchase_id = m.id AND p.is_continue = 1
+ WHERE {$where}
+ GROUP BY p.id, s.packing{$having}
+ ORDER BY p.item_name";
+
+ $data['stock_list'] = $this->db->query($sql, $whereParams)->getResult();
+
+ $html = view('storestock/stock_search_result', $data);
+ return $this->response->setBody($html);
+ }
+
+ // -------------------------------------------------------------------------
+ // Get product stock detail (batch-wise)
+ // -------------------------------------------------------------------------
+
+ public function get_product_stock(int $product_id)
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+
+ $supplier_id = (int) $this->request->getPost('input_supplier');
+ $date_range = (string) $this->request->getPost('date_range');
+ $product_pak = (float) $this->request->getPost('product_pak');
+
+ $where = '1 = 1';
+ $whereParams = [];
+
+ if ($date_range !== '' && $date_range !== '0') {
+ $rangeArray = explode('S', $date_range);
+ if (count($rangeArray) === 2) {
+ $where .= ' AND p.date_of_invoice BETWEEN ? AND ?';
+ $whereParams[] = $rangeArray[0];
+ $whereParams[] = $rangeArray[1];
+ }
+ }
+
+ if ($supplier_id > 0) {
+ $where .= ' AND m.sid = ?';
+ $whereParams[] = $supplier_id;
+ }
+
+ $where .= ' AND i.item_code = ?';
+ $whereParams[] = $product_id;
+
+ if ($product_pak > 0) {
+ $where .= ' AND i.packing = ?';
+ $whereParams[] = $product_pak;
+ }
+
+ $sql = "SELECT i.id, s.name_supplier, s.short_name, i.Item_name,
+ p.Invoice_no, p.date_of_invoice, i.mrp, i.packing,
+ i.batch_no, i.expiry_date, i.purchase_price, i.purchase_unit_rate,
+ i.qty, i.qty_free, i.tqty, i.total_unit, i.total_sale_unit,
+ i.total_return_unit, i.total_lost_unit,
+ (i.total_unit - i.total_sale_unit - i.total_lost_unit - i.total_return_unit) AS cur_unit
+ FROM purchase_invoice_stock p
+ JOIN purchase_invoice_item_stock i ON p.id = i.purchase_id AND i.item_return = 0
+ JOIN stock_supplier s ON p.sid = s.sid
+ WHERE {$where} AND i.remove_item = 0
+ ORDER BY p.id";
+
+ $data['product_purchase_detail'] = $this->db->query($sql, $whereParams)->getResult();
+
+ $html = view('storestock/stock_item_history', $data);
+ return $this->response->setBody($html);
+ }
+
+ // -------------------------------------------------------------------------
+ // Day Report (stub)
+ // -------------------------------------------------------------------------
+
+ public function report_2()
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+ return view('storestock/day_report');
+ }
+
+ // -------------------------------------------------------------------------
+ // Main Store Dashboard
+ // -------------------------------------------------------------------------
+
+ public function main_store()
+ {
+ if ($err = $this->ensureStoreAccess()) {
+ return $err;
+ }
+ return view('storestock/main_store_dashboard');
+ }
+}
diff --git a/app/Database/Migrations/2026-04-21-000044_AddCashSubmissionPeriodFields.php b/app/Database/Migrations/2026-04-21-000044_AddCashSubmissionPeriodFields.php
new file mode 100644
index 0000000..de17e00
--- /dev/null
+++ b/app/Database/Migrations/2026-04-21-000044_AddCashSubmissionPeriodFields.php
@@ -0,0 +1,90 @@
+db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getNumRows() > 0;
+ }
+
+ private function fieldExists(string $table, string $field): bool
+ {
+ return $this->db->query('SHOW COLUMNS FROM `' . $table . '` LIKE ' . $this->db->escape($field))->getNumRows() > 0;
+ }
+
+ public function up()
+ {
+ $table = 'finance_scroll_submissions';
+ if (! $this->tableExists($table)) {
+ return;
+ }
+
+ if (! $this->fieldExists($table, 'start_datetime')) {
+ $this->forge->addColumn($table, [
+ 'start_datetime' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ 'after' => 'scroll_date',
+ ],
+ ]);
+ }
+
+ if (! $this->fieldExists($table, 'end_datetime')) {
+ $this->forge->addColumn($table, [
+ 'end_datetime' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ 'after' => 'start_datetime',
+ ],
+ ]);
+ }
+
+ if (! $this->fieldExists($table, 'collected_by')) {
+ $this->forge->addColumn($table, [
+ 'collected_by' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 120,
+ 'null' => true,
+ 'after' => 'department',
+ ],
+ ]);
+ }
+
+ if (! $this->fieldExists($table, 'payment_count')) {
+ $this->forge->addColumn($table, [
+ 'payment_count' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'default' => 0,
+ 'null' => false,
+ 'after' => 'submitted_amount',
+ ],
+ ]);
+ }
+ }
+
+ public function down()
+ {
+ $table = 'finance_scroll_submissions';
+ if (! $this->tableExists($table)) {
+ return;
+ }
+
+ if ($this->fieldExists($table, 'payment_count')) {
+ $this->forge->dropColumn($table, 'payment_count');
+ }
+ if ($this->fieldExists($table, 'collected_by')) {
+ $this->forge->dropColumn($table, 'collected_by');
+ }
+ if ($this->fieldExists($table, 'end_datetime')) {
+ $this->forge->dropColumn($table, 'end_datetime');
+ }
+ if ($this->fieldExists($table, 'start_datetime')) {
+ $this->forge->dropColumn($table, 'start_datetime');
+ }
+ }
+}
diff --git a/app/Database/Migrations/2026-04-21-000045_CreateCashSubmissionItemsTable.php b/app/Database/Migrations/2026-04-21-000045_CreateCashSubmissionItemsTable.php
new file mode 100644
index 0000000..87a489f
--- /dev/null
+++ b/app/Database/Migrations/2026-04-21-000045_CreateCashSubmissionItemsTable.php
@@ -0,0 +1,105 @@
+db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getNumRows() > 0;
+ }
+
+ private function fieldExists(string $table, string $field): bool
+ {
+ return $this->db->query('SHOW COLUMNS FROM `' . $table . '` LIKE ' . $this->db->escape($field))->getNumRows() > 0;
+ }
+
+ public function up()
+ {
+ if ($this->tableExists('finance_scroll_submission_items')) {
+ return;
+ }
+
+ $this->forge->addField([
+ 'id' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'unsigned' => true,
+ 'auto_increment' => true,
+ ],
+ 'scroll_submission_id' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'unsigned' => true,
+ 'null' => false,
+ ],
+ 'payment_history_id' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'unsigned' => true,
+ 'null' => false,
+ ],
+ 'payment_date' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ 'amount' => [
+ 'type' => 'DECIMAL',
+ 'constraint' => '12,2',
+ 'default' => 0,
+ ],
+ 'payment_mode' => [
+ 'type' => 'TINYINT',
+ 'constraint' => 2,
+ 'default' => 0,
+ ],
+ 'payof_type' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'default' => 0,
+ ],
+ 'payof_id' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'default' => 0,
+ ],
+ 'payof_code' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 120,
+ 'null' => true,
+ ],
+ 'update_by_id' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'default' => 0,
+ ],
+ 'update_by' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 120,
+ 'null' => true,
+ ],
+ 'snapshot_json' => [
+ 'type' => 'LONGTEXT',
+ 'null' => true,
+ ],
+ 'created_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ ]);
+
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('scroll_submission_id');
+ $this->forge->addKey('payment_history_id');
+ $this->forge->createTable('finance_scroll_submission_items', true);
+ }
+
+ public function down()
+ {
+ if ($this->tableExists('finance_scroll_submission_items')) {
+ $this->forge->dropTable('finance_scroll_submission_items', true);
+ }
+ }
+}
diff --git a/app/Database/Migrations/2026-04-21-000046_AddCashSubmissionFieldsToPaymentHistory.php b/app/Database/Migrations/2026-04-21-000046_AddCashSubmissionFieldsToPaymentHistory.php
new file mode 100644
index 0000000..250e99f
--- /dev/null
+++ b/app/Database/Migrations/2026-04-21-000046_AddCashSubmissionFieldsToPaymentHistory.php
@@ -0,0 +1,79 @@
+db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getNumRows() > 0;
+ }
+
+ private function fieldExists(string $table, string $field): bool
+ {
+ return $this->db->query('SHOW COLUMNS FROM `' . $table . '` LIKE ' . $this->db->escape($field))->getNumRows() > 0;
+ }
+
+ public function up()
+ {
+ if (! $this->tableExists('payment_history')) {
+ return;
+ }
+
+ if (! $this->fieldExists('payment_history', 'cash_submission_status')) {
+ $this->forge->addColumn('payment_history', [
+ 'cash_submission_status' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 20,
+ 'null' => false,
+ 'default' => 'open',
+ 'after' => 'payment_mode',
+ ],
+ ]);
+ }
+
+ if (! $this->fieldExists('payment_history', 'cash_submission_scroll_id')) {
+ $this->forge->addColumn('payment_history', [
+ 'cash_submission_scroll_id' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'null' => true,
+ 'after' => 'cash_submission_status',
+ ],
+ ]);
+ }
+
+ if (! $this->fieldExists('payment_history', 'cash_submission_updated_at')) {
+ $this->forge->addColumn('payment_history', [
+ 'cash_submission_updated_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ 'after' => 'cash_submission_scroll_id',
+ ],
+ ]);
+ }
+
+ $this->db->query('UPDATE payment_history SET cash_submission_status = "open" WHERE cash_submission_status IS NULL OR cash_submission_status = ""');
+ }
+
+ public function down()
+ {
+ if (! $this->tableExists('payment_history')) {
+ return;
+ }
+
+ if ($this->fieldExists('payment_history', 'cash_submission_updated_at')) {
+ $this->forge->dropColumn('payment_history', 'cash_submission_updated_at');
+ }
+
+ if ($this->fieldExists('payment_history', 'cash_submission_scroll_id')) {
+ $this->forge->dropColumn('payment_history', 'cash_submission_scroll_id');
+ }
+
+ if ($this->fieldExists('payment_history', 'cash_submission_status')) {
+ $this->forge->dropColumn('payment_history', 'cash_submission_status');
+ }
+ }
+}
diff --git a/app/Database/Migrations/2026-04-21-000047_CreateBankAuditTables.php b/app/Database/Migrations/2026-04-21-000047_CreateBankAuditTables.php
new file mode 100644
index 0000000..855fa67
--- /dev/null
+++ b/app/Database/Migrations/2026-04-21-000047_CreateBankAuditTables.php
@@ -0,0 +1,84 @@
+forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'entry_date' => ['type' => 'DATE', 'null' => false],
+ 'reference_no' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
+ 'narration' => ['type' => 'VARCHAR', 'constraint' => 500, 'null' => true],
+ 'amount' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
+ 'transaction_type' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => false, 'default' => 'credit'],
+ 'matched_payment_id' => ['type' => 'BIGINT', 'unsigned' => true, 'null' => true],
+ 'reconciliation_status'=> ['type' => 'VARCHAR', 'constraint' => 20, 'null' => false, 'default' => 'unmatched'],
+ 'matched_by' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
+ 'matched_at' => ['type' => 'DATETIME', 'null' => true],
+ 'remarks' => ['type' => 'VARCHAR', 'constraint' => 500, 'null' => true],
+ 'created_by' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('entry_date');
+ $this->forge->addKey('reconciliation_status');
+ $this->forge->createTable('finance_bank_statement_entries', true);
+
+ // Table 2: POS terminal daily settlement records
+ // (POS transactions don't appear individually in bank; the end-of-day settlement total does)
+ $this->forge->addField([
+ 'id' => ['type' => 'INT', 'unsigned' => true, 'auto_increment' => true],
+ 'settlement_date' => ['type' => 'DATE', 'null' => false],
+ 'terminal_id' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => false],
+ 'terminal_name' => ['type' => 'VARCHAR', 'constraint' => 200, 'null' => true],
+ 'settlement_amount'=> ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
+ 'system_total' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
+ 'variance' => ['type' => 'DECIMAL', 'constraint' => '12,2', 'null' => false, 'default' => '0.00'],
+ 'payment_count' => ['type' => 'INT', 'null' => false, 'default' => 0],
+ 'status' => ['type' => 'VARCHAR', 'constraint' => 20, 'null' => false, 'default' => 'pending'],
+ 'reconciled_by' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
+ 'reconciled_at' => ['type' => 'DATETIME', 'null' => true],
+ 'remarks' => ['type' => 'VARCHAR', 'constraint' => 500, 'null' => true],
+ 'created_by' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true],
+ 'created_at' => ['type' => 'DATETIME', 'null' => true],
+ 'updated_at' => ['type' => 'DATETIME', 'null' => true],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('settlement_date');
+ $this->forge->addKey(['settlement_date', 'terminal_id']);
+ $this->forge->createTable('finance_bank_pos_settlements', true);
+
+ // Add reconciliation tracking fields to payment_history
+ $this->forge->addColumn('payment_history', [
+ 'bank_reconcile_status' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 20,
+ 'null' => true,
+ 'default' => null,
+ 'after' => 'cash_submission_updated_at',
+ ],
+ 'bank_statement_entry_id' => [
+ 'type' => 'INT',
+ 'unsigned' => true,
+ 'null' => true,
+ 'default' => null,
+ 'after' => 'bank_reconcile_status',
+ ],
+ ]);
+ }
+
+ public function down(): void
+ {
+ $this->forge->dropTable('finance_bank_statement_entries', true);
+ $this->forge->dropTable('finance_bank_pos_settlements', true);
+ $this->forge->dropColumn('payment_history', 'bank_reconcile_status');
+ $this->forge->dropColumn('payment_history', 'bank_statement_entry_id');
+ }
+}
diff --git a/app/Database/Migrations/2026-04-21-000048_CreateBankReconciliationAuditTable.php b/app/Database/Migrations/2026-04-21-000048_CreateBankReconciliationAuditTable.php
new file mode 100644
index 0000000..e2490da
--- /dev/null
+++ b/app/Database/Migrations/2026-04-21-000048_CreateBankReconciliationAuditTable.php
@@ -0,0 +1,79 @@
+forge->addField([
+ 'id' => [
+ 'type' => 'BIGINT',
+ 'unsigned' => true,
+ 'auto_increment' => true,
+ ],
+ 'payment_history_id' => [
+ 'type' => 'BIGINT',
+ 'unsigned' => true,
+ 'null' => false,
+ ],
+ 'action_type' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 30,
+ 'null' => false,
+ 'default' => 'matched',
+ ],
+ 'old_status' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 30,
+ 'null' => true,
+ ],
+ 'new_status' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 30,
+ 'null' => true,
+ ],
+ 'batch_ref' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 100,
+ 'null' => true,
+ ],
+ 'remarks' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 500,
+ 'null' => true,
+ ],
+ 'action_by' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 100,
+ 'null' => true,
+ ],
+ 'action_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addKey('payment_history_id');
+ $this->forge->addKey('action_type');
+ $this->forge->addKey('batch_ref');
+ $this->forge->createTable('finance_bank_reconciliation_audit', true);
+
+ $this->forge->addColumn('payment_history', [
+ 'bank_reconcile_batch_ref' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 100,
+ 'null' => true,
+ 'after' => 'bank_statement_entry_id',
+ ],
+ ]);
+ }
+
+ public function down(): void
+ {
+ $this->forge->dropTable('finance_bank_reconciliation_audit', true);
+ $this->forge->dropColumn('payment_history', 'bank_reconcile_batch_ref');
+ }
+}
diff --git a/app/Database/Migrations/2026-04-21-000049_CreateBankSettlementEntriesTable.php b/app/Database/Migrations/2026-04-21-000049_CreateBankSettlementEntriesTable.php
new file mode 100644
index 0000000..10f3dea
--- /dev/null
+++ b/app/Database/Migrations/2026-04-21-000049_CreateBankSettlementEntriesTable.php
@@ -0,0 +1,104 @@
+hasTable('finance_bank_settlement_entries')) {
+ $this->forge->addField([
+ 'id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ 'auto_increment' => true,
+ ],
+ 'settlement_ref' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 100,
+ ],
+ 'settlement_date' => [
+ 'type' => 'DATE',
+ ],
+ 'payment_count' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'default' => 0,
+ ],
+ 'total_amount' => [
+ 'type' => 'DECIMAL',
+ 'constraint' => '12,2',
+ 'default' => '0.00',
+ ],
+ 'remarks' => [
+ 'type' => 'TEXT',
+ 'null' => true,
+ ],
+ 'created_by' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 100,
+ 'null' => true,
+ ],
+ 'created_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ ],
+ ]);
+ $this->forge->addKey('id', true);
+ $this->forge->addUniqueKey('settlement_ref');
+ $this->forge->addKey('settlement_date');
+ $this->forge->createTable('finance_bank_settlement_entries', true);
+ }
+
+ if ($this->hasTable('payment_history') && ! $this->hasField('payment_history', 'bank_settlement_entry_id')) {
+ $this->forge->addColumn('payment_history', [
+ 'bank_settlement_entry_id' => [
+ 'type' => 'BIGINT',
+ 'constraint' => 20,
+ 'unsigned' => true,
+ 'null' => true,
+ 'after' => 'bank_reconcile_batch_ref',
+ ],
+ ]);
+ }
+ }
+
+ public function down()
+ {
+ if ($this->hasTable('payment_history') && $this->hasField('payment_history', 'bank_settlement_entry_id')) {
+ $this->forge->dropColumn('payment_history', 'bank_settlement_entry_id');
+ }
+
+ if ($this->hasTable('finance_bank_settlement_entries')) {
+ $this->forge->dropTable('finance_bank_settlement_entries', true);
+ }
+ }
+
+ private function hasTable(string $table): bool
+ {
+ try {
+ $result = $this->db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getResultArray();
+ return ! empty($result);
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+
+ private function hasField(string $table, string $field): bool
+ {
+ if (! $this->hasTable($table)) {
+ return false;
+ }
+
+ try {
+ $sql = 'SHOW COLUMNS FROM `' . str_replace('`', '``', $table) . '` LIKE ' . $this->db->escape($field);
+ $result = $this->db->query($sql)->getResultArray();
+ return ! empty($result);
+ } catch (\Throwable $e) {
+ return false;
+ }
+ }
+}
diff --git a/app/Database/Migrations/2026-04-22-000051_AddOpdPayoutTrackingFields.php b/app/Database/Migrations/2026-04-22-000051_AddOpdPayoutTrackingFields.php
new file mode 100644
index 0000000..fbda091
--- /dev/null
+++ b/app/Database/Migrations/2026-04-22-000051_AddOpdPayoutTrackingFields.php
@@ -0,0 +1,92 @@
+db->query('SHOW TABLES LIKE ' . $this->db->escape($table))->getRowArray();
+
+ return ! empty($row);
+ }
+
+ private function getTableFields(string $table): array
+ {
+ if (! $this->hasTable($table)) {
+ return [];
+ }
+
+ try {
+ $rows = $this->db->query('SHOW COLUMNS FROM `' . $table . '`')->getResultArray();
+ return array_values(array_filter(array_map(static function (array $row): string {
+ return (string) ($row['Field'] ?? '');
+ }, $rows), static fn(string $field): bool => $field !== ''));
+ } catch (\Throwable $e) {
+ return [];
+ }
+ }
+
+ public function up()
+ {
+ if (! $this->hasTable('opd_master')) {
+ return;
+ }
+
+ $fields = $this->getTableFields('opd_master');
+
+ if (! in_array('payout_draft_id', $fields, true)) {
+ $this->forge->addColumn('opd_master', [
+ 'payout_draft_id' => [
+ 'type' => 'INT',
+ 'constraint' => 11,
+ 'unsigned' => true,
+ 'null' => true,
+ 'after' => 'doc_id',
+ ],
+ ]);
+ }
+
+ if (! in_array('payout_calculated_at', $fields, true)) {
+ $this->forge->addColumn('opd_master', [
+ 'payout_calculated_at' => [
+ 'type' => 'DATETIME',
+ 'null' => true,
+ 'after' => 'payout_draft_id',
+ ],
+ ]);
+ }
+
+ if (! in_array('payout_calculated_by', $fields, true)) {
+ $this->forge->addColumn('opd_master', [
+ 'payout_calculated_by' => [
+ 'type' => 'VARCHAR',
+ 'constraint' => 120,
+ 'null' => true,
+ 'after' => 'payout_calculated_at',
+ ],
+ ]);
+ }
+ }
+
+ public function down()
+ {
+ if (! $this->hasTable('opd_master')) {
+ return;
+ }
+
+ $fields = $this->getTableFields('opd_master');
+
+ if (in_array('payout_calculated_by', $fields, true)) {
+ $this->forge->dropColumn('opd_master', 'payout_calculated_by');
+ }
+ if (in_array('payout_calculated_at', $fields, true)) {
+ $this->forge->dropColumn('opd_master', 'payout_calculated_at');
+ }
+ if (in_array('payout_draft_id', $fields, true)) {
+ $this->forge->dropColumn('opd_master', 'payout_draft_id');
+ }
+ }
+}
diff --git a/app/Models/FinanceBankPosSettlementModel.php b/app/Models/FinanceBankPosSettlementModel.php
new file mode 100644
index 0000000..388f5d4
--- /dev/null
+++ b/app/Models/FinanceBankPosSettlementModel.php
@@ -0,0 +1,30 @@
+
-
+
Hospital Stock
diff --git a/app/Views/billing/Patient_Profile_Opd_V.php b/app/Views/billing/Patient_Profile_Opd_V.php
index 105cfc4..34fff5e 100644
--- a/app/Views/billing/Patient_Profile_Opd_V.php
+++ b/app/Views/billing/Patient_Profile_Opd_V.php
@@ -10,105 +10,123 @@