diff --git a/app/Models/ClaimsCollectionV2DashboardModel.php b/app/Models/ClaimsCollectionV2DashboardModel.php index 447dcee0..063b5563 100644 --- a/app/Models/ClaimsCollectionV2DashboardModel.php +++ b/app/Models/ClaimsCollectionV2DashboardModel.php @@ -49,6 +49,8 @@ class ClaimsCollectionV2DashboardModel extends Model 232 => 'inception_emp_lives', 233 => 'current_emp_lives', 234 => 'claim_value_by_month', + 235 => 'claim_amount_by_relationship', + 236 => 'top_10_ailments_by_claim_count', ]; /** @@ -89,6 +91,8 @@ class ClaimsCollectionV2DashboardModel extends Model 'inception_emp_lives' => 'Inception Employees & Lives', 'current_emp_lives' => 'Current Employees & Lives', 'claim_value_by_month' => 'Claim Value by Month', + 'claim_amount_by_relationship' => 'Claim Amount by Relationship', + 'top_10_ailments_by_claim_count' => 'Top 10 Ailments by Claim Count', ]; protected function runKpiQuery(string $sql, int $policyId): array @@ -2175,6 +2179,113 @@ GROUP BY totals.total_count, totals.total_value ORDER BY month_sort; +SQL; + return $this->runKpiQuery($sql, $policyId); + } + + /** Metabase #235: Claim Amount by Relationship */ + public function claim_amount_by_relationship(int $policyId): array + { + $sql = <<<'SQL' +SELECT + COALESCE(tm.relationship, 'Unknown') AS relationship, + COUNT(tm.id) AS claim_count, + ROUND(COUNT(tm.id) / NULLIF(totals.total_count, 0) * 100, 2) + AS count_pct, + COALESCE(SUM(CAST(NULLIF(tm.claim_amount, '') AS DECIMAL(15,2))), 0) AS claim_value, + ROUND( + COALESCE(SUM(CAST(NULLIF(tm.claim_amount, '') AS DECIMAL(15,2))), 0) + / NULLIF(totals.total_value, 0) * 100, + 2) + AS value_pct + +FROM ticket_master tm + +JOIN ( + SELECT + COUNT(id) AS total_count, + COALESCE(SUM(CAST(NULLIF(claim_amount, '') AS DECIMAL(15,2))), 0) AS total_value + FROM ticket_master + WHERE client_policy_id = :policy_id: AND is_active = 1 +) totals ON 1=1 + +WHERE tm.client_policy_id = :policy_id: + AND tm.is_active = 1 + +GROUP BY tm.relationship + +ORDER BY claim_count DESC; + +-- SELECT +-- COALESCE( +-- CONCAT(UPPER(SUBSTRING(tm.relationship, 1, 1)), LOWER(SUBSTRING(tm.relationship, 2))), +-- 'Unknown' +-- ) AS relationship, +-- COUNT(tm.id) AS claim_count, +-- ROUND(COUNT(tm.id) / NULLIF(totals.total_count, 0) * 100, 2) AS count_pct, +-- COALESCE(SUM(CAST(NULLIF(tm.claim_amount, '') AS DECIMAL(15,2))), 0) AS claim_value, +-- ROUND( +-- COALESCE(SUM(CAST(NULLIF(tm.claim_amount, '') AS DECIMAL(15,2))), 0) +-- / NULLIF(totals.total_value, 0) * 100, +-- 2) AS value_pct +-- FROM ticket_master tm +-- JOIN ( +-- SELECT +-- COUNT(id) AS total_count, +-- COALESCE(SUM(CAST(NULLIF(claim_amount, '') AS DECIMAL(15,2))), 0) AS total_value +-- FROM ticket_master +-- WHERE client_policy_id = :policy_id: AND is_active = 1 +-- ) totals ON 1=1 +-- WHERE tm.client_policy_id = :policy_id: +-- AND tm.is_active = 1 +-- GROUP BY tm.relationship +-- ORDER BY +-- CASE LOWER(tm.relationship) +-- WHEN 'self' THEN 1 +-- WHEN 'spouse' THEN 2 +-- WHEN 'son' THEN 3 +-- WHEN 'daughter' THEN 4 +-- WHEN 'father' THEN 5 +-- WHEN 'mother' THEN 6 +-- WHEN 'father-in-law' THEN 7 +-- WHEN 'mother-in-law' THEN 8 +-- WHEN 'unknown' THEN 9 +-- ELSE 10 +-- END; +SQL; + return $this->runKpiQuery($sql, $policyId); + } + + /** Metabase #236: Top 10 Ailments by Claim Count */ + public function top_10_ailments_by_claim_count(int $policyId): array + { + $sql = <<<'SQL' +SELECT + COALESCE(tm.tpa_ailments, 'Not Specified') AS ailment, + COUNT(tm.id) AS claim_count, + CONCAT(ROUND(COUNT(tm.id) / totals.total_count * 100, 2), '%') + AS count_pct, + FORMAT( + COALESCE(SUM(CAST(NULLIF(tm.claim_amount, '') AS DECIMAL(15,2))), 0) + , 0) AS claim_value, + CONCAT(ROUND( + COALESCE(SUM(CAST(NULLIF(tm.claim_amount, '') AS DECIMAL(15,2))), 0) + / NULLIF(totals.total_value, 0) * 100 + , 2), '%') AS value_pct +FROM ticket_master tm +JOIN ( + SELECT + COUNT(id) AS total_count, + COALESCE(SUM(CAST(NULLIF(claim_amount, '') AS DECIMAL(15,2))), 0) AS total_value + FROM ticket_master + WHERE client_policy_id = :policy_id: AND is_active = 1 +) totals ON 1=1 +WHERE tm.client_policy_id = :policy_id: + AND tm.is_active = 1 + AND tm.tpa_ailments IS NOT NULL +GROUP BY tm.tpa_ailments, totals.total_count, totals.total_value +ORDER BY claim_count DESC +LIMIT 10; SQL; return $this->runKpiQuery($sql, $policyId); } diff --git a/hr-dashboard.md b/hr-dashboard.md index f1ca5a87..f56a6a9f 100644 --- a/hr-dashboard.md +++ b/hr-dashboard.md @@ -4,9 +4,9 @@ **Updated:** 2026-06-03 **Controller:** `App\Controllers\ClaimsCollectionV2DashboardController` **Model:** `App\Models\ClaimsCollectionV2DashboardModel` -**Source queries:** `metabase_raw_queries.csv` → collection `Claims Collection V2` (31 original) + 3 HR exposure/trend KPIs added in-model +**Source queries:** `metabase_raw_queries.csv` → collection `Claims Collection V2` (31 original) + 5 KPIs added in-model (232–236) **Base app URL (local):** `https://localhost/PHP828APPS/ruc/nhance/index.php` -**Total KPIs:** 34 +**Total KPIs:** 36 --- @@ -19,7 +19,7 @@ The API exposes them in three ways: | What | URL fragment | Use case | |------|-------------|----------| | **Single KPI** | `kpi/{slug or Metabase id}` | FE loads one card at a time | -| **All KPIs** | `all` | FE loads entire dashboard in one call (34 KPIs) | +| **All KPIs** | `all` | FE loads entire dashboard in one call (36 KPIs) | | **Debug / preview** | `debug` / `preview` | Admin checks raw output in browser | All endpoints require `client_policy` or `client_policy_id` as a query param @@ -39,7 +39,7 @@ Prefix: `util/claims-collection-v2` | GET | `util/claims-collection-v2/preview/{policy_id}` | `::preview` | Same, policy in URL | | GET | `util/claims-collection-v2/debug` | `::debug` | Raw JSON dump (all KPIs) | | GET | `util/claims-collection-v2/debug/{policy_id}` | `::debug` | Same, policy in URL | -| GET | `util/claims-collection-v2/all` | `::all` | JSON — all 34 KPIs | +| GET | `util/claims-collection-v2/all` | `::all` | JSON — all 36 KPIs | | GET | `util/claims-collection-v2/kpi/{slug\|id}` | `::kpi` | JSON — single KPI | **Filters applied:** `authMVC`, `AclFilter`, `HttpRequestLog`, `Cors`, `SecurityInputFilter` @@ -52,7 +52,7 @@ Prefix: `employeeRest/claims-collection-v2` | Method | Path | Handler | Purpose | |--------|------|---------|---------| -| GET | `employeeRest/claims-collection-v2/all` | `::all` | JSON — all 34 KPIs | +| GET | `employeeRest/claims-collection-v2/all` | `::all` | JSON — all 36 KPIs | | GET | `employeeRest/claims-collection-v2/kpi/{slug\|id}` | `::kpi` | JSON — single KPI | | GET | `employeeRest/claims-collection-v2/preview` | `::preview` | UI grid (FE debug) | | GET | `employeeRest/claims-collection-v2/preview/{policy_id}` | `::preview` | Same, policy in URL | @@ -88,7 +88,7 @@ GET /index.php/util/claims-collection-v2/preview?client_policy=4687 # Preview with policy in URL GET /index.php/util/claims-collection-v2/preview/4687 -# Raw JSON — all 34 KPIs +# Raw JSON — all 36 KPIs GET /index.php/util/claims-collection-v2/debug?client_policy=4687 # Raw JSON — single KPI by slug @@ -101,6 +101,8 @@ GET /index.php/util/claims-collection-v2/kpi/207?client_policy=4687 GET /index.php/util/claims-collection-v2/kpi/inception_emp_lives?client_policy=4687 GET /index.php/util/claims-collection-v2/kpi/current_emp_lives?client_policy=4687 GET /index.php/util/claims-collection-v2/kpi/claim_value_by_month?client_policy=4687 +GET /index.php/util/claims-collection-v2/kpi/claim_amount_by_relationship?client_policy=4687 +GET /index.php/util/claims-collection-v2/kpi/top_10_ailments_by_claim_count?client_policy=4687 ``` --- @@ -127,7 +129,13 @@ X-App-Signature: ``` ```http -GET /index.php/employeeRest/claims-collection-v2/kpi/234?client_policy=4687 +GET /index.php/employeeRest/claims-collection-v2/kpi/claim_amount_by_relationship?client_policy=4687 +Authorization: Bearer +X-App-Signature: +``` + +```http +GET /index.php/employeeRest/claims-collection-v2/kpi/235?client_policy=4687 Authorization: Bearer X-App-Signature: ``` @@ -265,15 +273,17 @@ X-App-Signature: "207": "incurred_ratio", "232": "inception_emp_lives", "233": "current_emp_lives", - "234": "claim_value_by_month" + "234": "claim_value_by_month", + "235": "claim_amount_by_relationship", + "236": "top_10_ailments_by_claim_count" } } ``` -**HTTP 404** — `allowed` lists the full `KPI_MAP` (34 entries). +**HTTP 404** — `allowed` lists the full `KPI_MAP` (36 entries). --- -## All 34 KPIs +## All 36 KPIs | Metabase ID | Method slug | Label | Rows | |-------------|-------------|-------|------| @@ -311,20 +321,56 @@ X-App-Signature: | 232 | `inception_emp_lives` | Inception Employees & Lives | single | | 233 | `current_emp_lives` | Current Employees & Lives | single | | 234 | `claim_value_by_month` | Claim Value by Month | multi | +| 235 | `claim_amount_by_relationship` | Claim Amount by Relationship | multi | +| 236 | `top_10_ailments_by_claim_count` | Top 10 Ailments by Claim Count | multi | > **Note:** KPIs 214 and 216 were renamed from the auto-generated slug to avoid collision with 213 and 215. -> **Note:** IDs **232–234** were assigned in-app for KPIs added outside the original Metabase CSV export. Confirm real Metabase question IDs and update `KPI_MAP` if they differ. +> **Note:** IDs **232–236** were assigned in-app for KPIs added outside the original Metabase CSV export. Confirm real Metabase question IDs and update `KPI_MAP` if they differ. --- -## Output columns — KPIs 232–234 +## Output columns — KPIs 232–236 | Slug | Row fields | Description | |------|------------|-------------| | `inception_emp_lives` | `inception_emp`, `inception_lives` | Distinct Self employees and all lives at inception (`change_event` contains inception) | | `current_emp_lives` | `current_emp`, `current_lives`, `avg_family_size` | Active employees/lives; `avg_family_size` = lives ÷ employees (2 dp) | | `claim_value_by_month` | `claim_month`, `month_sort`, `claim_value` | Monthly sum of `claim_amount`; ordered by `month_sort` | +| `claim_amount_by_relationship` | `relationship`, `claim_count`, `count_pct`, `claim_value`, `value_pct` | Claims by `ticket_master.relationship`; `count_pct` / `value_pct` are numeric (not `%` strings) | +| `top_10_ailments_by_claim_count` | `ailment`, `claim_count`, `count_pct`, `claim_value`, `value_pct` | Top 10 `tpa_ailments` (non-null); `claim_value` formatted via `FORMAT()`; pct fields as `%` strings | + +--- + +### `kpi/claim_amount_by_relationship` — breakdown (multi-row) + +```json +{ + "status": true, + "policy_id": 4687, + "kpi_id": 235, + "kpi": "claim_amount_by_relationship", + "label": "Claim Amount by Relationship", + "rows": [ + { + "relationship": "Self", + "claim_count": "42", + "count_pct": "35.00", + "claim_value": "850000.00", + "value_pct": "40.50" + }, + { + "relationship": "Spouse", + "claim_count": "28", + "count_pct": "23.33", + "claim_value": "520000.00", + "value_pct": "24.80" + } + ] +} +``` + +> Not the same as `claim_amount_by_gender` (#190), which groups by employee `gender` via join — not `ticket_master.relationship`. --- @@ -332,7 +378,7 @@ X-App-Signature: | File | Purpose | |------|---------| -| `app/Models/ClaimsCollectionV2DashboardModel.php` | 34 KPI query methods, `KPI_MAP`, `KPI_LABELS`, `getAllKpis()`, `getKpi()` | +| `app/Models/ClaimsCollectionV2DashboardModel.php` | 36 KPI query methods, `KPI_MAP`, `KPI_LABELS`, `getAllKpis()`, `getKpi()` | | `app/Controllers/ClaimsCollectionV2DashboardController.php` | `kpi()`, `all()`, `preview()`, `debug()` | | `app/Views/claims_collection_v2_dashboard.php` | Admin/FE debug preview UI (KPI card grid) | | `app/Config/Routes.php` | Both route groups (search `claims-collection-v2`) | @@ -344,7 +390,7 @@ X-App-Signature: ## FE integration notes -- Call `all` once on dashboard mount; render each `data[method].rows` into its card (34 keys under `data`). +- Call `all` once on dashboard mount; render each `data[method].rows` into its card (36 keys under `data`). - Call `kpi/{slug}` for lazy/on-demand loading of individual cards. - `policy_id` should come from the HR session / selected policy context — never hardcoded. - All rows are raw arrays; formatting (currency, %, dates) is already applied inside the SQL where applicable (`FORMAT()`, `CONCAT()`, `DATE_FORMAT()`). @@ -360,4 +406,4 @@ X-App-Signature: - To add a new KPI: add an entry to `KPI_MAP` + `KPI_LABELS` in the model, write `public function my_kpi(int $policyId): array`, update this doc and bump the smoke test KPI count. - All queries use named binding `:policy_id:` (CodeIgniter style, replaces Metabase `{{policy_id}}`). - Literal `\t` / `\n` in CSV SQL is normalized in `runKpiQuery()` — safe to re-generate from CSV. -- Run `php tests/smoke_claims_collection_v2.php {policy_id}` after any model change (expects `KPI_MAP` count === 34). +- Run `php tests/smoke_claims_collection_v2.php {policy_id}` after any model change (expects `KPI_MAP` count === 36). diff --git a/tests/smoke_claims_collection_v2.php b/tests/smoke_claims_collection_v2.php index 606fe2c4..5b10923d 100644 --- a/tests/smoke_claims_collection_v2.php +++ b/tests/smoke_claims_collection_v2.php @@ -51,10 +51,10 @@ function ok(string $label, bool $cond, string $detail = ''): void $model = new ClaimsCollectionV2DashboardModel(); $kpiMap = ClaimsCollectionV2DashboardModel::KPI_MAP; -ok('KPI_MAP count', count($kpiMap) === 34, (string) count($kpiMap)); +ok('KPI_MAP count', count($kpiMap) === 36, (string) count($kpiMap)); $uniqueMethods = array_unique(array_values($kpiMap)); -ok('unique KPI method names', count($uniqueMethods) === 34, count($uniqueMethods) . ' methods'); +ok('unique KPI method names', count($uniqueMethods) === 36, count($uniqueMethods) . ' methods'); foreach (['policy_exposure_summary', 'incurred_ratio'] as $slug) { ok("slug map contains {$slug}", in_array($slug, $kpiMap, true)); @@ -93,7 +93,7 @@ $badResp = json_decode($controller->kpi('not_a_kpi')->getJSON(), true); ok('controller unknown kpi 404', ($badResp['status'] ?? true) === false); $allResp = json_decode($controller->all()->getJSON(), true); -ok('controller all KPIs', ($allResp['status'] ?? false) === true && count($allResp['data'] ?? []) === 34); +ok('controller all KPIs', ($allResp['status'] ?? false) === true && count($allResp['data'] ?? []) === 36); $debugOut = $controller->debug($policyId); $debugBody = is_string($debugOut) ? $debugOut : $debugOut->getBody();