From d02244444e470e3b1e57316222fdee79913b1083 Mon Sep 17 00:00:00 2001 From: DevSoftTech Server Date: Wed, 22 Apr 2026 10:53:20 +0530 Subject: [PATCH] finance/storestock: implement cash submission audits, bank settlements, and opd payout tracking --- WORKLOG_COPILOT.md | 32 + app/Config/AuthGroups.php | 36 +- app/Config/Routes.php | 6 + app/Controllers/Finance.php | 742 ++++++++++++++ app/Controllers/Patient.php | 74 +- app/Controllers/Storestock.php | 950 ++++++++++++++++++ ...1-000044_AddCashSubmissionPeriodFields.php | 90 ++ ...-000045_CreateCashSubmissionItemsTable.php | 105 ++ ...ddCashSubmissionFieldsToPaymentHistory.php | 79 ++ ...026-04-21-000047_CreateBankAuditTables.php | 84 ++ ...048_CreateBankReconciliationAuditTable.php | 79 ++ ...00049_CreateBankSettlementEntriesTable.php | 104 ++ ...4-22-000051_AddOpdPayoutTrackingFields.php | 92 ++ app/Models/FinanceBankPosSettlementModel.php | 30 + .../FinanceBankReconciliationAuditModel.php | 24 + app/Models/FinanceBankStatementEntryModel.php | 29 + .../FinanceScrollSubmissionItemModel.php | 29 + app/Models/FinanceScrollSubmissionModel.php | 4 + app/Views/Setting/Admin/index.php | 2 +- app/Views/billing/Patient_Profile_Opd_V.php | 184 ++-- app/Views/finance/bank_deposits.php | 28 +- app/Views/finance/cashbook.php | 363 +++++-- app/Views/finance/index.php | 154 +-- .../partials/bank_audit_direct_table.php | 94 ++ .../finance/partials/bank_deposits_table.php | 18 +- .../partials/bank_pos_settlements_table.php | 77 ++ .../bank_settlement_linked_payments_table.php | 80 ++ .../partials/bank_statement_entries_table.php | 61 ++ .../partials/payment_selection_table.php | 52 + .../payout_opd_consult_drafts_table.php | 76 ++ .../partials/payout_opd_consult_summary.php | 256 +++++ .../finance/partials/scroll_items_table.php | 43 + app/Views/finance/partials/scroll_table.php | 43 +- app/Views/partials/sidebar.php | 100 +- app/Views/report/index.php | 12 +- app/Views/storestock/_niceadmin_theme.php | 392 ++++++++ app/Views/storestock/dashboard.php | 151 +++ app/Views/storestock/day_report.php | 47 + app/Views/storestock/final_invoice.php | 103 ++ app/Views/storestock/indent_edit.php | 261 +++++ app/Views/storestock/indent_item_list.php | 98 ++ app/Views/storestock/indent_list.php | 99 ++ app/Views/storestock/main_store_dashboard.php | 47 + app/Views/storestock/new_indent.php | 92 ++ app/Views/storestock/stock_item_history.php | 42 + app/Views/storestock/stock_search_result.php | 99 ++ app/Views/storestock/store_print.php | 104 ++ app/Views/storestock/store_stock_search.php | 101 ++ 48 files changed, 5533 insertions(+), 335 deletions(-) create mode 100644 app/Controllers/Storestock.php create mode 100644 app/Database/Migrations/2026-04-21-000044_AddCashSubmissionPeriodFields.php create mode 100644 app/Database/Migrations/2026-04-21-000045_CreateCashSubmissionItemsTable.php create mode 100644 app/Database/Migrations/2026-04-21-000046_AddCashSubmissionFieldsToPaymentHistory.php create mode 100644 app/Database/Migrations/2026-04-21-000047_CreateBankAuditTables.php create mode 100644 app/Database/Migrations/2026-04-21-000048_CreateBankReconciliationAuditTable.php create mode 100644 app/Database/Migrations/2026-04-21-000049_CreateBankSettlementEntriesTable.php create mode 100644 app/Database/Migrations/2026-04-22-000051_AddOpdPayoutTrackingFields.php create mode 100644 app/Models/FinanceBankPosSettlementModel.php create mode 100644 app/Models/FinanceBankReconciliationAuditModel.php create mode 100644 app/Models/FinanceBankStatementEntryModel.php create mode 100644 app/Models/FinanceScrollSubmissionItemModel.php create mode 100644 app/Views/finance/partials/bank_audit_direct_table.php create mode 100644 app/Views/finance/partials/bank_pos_settlements_table.php create mode 100644 app/Views/finance/partials/bank_settlement_linked_payments_table.php create mode 100644 app/Views/finance/partials/bank_statement_entries_table.php create mode 100644 app/Views/finance/partials/payment_selection_table.php create mode 100644 app/Views/finance/partials/payout_opd_consult_drafts_table.php create mode 100644 app/Views/finance/partials/payout_opd_consult_summary.php create mode 100644 app/Views/finance/partials/scroll_items_table.php create mode 100644 app/Views/storestock/_niceadmin_theme.php create mode 100644 app/Views/storestock/dashboard.php create mode 100644 app/Views/storestock/day_report.php create mode 100644 app/Views/storestock/final_invoice.php create mode 100644 app/Views/storestock/indent_edit.php create mode 100644 app/Views/storestock/indent_item_list.php create mode 100644 app/Views/storestock/indent_list.php create mode 100644 app/Views/storestock/main_store_dashboard.php create mode 100644 app/Views/storestock/new_indent.php create mode 100644 app/Views/storestock/stock_item_history.php create mode 100644 app/Views/storestock/stock_search_result.php create mode 100644 app/Views/storestock/store_print.php create mode 100644 app/Views/storestock/store_stock_search.php 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 @@
-
-

