diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 3167100..d5bfca8 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -242,6 +242,7 @@ $routes->match(['get', 'post'], 'Medical/credit_payout_pool_datatable', 'Medical $routes->get('Medical/credit_payout_request', 'Medical::credit_payout_request'); $routes->post('Medical/credit_payout_request_create', 'Medical::credit_payout_request_create'); $routes->get('Medical/credit_payout_requests', 'Medical::credit_payout_requests'); +$routes->match(['get', 'post'], 'Medical/credit_payout_requests_datatable', 'Medical::credit_payout_requests_datatable'); $routes->get('Medical/credit_payout_request_detail/(:num)', 'Medical::credit_payout_request_detail/$1'); $routes->get('Payment_Medical', 'Payment_Medical::index'); @@ -276,6 +277,12 @@ $routes->get('Finance/payout/opd-consult-drafts-table', 'Finance::payoutOpdConsu $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/payout/doctor-charges', 'Finance::doctorChargesPayout'); +$routes->get('Finance/payout/doctor-charges-summary', 'Finance::doctorChargesPayoutSummary'); +$routes->get('Finance/payout/doctor-charges-drafts-table', 'Finance::doctorChargesPayoutDraftsTable'); +$routes->post('Finance/payout/doctor-charges-draft-create', 'Finance::doctorChargesPayoutDraftCreate'); +$routes->post('Finance/payout/doctor-charges-draft-update', 'Finance::doctorChargesPayoutDraftUpdate'); +$routes->post('Finance/payout/doctor-charges-draft-delete', 'Finance::doctorChargesPayoutDraftDelete'); $routes->get('Finance/bank_audit_direct_payments_table', 'Finance::bankAuditDirectPaymentsTable'); $routes->get('Finance/bank_audit_outgoing_payments_table', 'Finance::bankAuditOutgoingPaymentsTable'); $routes->get('Finance/bank_statement_entries_table', 'Finance::bankStatementEntriesTable'); diff --git a/app/Controllers/Finance.php b/app/Controllers/Finance.php index bf6fc3c..4046644 100644 --- a/app/Controllers/Finance.php +++ b/app/Controllers/Finance.php @@ -1858,6 +1858,48 @@ class Finance extends BaseController ]); } + public function doctorChargesPayout() + { + 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/doctor_charges_payout', [ + 'doctor_options' => $doctorOptions, + 'state_unit_options' => $this->fetchOpdStateUnitOptions(), + ]); + } + public function payoutOpdConsultSummary() { if (! $this->canFinanceAny([ @@ -1876,6 +1918,7 @@ class Finance extends BaseController $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') ?? '')); + $payoutSource = $this->normalizePayoutSource((string) ($this->request->getGet('payout_source') ?? 'consultation')); if ($fromDate === '') { $fromDate = date('Y-m-01'); @@ -1887,7 +1930,40 @@ class Finance extends BaseController [$fromDate, $toDate] = [$toDate, $fromDate]; } - return view('finance/partials/payout_opd_consult_summary', $this->buildOpdConsultPayoutSummaryData($fromDate, $toDate, $doctorId, $stateUnit)); + return view('finance/partials/payout_opd_consult_summary', $this->buildPayoutSummaryDataForSource($payoutSource, $fromDate, $toDate, $doctorId, $stateUnit)); + } + + public function doctorChargesPayoutSummary() + { + 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') ?? '')); + $payoutSource = $this->normalizePayoutSource((string) ($this->request->getGet('payout_source') ?? 'pathology')); + + 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/doctor_charges_payout_summary', $this->buildPayoutSummaryDataForSource($payoutSource, $fromDate, $toDate, $doctorId, $stateUnit)); } public function payoutOpdConsultDraftsTable() @@ -1909,6 +1985,8 @@ class Finance extends BaseController $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') ?? '')); + $payoutSource = $this->normalizePayoutSource((string) ($this->request->getGet('payout_source') ?? 'consultation')); + $payoutType = $this->payoutTypeFromSource($payoutSource); if ($fromDate === '') { $fromDate = date('Y-m-01'); @@ -1923,7 +2001,7 @@ class Finance extends BaseController $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_type', $payoutType) ->where('finance_doctor_payouts.payout_date >=', $fromDate) ->where('finance_doctor_payouts.payout_date <=', $toDate) ->orderBy('finance_doctor_payouts.id', 'DESC'); @@ -1943,6 +2021,68 @@ class Finance extends BaseController return view('finance/partials/payout_opd_consult_drafts_table', [ 'rows' => $builder->findAll(30), + 'source_type' => $payoutSource, + 'source_label' => $this->payoutSourceLabel($payoutSource), + ]); + } + + public function doctorChargesPayoutDraftsTable() + { + 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') ?? '')); + $payoutSource = $this->normalizePayoutSource((string) ($this->request->getGet('payout_source') ?? 'pathology')); + $payoutType = $this->payoutTypeFromSource($payoutSource); + + 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', $payoutType) + ->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/doctor_charges_payout_drafts_table', [ + 'rows' => $builder->findAll(30), + 'source_type' => $payoutSource, + 'source_label' => $this->payoutSourceLabel($payoutSource), ]); } @@ -1969,12 +2109,28 @@ class Finance extends BaseController $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') ?? '')); + $payoutSource = $this->normalizePayoutSource((string) ($this->request->getPost('payout_source') ?? 'consultation')); $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 ($payoutSource !== 'consultation') { + return $this->createChargeInvoicePayoutDraft( + $payoutSource, + $doctorId, + $fromDate, + $toDate, + $stateUnit, + $baseAmount, + $doctorShare, + $hospitalShare, + $deductions, + $adjustments + ); + } + if ($doctorId <= 0) { return $this->response->setStatusCode(422)->setJSON(['status' => 0, 'message' => 'Please select a doctor before creating payout draft.']); } @@ -2019,7 +2175,7 @@ class Finance extends BaseController $caseReference .= '-U' . $this->normalizeStateUnitToken($stateUnit); } - $remarks = 'OPD payout draft from OPD Consult Payout | Completed=' . $completedOpd + $remarks = 'OPD payout draft from Hospital Doctor Payout | Completed=' . $completedOpd . ', Base=' . number_format($baseAmount, 2, '.', '') . ', Doc%=' . number_format($doctorShare, 2, '.', '') . ', Hosp%=' . number_format($hospitalShare, 2, '.', '') @@ -2077,6 +2233,11 @@ class Finance extends BaseController ]); } + public function doctorChargesPayoutDraftCreate() + { + return $this->payoutOpdConsultDraftCreate(); + } + public function payoutOpdConsultDraftUpdate() { if (! $this->canFinanceAny([ @@ -2133,6 +2294,11 @@ class Finance extends BaseController ]); } + public function doctorChargesPayoutDraftUpdate() + { + return $this->payoutOpdConsultDraftUpdate(); + } + public function payoutOpdConsultDraftDelete() { if (! $this->canFinanceAny([ @@ -2164,20 +2330,39 @@ class Finance extends BaseController $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; - } + $payoutType = strtolower(trim((string) ($row['payout_type'] ?? 'consultation'))); + if ($payoutType === 'consultation') { + 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->db->table('opd_master') + ->where('payout_draft_id', $payoutId) + ->update($clearData); + } + } else { + if (method_exists($this->db, 'tableExists') && $this->db->tableExists('invoice_item') && $this->tableHasField('invoice_item', 'payout_draft_id')) { + $clearData = [ + 'payout_draft_id' => null, + ]; + if ($this->tableHasField('invoice_item', 'payout_calculated_at')) { + $clearData['payout_calculated_at'] = null; + } + if ($this->tableHasField('invoice_item', 'payout_calculated_by')) { + $clearData['payout_calculated_by'] = null; + } + + $this->db->table('invoice_item') + ->where('payout_draft_id', $payoutId) + ->update($clearData); + } } $this->doctorPayoutModel->delete($payoutId); @@ -2190,10 +2375,15 @@ class Finance extends BaseController return $this->response->setJSON([ 'status' => 1, - 'message' => 'Payout draft deleted and linked OPD records unlocked for recalculation.', + 'message' => 'Payout draft deleted and linked records unlocked for recalculation.', ]); } + public function doctorChargesPayoutDraftDelete() + { + return $this->payoutOpdConsultDraftDelete(); + } + public function phase2() { if (! $this->canFinance('finance.access')) { @@ -4517,6 +4707,567 @@ class Finance extends BaseController ]; } + private function normalizePayoutSource(string $source): string + { + $source = strtolower(trim($source)); + $allowed = ['consultation', 'pathology', 'radiology', 'other_items']; + + return in_array($source, $allowed, true) ? $source : 'consultation'; + } + + private function payoutTypeFromSource(string $source): string + { + return match ($this->normalizePayoutSource($source)) { + 'pathology' => 'pathology', + 'radiology' => 'radiology', + 'other_items' => 'other_items', + default => 'consultation', + }; + } + + private function payoutSourceLabel(string $source): string + { + return match ($this->normalizePayoutSource($source)) { + 'pathology' => 'Pathology Charges', + 'radiology' => 'Radiology Charges', + 'other_items' => 'Other Charge Items', + default => 'OPD Consultation', + }; + } + + private function buildPayoutSummaryDataForSource(string $source, string $fromDate, string $toDate, int $doctorId = 0, string $stateUnit = ''): array + { + $source = $this->normalizePayoutSource($source); + if ($source === 'consultation') { + $payload = $this->buildOpdConsultPayoutSummaryData($fromDate, $toDate, $doctorId, $stateUnit); + $payload['source_type'] = $source; + $payload['source_label'] = $this->payoutSourceLabel($source); + $payload['scope_label'] = 'Completed OPDs'; + $payload['unit_label'] = 'OPDs'; + + return $payload; + } + + $payload = $this->buildChargeInvoicePayoutSummaryData($source, $fromDate, $toDate, $doctorId, $stateUnit); + $payload['source_type'] = $source; + $payload['source_label'] = $this->payoutSourceLabel($source); + $payload['scope_label'] = 'Eligible Charge Invoices'; + $payload['unit_label'] = 'Invoices'; + + return $payload; + } + + private function resolveInvoiceDoctorColumn(): ?string + { + $candidates = ['doctor_id', 'doc_id', 'dr_id']; + foreach ($candidates as $column) { + if ($this->tableHasField('invoice_master', $column)) { + return $column; + } + } + + return null; + } + + private function resolveInvoiceStateUnitColumn(): ?string + { + $candidates = ['unit_name', 'unit', 'state_name', 'state', 'branch_name', 'branch', 'center_name', 'location', 'city']; + foreach ($candidates as $column) { + if ($this->tableHasField('invoice_master', $column)) { + return $column; + } + } + + return null; + } + + private function applyChargeCategoryFilter($builder, string $source, string $itemTypeColumn): void + { + $source = $this->normalizePayoutSource($source); + if (! $this->tableHasField('invoice_item', 'item_type')) { + if ($source !== 'other_items') { + $builder->where('1 = 0', null, false); + } + + return; + } + + if ($source === 'pathology') { + $builder->whereIn($itemTypeColumn, [5, 30]); + return; + } + + if ($source === 'radiology') { + $builder->whereIn($itemTypeColumn, [1, 2, 3, 4, 6]); + return; + } + + if ($source === 'other_items') { + $builder->groupStart() + ->where($itemTypeColumn . ' IS NULL', null, false) + ->orWhereNotIn($itemTypeColumn, [1, 2, 3, 4, 5, 6, 30]) + ->groupEnd(); + } + } + + private function fetchEligibleChargeItemsForPayout(string $source, string $fromDate, string $toDate, int $doctorId, string $stateUnit = ''): array + { + if (! method_exists($this->db, 'tableExists') || ! $this->db->tableExists('invoice_master') || ! $this->db->tableExists('invoice_item')) { + return ['item_ids' => [], 'invoice_ids' => []]; + } + + $doctorColumn = $this->resolveInvoiceDoctorColumn(); + if ($doctorColumn === null) { + return ['item_ids' => [], 'invoice_ids' => []]; + } + + $stateColumn = $this->resolveInvoiceStateUnitColumn(); + $itemAmountExpr = $this->tableHasField('invoice_item', 'item_amount') + ? 'COALESCE(ii.item_amount, 0)' + : 'COALESCE(ii.item_rate,0) * COALESCE(ii.item_qty,0)'; + + $builder = $this->db->table('invoice_item ii') + ->select('ii.id AS item_id, ii.inv_master_id AS invoice_id') + ->join('invoice_master inv', 'inv.id = ii.inv_master_id', 'inner') + ->where('DATE(inv.inv_date) >=', $fromDate) + ->where('DATE(inv.inv_date) <=', $toDate) + ->where($itemAmountExpr . ' > 0', null, false); + + if ($doctorId > 0) { + $builder->where('inv.' . $doctorColumn, $doctorId); + } + + if ($stateUnit !== '' && $stateColumn !== null) { + $builder->where('inv.' . $stateColumn, $stateUnit); + } + + $this->applyChargeCategoryFilter($builder, $source, 'ii.item_type'); + + if ($this->tableHasField('invoice_item', 'payout_draft_id')) { + $builder->groupStart() + ->where('ii.payout_draft_id IS NULL', null, false) + ->orWhere('ii.payout_draft_id', 0) + ->groupEnd(); + } + + $rows = $builder->get()->getResultArray(); + + $itemIds = []; + $invoiceIds = []; + foreach ($rows as $row) { + $itemId = (int) ($row['item_id'] ?? 0); + $invoiceId = (int) ($row['invoice_id'] ?? 0); + if ($itemId > 0) { + $itemIds[] = $itemId; + } + if ($invoiceId > 0) { + $invoiceIds[] = $invoiceId; + } + } + + return [ + 'item_ids' => array_values(array_unique($itemIds)), + 'invoice_ids' => array_values(array_unique($invoiceIds)), + ]; + } + + private function buildChargeInvoicePayoutSummaryData(string $source, string $fromDate, string $toDate, int $doctorId = 0, string $stateUnit = ''): array + { + $db = \Config\Database::connect(); + $source = $this->normalizePayoutSource($source); + $doctorColumn = $this->resolveInvoiceDoctorColumn(); + $stateColumn = $this->resolveInvoiceStateUnitColumn(); + + $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, + ]; + + $doctorBreakdown = []; + $doctorName = 'All Doctors'; + if ($doctorId > 0 && $db->tableExists('doctor_master')) { + $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 ?? ''))); + } + } + + if ($doctorColumn === null || ! $db->tableExists('invoice_master') || ! $db->tableExists('invoice_item')) { + return [ + 'summary' => $summary, + 'doctor_breakdown' => [], + 'from_date' => $fromDate, + 'to_date' => $toDate, + 'doctor_name' => $doctorName, + 'state_unit' => $stateUnit, + 'state_unit_label' => $stateColumn !== null ? ucwords(str_replace('_', ' ', $stateColumn)) : 'State/Unit', + ]; + } + + $itemAmountExpr = $this->tableHasField('invoice_item', 'item_amount') + ? 'COALESCE(ii.item_amount, 0)' + : 'COALESCE(ii.item_rate,0) * COALESCE(ii.item_qty,0)'; + + $breakdownBuilder = $db->table('invoice_item ii') + ->select('inv.' . $doctorColumn . ' AS doc_id', false) + ->select("COALESCE(NULLIF(TRIM(CONCAT(COALESCE(dm.p_title,''), ' ', COALESCE(dm.p_fname,''))), ''), CONCAT('Doctor #', inv." . $doctorColumn . ")) AS doctor_name", false) + ->select('COUNT(DISTINCT inv.id) AS completed_opd', false) + ->select('SUM(' . $itemAmountExpr . ') AS gross_amount', false) + ->join('invoice_master inv', 'inv.id = ii.inv_master_id', 'inner') + ->join('doctor_master dm', 'dm.id = inv.' . $doctorColumn, 'left') + ->where('DATE(inv.inv_date) >=', $fromDate) + ->where('DATE(inv.inv_date) <=', $toDate) + ->where($itemAmountExpr . ' > 0', null, false); + + if ($doctorId > 0) { + $breakdownBuilder->where('inv.' . $doctorColumn, $doctorId); + } + if ($stateUnit !== '' && $stateColumn !== null) { + $breakdownBuilder->where('inv.' . $stateColumn, $stateUnit); + } + + $this->applyChargeCategoryFilter($breakdownBuilder, $source, 'ii.item_type'); + + if ($this->tableHasField('invoice_item', 'payout_draft_id')) { + $breakdownBuilder->groupStart() + ->where('ii.payout_draft_id IS NULL', null, false) + ->orWhere('ii.payout_draft_id', 0) + ->groupEnd(); + } + + $doctorBreakdown = $breakdownBuilder + ->groupBy('inv.' . $doctorColumn) + ->orderBy('doctor_name', 'ASC') + ->get() + ->getResultArray(); + + foreach ($doctorBreakdown as &$row) { + $row['completed_opd'] = (int) ($row['completed_opd'] ?? 0); + $row['routine_opd'] = 0; + $row['emergency_opd'] = 0; + $row['running_opd'] = 0; + $row['new_opd'] = 0; + $row['gross_amount'] = (float) ($row['gross_amount'] ?? 0); + $row['org_credit_amount'] = 0.0; + $row['cash_received'] = 0.0; + $row['bank_received'] = 0.0; + $row['total_received'] = 0.0; + $row['credit_amount'] = 0.0; + $row['payout_amount'] = 0.0; + + $summary['completed_opd'] += $row['completed_opd']; + $summary['gross_amount'] += $row['gross_amount']; + } + unset($row); + + $summary['doctor_count'] = count($doctorBreakdown); + + if ($this->tableHasField('invoice_item', 'payout_draft_id')) { + $calcBuilder = $db->table('invoice_item ii') + ->select('COUNT(DISTINCT inv.id) AS calculated_opd', false) + ->join('invoice_master inv', 'inv.id = ii.inv_master_id', 'inner') + ->where('DATE(inv.inv_date) >=', $fromDate) + ->where('DATE(inv.inv_date) <=', $toDate) + ->where($itemAmountExpr . ' > 0', null, false) + ->where('ii.payout_draft_id >', 0); + + if ($doctorId > 0) { + $calcBuilder->where('inv.' . $doctorColumn, $doctorId); + } + if ($stateUnit !== '' && $stateColumn !== null) { + $calcBuilder->where('inv.' . $stateColumn, $stateUnit); + } + $this->applyChargeCategoryFilter($calcBuilder, $source, 'ii.item_type'); + + $calcRow = $calcBuilder->get()->getRowArray(); + $summary['calculated_opd'] = (int) ($calcRow['calculated_opd'] ?? 0); + } + + $eligible = $this->fetchEligibleChargeItemsForPayout($source, $fromDate, $toDate, $doctorId, $stateUnit); + if (! empty($eligible['invoice_ids'])) { + $invoiceIds = $eligible['invoice_ids']; + + $categoryRows = $db->table('invoice_item ii') + ->select('ii.inv_master_id AS invoice_id, SUM(' . $itemAmountExpr . ') AS category_total', false) + ->whereIn('ii.inv_master_id', $invoiceIds) + ->where($itemAmountExpr . ' > 0', null, false); + $this->applyChargeCategoryFilter($categoryRows, $source, 'ii.item_type'); + $categoryRows = $categoryRows->groupBy('ii.inv_master_id')->get()->getResultArray(); + + $invoiceTotals = $db->table('invoice_item ii') + ->select('ii.inv_master_id AS invoice_id, SUM(' . $itemAmountExpr . ') AS invoice_total', false) + ->whereIn('ii.inv_master_id', $invoiceIds) + ->where($itemAmountExpr . ' > 0', null, false) + ->groupBy('ii.inv_master_id') + ->get()->getResultArray(); + + $payments = $db->table('payment_history p') + ->select('p.payof_id AS invoice_id', false) + ->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) + ->where('p.payof_type', 2) + ->whereIn('p.payof_id', $invoiceIds) + ->where('DATE(p.payment_date) >=', $fromDate) + ->where('DATE(p.payment_date) <=', $toDate) + ->groupBy('p.payof_id') + ->get()->getResultArray(); + + $invoiceToDocRows = $db->table('invoice_master inv') + ->select('inv.id AS invoice_id, inv.' . $doctorColumn . ' AS doc_id', false) + ->whereIn('inv.id', $invoiceIds) + ->get()->getResultArray(); + + $categoryMap = []; + foreach ($categoryRows as $row) { + $categoryMap[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['category_total'] ?? 0); + } + + $invoiceTotalMap = []; + foreach ($invoiceTotals as $row) { + $invoiceTotalMap[(int) ($row['invoice_id'] ?? 0)] = (float) ($row['invoice_total'] ?? 0); + } + + $paymentMap = []; + foreach ($payments as $row) { + $paymentMap[(int) ($row['invoice_id'] ?? 0)] = [ + 'total_received' => (float) ($row['total_received'] ?? 0), + 'cash_received' => (float) ($row['cash_received'] ?? 0), + 'bank_received' => (float) ($row['bank_received'] ?? 0), + ]; + } + + $invoiceDocMap = []; + foreach ($invoiceToDocRows as $row) { + $invoiceDocMap[(int) ($row['invoice_id'] ?? 0)] = (int) ($row['doc_id'] ?? 0); + } + + $perDoctorReceived = []; + foreach ($invoiceIds as $invoiceId) { + $invoiceId = (int) $invoiceId; + $docId = (int) ($invoiceDocMap[$invoiceId] ?? 0); + if ($docId <= 0) { + continue; + } + + $categoryTotal = (float) ($categoryMap[$invoiceId] ?? 0); + $invoiceTotal = (float) ($invoiceTotalMap[$invoiceId] ?? 0); + if ($categoryTotal <= 0 || $invoiceTotal <= 0) { + continue; + } + + $ratio = $categoryTotal / $invoiceTotal; + $recv = $paymentMap[$invoiceId] ?? ['total_received' => 0.0, 'cash_received' => 0.0, 'bank_received' => 0.0]; + + $perDoctorReceived[$docId]['total_received'] = (float) ($perDoctorReceived[$docId]['total_received'] ?? 0) + ($recv['total_received'] * $ratio); + $perDoctorReceived[$docId]['cash_received'] = (float) ($perDoctorReceived[$docId]['cash_received'] ?? 0) + ($recv['cash_received'] * $ratio); + $perDoctorReceived[$docId]['bank_received'] = (float) ($perDoctorReceived[$docId]['bank_received'] ?? 0) + ($recv['bank_received'] * $ratio); + } + + foreach ($doctorBreakdown as &$row) { + $docId = (int) ($row['doc_id'] ?? 0); + $recv = $perDoctorReceived[$docId] ?? ['total_received' => 0.0, 'cash_received' => 0.0, 'bank_received' => 0.0]; + + $row['total_received'] = round((float) ($recv['total_received'] ?? 0), 2); + $row['cash_received'] = round((float) ($recv['cash_received'] ?? 0), 2); + $row['bank_received'] = round((float) ($recv['bank_received'] ?? 0), 2); + $row['credit_amount'] = max(0, round((float) $row['gross_amount'] - (float) $row['total_received'], 2)); + + $summary['cash_received'] += (float) $row['cash_received']; + $summary['bank_received'] += (float) $row['bank_received']; + } + unset($row); + } + + $summary['total_received'] = round((float) ($summary['cash_received'] + $summary['bank_received']), 2); + $summary['credit_amount'] = max(0, round((float) $summary['gross_amount'] - (float) $summary['total_received'], 2)); + + 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_type', $this->payoutTypeFromSource($source)) + ->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' => $stateColumn !== null ? ucwords(str_replace('_', ' ', $stateColumn)) : 'State/Unit', + ]; + } + + private function createChargeInvoicePayoutDraft( + string $source, + int $doctorId, + string $fromDate, + string $toDate, + string $stateUnit, + float $baseAmount, + float $doctorShare, + float $hospitalShare, + float $deductions, + float $adjustments + ) { + 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]; + } + + $source = $this->normalizePayoutSource($source); + $summaryData = $this->buildChargeInvoicePayoutSummaryData($source, $fromDate, $toDate, $doctorId, $stateUnit); + $summary = (array) ($summaryData['summary'] ?? []); + $eligible = $this->fetchEligibleChargeItemsForPayout($source, $fromDate, $toDate, $doctorId, $stateUnit); + $eligibleItemIds = $eligible['item_ids'] ?? []; + $eligibleInvoiceIds = $eligible['invoice_ids'] ?? []; + $eligibleUnits = count($eligibleInvoiceIds); + + if ($eligibleUnits <= 0) { + return $this->response->setStatusCode(422)->setJSON([ + 'status' => 0, + 'message' => 'No eligible ' . strtolower($this->payoutSourceLabel($source)) . ' found for selected filters.', + ]); + } + + if ($baseAmount <= 0) { + $baseAmount = (float) ($summary['total_received'] ?? 0); + if ($baseAmount <= 0) { + $baseAmount = (float) ($summary['gross_amount'] ?? 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, $eligibleUnits), 2); + $token = match ($source) { + 'pathology' => 'PATH', + 'radiology' => 'RAD', + 'other_items' => 'OTH', + default => 'CHG', + }; + $caseReference = 'CHG-' . $token . '-' . str_replace('-', '', $fromDate) . '-' . str_replace('-', '', $toDate) . '-D' . $doctorId; + if ($stateUnit !== '') { + $caseReference .= '-U' . $this->normalizeStateUnitToken($stateUnit); + } + + $remarks = $this->payoutSourceLabel($source) . ' payout draft | Invoices=' . $eligibleUnits + . ', Items=' . count($eligibleItemIds) + . ', 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' => $this->payoutTypeFromSource($source), + 'units' => $eligibleUnits, + '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($eligibleItemIds) && $this->tableHasField('invoice_item', 'payout_draft_id')) { + $itemUpdate = [ + 'payout_draft_id' => $insertId, + ]; + if ($this->tableHasField('invoice_item', 'payout_calculated_at')) { + $itemUpdate['payout_calculated_at'] = date('Y-m-d H:i:s'); + } + if ($this->tableHasField('invoice_item', 'payout_calculated_by')) { + $itemUpdate['payout_calculated_by'] = $this->currentUserName(); + } + if ($this->tableHasField('invoice_item', 'updated_at')) { + $itemUpdate['updated_at'] = date('Y-m-d H:i:s'); + } + + $this->db->table('invoice_item') + ->whereIn('id', $eligibleItemIds) + ->update($itemUpdate); + } + + $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, + ]); + } + private function fetchOpdStateUnitOptions(): array { $column = $this->resolveOpdStateUnitColumn(); diff --git a/app/Controllers/Medical.php b/app/Controllers/Medical.php index c2f5ae9..cc8c071 100644 --- a/app/Controllers/Medical.php +++ b/app/Controllers/Medical.php @@ -11477,6 +11477,165 @@ class Medical extends BaseController ]); } + public function credit_payout_requests_datatable() + { + if ($deny = $this->ensurePharmacyAccess()) { + return $deny; + } + + $draw = (int) ($this->request->getPost('draw') ?? $this->request->getGet('draw') ?? 1); + $start = max(0, (int) ($this->request->getPost('start') ?? $this->request->getGet('start') ?? 0)); + $length = (int) ($this->request->getPost('length') ?? $this->request->getGet('length') ?? 25); + if ($length <= 0) { + $length = 25; + } + if ($length > 200) { + $length = 200; + } + + if (! $this->db->tableExists('finance_payout_requests')) { + return $this->response->setJSON([ + 'draw' => $draw, + 'recordsTotal' => 0, + 'recordsFiltered' => 0, + 'data' => [], + 'status_summary' => [ + 'total' => 0, + 'submitted' => 0, + 'finance_review' => 0, + 'approved' => 0, + 'partially_paid' => 0, + 'paid' => 0, + 'rejected' => 0, + ], + ]); + } + + $searchRaw = $this->request->getPost('search'); + if ($searchRaw === null) { + $searchRaw = $this->request->getGet('search'); + } + $searchValue = ''; + if (is_array($searchRaw)) { + $searchValue = trim((string) ($searchRaw['value'] ?? '')); + } elseif (is_string($searchRaw)) { + $searchValue = trim($searchRaw); + } + + $orderRaw = $this->request->getPost('order'); + if ($orderRaw === null) { + $orderRaw = $this->request->getGet('order'); + } + $order = is_array($orderRaw) ? $orderRaw : []; + + $columnsRaw = $this->request->getPost('columns'); + if ($columnsRaw === null) { + $columnsRaw = $this->request->getGet('columns'); + } + $columns = is_array($columnsRaw) ? $columnsRaw : []; + + $builder = $this->db->table('finance_payout_requests pr') + ->where('pr.request_type', 'medical_store_credit'); + + $recordsTotal = (int) $builder->countAllResults(false); + + if ($searchValue !== '') { + $builder + ->groupStart() + ->like('pr.request_no', $searchValue) + ->orLike('pr.status', $searchValue) + ->orLike('pr.request_date', $searchValue) + ->orLike('pr.created_by', $searchValue) + ->groupEnd(); + } + + $recordsFiltered = (int) $builder->countAllResults(false); + + $orderableMap = [ + 'request_no' => 'pr.request_no', + 'request_date' => 'pr.request_date', + 'status' => 'pr.status', + 'requested_amount' => 'pr.requested_amount', + 'paid_amount' => 'pr.paid_amount', + 'pending_amount' => 'pr.pending_amount', + 'created_by' => 'pr.created_by', + ]; + + $appliedOrder = false; + if (! empty($order)) { + foreach ($order as $ord) { + $colIndex = (int) ($ord['column'] ?? -1); + $dir = strtolower((string) ($ord['dir'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC'; + $colData = ''; + if ($colIndex >= 0 && isset($columns[$colIndex]) && is_array($columns[$colIndex])) { + $colData = (string) ($columns[$colIndex]['data'] ?? ''); + } + if ($colData !== '' && isset($orderableMap[$colData])) { + $builder->orderBy($orderableMap[$colData], $dir); + $appliedOrder = true; + } + } + } + + if (! $appliedOrder) { + $builder->orderBy('pr.id', 'DESC'); + } + + $rows = $builder + ->select('pr.id, pr.request_no, pr.request_date, pr.status, pr.requested_amount, pr.paid_amount, pr.pending_amount, pr.created_by') + ->limit($length, $start) + ->get() + ->getResultArray(); + + $data = []; + foreach ($rows as $i => $row) { + $data[] = [ + 'row_no' => $start + $i + 1, + 'id' => (int) ($row['id'] ?? 0), + 'request_no' => (string) ($row['request_no'] ?? ''), + 'request_date' => (string) ($row['request_date'] ?? ''), + 'status' => (string) ($row['status'] ?? ''), + 'requested_amount' => round((float) ($row['requested_amount'] ?? 0), 2), + 'paid_amount' => round((float) ($row['paid_amount'] ?? 0), 2), + 'pending_amount' => round((float) ($row['pending_amount'] ?? 0), 2), + 'created_by' => (string) ($row['created_by'] ?? ''), + ]; + } + + $summaryRows = $this->db->table('finance_payout_requests') + ->select('LOWER(status) AS status_key, COUNT(1) AS cnt', false) + ->where('request_type', 'medical_store_credit') + ->groupBy('LOWER(status)') + ->get() + ->getResultArray(); + + $statusSummary = [ + 'total' => 0, + 'submitted' => 0, + 'finance_review' => 0, + 'approved' => 0, + 'partially_paid' => 0, + 'paid' => 0, + 'rejected' => 0, + ]; + foreach ($summaryRows as $sr) { + $key = strtolower(trim((string) ($sr['status_key'] ?? ''))); + $cnt = (int) ($sr['cnt'] ?? 0); + $statusSummary['total'] += $cnt; + if (array_key_exists($key, $statusSummary)) { + $statusSummary[$key] += $cnt; + } + } + + return $this->response->setJSON([ + 'draw' => $draw, + 'recordsTotal' => $recordsTotal, + 'recordsFiltered' => $recordsFiltered, + 'data' => $data, + 'status_summary' => $statusSummary, + ]); + } + public function credit_payout_request_detail($requestId = 0) { if ($deny = $this->ensurePharmacyAccess()) { diff --git a/app/Database/Migrations/2026-05-01-000201_AddInvoiceItemPayoutTrackingFields.php b/app/Database/Migrations/2026-05-01-000201_AddInvoiceItemPayoutTrackingFields.php new file mode 100644 index 0000000..70f5cc6 --- /dev/null +++ b/app/Database/Migrations/2026-05-01-000201_AddInvoiceItemPayoutTrackingFields.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('invoice_item')) { + return; + } + + $fields = $this->getTableFields('invoice_item'); + + if (! in_array('payout_draft_id', $fields, true)) { + $this->forge->addColumn('invoice_item', [ + 'payout_draft_id' => [ + 'type' => 'INT', + 'constraint' => 11, + 'unsigned' => true, + 'null' => true, + 'after' => 'inv_master_id', + ], + ]); + } + + if (! in_array('payout_calculated_at', $fields, true)) { + $this->forge->addColumn('invoice_item', [ + 'payout_calculated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + 'after' => 'payout_draft_id', + ], + ]); + } + + if (! in_array('payout_calculated_by', $fields, true)) { + $this->forge->addColumn('invoice_item', [ + 'payout_calculated_by' => [ + 'type' => 'VARCHAR', + 'constraint' => 120, + 'null' => true, + 'after' => 'payout_calculated_at', + ], + ]); + } + } + + public function down() + { + if (! $this->hasTable('invoice_item')) { + return; + } + + $fields = $this->getTableFields('invoice_item'); + + if (in_array('payout_calculated_by', $fields, true)) { + $this->forge->dropColumn('invoice_item', 'payout_calculated_by'); + } + if (in_array('payout_calculated_at', $fields, true)) { + $this->forge->dropColumn('invoice_item', 'payout_calculated_at'); + } + if (in_array('payout_draft_id', $fields, true)) { + $this->forge->dropColumn('invoice_item', 'payout_draft_id'); + } + } +} diff --git a/app/Views/finance/doctor_charges_payout.php b/app/Views/finance/doctor_charges_payout.php new file mode 100644 index 0000000..145f98a --- /dev/null +++ b/app/Views/finance/doctor_charges_payout.php @@ -0,0 +1,208 @@ +
+
+

Doctor Charges Payout

+

Calculate and manage doctor payout from Pathology, Radiology, and Other charge invoices.

+
+ +
+ +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
Select filters and click Load Summary.
+
+ +
+
Loading payout drafts...
+
+
+
+
+ + + + diff --git a/app/Views/finance/medical_store_credit_account.php b/app/Views/finance/medical_store_credit_account.php index dbe643d..02dc62e 100644 --- a/app/Views/finance/medical_store_credit_account.php +++ b/app/Views/finance/medical_store_credit_account.php @@ -14,64 +14,8 @@
-
-
-
Request Actions
-
-
-
- - -
-
- - -
-
- - - -
-
- -
- -
-
Settlement Payment
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
-
-
-
-
-
-
-
- -
-
+
+
Request List
@@ -95,15 +39,119 @@
-