clqms-be/docs/MIGRATION_VALUESET_VID_TO_VVALUE.md

646 lines
21 KiB
Markdown
Raw Normal View History

feat(valueset): refactor from ID-based to name-based lookups Complete overhaul of the valueset system to use human-readable names instead of numeric IDs for improved maintainability and API consistency. - PatientController: Renamed 'Gender' field to 'Sex' in validation rules - ValuesetController: Changed API endpoints from ID-based (/:num) to name-based (/:any) - TestsController: Refactored to use ValueSet library instead of direct valueset queries - Added ValueSet library (app/Libraries/ValueSet.php) with static lookup methods: - getOptions() - returns dropdown format [{value, label}] - getLabel(, ) - returns label for a value - transformLabels(, ) - batch transform records - get() and getRaw() for Lookups compatibility - Added ValueSetApiController for public valueset API endpoints - Added ValueSet refresh endpoint (POST /api/valueset/refresh) - Added DemoOrderController for testing order creation without auth - 2026-01-12-000001: Convert valueset references from VID to VValue - 2026-01-12-000002: Rename patient.Gender column to Sex - OrderTestController: Now uses OrderTestModel with proper model pattern - TestsController: Uses ValueSet library for all lookup operations - ValueSetController: Simplified to use name-based lookups - Updated all organization (account/site/workstation) dialogs and index views - Updated specimen container dialogs and index views - Updated tests_index.php with ValueSet integration - Updated patient dialog form and index views - Removed .factory/config.json and CLAUDE.md (replaced by AGENTS.md) - Consolidated lookups in Lookups.php (removed inline valueset constants) - Updated all test files to match new field names - 32 modified files, 17 new files, 2 deleted files - Net: +661 insertions, -1443 deletions (significant cleanup)
2026-01-12 16:53:41 +07:00
# Migration Plan: Valueset VID → VValue
## Overview
Transition from using database `valueset` table (VID as primary key) to the new `App\Libraries\ValueSet` library (VValue as key). This eliminates database joins for lookup values and uses JSON-based static lookup files.
## Current State
- Database `valueset` table with columns: `VID` (PK, INT), `VValue` (VARCHAR), `VDesc` (VARCHAR)
- 30+ places using `->join('valueset', 'valueset.VID = ...')`
- Selects use `valueset.VValue` and `valueset.VDesc` for display text
## Target State
- Use `App\Libraries\ValueSet` library with `getLabel(lookupName, key)` method
- Lookup names use table-prefixed PascalCase (e.g., `patient_Sex`, `test_TestType`, `container_ContainerCapColor`)
- All fields store `VValue` codes directly (e.g., '1', '2', 'M', 'F', 'TEST')
- Remove all `valueset` table joins from queries
- Keep raw field values for codes; use `ValueSet::getLabel()` for display text
- JSON files in `app/Libraries/Data/valuesets/` are already populated
---
## Phase 1: JSON Files Rename
Rename all JSON files in `app/Libraries/Data/valuesets/` to use table-prefixed PascalCase format:
| Old Name | New Name | Source Table | Field |
|----------|----------|--------------|-------|
| `gender.json` | `patient_Sex.json` | patient | Gender |
| `country.json` | `patient_Country.json` | patient | Country |
| `race.json` | `patient_Race.json` | patient | Race |
| `religion.json` | `patient_Religion.json` | patient | Religion |
| `ethnic.json` | `patient_Ethnic.json` | patient | Ethnic |
| `marital_status.json` | `patient_MaritalStatus.json` | patient | MaritalStatus |
| `death_indicator.json` | `patient_DeathIndicator.json` | patient | DeathIndicator |
| `test_type.json` | `test_TestType.json` | testdefsite | TestType |
| `container_cap_color.json` | `container_ContainerCapColor.json` | containerdef | Color |
| `container_class.json` | `container_ContainerClass.json` | containerdef | ConClass |
| `additive.json` | `container_Additive.json` | containerdef | Additive |
| `location_type.json` | `location_LocationType.json` | location | LocType |
| `ws_type.json` | `organization_WorkstationType.json` | workstation | Type |
| `enable_disable.json` | `organization_EnableDisable.json` | workstation | Enable |
| `site_type.json` | `organization_SiteType.json` | site | SiteTypeID |
| `site_class.json` | `organization_SiteClass.json` | site | SiteClassID |
| `numeric_ref_type.json` | `ref_NumericRefType.json` | refnum | NumRefType |
| `range_type.json` | `ref_RangeType.json` | refnum | RangeType |
| `text_ref_type.json` | `ref_TextRefType.json` | reftxt | TxtRefType |
| `reference_type.json` | `test_ReferenceType.json` | testdeftech | RefType |
| `math_sign.json` | `ref_MathSign.json` | refnum | LowSign, HighSign |
| `country.json` | `account_Country.json` | account | Country |
| ... | ... | ... | ... |
All lookup names use `{table}_{Field}` format for clarity and namespace isolation.
---
## Phase 2: Database Schema Migration
### Files to DELETE
| File | Action |
|------|--------|
| `app\Database\Seeds\ValueSetSeeder.php` | DELETE |
| `app\Database\Seeds\ValueSetCountrySeeder.php` | DELETE |
| `app\Database\Seeds\MinimalMasterDataSeeder.php` | DELETE |
| `app\Database\Seeds\PatientSeeder.php` | DELETE |
| `app\Database\Migrations\2025-09-15-130122_ValueSet.php` | DELETE |
### Migration: Modify Columns INT → VARCHAR(10)
**File:** `app\Database\Migrations\2026-01-12-000001_ValuesetVidToVvalue.php`
```php
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class ValuesetVidToVvalue extends Migration
{
public function up()
{
// patient table
$this->forge->modifyColumn('patient', [
'Gender' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'Country' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'Race' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'Religion' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'Ethnic' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'MaritalStatus' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'DeathIndicator' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
]);
// testdefsite table
$this->forge->modifyColumn('testdefsite', [
'TestType' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => false],
]);
// containerdef table
$this->forge->modifyColumn('containerdef', [
'Additive' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'ConClass' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'Color' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
]);
// location table
$this->forge->modifyColumn('location', [
'LocType' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
]);
// workstation table
$this->forge->modifyColumn('workstation', [
'Type' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'Enable' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
]);
// site table
$this->forge->modifyColumn('site', [
'SiteTypeID' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'SiteClassID' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
]);
// account table
$this->forge->modifyColumn('account', [
'Country' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
]);
// refnum table
$this->forge->modifyColumn('refnum', [
'Sex' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'NumRefType' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'RangeType' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'LowSign' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'HighSign' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
]);
// reftxt table
$this->forge->modifyColumn('reftxt', [
'Sex' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
'TxtRefType' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => true],
]);
// orderstatus table
$this->forge->modifyColumn('orderstatus', [
'OrderStatus' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => false],
]);
}
public function down()
{
// Revert to INT
$this->forge->modifyColumn('patient', [
'Gender' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'Country' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'Race' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'Religion' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'Ethnic' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'MaritalStatus' => ['type' => 'TINYINT', 'null' => true],
'DeathIndicator' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
]);
$this->forge->modifyColumn('testdefsite', [
'TestType' => ['type' => 'INT', 'null' => false],
]);
$this->forge->modifyColumn('containerdef', [
'Additive' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'ConClass' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'Color' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
]);
$this->forge->modifyColumn('location', [
'LocType' => ['type' => 'INT', 'null' => true],
]);
$this->forge->modifyColumn('workstation', [
'Type' => ['type' => 'TINYINT', 'null' => true],
'Enable' => ['type' => 'INT', 'null' => true],
]);
$this->forge->modifyColumn('site', [
'SiteTypeID' => ['type' => 'INT', 'null' => true],
'SiteClassID' => ['type' => 'INT', 'null' => true],
]);
$this->forge->modifyColumn('account', [
'Country' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
]);
$this->forge->modifyColumn('refnum', [
'Sex' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'NumRefType' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'RangeType' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'LowSign' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'HighSign' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
]);
$this->forge->modifyColumn('reftxt', [
'Sex' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
'TxtRefType' => ['type' => 'INT', 'constraint' => 11, 'null' => true],
]);
$this->forge->modifyColumn('orderstatus', [
'OrderStatus' => ['type' => 'INT', 'null' => false],
]);
}
}
```
**Note:** No data migration needed - dummy data will be lost. This is acceptable for development/testing environments.
---
## Phase 3: Library & Model Updates
### ValueSet Library - Update to read from JSON files
**File:** `app/Libraries/ValueSet.php`
Ensure the library reads from JSON files in `app/Libraries/Data/valuesets/`:
```php
<?php
namespace App\Libraries;
class ValueSet
{
private static $cache = [];
private static function loadFile(string $name): array
{
if (!isset(self::$cache[$name])) {
$path = APPPATH . 'Libraries/Data/valuesets/' . $name . '.json';
if (file_exists($path)) {
$content = file_get_contents($path);
self::$cache[$name] = json_decode($content, true)['values'] ?? [];
} else {
self::$cache[$name] = [];
}
}
return self::$cache[$name];
}
public static function getLabel(string $lookupName, string $key): ?string
{
$values = self::loadFile($lookupName);
foreach ($values as $item) {
if (($item['key'] ?? $item['value'] ?? null) === $key) {
return $item['value'] ?? $item['label'] ?? null;
}
}
return null;
}
public static function getOptions(string $lookupName): array
{
$values = self::loadFile($lookupName);
return array_map(function ($item) {
return [
'key' => $item['key'] ?? '',
'value' => $item['value'] ?? $item['label'] ?? '',
];
}, $values);
}
public static function transformLabels(array $data, array $fieldMappings): array
{
foreach ($data as &$row) {
foreach ($fieldMappings as $field => $lookupName) {
if (isset($row[$field]) && $row[$field] !== null) {
$row[$field . 'Text'] = self::getLabel($lookupName, $row[$field]) ?? '';
}
}
}
return $data;
}
}
```
### ValueSetModel - Deprecate or Repurpose
**File:** `app/Models/ValueSet/ValueSetModel.php`
Options:
1. **Deprecate entirely** - No longer needed after migration
2. **Repurpose for JSON file management** - Read/write to JSON files
3. **Keep as-is for backward compatibility** - If database valuesets are still needed
Recommended: Deprecate and remove references after migration.
---
## Phase 4: Model Changes
### Pattern for Model Updates
**Before:**
```php
$this->select("..., gender.VValue as Gender, gender.VDesc as GenderText")
->join('valueset gender', 'gender.VID = patient.Gender', 'left')
```
**After:**
```php
use App\Libraries\ValueSet;
$this->select("..., patient.Gender");
// After fetching:
$rows = ValueSet::transformLabels($rows, [
'Gender' => 'patient_Sex',
'Country' => 'patient_Country',
'Race' => 'patient_Race',
'Religion' => 'patient_Religion',
'Ethnic' => 'patient_Ethnic',
'DeathIndicator' => 'patient_DeathIndicator',
'MaritalStatus' => 'patient_MaritalStatus',
]);
```
### Models to Modify (8 files)
#### 1. `app/Models/Patient/PatientModel.php`
**Remove:**
- Line 27: `$this->join('valueset vs', 'vs.vid = Gender', 'left');`
- Lines 52-64: All `*.VID as *VID` aliases
- Lines 75-81: All `valueset.*` joins
**Add transformation in `getPatient()`:**
```php
$patient = ValueSet::transformLabels([$patient], [
'Gender' => 'patient_gender',
'Country' => 'patient_country',
'Race' => 'patient_race',
'Religion' => 'patient_religion',
'Ethnic' => 'patient_ethnic',
'DeathIndicator' => 'patient_death_indicator',
'MaritalStatus' => 'patient_marital_status',
])[0];
```
#### 2. `app/Models/Location/LocationModel.php`
**Remove:**
- Lines 18, 30: `->join("valueset v", "v.VID=location.loctype", ...)`
**Add transformation:**
```php
$rows = ValueSet::transformLabels($rows, [
'LocType' => 'location_LocationType',
]);
```
#### 3. `app/Models/Test/TestDefSiteModel.php`
**Remove:**
- Lines 42, 75, 103: `->join("valueset", "valueset.VID=...")`
**Add transformation:**
```php
$rows = ValueSet::transformLabels($rows, [
'TestType' => 'test_TestType',
]);
```
#### 4. `app/Models/Test/TestDefGrpModel.php`
**Remove:**
- Line 32: `->join('valueset vs', 'vs.VID=t.TestType', 'left')`
#### 5. `app/Models/Specimen/ContainerDefModel.php`
**Remove:**
- Lines 20-22, 37-39: All 6 `valueset.*` joins
**Add transformation:**
```php
$rows = ValueSet::transformLabels($rows, [
'Color' => 'container_ContainerCapColor',
'ConClass' => 'container_ContainerClass',
'Additive' => 'container_Additive',
]);
```
#### 6. `app/Models/Organization/SiteModel.php`
**Remove:**
- Lines 38-39: `->join('valueset sitetype'...)` and `->join('valueset siteclass'...)`
**Add transformation:**
```php
$row = ValueSet::transformLabels([$row], [
'SiteTypeID' => 'organization_SiteType',
'SiteClassID' => 'organization_SiteClass',
])[0];
```
#### 7. `app/Models/Organization/AccountModel.php`
**Remove:**
- Line 41: `->join('valueset country'...)`
**Remove from select:**
- Line 36: `country.VID as country`
**Add transformation in controller if needed:**
```php
$rows = ValueSet::transformLabels($rows, [
'Country' => 'account_Country',
]);
```
#### 8. `app/Models/Organization/WorkstationModel.php`
**Remove:**
- Lines 36-37: `->join('valueset wstype'...)` and `->join('valueset enable'...)`
**Add transformation:**
```php
$row = ValueSet::transformLabels([$row], [
'Type' => 'organization_WorkstationType',
'Enable' => 'organization_EnableDisable',
])[0];
```
---
## Phase 5: Controller Changes
### `app/Controllers/TestsController.php`
**Remove:**
- Line 69: `->join("valueset", "valueset.VID=testdefsite.TestType", "left")`
- Line 111: `->join("valueset", "valueset.VID=testdefsite.TestType", "left")`
- Line 140: `->join('valueset vs', 'vs.VID=t.TestType', 'left')`
**Replace `getVValue()` method:**
```php
private function getVValue($vsetID, $vid) {
// DEPRECATED - Use ValueSet::getLabel() instead
return null;
}
```
**Update references from `getVValue()` to `ValueSet::getLabel()`:**
```php
// Before:
'NumRefTypeVValue' => $this->getVValue(46, $r['NumRefType']),
// After:
'NumRefTypeVValue' => \App\Libraries\ValueSet::getLabel('ref_NumericRefType', $r['NumRefType']),
```
**VSetID to Lookup Name Mapping:**
| VSetID | Constant | Lookup Name |
|--------|----------|-------------|
| 44 | `VALUESET_REF_TYPE` | `test_ReferenceType` |
| 45 | `VALUESET_RANGE_TYPE` | `ref_RangeType` |
| 46 | `VALUESET_NUM_REF_TYPE` | `ref_NumericRefType` |
| 47 | `VALUESET_TXT_REF_TYPE` | `ref_TextRefType` |
| 3 | `VALUESET_SEX` | `patient_Sex` |
| 41 | `VALUESET_MATH_SIGN` | `ref_MathSign` |
**Update `getValuesetOptions()` to use JSON:**
```php
private function getValuesetOptions($lookupName)
{
return \App\Libraries\ValueSet::getOptions($lookupName);
}
```
---
## Phase 6: API Endpoints - Replace with JSON-based endpoints
### New API Controller: `app/Controllers/ValueSetApiController.php`
```php
<?php
namespace App\Controllers;
use App\Libraries\ValueSet;
class ValueSetApiController extends \CodeIgniter\Controller
{
use \CodeIgniter\API\ResponseTrait;
public function index(string $lookupName)
{
$data = ValueSet::getOptions($lookupName);
return $this->respond([
'status' => 'success',
'data' => $data
], 200);
}
public function all()
{
$dir = APPPATH . 'Libraries/Data/valuesets/';
$files = glob($dir . '*.json');
$result = [];
foreach ($files as $file) {
$name = basename($file, '.json');
$result[] = [
'name' => $name,
'options' => ValueSet::getOptions($name)
];
}
return $this->respond([
'status' => 'success',
'data' => $result
], 200);
}
}
```
### Update Routes: `app/Config/Routes.php`
```php
$routes->group('api', function ($routes) {
$routes->get('valueset/(:segment)', 'ValueSetApiController::index/$1');
$routes->get('valueset', 'ValueSetApiController::all');
});
```
---
## Phase 7: View Updates
### 1. `app/Views/v2/master/valuesets/valuesets_index.php`
Repurpose to manage JSON-based valuesets instead of database table.
| Before | After |
|--------|-------|
| `fetch(...api/valueset...)` | `fetch(...api/valueset/lookupName...)` |
| Database CRUD operations | File-based CRUD operations |
### 2. `app/Views/v2/master/valuesets/valueset_nested_crud.php`
Repurpose for JSON file management.
### 3. `app/Views/v2/master/valuesets/valueset_dialog.php`
Update for JSON file format.
### 4. `app/Views/v2/master/tests/tests_index.php`
| Before | After |
|--------|-------|
| `type?.VID` | `type?.key` |
| `type?.VValue` | `type?.value` |
| `type?.VDesc` | `type?.label` |
| `{ VID: 1, VValue: 'TEST', ... }` | `{ key: 'TEST', value: 'Test', ... }` |
| `getTypeName(vid)` | `getTypeName(value)` |
| `api/valuesetdef/27` | `api/valueset/test_TestType` |
| Hardcoded fallback: `{ VID: 1, VValue: 'TEST', VDesc: 'Test' }` | `{ key: 'TEST', value: 'Test' }` |
### 5. Additional Views to Update
| View File | Fields to Update | Lookup Name |
|-----------|------------------|-------------|
| `app/Views/v2/patients/patients_index.php` | Gender, Country, Race, Religion, Ethnic, MaritalStatus, DeathIndicator | `patient_*` |
| `app/Views/v2/master/specimen/containers_index.php` | Color, ConClass, Additive | `container_*` |
| `app/Views/v2/master/organization/sites_index.php` | SiteTypeID, SiteClassID | `organization_*` |
| `app/Views/v2/master/organization/workstations_index.php` | Type, Enable | `organization_*` |
| `app/Views/v2/master/organization/accounts_index.php` | Country | `account_Country` |
| `app/Views/v2/master/organization/locations_index.php` | LocType | `location_LocationType` |
---
## Phase 8: Test Files Update
### `tests/feature/ValueSet/ValueSetApiControllerTest.php`
Update tests to use new JSON-based API endpoints.
### `tests/_support/v2/MasterTestCase.php`
Update any valueset-related test data setup.
---
## Testing Checklist
1. **Unit Tests**
- Test `ValueSet::getLabel('patient_Sex', '1')` returns 'Female'
- Test `ValueSet::getLabel('test_TestType', 'TEST')` returns 'Test'
- Test `ValueSet::getOptions('container_ContainerCapColor')` returns correct format
- Test `ValueSet::transformLabels()` with table-prefixed field mappings
2. **Integration Tests**
- Patient CRUD (create, read, update, delete)
- Test definition CRUD
- Location CRUD
- Container definition CRUD
- Organization (site, account, workstation) CRUD
3. **Manual Testing**
- Verify all dropdowns display correct labels
- Verify filtering by valueset fields works
- Verify form submissions save correct VValue codes
---
## Rollback Plan
1. Run migration `down()` to revert column types
2. Restore deleted seeders from git if needed
3. Restore deleted migration file from git
---
## Files Summary
| Phase | Action | Files |
|-------|--------|-------|
| 1 | RENAME | ~50 JSON files in `app/Libraries/Data/valuesets/` |
| 2 | DELETE | 5 seeders, 1 migration |
| 2 | CREATE | 1 migration (column changes) |
| 3 | UPDATE | 1 library (ValueSet.php), ValueSetModel.php deprecation |
| 4 | UPDATE | 8 Model files |
| 5 | UPDATE | 1 Controller, Routes |
| 6 | CREATE | 1 Controller (ValueSetApiController.php) |
| 6 | UPDATE | ~6+ View files |
| 8 | UPDATE | 2+ Test files |
---
## Estimated Effort
- Phase 1 (JSON Rename): 15 minutes
- Phase 2 (Migration): 30 minutes
- Phase 3 (Library + Model): 30 minutes
- Phase 4 (Models): 1.5 hours
- Phase 5 (Controller): 30 minutes
- Phase 6 (API + Views): 2 hours
- Phase 8 (Tests): 30 minutes
- Testing: 1 hour
**Total Estimated Time: ~7 hours**