OPD History

+
+
+

Old Prescription With Scanned Records

+
Patient: p_fname ?? '') ?>
+
+ OPD Record(s)
-

Patient: p_fname ?? '') ?>

- -
No OPD scans found.
+
No OPD history found.
-
-
+
+
- - Dr. -
-
- -
-
- -
- 0) { ?> - - Prescription Print - - - - -
- BP: - Diastolic: - Pulse: - Temp: - SPO2: +
Dr.
+
+ OPD ID: + Date: +
- - - -
- -
-
- Complaints: -
-
- - -
-
- Diagnosis: -
-
- - -
-
- Investigation: -
-
- - -
-
- Advice: -
-
- -
- -
- - -
No scanned files for this OPD.
- -
Scanned Files
-
- $file) { ?> -
- - Open PDF - - OPD Scan - -
+
+ +
+ + +
+ BP: + Diastolic: + Pulse: + Temp: + SPO2: +
-
+ + +
+
Complaint:
+
Diagnosis:
+
Investigation Advised:
+
Advice:
+
Next Visit:
+
Refer To:
+
+ + + +
+ + + + + + + + + + + + + + + + + + + +
PrescribedDoseTiming - Freq. - Route - DurationQty
+ + +
+ +
+ trim((string) $value) !== '')))) ?> +
+
+ + + +
No scanned files for this OPD.
+ +
Scanned Files
+ +
+ +
+ Open PDF +
+ + + OPD Scan + +
+ + +
@@ -100,27 +139,214 @@ } } - function refreshTables() { - load_form_div('', 'cash_txn_table_wrap'); - load_form_div('', 'scroll_table_wrap'); + function refreshScrollTable() { + if (document.getElementById('scroll_table_wrap')) { + load_form_div('', 'scroll_table_wrap'); + } } - function wireForm(formId, endpoint) { - var form = document.getElementById(formId); + function copyFiltersToSubmitForm() { + var filterForm = document.getElementById('scroll_filter_form'); + var submitForm = document.getElementById('scroll_form'); + if (!filterForm || !submitForm) { + return; + } + + ['start_datetime', 'end_datetime', 'collected_by', 'payment_mode', 'department'].forEach(function(name) { + var src = filterForm.querySelector('[name="' + name + '"]'); + var dst = submitForm.querySelector('[name="' + name + '"]'); + if (src && dst) { + dst.value = src.value; + } + }); + } + + function updateSelectionSummary() { + var wrap = document.getElementById('payment_selection_wrap'); + var selected = 0; + var total = 0; + + if (wrap) { + wrap.querySelectorAll('.payment-checkbox:checked').forEach(function(chk) { + selected++; + var row = chk.closest('tr'); + if (row) { + var amountCell = row.querySelector('.payment-amount'); + if (amountCell) { + total += Number(amountCell.getAttribute('data-amount') || '0'); + } + } + }); + } + + var amountInput = document.querySelector('#scroll_form [name="submitted_amount"]'); + if (amountInput) { + amountInput.value = total.toFixed(2); + } + + var info = document.getElementById('selection_info'); + if (info) { + info.value = 'Selected payments: ' + String(selected); + } + } + + function wirePaymentSelectionEvents() { + var wrap = document.getElementById('payment_selection_wrap'); + if (!wrap) { + return; + } + + var selectAll = wrap.querySelector('#payment_select_all'); + if (selectAll) { + selectAll.addEventListener('change', function() { + var checked = !!selectAll.checked; + wrap.querySelectorAll('.payment-checkbox').forEach(function(chk) { + chk.checked = checked; + }); + updateSelectionSummary(); + }); + } + + wrap.querySelectorAll('.payment-checkbox').forEach(function(chk) { + chk.addEventListener('change', updateSelectionSummary); + }); + + updateSelectionSummary(); + } + + function loadPayments() { + var form = document.getElementById('scroll_filter_form'); if (!form) { return; } - form.addEventListener('submit', function(e) { - e.preventDefault(); - var formData = new window.FormData(form); + copyFiltersToSubmitForm(); - fetch(endpoint, { + var params = new window.URLSearchParams(new window.FormData(form)); + fetch('?' + params.toString(), { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(function(res) { + return res.text().then(function(html) { + return { ok: res.ok, html: html }; + }); + }) + .then(function(result) { + if (!result.ok) { + showAlert('Unable to load payment list.', false); + return; + } + + var wrap = document.getElementById('payment_selection_wrap'); + if (wrap) { + wrap.innerHTML = result.html; + } + wirePaymentSelectionEvents(); + showAlert('Payments loaded. Select rows to submit.', true); + }) + .catch(function() { + showAlert('Network or server error while loading payments.', false); + }); + } + + window.financeScrollAction = function(scrollId, action) { + var endpoint = action === 'accept' + ? '' + : ''; + + var fd = new window.FormData(); + fd.append('scroll_id', String(scrollId)); + + fetch(endpoint, { + method: 'POST', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + }, + body: fd + }) + .then(function(res) { + return res.json().then(function(data) { + return { ok: res.ok, data: data }; + }); + }) + .then(function(result) { + if (!result.ok || !result.data || result.data.status !== 1) { + showAlert((result.data && result.data.message) ? result.data.message : 'Action failed', false); + return; + } + + showAlert(result.data.message || 'Updated successfully', true); + refreshScrollTable(); + }) + .catch(function() { + showAlert('Network or server error.', false); + }); + }; + + window.financeViewScrollItems = function(scrollId) { + var target = document.getElementById('scroll_items_body'); + if (target) { + target.innerHTML = '
Loading...
'; + } + + fetch('?scroll_id=' + encodeURIComponent(String(scrollId)), { + method: 'GET', + headers: { + 'X-Requested-With': 'XMLHttpRequest' + } + }) + .then(function(res) { + return res.text().then(function(html) { + return { ok: res.ok, html: html }; + }); + }) + .then(function(result) { + if (!target) { + return; + } + target.innerHTML = result.ok ? result.html : '
Unable to load submission items.
'; + }) + .catch(function() { + if (target) { + target.innerHTML = '
Network or server error.
'; + } + }); + + var modalEl = document.getElementById('scrollItemsModal'); + if (modalEl && window.bootstrap && window.bootstrap.Modal) { + var modal = window.bootstrap.Modal.getOrCreateInstance(modalEl); + modal.show(); + } + }; + + var loadBtn = document.getElementById('load_payments_btn'); + if (loadBtn) { + loadBtn.addEventListener('click', loadPayments); + } + + var submitForm = document.getElementById('scroll_form'); + if (submitForm) { + submitForm.addEventListener('submit', function(e) { + e.preventDefault(); + copyFiltersToSubmitForm(); + + var payload = new window.FormData(submitForm); + var wrap = document.getElementById('payment_selection_wrap'); + if (wrap) { + wrap.querySelectorAll('.payment-checkbox:checked').forEach(function(chk) { + payload.append('payment_ids[]', chk.value); + }); + } + + fetch('', { method: 'POST', headers: { 'X-Requested-With': 'XMLHttpRequest' }, - body: formData + body: payload }) .then(function(res) { return res.json().then(function(data) { @@ -133,9 +359,9 @@ return; } - showAlert(result.data.message || 'Saved successfully', true); - form.reset(); - refreshTables(); + showAlert(result.data.message || 'Submission sent.', true); + refreshScrollTable(); + loadPayments(); }) .catch(function() { showAlert('Network or server error.', false); @@ -143,7 +369,6 @@ }); } - wireForm('cash_txn_form', ''); - wireForm('scroll_form', ''); + wirePaymentSelectionEvents(); })(); diff --git a/app/Views/finance/index.php b/app/Views/finance/index.php index 024a2e1..30abc54 100644 --- a/app/Views/finance/index.php +++ b/app/Views/finance/index.php @@ -1,123 +1,53 @@
-
-

Finance & Accounting - Phase 1

-

Vendor onboarding, PO booking, GRN capture, and invoice registration.

- + user() : null; ?> + can('finance.cash.billing.submit') || $user->can('finance.*')) : false; ?> + can('finance.cash.accounts.accept') || $user->can('finance.cash.accounts.verify') || $user->can('finance.*')) : false; ?> + can('finance.bank.deposit.create') || $user->can('finance.bank.audit') || $user->can('finance.bank.statement.update') || $user->can('finance.*')) : false; ?> + +
+

Accounts And Finance

+

Simple operational workflow for Billing cash statement submission, Accounts verification, and bank transaction audit.

-
-
-
-
-
Matched
-
+
+ +
+
+
+
1) Cash Collection Statement
+

Billing Department creates and submits daily cash statement to Accounts.

+ + Open Billing Statement + +
-
-
-
-
-
Minor Variance
-
+ + +
+
+
+
2) Accounts Accept & Verify
+

Accounts Department accepts submitted statements and verifies payment records.

+ + Open Verification Queue + +
-
-
-
-
-
Mismatch / Not Checked
-
+ + +
+
+
+
3) Bank Trans Audit
+

Audit bank transactions and mark updates posted in the bank statement register.

+ + Open Bank Audit Register + +
-
-
-
-
-
Compliance Hold
-
-
-
-
-
- -
-
- Vendor Master -
-
-
-
+
- - diff --git a/app/Views/finance/partials/bank_audit_direct_table.php b/app/Views/finance/partials/bank_audit_direct_table.php new file mode 100644 index 0000000..4aa8583 --- /dev/null +++ b/app/Views/finance/partials/bank_audit_direct_table.php @@ -0,0 +1,94 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#HMS RefPayment DateAmountTxn Ref / UTRChannelAccepted By / Matched ByStatusAction
No bank payments found for the selected criteria.
+ + + + PH-₹ + + +
+ +
+ +
+
+ +
+ Matched: + +
+ +
+ +
+ + Matched + +
+ + 0): ?> +
Settlement
+ +
+ + + + Unmatched + +
+ + + + + +
+
diff --git a/app/Views/finance/partials/bank_deposits_table.php b/app/Views/finance/partials/bank_deposits_table.php index 58b86b0..88c0292 100644 --- a/app/Views/finance/partials/bank_deposits_table.php +++ b/app/Views/finance/partials/bank_deposits_table.php @@ -9,11 +9,12 @@ Slip Amount Status + Action - No bank deposits found. + No bank deposits found. @@ -25,12 +26,25 @@ - + + Statement Updated + + Audited + Matched Pending + + + Completed + + + + + + diff --git a/app/Views/finance/partials/bank_pos_settlements_table.php b/app/Views/finance/partials/bank_pos_settlements_table.php new file mode 100644 index 0000000..ef57ed4 --- /dev/null +++ b/app/Views/finance/partials/bank_pos_settlements_table.php @@ -0,0 +1,77 @@ +
+ + + + + + + + + + + + + + + + + + + + + 'Matched', + 'variance' => 'Variance', + 'accepted' => 'Accepted', + 'pending' => 'Pending', + ]; + $badge = $badgeMap[$status] ?? '' . esc($status) . ''; + ?> + + + + + + + + + + + + + + + +
#DateTerminalSettlement AmtSystem TotalVarianceTransactionsStatusByAction
No POS settlements recorded yet.
+ + +
+ +
₹₹ + = 0 ? '+' : '') . number_format($variance, 2) ?> + + + +
+ +
+ + Closed + + + + + + No txns found + +
+
diff --git a/app/Views/finance/partials/bank_settlement_linked_payments_table.php b/app/Views/finance/partials/bank_settlement_linked_payments_table.php new file mode 100644 index 0000000..410655a --- /dev/null +++ b/app/Views/finance/partials/bank_settlement_linked_payments_table.php @@ -0,0 +1,80 @@ + +
+
+ Settlement Ref: + Date: + Rows: + Total: ₹ +
+ 0): ?> + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#HMS RefPayment DateAmountTxn Ref / UTRChannelAccepted ByStatus
No linked payments found.
PH-₹ + + +
+ +
+ + Matched + + Unmatched + + +
+ +
+
diff --git a/app/Views/finance/partials/bank_statement_entries_table.php b/app/Views/finance/partials/bank_statement_entries_table.php new file mode 100644 index 0000000..da052ca --- /dev/null +++ b/app/Views/finance/partials/bank_statement_entries_table.php @@ -0,0 +1,61 @@ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#DateRef / UTRNarrationAmountTypeStatusAction
No statement entries found.
₹ + + Debit + + Credit + + + + Matched + +
Pmt #
+ + + Unmatched + +
+ + + + + + +
+
diff --git a/app/Views/finance/partials/payment_selection_table.php b/app/Views/finance/partials/payment_selection_table.php new file mode 100644 index 0000000..763b052 --- /dev/null +++ b/app/Views/finance/partials/payment_selection_table.php @@ -0,0 +1,52 @@ + 0, 'total_receipts' => 0]; +?> +
+
Matched Payments:
+
Total:
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Payment DateInvoice RefCollected ByModeAmount
No payments found for selected filter.
+ + + +
+
diff --git a/app/Views/finance/partials/payout_opd_consult_drafts_table.php b/app/Views/finance/partials/payout_opd_consult_drafts_table.php new file mode 100644 index 0000000..5556297 --- /dev/null +++ b/app/Views/finance/partials/payout_opd_consult_drafts_table.php @@ -0,0 +1,76 @@ + + +
+
+ Recent OPD Payout Drafts + Filtered by current date/doctor/state selection +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Payout DateCase RefDoctorUnitsRateCalculatedApprovedStatusAction
No payout drafts found for selected filters.
Rs Rs Rs + + Paid + + CEO Approved + + Finance Approved + + Draft + + + + + + + Locked + +
+
+
+
diff --git a/app/Views/finance/partials/payout_opd_consult_summary.php b/app/Views/finance/partials/payout_opd_consult_summary.php new file mode 100644 index 0000000..8c326e2 --- /dev/null +++ b/app/Views/finance/partials/payout_opd_consult_summary.php @@ -0,0 +1,256 @@ + + +
+
+
+ Summary Range: to +
+
+ Doctor: +
+
+ : +
+
+
+ +
+
Completed OPDs
+
Routine OPDs
+
Emergency OPDs
+
Doctors Involved
+
+ +
+
Already Calculated OPDs
+
+ +
+
Gross OPD Revenue
+
Total Received
+
Cash Received
+
Bank Received
+
+ +
+
Total Credit Amount
+
Organizational Credit
+
Approved Consents
+
Payout Records / Amount
/
+
+ +
+
+ Doctor-wise OPD and Collection Summary + Running and New are shown for internal review; routine figure is non-emergency total. +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#DoctorCompletedRoutineEmergencyRunningNewGross AmountCashBankCredit
No OPD summary records found for the selected filters.
+
+
+
+ + +
+
+ Payout Calculation (Preview) +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
Doctor Gross Share
Rs 0.00
+
Hospital Share
Rs 0.00
+
Net Payable To Doctor
Rs 0.00
+
+
+
+ + diff --git a/app/Views/finance/partials/scroll_items_table.php b/app/Views/finance/partials/scroll_items_table.php new file mode 100644 index 0000000..337ee7a --- /dev/null +++ b/app/Views/finance/partials/scroll_items_table.php @@ -0,0 +1,43 @@ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
#Payment IDPayment DateInvoice RefCollectorModeAmount
No payment rows were saved for this submission.
+ +
Total
+
diff --git a/app/Views/finance/partials/scroll_table.php b/app/Views/finance/partials/scroll_table.php index cd7d818..63005f9 100644 --- a/app/Views/finance/partials/scroll_table.php +++ b/app/Views/finance/partials/scroll_table.php @@ -3,32 +3,61 @@ # - Date + Collection Period Department + Collected By + Payments Total Receipts Submitted Variance Status + Action - No scroll submissions yet. + No cash statements submitted yet. + - + + + + + - - - Matched - + + Deposited + + Received + Pending + + + + + + + + + + + + No action diff --git a/app/Views/partials/sidebar.php b/app/Views/partials/sidebar.php index 1a4d6a0..6aceb8a 100644 --- a/app/Views/partials/sidebar.php +++ b/app/Views/partials/sidebar.php @@ -74,20 +74,23 @@ if (! $canHospitalStock && $user && method_exists($user, 'inGroup')) { $canFinance = false; if ($user && method_exists($user, 'can')) { - $canFinance = $user->can('finance.access') - || $user->can('finance.vendor.manage') - || $user->can('finance.po.manage') - || $user->can('finance.grn.manage') - || $user->can('finance.invoice.manage') - || $user->can('finance.cash.manage') - || $user->can('finance.doctor_payout.manage') - || $user->can('finance.bank_deposit.manage') - || $user->can('finance.compliance.view') + $canFinance = $user->can('finance.workflow.view') + || $user->can('finance.cash.billing.submit') + || $user->can('finance.cash.accounts.accept') + || $user->can('finance.cash.accounts.verify') + || $user->can('finance.bank.deposit.create') + || $user->can('finance.bank.audit') + || $user->can('finance.bank.statement.update') || $user->can('finance.*'); } -if (! $canFinance && $user && method_exists($user, 'inGroup')) { - $canFinance = $user->inGroup('superadmin', 'admin', 'developer'); +$canFinanceBilling = false; +$canFinanceAccounts = false; +$canFinanceBank = false; +if ($user && method_exists($user, 'can')) { + $canFinanceBilling = $user->can('finance.cash.billing.submit') || $user->can('finance.*'); + $canFinanceAccounts = $user->can('finance.cash.accounts.accept') || $user->can('finance.cash.accounts.verify') || $user->can('finance.*'); + $canFinanceBank = $user->can('finance.bank.deposit.create') || $user->can('finance.bank.audit') || $user->can('finance.bank.statement.update') || $user->can('finance.*'); } $canDoctorWork = false; @@ -246,44 +249,61 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) { Org Packing + + + + + + + + + + + + - - - - @@ -360,7 +380,7 @@ if (! $canChargesSettings && $user && method_exists($user, 'inGroup')) {
  • Total Payment (Cash and Bank)
  • OPD Total Report
  • Billing Operations Report
  • -
  • Bank Deposit Register
  • +
  • Bank Transaction Audit
  • @@ -32,12 +32,10 @@
  • Total Payment (Cash and Bank)
  • OPD Total Report
  • Billing Operations Report
  • -
  • Finance & Accounting (Phase 1)
  • -
  • Finance & Accounting (Phase 2)
  • -
  • Cash Collection & Disbursement SOP
  • -
  • Doctor Payout Workflow
  • -
  • Bank Deposit Register
  • -
  • Finance Compliance Report
  • +
  • Accounts And Finance Dashboard
  • +
  • Billing Cash Statement Submission
  • +
  • Accounts Accept and Verify
  • +
  • Bank Transaction Audit
  • NABH Audit Report
  • diff --git a/app/Views/storestock/_niceadmin_theme.php b/app/Views/storestock/_niceadmin_theme.php new file mode 100644 index 0000000..a80f6ed --- /dev/null +++ b/app/Views/storestock/_niceadmin_theme.php @@ -0,0 +1,392 @@ + \ No newline at end of file diff --git a/app/Views/storestock/dashboard.php b/app/Views/storestock/dashboard.php new file mode 100644 index 0000000..b6be8b9 --- /dev/null +++ b/app/Views/storestock/dashboard.php @@ -0,0 +1,151 @@ +include('storestock/_niceadmin_theme') ?> +
    + +
    +

    Store Stock Dashboard

    +
    +
    +
    +

    Hospital Stock Control Center

    +

    Track indents, monitor stock movement, and jump to master controls from one screen.

    +
    + +
    +
    + Indents Today + +
    +
    + Indents This Month + +
    +
    + Low Stock Items + +
    +
    + Near Expiry (60 Days) + +
    +
    + +
    +
    + Pending Purchase Invoices + +
    +
    + Approx. Current Stock Value + Rs. +
    +
    + + + +
    +
    +

    Workspace

    +
    +
    +
    +
    +
    + +
    +
    +
    +

    Low Stock Alerts

    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + +
    ItemCurrent UnitReorder Threshold
    No low-stock items right now.
    x
    +
    +
    +
    + +
    +
    +

    Near Expiry Alerts

    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + +
    Item / BatchExpiryDays LeftCur Packs
    No near-expiry batches in next 60 days.
    () + + + +
    +
    +
    +
    +
    +
    +
    diff --git a/app/Views/storestock/day_report.php b/app/Views/storestock/day_report.php new file mode 100644 index 0000000..89a9880 --- /dev/null +++ b/app/Views/storestock/day_report.php @@ -0,0 +1,47 @@ +include('storestock/_niceadmin_theme') ?> +
    +
    +
    +
    +

    Day Report - Store

    +
    +
    +
    + +
    +
    +
    + + +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    + + +
    diff --git a/app/Views/storestock/final_invoice.php b/app/Views/storestock/final_invoice.php new file mode 100644 index 0000000..9847444 --- /dev/null +++ b/app/Views/storestock/final_invoice.php @@ -0,0 +1,103 @@ +include('storestock/_niceadmin_theme') ?> +
    +
    +
    +

    Indent No. : indent_code ?? '') ?>

    +
    +
    + +
    +
    +
    + + + + location_id ?? 0) > 0) { ?> +
    +

    + Location Name : issued_name) ?> + / Indent No. : indent_code) ?> + / Date : indent_date)) ?> +

    +
    + +
    +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    #Item CodeItem NameFormulationBatch NoExp.RateQty.PriceDisc.HSNCODECGSTSGSTAmount
    item_code) ?>item_Name) ?>formulation) ?>batch_no) ?>expiry) ?>price ?>qty ?>amount ?>disc_amount ?>HSNCODE) ?>CGST ?? 0 ?>SGST ?? 0 ?>tamount ?>
    Gross TotalGtotal ?? 0 ?>tamt ?? 0 ?>
    +
    +
    +
    + +
    +
    +
    +
    + + +
    +
    diff --git a/app/Views/storestock/indent_edit.php b/app/Views/storestock/indent_edit.php new file mode 100644 index 0000000..f538b97 --- /dev/null +++ b/app/Views/storestock/indent_edit.php @@ -0,0 +1,261 @@ +include('storestock/_niceadmin_theme') ?> +
    +
    +

    + Indent No. indent_code ?? '') ?> + Edit +

    +
    +
    + +
    +
    +
    +
    + + + + location_id ?? 0) > 0) { ?> +
    +

    + Location Name : issued_name) ?> + / Indent No. : indent_code) ?> + / Date : indent_date)) ?> +

    +
    + +
    +
    +
    +
    + +
    +
    + +
    +
    +
    +
    +
    +
    + + +
    +
    +
    + + +
    +
    +
    + +
    +
    +
    +
    + + +

    + + + + +

    +
    +
    +
    +
    +
    + + +
    +
    +
    +
    + + + +
    +
    +
    +
    + + +
    +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    +

    Search Medicine

    +
    +
    + +
    +
    +
    + + +
    diff --git a/app/Views/storestock/indent_item_list.php b/app/Views/storestock/indent_item_list.php new file mode 100644 index 0000000..a553a5e --- /dev/null +++ b/app/Views/storestock/indent_item_list.php @@ -0,0 +1,98 @@ +item_code; +} +$duplicate_codes = array_filter( + array_count_values($item_codes), + fn($c) => $c > 1 +); +?> + + + + + + + + + + + + + + sale_return) && $row->sale_return == 1) { + $style = 'style="background:orange;"'; + } + + if (isset($row->no_day) && $row->no_day < 90) { + $style = 'style="color:red;"'; + } + + if (isset($row->cur_qty) && $row->cur_qty < 6) { + $style = 'style="color:red;"'; + } + + $style_repeat = isset($duplicate_codes[$row->item_code]) ? 'style="color:green;"' : ''; + ?> + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    #Item NameBatch NoExp.RateS.Qty.Qty.PriceAmount
    >item_Name) ?>batch_no) ?>expiry) ?>price ?>qty ?> + + + sale_return) || $row->sale_return == 0) { ?> +
    + + + + +
    + +
    amount ?>tamount ?> + +
    #Gross TotalGtotal ?? 0 ?>tamt ?? 0 ?>
    #Tax TotalTCGST : TCGST ?? 0 ?> / TSGST : TSGST ?? 0 ?>
    diff --git a/app/Views/storestock/indent_list.php b/app/Views/storestock/indent_list.php new file mode 100644 index 0000000..2a04322 --- /dev/null +++ b/app/Views/storestock/indent_list.php @@ -0,0 +1,99 @@ +include('storestock/_niceadmin_theme') ?> +
    +
    +
    +
    +
    +
    +

    Indent List

    + + +
    + +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + +
    Indent No.DepartmentDateID
    +
    +
    +
    +
    + + +
    diff --git a/app/Views/storestock/main_store_dashboard.php b/app/Views/storestock/main_store_dashboard.php new file mode 100644 index 0000000..ca229a7 --- /dev/null +++ b/app/Views/storestock/main_store_dashboard.php @@ -0,0 +1,47 @@ +include('storestock/_niceadmin_theme') ?> +
    +
    +

    Store Main Panel

    +
    +
    +
    +

    Store Operations

    +

    Use grouped controls to manage masters, inventory, and procurement operations.

    +
    + + +
    +
    diff --git a/app/Views/storestock/new_indent.php b/app/Views/storestock/new_indent.php new file mode 100644 index 0000000..b0fca9d --- /dev/null +++ b/app/Views/storestock/new_indent.php @@ -0,0 +1,92 @@ +include('storestock/_niceadmin_theme') ?> +
    +
    +
    +
    +

    New Indent

    +
    +
    + +
    +

    Create Indent Request

    +

    Select date, then choose request origin: location or employee.

    +
    + +
    +
    + +
    +
    + +
    +
    +
    + +
    +
    +
    By Location
    +
    + + +
    + +
    Use when indent is raised for a ward/department location.
    +
    + +
    +
    By Employee
    +
    + + +
    + +
    Use when indent is raised in personal accountability mode.
    +
    +
    +
    + +
    +
    + + +
    diff --git a/app/Views/storestock/stock_item_history.php b/app/Views/storestock/stock_item_history.php new file mode 100644 index 0000000..857cd53 --- /dev/null +++ b/app/Views/storestock/stock_item_history.php @@ -0,0 +1,42 @@ +include('storestock/_niceadmin_theme') ?> +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Pur. IDSupplierInv.No / DateBat.No.Exp.Dt.MRPPurchaseQty / PackingUnit QtySale U.QtyLost UnitCurr.U.Qty
    id) ?>name_supplier) ?>Invoice_no) ?>
    date_of_invoice) ?>
    batch_no) ?>expiry_date) ?>mrp) ?>purchase_price) ?>tqty) ?> / packing) ?>total_unit) ?>total_sale_unit) ?>total_lost_unit) ?>cur_unit) ?>
    No stock records found.
    +
    diff --git a/app/Views/storestock/stock_search_result.php b/app/Views/storestock/stock_search_result.php new file mode 100644 index 0000000..b6e0178 --- /dev/null +++ b/app/Views/storestock/stock_search_result.php @@ -0,0 +1,99 @@ +include('storestock/_niceadmin_theme') ?> +
    +
    +
    +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    Item NameCurrent Pak.Current Unit QtyTotal Sale Pak.Total Sale Unit QtyLost UnitPackage / Re-Order QtyDetail
    +

    + + item_name) ?> + +

    +

    C_Pak_Qty ?? '') ?>

    C_Unit_Stock_Qty ?? 0, 2) ?>

    C_Pak_Sale_Qty ?? 0, 2) ?>

    sale_unit ?? 0, 2) ?>

    total_lost_unit ?? 0, 2) ?>

    packing ?? '') ?> / re_order_qty ?? '') ?>

    + +
    Item NameCurrent Pak.Current Unit QtyTotal Sale Pak.Total Sale Unit QtyLost UnitPackage / Re-Order QtyDetail
    +
    +
    +
    + + + + + +
    diff --git a/app/Views/storestock/store_print.php b/app/Views/storestock/store_print.php new file mode 100644 index 0000000..c106546 --- /dev/null +++ b/app/Views/storestock/store_print.php @@ -0,0 +1,104 @@ + + + + + + + + +
    +

    + +

    +

    + , + +

    +
    +

    + Location Name : issued_name ?? '') ?>
    + Indent No. : indent_code ?? '') ?>
    + Date : str_inv_date ?? '') ?> +

    +
    +

    Indent

    +
    + + + + + + + +
    Page : {PAGENO}/{nbpg} + Invoice No.: indent_code ?? '') ?> + / Location : issued_name ?? '') ?> +
    +
    + + +'; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + } + $srno++; + $head_start++; + + if (isset($row->sale_return) && $row->sale_return == 1) { + echo ''; + } + + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; +} + +if (count($invoice_stock_master) > 0) { + echo ''; + echo ''; + echo ''; + echo ''; + echo ''; +} +?> +
    Invoice ID : ' + . esc($row->indent_code) . ' [Dated : ' . esc($row->str_inv_date) . ']
    #Item NameBatch NoExp.RateQty.Gross Amt
    Sale Return
    ' . $srno . '' . esc($row->item_Name) . ' ' . esc($row->formulation) . '' . esc($row->batch_no) . '' . esc($row->expiry) . '' . $row->price . '' . $row->qty . '' . $row->amount . '
    #Total' . ($invoice_stock_master[0]->gross_amount ?? '') . '
    + +

    + + + + + +
    + For +
    diff --git a/app/Views/storestock/store_stock_search.php b/app/Views/storestock/store_stock_search.php new file mode 100644 index 0000000..31ad096 --- /dev/null +++ b/app/Views/storestock/store_stock_search.php @@ -0,0 +1,101 @@ +include('storestock/_niceadmin_theme') ?> +
    +
    +

    Store Stock Search

    +
    +
    +
    +

    Stock Visibility & Reorder Controls

    +

    Filter by schedule, quickly export, and inspect product movement batch-wise.

    +
    + +
    +
    + +
    +
    + +
    +
    +

    Search Result

    +
    +
    +
    +
    +
    +
    + + +