nhance/app/Libraries/VidalMisReportService.php
2026-08-07 10:22:44 +05:30

1615 lines
54 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Libraries;
use App\Models\ClaimDumpFileModel;
use App\Models\ClaimReportDashboardModel;
/**
* Builds Vidal Health Corporate Analysis MIS from claim_report → claims_dump_vidal.
*/
class VidalMisReportService
{
public const DUMP_TABLE = 'claims_dump_vidal';
private const VERSION = '1.0';
/** Exact ICR table row labels (HTML). */
private const STATUS_ROWS = [
'Reported',
'Settled',
'Rejected',
'Cancelled',
'Awaiting Utr',
'Shortfall',
'Approved',
'Underprocess',
'Bills Pending',
'Recommended For Repudiation',
'Recommended For Approval',
'Outstanding Claims',
'Incurred(Os+Settled)',
];
private const OS_STATUSES = [
'Awaiting Utr',
'Shortfall',
'Approved',
'Underprocess',
'Bills Pending',
'Recommended For Repudiation',
'Recommended For Approval',
];
private const DISPOSAL_STATUSES = [
'Settled',
'Rejected',
'Awaiting Utr',
'Cancelled',
];
/** Settled + Awaiting UTR for "approved claims" sections. */
private const APPROVED_STATUSES = [
'Settled',
'Awaiting Utr',
];
/** Settled / Approved / Awaiting UTR for hospitalisation type. */
private const HOSP_STATUSES = [
'Settled',
'Approved',
'Awaiting Utr',
];
private const TAT_STATUSES = [
'Settled',
'Awaiting Utr',
'Approved',
'Rejected',
];
private const LANES = ['Cashless', 'Member'];
private const HOSP_SUBTYPES = [
'Claim Benefits',
'Daycare',
'Domiciliary',
'Health_Check_Up',
'Hospitalization',
'Opd',
];
private const GENDER_RELATIONS = [
'Self', 'Spouse', 'Partner', 'Child', 'Parents', 'In Laws', 'Others',
];
private const AGE_RELATIONS = [
'Self', 'Spouse', 'Partner', 'Child', 'Parents', 'In Law', 'Other',
];
private const AGE_BANDS = [
'0-5', '6-10', '11-20', '21-30', '31-40', '41-50', '51-60', '61-70', '>70',
];
private const AMOUNT_BANDS = [
'00K-10K', '10K-20K', '20K-30K', '30K-40K', '40K-50K',
'50K-60K', '60K-70K', '70K-80K', '80K-90K', '90K-100K', '>100K',
];
private const TAT_BANDS = [
'0-7', '8-15', '16-30', '31-45', '46-60', '61-90', '>90',
];
private const TOC = [
'Incurred Claims Ratio (ICR)',
'Hospitalisation Type Details',
'Member Details - Relationship & Gender wise',
'Member Details - Age Band & Relationship wise',
'Claims Approved - Age Band & Relationship wise',
'Claims Approved - Amount Band & Relationship wise',
'Claims Approved - Ailment wise',
'Top 15 Hospital wise utilization',
'Claims Approved - Cashless & Member Summary',
'Turn Around Time (TAT)',
'Month On Month',
'Payout Ratio',
'Policy Details',
];
private ClaimReportDashboardModel $kpiModel;
private ClaimDumpFileModel $dumpFileModel;
public function __construct(
?ClaimReportDashboardModel $kpiModel = null,
?ClaimDumpFileModel $dumpFileModel = null
) {
$this->kpiModel = $kpiModel ?? new ClaimReportDashboardModel();
$this->dumpFileModel = $dumpFileModel ?? new ClaimDumpFileModel();
}
/**
* @return array{status:bool,message?:string,data?:array}
*/
public function build(int $policyId): array
{
if ($policyId <= 0) {
return ['status' => false, 'message' => 'client_policy_id is required.'];
}
$db = db_connect();
if (!$db->tableExists('claim_report') || !$db->tableExists(self::DUMP_TABLE)) {
return [
'status' => false,
'message' => 'Required tables claim_report or claims_dump_vidal do not exist.',
];
}
$dumpRows = $this->getLinkedDumpRows($policyId);
if ($dumpRows === []) {
return [
'status' => false,
'message' => 'No Vidal dump rows linked via claim_report. Run sync: /util/claims-collection-report/sync',
];
}
$policy = $db->table('client_policy cp')
->select('cp.*, c.client_name, i.name AS insurer_name, t.name AS tpa_name')
->join('clients c', 'c.id = cp.client_id', 'left')
->join('insurers i', 'i.id = cp.insurer_id', 'left')
->join('tpa t', 't.id = cp.tpa_id', 'left')
->where('cp.id', $policyId)
->get()
->getRowArray();
if (!$policy) {
return ['status' => false, 'message' => 'Policy not found.'];
}
$exposure = $this->firstRow($this->kpiModel->policy_exposure_summary($policyId));
$premiumRow = $this->firstRow($this->kpiModel->premium_as_on_date($policyId));
$livesRow = $this->firstRow($this->kpiModel->current_emp_lives($policyId));
$experience = $this->firstRow($this->kpiModel->claims_experience_summary($policyId));
$premium = $this->parseFormattedNumber($premiumRow['premium_as_on_date'] ?? null);
$earned = $this->parseFormattedNumber($experience['earned_premium'] ?? null);
$lives = (int) ($livesRow['current_lives'] ?? 0);
$firstDump = $dumpRows[0];
$startRaw = $policy['policy_start_date']
?? $exposure['policy_start_date']
?? ($firstDump['policy_start_date'] ?? null);
$endRaw = $policy['policy_end_date']
?? $exposure['policy_end_date']
?? ($firstDump['policy_end_date'] ?? null);
$generatedAtTs = $this->dumpFileModel->getLatestClaimDumpTimestamp($policyId) ?: time();
$generatedAtDisplay = date('d-M-Y', $generatedAtTs);
$corporateName = trim((string) (
$firstDump['corporate_name']
?? $policy['client_name']
?? ''
));
$policyNumber = trim((string) (
$firstDump['insurer_policy_number']
?? $policy['policy_no']
?? ''
));
$icr = $this->buildIcr($dumpRows, $earned, $lives);
$hospitalization = $this->buildHospitalization($dumpRows);
$enrollment = $this->loadEnrollmentMembers($policyId, $dumpRows, $lives);
$memberGender = $this->buildMemberGender($enrollment);
$memberAge = $this->buildMemberAge($enrollment);
$claimsAge = $this->buildClaimsByAgeRelation($dumpRows);
$claimsAmount = $this->buildClaimsByAmountRelation($dumpRows);
$ailments = $this->buildTopAilments($dumpRows, 15);
$hospitals = $this->buildTopHospitals($dumpRows, 15);
$cmSummary = $this->buildCashlessMemberSummary($dumpRows);
$tat = $this->buildTat($dumpRows);
$mom = $this->buildMonthOnMonth($dumpRows);
$payout = $this->buildPayout($dumpRows);
$data = [
'meta' => [
'policy_id' => $policyId,
'version' => self::VERSION,
'tpa_name' => trim((string) ($policy['tpa_name'] ?? 'Vidal Health')),
'generated_at' => $generatedAtDisplay,
'client_name' => trim((string) ($policy['client_name'] ?? '')),
],
'header' => [
'corporate_name' => $corporateName,
'policy_number' => $policyNumber,
'start' => $this->formatDate($startRaw),
'end' => $this->formatDate($endRaw),
'premium' => $premium,
'earned' => $earned,
'lives' => $lives > 0 ? $lives : (int) ($memberGender['total']['total'] ?? 0),
'generated_by' => '',
'generated_at' => $generatedAtDisplay,
'insurer_name' => trim((string) (
$firstDump['insurance_company_name']
?? $policy['insurer_name']
?? $exposure['insurer_name']
?? ''
)),
],
'toc' => self::TOC,
'icr' => $icr,
'hospitalization' => $hospitalization,
'member_gender' => $memberGender,
'member_age' => $memberAge,
'claims_age' => $claimsAge,
'claims_amount' => $claimsAmount,
'ailments' => $ailments,
'hospitals' => $hospitals,
'cashless_member_summary' => $cmSummary,
'tat' => $tat,
'month_on_month' => $mom,
'payout' => $payout,
'chart_gender' => $this->buildChartGender($memberGender),
'chart_age' => $this->buildChartAge($memberAge),
'dump_row_count' => count($dumpRows),
];
$data['view_model'] = $data;
return ['status' => true, 'data' => $data];
}
/**
* @return list<array<string,mixed>>
*/
public function getLinkedDumpRows(int $policyId): array
{
$db = db_connect();
$reportRows = $db->table('claim_report')
->select('source_row_id')
->where('client_policy_id', $policyId)
->where('is_active', 1)
->where('source_table', self::DUMP_TABLE)
->where('source_row_id IS NOT NULL', null, false)
->get()
->getResultArray();
if ($reportRows === []) {
return [];
}
$dumpIds = array_values(array_unique(array_filter(array_map(
static fn ($r) => (int) ($r['source_row_id'] ?? 0),
$reportRows
))));
if ($dumpIds === []) {
return [];
}
return $db->table(self::DUMP_TABLE)
->whereIn('id', $dumpIds)
->where('is_active', 1)
->orderBy('id', 'ASC')
->get()
->getResultArray();
}
// -------------------------------------------------------------------------
// ICR
// -------------------------------------------------------------------------
/**
* @param list<array<string,mixed>> $rows
* @return array{rows:list<array<string,mixed>>,summary:array<string,mixed>}
*/
private function buildIcr(array $rows, float $earned, int $lives): array
{
$empty = ['count' => 0, 'amount' => 0.0];
$grid = [];
foreach (self::STATUS_ROWS as $label) {
$grid[$label] = [
'Cashless' => $empty,
'Member' => $empty,
];
}
foreach ($rows as $row) {
$status = $this->statusLabel($row);
$lane = $this->claimLane($row);
if ($status === '' || !isset($grid[$status])) {
// Still count toward Reported even if status unknown
$statusKey = null;
} else {
$statusKey = $status;
}
$claimed = $this->claimedAmount($row);
$approved = $this->approvedAmount($row);
$osAmt = $this->osAmount($row);
// Reported = all
$grid['Reported'][$lane]['count']++;
$grid['Reported'][$lane]['amount'] += $claimed;
if ($statusKey === null) {
continue;
}
// Settled / Awaiting Utr / Approved → approved_amount; Rejected/Cancelled → claimed; other OS → claim/incurred
$amt = match ($statusKey) {
'Settled', 'Awaiting Utr', 'Approved' => $approved > 0 ? $approved : $claimed,
'Rejected', 'Cancelled' => $claimed,
default => $osAmt,
};
$grid[$statusKey][$lane]['count']++;
$grid[$statusKey][$lane]['amount'] += $amt;
}
// Outstanding = sum of OS statuses
foreach (self::LANES as $lane) {
$c = 0;
$a = 0.0;
foreach (self::OS_STATUSES as $os) {
$c += $grid[$os][$lane]['count'];
$a += $grid[$os][$lane]['amount'];
}
$grid['Outstanding Claims'][$lane] = ['count' => $c, 'amount' => $a];
}
// Incurred = OS + Settled
foreach (self::LANES as $lane) {
$grid['Incurred(Os+Settled)'][$lane] = [
'count' => $grid['Outstanding Claims'][$lane]['count'] + $grid['Settled'][$lane]['count'],
'amount' => $grid['Outstanding Claims'][$lane]['amount'] + $grid['Settled'][$lane]['amount'],
];
}
$outRows = [];
foreach (self::STATUS_ROWS as $label) {
$c = $grid[$label]['Cashless'];
$m = $grid[$label]['Member'];
$outRows[] = [
'label' => $label,
'cashless' => $c,
'member' => $m,
'total' => [
'count' => $c['count'] + $m['count'],
'amount' => $c['amount'] + $m['amount'],
],
];
}
$reportedC = $grid['Reported']['Cashless']['count'];
$reportedM = $grid['Reported']['Member']['count'];
$reportedT = $reportedC + $reportedM;
$disposalNum = static function (array $g, string $lane): int {
$n = 0;
foreach (self::DISPOSAL_STATUSES as $s) {
$n += $g[$s][$lane]['count'];
}
return $n;
};
$disposalC = $reportedC > 0
? round(($disposalNum($grid, 'Cashless') / $reportedC) * 100)
: 0;
$disposalM = $reportedM > 0
? round(($disposalNum($grid, 'Member') / $reportedM) * 100)
: 0;
$disposalT = $reportedT > 0
? round((($disposalNum($grid, 'Cashless') + $disposalNum($grid, 'Member')) / $reportedT) * 100)
: 0;
$incurredTotal = $grid['Incurred(Os+Settled)']['Cashless']['amount']
+ $grid['Incurred(Os+Settled)']['Member']['amount'];
$icrOnEp = $earned > 0 ? round(($incurredTotal / $earned) * 100, 1) : 0.0;
$incidence = $lives > 0 ? round(($reportedT / $lives) * 100, 1) : 0.0;
// CPC = approved amt / events for settled + awaiting utr
$cpc = $this->computeCpc($rows);
return [
'rows' => $outRows,
'summary' => [
'icr_on_ep' => $icrOnEp,
'incidence_rate' => $incidence,
'disposal_rate' => [
'cashless' => $disposalC,
'member' => $disposalM,
'total' => $disposalT,
],
'cpc' => $cpc,
],
];
}
/**
* @param list<array<string,mixed>> $rows
* @return array{cashless:float,member:float,total:float}
*/
private function computeCpc(array $rows): array
{
$amt = ['Cashless' => 0.0, 'Member' => 0.0];
$cnt = ['Cashless' => 0, 'Member' => 0];
foreach ($rows as $row) {
$status = $this->statusLabel($row);
if (!in_array($status, self::APPROVED_STATUSES, true)) {
continue;
}
if (!$this->isMainClaim($row)) {
continue;
}
$lane = $this->claimLane($row);
$amt[$lane] += $this->approvedAmount($row);
$cnt[$lane]++;
}
$cpcC = $cnt['Cashless'] > 0 ? round($amt['Cashless'] / $cnt['Cashless']) : 0.0;
$cpcM = $cnt['Member'] > 0 ? round($amt['Member'] / $cnt['Member']) : 0.0;
$totN = $cnt['Cashless'] + $cnt['Member'];
$totA = $amt['Cashless'] + $amt['Member'];
$cpcT = $totN > 0 ? round($totA / $totN) : 0.0;
return [
'cashless' => $cpcC,
'member' => $cpcM,
'total' => $cpcT,
];
}
// -------------------------------------------------------------------------
// Hospitalisation
// -------------------------------------------------------------------------
/**
* @param list<array<string,mixed>> $rows
* @return array{rows:list<array<string,mixed>>,total:array<string,mixed>}
*/
private function buildHospitalization(array $rows): array
{
$empty = ['count' => 0, 'amount' => 0.0];
$grid = [];
foreach (self::HOSP_SUBTYPES as $sub) {
$grid[$sub] = ['Cashless' => $empty, 'Member' => $empty];
}
foreach ($rows as $row) {
$status = $this->statusLabel($row);
if (!in_array($status, self::HOSP_STATUSES, true)) {
continue;
}
$sub = $this->hospSubtype($row);
$lane = $this->claimLane($row);
if (!isset($grid[$sub])) {
continue;
}
$grid[$sub][$lane]['count']++;
$grid[$sub][$lane]['amount'] += $this->approvedAmount($row) > 0
? $this->approvedAmount($row)
: $this->claimedAmount($row);
}
$outRows = [];
$totals = ['Cashless' => $empty, 'Member' => $empty];
foreach (self::HOSP_SUBTYPES as $sub) {
$c = $grid[$sub]['Cashless'];
$m = $grid[$sub]['Member'];
$outRows[] = [
'label' => $sub,
'cashless' => $c,
'member' => $m,
];
$totals['Cashless']['count'] += $c['count'];
$totals['Cashless']['amount'] += $c['amount'];
$totals['Member']['count'] += $m['count'];
$totals['Member']['amount'] += $m['amount'];
}
return [
'rows' => $outRows,
'total' => [
'cashless' => $totals['Cashless'],
'member' => $totals['Member'],
],
];
}
// -------------------------------------------------------------------------
// Enrollment / member matrices
// -------------------------------------------------------------------------
/**
* Prefer employees × employee_polices; else unique dump members.
*
* @param list<array<string,mixed>> $dumpRows
* @return list<array{relation:string,gender:string,age:int|null}>
*/
private function loadEnrollmentMembers(int $policyId, array $dumpRows, int $lives): array
{
$db = db_connect();
$members = [];
if ($db->tableExists('employees') && $db->tableExists('employee_polices')) {
try {
$rows = $db->table('employees e')
->select('e.gender, e.dob, e.relationship, e.relationship_code')
->join('employee_polices ep', 'ep.employee_id = e.id')
->where('ep.client_policy_id', $policyId)
->where('ep.is_active', 1)
->whereIn('ep.status', ['active', 'expired'])
->get()
->getResultArray();
foreach ($rows as $r) {
$age = null;
$dob = trim((string) ($r['dob'] ?? ''));
if ($dob !== '' && $dob !== '0000-00-00') {
$ts = strtotime($dob);
if ($ts) {
$age = (int) floor((time() - $ts) / 31557600);
}
}
$members[] = [
'relation' => $this->normalizeRelation(
(string) ($r['relationship'] ?? $r['relationship_code'] ?? ''),
true
),
'gender' => $this->normalizeGender($r['gender'] ?? ''),
'age' => $age,
];
}
} catch (\Throwable $e) {
$members = [];
}
}
if ($members !== []) {
return $members;
}
// Fallback: unique members from dump
$seen = [];
foreach ($dumpRows as $row) {
$uhid = trim((string) ($row['patient_health_card_id'] ?? ''));
$key = $uhid !== ''
? $uhid
: trim((string) ($row['patient_name'] ?? '')) . '|' . ($row['age'] ?? '') . '|' . ($row['relation'] ?? '');
if ($key === '|' || isset($seen[$key])) {
continue;
}
$seen[$key] = true;
$ageRaw = $row['age'] ?? null;
$age = is_numeric($ageRaw) ? (int) $ageRaw : null;
$members[] = [
'relation' => $this->normalizeRelation((string) ($row['relation'] ?? ''), true),
'gender' => $this->normalizeGender($row['gender'] ?? ''),
'age' => $age,
];
}
// If still empty but lives known, return empty (counts only from dump elsewhere)
unset($lives);
return $members;
}
/**
* @param list<array{relation:string,gender:string,age:int|null}> $members
* @return array{rows:list<array<string,mixed>>,total:array<string,mixed>,pct_gender:array{male:float,female:float}}
*/
private function buildMemberGender(array $members): array
{
$grid = [];
foreach (self::GENDER_RELATIONS as $rel) {
$grid[$rel] = ['male' => 0, 'female' => 0, 'total' => 0];
}
foreach ($members as $m) {
$rel = $this->mapToGenderRelation($m['relation']);
$g = $m['gender'];
if (!isset($grid[$rel])) {
$rel = 'Others';
}
if ($g === 'Male') {
$grid[$rel]['male']++;
} elseif ($g === 'Female') {
$grid[$rel]['female']++;
} else {
// Unknown gender — skip gender cells but still count? Put in Others male as 0
continue;
}
$grid[$rel]['total'] = $grid[$rel]['male'] + $grid[$rel]['female'];
}
$grand = ['male' => 0, 'female' => 0, 'total' => 0];
$rows = [];
foreach (self::GENDER_RELATIONS as $rel) {
$r = $grid[$rel];
$grand['male'] += $r['male'];
$grand['female'] += $r['female'];
$grand['total'] += $r['total'];
}
foreach (self::GENDER_RELATIONS as $rel) {
$r = $grid[$rel];
$rows[] = [
'relation' => $rel,
'male' => $r['male'],
'female' => $r['female'],
'total' => $r['total'],
'pct' => $grand['total'] > 0
? round(($r['total'] / $grand['total']) * 100, 2)
: 0.0,
];
}
return [
'rows' => $rows,
'total' => $grand,
'pct_gender' => [
'male' => $grand['total'] > 0 ? round(($grand['male'] / $grand['total']) * 100) : 0,
'female' => $grand['total'] > 0 ? round(($grand['female'] / $grand['total']) * 100) : 0,
],
];
}
/**
* @param list<array{relation:string,gender:string,age:int|null}> $members
* @return array{rows:list<array<string,mixed>>,total:array<string,mixed>,col_pct:array<string,float>}
*/
private function buildMemberAge(array $members): array
{
$matrix = [];
foreach (self::AGE_BANDS as $band) {
foreach (self::AGE_RELATIONS as $rel) {
$matrix[$band][$rel] = 0;
}
$matrix[$band]['_total'] = 0;
}
foreach ($members as $m) {
if ($m['age'] === null) {
continue;
}
$band = $this->ageBandFromInt((int) $m['age']);
$rel = $this->mapToAgeRelation($m['relation']);
if (!isset($matrix[$band])) {
continue;
}
$matrix[$band][$rel]++;
$matrix[$band]['_total']++;
}
$colTotals = array_fill_keys(self::AGE_RELATIONS, 0);
$grand = 0;
foreach (self::AGE_BANDS as $band) {
foreach (self::AGE_RELATIONS as $rel) {
$colTotals[$rel] += $matrix[$band][$rel];
}
$grand += $matrix[$band]['_total'];
}
$rows = [];
foreach (self::AGE_BANDS as $band) {
$row = ['band' => $band];
foreach (self::AGE_RELATIONS as $rel) {
$row[$rel] = $matrix[$band][$rel];
}
$row['total'] = $matrix[$band]['_total'];
$row['pct'] = $grand > 0
? round(($matrix[$band]['_total'] / $grand) * 100, 2)
: 0.0;
$rows[] = $row;
}
$colPct = [];
foreach (self::AGE_RELATIONS as $rel) {
$colPct[$rel] = $grand > 0
? round(($colTotals[$rel] / $grand) * 100)
: 0;
}
return [
'rows' => $rows,
'total' => array_merge($colTotals, ['_total' => $grand]),
'col_pct' => $colPct,
];
}
/**
* @param array<string,mixed> $memberGender
* @return list<array{relation:string,male:int,female:int}>
*/
private function buildChartGender(array $memberGender): array
{
$out = [];
foreach ($memberGender['rows'] ?? [] as $r) {
$out[] = [
'relation' => (string) ($r['relation'] ?? ''),
'male' => (int) ($r['male'] ?? 0),
'female' => (int) ($r['female'] ?? 0),
];
}
return $out;
}
/**
* @param array<string,mixed> $memberAge
* @return list<array{band:string,total:int,dominant:string}>
*/
private function buildChartAge(array $memberAge): array
{
$out = [];
foreach ($memberAge['rows'] ?? [] as $r) {
$band = (string) ($r['band'] ?? '');
$total = (int) ($r['total'] ?? 0);
$parts = [];
foreach (self::AGE_RELATIONS as $rel) {
if ((int) ($r[$rel] ?? 0) > 0) {
$parts[] = $rel;
}
}
$dominant = match (true) {
in_array('Child', $parts, true) && count($parts) === 1 => 'Child',
in_array('Self', $parts, true) && in_array('Child', $parts, true) => 'Self+Child',
in_array('Self', $parts, true) && in_array('Spouse', $parts, true) => 'Self+Spouse',
in_array('Parents', $parts, true) && count(array_diff($parts, ['Parents', 'In Law', 'Other'])) === 0 => 'Parents',
default => implode('+', array_slice($parts, 0, 2)),
};
$out[] = [
'band' => $band,
'total' => $total,
'dominant' => $dominant,
'counts' => array_combine(
self::AGE_RELATIONS,
array_map(static fn ($rel) => (int) ($r[$rel] ?? 0), self::AGE_RELATIONS)
),
];
}
return $out;
}
// -------------------------------------------------------------------------
// Claims approved matrices
// -------------------------------------------------------------------------
/**
* @param list<array<string,mixed>> $rows
* @return array{rows:list<array<string,mixed>>,total:array<string,mixed>,col_pct:array<string,mixed>}
*/
private function buildClaimsByAgeRelation(array $rows): array
{
return $this->buildApprovedRelationGrid(
$rows,
self::AGE_BANDS,
function ($row) {
$age = $this->rowAge($row);
return $age < 0 ? '' : $this->ageBandFromInt($age);
},
'band'
);
}
/**
* @param list<array<string,mixed>> $rows
* @return array{rows:list<array<string,mixed>>,total:array<string,mixed>,col_pct:array<string,mixed>}
*/
private function buildClaimsByAmountRelation(array $rows): array
{
return $this->buildApprovedRelationGrid(
$rows,
self::AMOUNT_BANDS,
fn ($row) => $this->amountBand($this->approvedAmount($row) > 0
? $this->approvedAmount($row)
: $this->claimedAmount($row)),
'band'
);
}
/**
* @param list<array<string,mixed>> $rows
* @param list<string> $rowKeys
* @param callable(array):string $keyFn
* @return array{rows:list<array<string,mixed>>,total:array<string,mixed>,col_pct:array<string,mixed>}
*/
private function buildApprovedRelationGrid(array $rows, array $rowKeys, callable $keyFn, string $keyName): array
{
$emptyCell = ['count' => 0, 'amount' => 0.0];
$matrix = [];
foreach ($rowKeys as $k) {
foreach (self::AGE_RELATIONS as $rel) {
$matrix[$k][$rel] = $emptyCell;
}
$matrix[$k]['_total'] = $emptyCell;
}
foreach ($rows as $row) {
if (!$this->isApprovedClaim($row)) {
continue;
}
$key = $keyFn($row);
if ($key === '' || !isset($matrix[$key])) {
continue;
}
$rel = $this->mapToAgeRelation($this->normalizeRelation((string) ($row['relation'] ?? ''), false));
$amt = $this->approvedAmount($row) > 0
? $this->approvedAmount($row)
: $this->claimedAmount($row);
$matrix[$key][$rel]['count']++;
$matrix[$key][$rel]['amount'] += $amt;
$matrix[$key]['_total']['count']++;
$matrix[$key]['_total']['amount'] += $amt;
}
$colTotals = [];
foreach (self::AGE_RELATIONS as $rel) {
$colTotals[$rel] = $emptyCell;
}
$grand = $emptyCell;
foreach ($rowKeys as $k) {
foreach (self::AGE_RELATIONS as $rel) {
$colTotals[$rel]['count'] += $matrix[$k][$rel]['count'];
$colTotals[$rel]['amount'] += $matrix[$k][$rel]['amount'];
}
$grand['count'] += $matrix[$k]['_total']['count'];
$grand['amount'] += $matrix[$k]['_total']['amount'];
}
$outRows = [];
foreach ($rowKeys as $k) {
$row = [$keyName => $k];
foreach (self::AGE_RELATIONS as $rel) {
$row[$rel] = $matrix[$k][$rel];
}
$row['total'] = $matrix[$k]['_total'];
$row['pct'] = [
'count' => $grand['count'] > 0
? round(($matrix[$k]['_total']['count'] / $grand['count']) * 100, 2)
: 0.0,
'amount' => $grand['amount'] > 0
? round(($matrix[$k]['_total']['amount'] / $grand['amount']) * 100, 2)
: 0.0,
];
$outRows[] = $row;
}
$colPct = [];
foreach (self::AGE_RELATIONS as $rel) {
$colPct[$rel] = [
'count' => $grand['count'] > 0
? round(($colTotals[$rel]['count'] / $grand['count']) * 100)
: 0,
'amount' => $grand['amount'] > 0
? round(($colTotals[$rel]['amount'] / $grand['amount']) * 100)
: 0,
];
}
return [
'rows' => $outRows,
'total' => array_merge($colTotals, ['_total' => $grand]),
'col_pct' => $colPct,
];
}
/**
* @param list<array<string,mixed>> $rows
* @return array{rows:list<array<string,mixed>>,total:array<string,mixed>,col_pct:array<string,mixed>}
*/
private function buildTopAilments(array $rows, int $limit = 15): array
{
$emptyCell = ['count' => 0, 'amount' => 0.0];
$byAilment = [];
foreach ($rows as $row) {
if (!$this->isApprovedClaim($row)) {
continue;
}
$name = trim((string) ($row['ailment_grouping'] ?? ''));
if ($name === '') {
$name = trim((string) ($row['diagnosis'] ?? ''));
}
if ($name === '') {
$name = 'OTHERS';
}
$name = strtoupper($name);
$rel = $this->mapToAgeRelation($this->normalizeRelation((string) ($row['relation'] ?? ''), false));
$amt = $this->approvedAmount($row) > 0
? $this->approvedAmount($row)
: $this->claimedAmount($row);
if (!isset($byAilment[$name])) {
$byAilment[$name] = ['_total' => $emptyCell];
foreach (self::AGE_RELATIONS as $r) {
$byAilment[$name][$r] = $emptyCell;
}
}
$byAilment[$name][$rel]['count']++;
$byAilment[$name][$rel]['amount'] += $amt;
$byAilment[$name]['_total']['count']++;
$byAilment[$name]['_total']['amount'] += $amt;
}
uasort($byAilment, static function ($a, $b) {
$cmp = ($b['_total']['amount'] <=> $a['_total']['amount']);
if ($cmp !== 0) {
return $cmp;
}
return $b['_total']['count'] <=> $a['_total']['count'];
});
$top = array_slice($byAilment, 0, $limit, true);
$grand = $emptyCell;
foreach ($byAilment as $block) {
$grand['count'] += $block['_total']['count'];
$grand['amount'] += $block['_total']['amount'];
}
// Recalculate col totals from top only for display total row of top-N
$colTotals = [];
foreach (self::AGE_RELATIONS as $rel) {
$colTotals[$rel] = $emptyCell;
}
$topGrand = $emptyCell;
$outRows = [];
foreach ($top as $name => $block) {
$row = ['ailment' => $name];
foreach (self::AGE_RELATIONS as $rel) {
$row[$rel] = $block[$rel];
$colTotals[$rel]['count'] += $block[$rel]['count'];
$colTotals[$rel]['amount'] += $block[$rel]['amount'];
}
$row['total'] = $block['_total'];
$topGrand['count'] += $block['_total']['count'];
$topGrand['amount'] += $block['_total']['amount'];
// % against overall approved (not just top-N), matching HTML sample
$row['pct'] = [
'count' => $grand['count'] > 0
? round(($block['_total']['count'] / $grand['count']) * 100, 2)
: 0.0,
'amount' => $grand['amount'] > 0
? round(($block['_total']['amount'] / $grand['amount']) * 100, 2)
: 0.0,
];
$outRows[] = $row;
}
// Total row = all approved (HTML shows full total), not just top-N sum
$allColTotals = [];
foreach (self::AGE_RELATIONS as $rel) {
$allColTotals[$rel] = $emptyCell;
}
foreach ($byAilment as $block) {
foreach (self::AGE_RELATIONS as $rel) {
$allColTotals[$rel]['count'] += $block[$rel]['count'];
$allColTotals[$rel]['amount'] += $block[$rel]['amount'];
}
}
$colPct = [];
foreach (self::AGE_RELATIONS as $rel) {
$colPct[$rel] = [
'count' => $grand['count'] > 0
? round(($allColTotals[$rel]['count'] / $grand['count']) * 100)
: 0,
'amount' => $grand['amount'] > 0
? round(($allColTotals[$rel]['amount'] / $grand['amount']) * 100)
: 0,
];
}
return [
'rows' => $outRows,
'total' => array_merge($allColTotals, ['_total' => $grand]),
'col_pct' => $colPct,
];
}
/**
* Top 15 cashless hospitals by approved_amount.
*
* @param list<array<string,mixed>> $rows
* @return list<array{hospital_id:string,hospital_name:string,count:int,amount:float}>
*/
private function buildTopHospitals(array $rows, int $limit = 15): array
{
$map = [];
foreach ($rows as $row) {
if ($this->claimLane($row) !== 'Cashless') {
continue;
}
if (!$this->isApprovedClaim($row)) {
continue;
}
$id = trim((string) ($row['hospital_id'] ?? ''));
$name = trim((string) ($row['hospital_name'] ?? ''));
$key = $id !== '' ? $id : ('name:' . strtoupper($name));
if ($name === '' && $id === '') {
continue;
}
if (!isset($map[$key])) {
$map[$key] = [
'hospital_id' => $id,
'hospital_name' => $name !== '' ? $name : $id,
'count' => 0,
'amount' => 0.0,
];
}
$map[$key]['count']++;
$map[$key]['amount'] += $this->approvedAmount($row) > 0
? $this->approvedAmount($row)
: $this->claimedAmount($row);
}
usort($map, static function ($a, $b) {
$cmp = $b['amount'] <=> $a['amount'];
if ($cmp !== 0) {
return $cmp;
}
return $b['count'] <=> $a['count'];
});
return array_values(array_slice($map, 0, $limit));
}
/**
* @param list<array<string,mixed>> $rows
* @return array{rows:list<array<string,mixed>>,total:array{count:int,amount:float}}
*/
private function buildCashlessMemberSummary(array $rows): array
{
$data = [
'CASHLESS' => ['count' => 0, 'amount' => 0.0],
'MEMBER' => ['count' => 0, 'amount' => 0.0],
];
foreach ($rows as $row) {
if (!$this->isApprovedClaim($row)) {
continue;
}
$lane = strtoupper($this->claimLane($row));
if (!isset($data[$lane])) {
continue;
}
$data[$lane]['count']++;
$data[$lane]['amount'] += $this->approvedAmount($row) > 0
? $this->approvedAmount($row)
: $this->claimedAmount($row);
}
$totalCount = $data['CASHLESS']['count'] + $data['MEMBER']['count'];
$totalAmount = $data['CASHLESS']['amount'] + $data['MEMBER']['amount'];
$outRows = [];
foreach (['CASHLESS', 'MEMBER'] as $label) {
$outRows[] = [
'label' => $label,
'count' => $data[$label]['count'],
'count_pct' => $totalCount > 0
? round(($data[$label]['count'] / $totalCount) * 100, 2)
: 0.0,
'amount' => $data[$label]['amount'],
'amount_pct' => $totalAmount > 0
? round(($data[$label]['amount'] / $totalAmount) * 100, 2)
: 0.0,
];
}
return [
'rows' => $outRows,
'total' => ['count' => $totalCount, 'amount' => $totalAmount],
];
}
// -------------------------------------------------------------------------
// TAT / MoM / Payout
// -------------------------------------------------------------------------
/**
* @param list<array<string,mixed>> $rows
* @return array{rows:list<array{band:string,count:int,pct:float}>,total:int}
*/
private function buildTat(array $rows): array
{
$counts = array_fill_keys(self::TAT_BANDS, 0);
$total = 0;
foreach ($rows as $row) {
if ($this->claimLane($row) !== 'Member') {
continue;
}
$status = $this->statusLabel($row);
if (!in_array($status, self::TAT_STATUSES, true)) {
continue;
}
$days = $this->tatDays($row);
if ($days === null) {
continue;
}
$band = $this->tatBand($days);
$counts[$band]++;
$total++;
}
$outRows = [];
foreach (self::TAT_BANDS as $band) {
$outRows[] = [
'band' => $band,
'count' => $counts[$band],
'pct' => $total > 0 ? round(($counts[$band] / $total) * 100, 2) : 0.0,
];
}
return ['rows' => $outRows, 'total' => $total];
}
/**
* @param list<array<string,mixed>> $rows
* @return array{rows:list<array<string,mixed>>,total:array<string,mixed>}
*/
private function buildMonthOnMonth(array $rows): array
{
$empty = ['count' => 0, 'amount' => 0.0];
$byMonth = [];
foreach ($rows as $row) {
if (!$this->isApprovedClaim($row)) {
continue;
}
$adm = $this->parseDate($row['date_of_admission'] ?? null);
if ($adm === null) {
continue;
}
$key = date('Y-m', $adm);
$label = date('M Y', $adm);
if (!isset($byMonth[$key])) {
$byMonth[$key] = [
'label' => $label,
'hosp' => $empty,
'other' => $empty,
];
}
$amt = $this->approvedAmount($row) > 0
? $this->approvedAmount($row)
: $this->claimedAmount($row);
$sub = strtolower(trim((string) ($row['type_of_hospitalization'] ?? '')));
$isHosp = str_contains($sub, 'hospital') || str_contains($sub, 'daycare') || $sub === 'day care';
$bucket = $isHosp ? 'hosp' : 'other';
$byMonth[$key][$bucket]['count']++;
$byMonth[$key][$bucket]['amount'] += $amt;
}
ksort($byMonth);
$totals = ['hosp' => $empty, 'other' => $empty, 'total' => $empty];
$outRows = [];
foreach ($byMonth as $block) {
$total = [
'count' => $block['hosp']['count'] + $block['other']['count'],
'amount' => $block['hosp']['amount'] + $block['other']['amount'],
];
$outRows[] = [
'month' => $block['label'],
'hosp' => $block['hosp'],
'other' => $block['other'],
'total' => $total,
];
$totals['hosp']['count'] += $block['hosp']['count'];
$totals['hosp']['amount'] += $block['hosp']['amount'];
$totals['other']['count'] += $block['other']['count'];
$totals['other']['amount'] += $block['other']['amount'];
$totals['total']['count'] += $total['count'];
$totals['total']['amount'] += $total['amount'];
}
return ['rows' => $outRows, 'total' => $totals];
}
/**
* @param list<array<string,mixed>> $rows
* @return array{claimed:float,settled:float,payout_pct:float}
*/
private function buildPayout(array $rows): array
{
$claimed = 0.0;
$settled = 0.0;
foreach ($rows as $row) {
$status = $this->statusLabel($row);
if ($status === 'Settled') {
$claimed += $this->claimedAmount($row);
$settled += $this->approvedAmount($row) > 0
? $this->approvedAmount($row)
: $this->claimedAmount($row);
}
}
// HTML sample claimed > settled sum — claimed across settled claims only
return [
'claimed' => $claimed,
'settled' => $settled,
'payout_pct' => $claimed > 0 ? round(($settled / $claimed) * 100) : 0.0,
];
}
// -------------------------------------------------------------------------
// Field helpers
// -------------------------------------------------------------------------
/** @param array<string,mixed> $row */
private function statusLabel(array $row): string
{
$raw = strtoupper(trim((string) ($row['claim_status'] ?? '')));
$raw = preg_replace('/\s+/', ' ', $raw) ?? $raw;
return match (true) {
$raw === 'SETTLED' => 'Settled',
$raw === 'REJECTED' => 'Rejected',
$raw === 'CANCELLED', $raw === 'CANCELED' => 'Cancelled',
$raw === 'AWAITING UTR', str_contains($raw, 'AWAITING UTR') => 'Awaiting Utr',
$raw === 'SHORTFALL' => 'Shortfall',
$raw === 'APPROVED' => 'Approved',
$raw === 'UNDERPROCESS', $raw === 'UNDER PROCESS', $raw === 'IN PROCESS' => 'Underprocess',
$raw === 'BILLS PENDING', str_contains($raw, 'BILLS PENDING') => 'Bills Pending',
str_contains($raw, 'RECOMMENDED FOR REPUDIATION') => 'Recommended For Repudiation',
str_contains($raw, 'RECOMMENDED FOR APPROVAL') => 'Recommended For Approval',
default => '',
};
}
/** @param array<string,mixed> $row */
private function claimLane(array $row): string
{
$t = strtoupper(trim((string) ($row['type_of_claim'] ?? '')));
if (str_contains($t, 'CASHLESS')) {
return 'Cashless';
}
// Member / Reimbursement / RI
return 'Member';
}
/** @param array<string,mixed> $row */
private function isApprovedClaim(array $row): bool
{
return in_array($this->statusLabel($row), self::APPROVED_STATUSES, true);
}
/** @param array<string,mixed> $row */
private function isMainClaim(array $row): bool
{
$t = strtolower(trim((string) ($row['mainclaim_prepost_type'] ?? '')));
if ($t === '') {
return true;
}
if (str_contains($t, 'pre') || str_contains($t, 'post') || str_contains($t, 'addendum')) {
return false;
}
return true; // Normal / Main / blank
}
/** @param array<string,mixed> $row */
private function claimedAmount(array $row): float
{
return $this->toFloat($row['claim_amount'] ?? 0);
}
/** @param array<string,mixed> $row */
private function approvedAmount(array $row): float
{
return $this->toFloat($row['approved_amount'] ?? 0);
}
/** @param array<string,mixed> $row */
private function osAmount(array $row): float
{
$incurred = $this->toFloat($row['total_incurred_amount'] ?? 0);
if ($incurred > 0) {
return $incurred;
}
return $this->claimedAmount($row);
}
/** @param array<string,mixed> $row */
private function hospSubtype(array $row): string
{
$raw = trim((string) ($row['type_of_hospitalization'] ?? ''));
$norm = strtolower(str_replace([' ', '-'], '_', $raw));
return match (true) {
str_contains($norm, 'claim_benefit') || $norm === 'claim_benefits' => 'Claim Benefits',
str_contains($norm, 'daycare') || $norm === 'day_care' => 'Daycare',
str_contains($norm, 'domiciliary') => 'Domiciliary',
str_contains($norm, 'health_check') || str_contains($norm, 'healthcheck') => 'Health_Check_Up',
str_contains($norm, 'hospital') => 'Hospitalization',
$norm === 'opd' || str_contains($norm, 'opd') => 'Opd',
default => 'Hospitalization',
};
}
/** @param array<string,mixed> $row */
private function rowAge(array $row): int
{
if (is_numeric($row['age'] ?? null)) {
return (int) $row['age'];
}
$dob = trim((string) ($row['date_of_birth'] ?? ''));
if ($dob !== '' && $dob !== '0000-00-00') {
$ts = strtotime($dob);
if ($ts) {
return (int) floor((time() - $ts) / 31557600);
}
}
return -1;
}
private function ageBandFromInt(int $age): string
{
if ($age < 0) {
return '>70';
}
if ($age <= 5) {
return '0-5';
}
if ($age <= 10) {
return '6-10';
}
if ($age <= 20) {
return '11-20';
}
if ($age <= 30) {
return '21-30';
}
if ($age <= 40) {
return '31-40';
}
if ($age <= 50) {
return '41-50';
}
if ($age <= 60) {
return '51-60';
}
if ($age <= 70) {
return '61-70';
}
return '>70';
}
private function amountBand(float $amount): string
{
if ($amount <= 10000) {
return '00K-10K';
}
if ($amount <= 20000) {
return '10K-20K';
}
if ($amount <= 30000) {
return '20K-30K';
}
if ($amount <= 40000) {
return '30K-40K';
}
if ($amount <= 50000) {
return '40K-50K';
}
if ($amount <= 60000) {
return '50K-60K';
}
if ($amount <= 70000) {
return '60K-70K';
}
if ($amount <= 80000) {
return '70K-80K';
}
if ($amount <= 90000) {
return '80K-90K';
}
if ($amount <= 100000) {
return '90K-100K';
}
return '>100K';
}
/** @param array<string,mixed> $row */
private function tatDays(array $row): ?int
{
$start = $this->parseDate($row['last_document_received_date'] ?? null)
?? $this->parseDate($row['claim_received_date'] ?? null);
$end = $this->parseDate($row['claim_decision_date'] ?? null);
if ($start === null || $end === null) {
return null;
}
$days = (int) floor(($end - $start) / 86400);
if ($days < 0) {
return null;
}
return $days;
}
private function tatBand(int $days): string
{
if ($days <= 7) {
return '0-7';
}
if ($days <= 15) {
return '8-15';
}
if ($days <= 30) {
return '16-30';
}
if ($days <= 45) {
return '31-45';
}
if ($days <= 60) {
return '46-60';
}
if ($days <= 90) {
return '61-90';
}
return '>90';
}
private function normalizeGender(mixed $value): string
{
$g = strtoupper(trim((string) $value));
if ($g === '' || $g === 'U' || $g === 'UNKNOWN') {
return '';
}
if (str_starts_with($g, 'M')) {
return 'Male';
}
if (str_starts_with($g, 'F')) {
return 'Female';
}
return '';
}
private function normalizeRelation(string $raw, bool $forGender): string
{
$r = strtolower(trim($raw));
$r = str_replace(['_', '-'], ' ', $r);
$r = preg_replace('/\s+/', ' ', $r) ?? $r;
if ($r === '' || $r === 'self' || $r === 'employee' || $r === 'primary' || $r === 'e') {
return 'Self';
}
if (str_contains($r, 'spouse') || $r === 'wife' || $r === 'husband') {
return 'Spouse';
}
if (str_contains($r, 'partner')) {
return 'Partner';
}
if (str_contains($r, 'child') || str_contains($r, 'son') || str_contains($r, 'daughter')) {
return 'Child';
}
if (str_contains($r, 'in law') || str_contains($r, 'inlaw') || str_contains($r, 'father in') || str_contains($r, 'mother in')) {
return $forGender ? 'In Laws' : 'In Law';
}
if (str_contains($r, 'parent') || $r === 'father' || $r === 'mother' || $r === 'f' || $r === 'm') {
return 'Parents';
}
return $forGender ? 'Others' : 'Other';
}
private function mapToGenderRelation(string $rel): string
{
return match ($rel) {
'Self', 'Spouse', 'Partner', 'Child', 'Parents', 'In Laws', 'Others' => $rel,
'In Law' => 'In Laws',
'Other' => 'Others',
default => 'Others',
};
}
private function mapToAgeRelation(string $rel): string
{
return match ($rel) {
'Self', 'Spouse', 'Partner', 'Child', 'Parents', 'In Law', 'Other' => $rel,
'In Laws' => 'In Law',
'Others' => 'Other',
default => 'Other',
};
}
// -------------------------------------------------------------------------
// Format helpers
// -------------------------------------------------------------------------
public static function fmtNum(mixed $value, int $decimals = 0): string
{
if ($value === null || $value === '') {
return '0';
}
$n = is_numeric($value) ? (float) $value : 0.0;
return number_format($n, $decimals, '.', ',');
}
public static function fmtPct(mixed $value, int $decimals = 0, bool $withSymbol = true): string
{
if ($value === null || $value === '') {
return $withSymbol ? '0%' : '0';
}
$n = is_numeric($value) ? (float) $value : 0.0;
$s = number_format($n, $decimals, '.', '');
return $withSymbol ? $s . '%' : $s;
}
private function toFloat(mixed $value): float
{
if ($value === null || $value === '') {
return 0.0;
}
if (is_int($value) || is_float($value)) {
return (float) $value;
}
$s = trim((string) $value);
$s = str_replace([',', '₹', 'Rs.', 'Rs', ' '], '', $s);
return $s === '' || $s === '-' || $s === '.' ? 0.0 : (float) $s;
}
private function parseFormattedNumber(mixed $value): float
{
return $this->toFloat($value);
}
/** @param list<array<string,mixed>> $rows */
private function firstRow(array $rows): array
{
return $rows[0] ?? [];
}
private function formatDate(mixed $value): string
{
if ($value === null || $value === '' || $value === '0000-00-00') {
return '';
}
$ts = strtotime((string) $value);
return $ts ? date('d-M-Y', $ts) : (string) $value;
}
private function formatDateTimeDisplay(string $raw): string
{
// ClaimDumpFileModel returns d-m-Y
$ts = strtotime(str_replace('/', '-', $raw));
if ($ts) {
if (preg_match('/\d{1,2}:\d{2}/', $raw)) {
return date('d-M-Y H:i', $ts);
}
return date('d-M-Y', $ts);
}
return $raw;
}
private function parseDate(mixed $value): ?int
{
if ($value === null || $value === '' || $value === '0000-00-00' || $value === '0000-00-00 00:00:00') {
return null;
}
$ts = strtotime((string) $value);
return $ts !== false ? $ts : null;
}
}