diff --git a/AGENTS.md b/AGENTS.md index 3d2001e..9cfc69e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,7 +6,6 @@ ## Build, Test & Lint Commands -### Running Tests ```bash # Run all tests ./vendor/bin/phpunit @@ -22,44 +21,17 @@ # Run tests by suite ./vendor/bin/phpunit --testsuite App -``` -### CodeIgniter CLI Commands -```bash -# Run spark commands -php spark - -# Generate migration +# Generate scaffolding php spark make:migration - -# Generate model php spark make:model - -# Generate controller php spark make:controller -# Generate seeder -php spark make:seeder - -# Run migrations +# Database migrations php spark migrate - -# Rollback migrations php spark migrate:rollback ``` -### Composer Commands -```bash -# Install dependencies -composer install - -# Run tests via composer -composer test - -# Update autoloader -composer dump-autoload -``` - --- ## Code Style Guidelines @@ -81,28 +53,10 @@ composer dump-autoload | Columns | PascalCase (legacy) | `PatientID`, `NameFirst` | | JSON fields | PascalCase | `"PatientID": "123"` | -### File Organization -``` -app/ -├── Config/ # Configuration files -├── Controllers/ # API controllers (grouped by feature) -│ ├── Patient/ -│ ├── Organization/ -│ └── Specimen/ -├── Models/ # Data models -├── Filters/ # Request filters (Auth, CORS) -├── Traits/ # Reusable traits -├── Libraries/ # Custom libraries -├── Helpers/ # Helper functions -└── Database/ - ├── Migrations/ - └── Seeds/ -``` - ### Imports & Namespaces -- Always use fully qualified namespaces at the top +- Fully qualified namespaces at the top - Group imports: Framework first, then App, then external -- Use statements must be in alphabetical order within groups +- Alphabetical order within groups ```php db = \Config\Database::connect(); $this->model = new \App\Models\ExampleModel(); } - // GET /example - public function index() - { - // Implementation - } - - // POST /example - public function create() - { - // Implementation - } + public function index() { /* ... */ } + public function create() { /* ... */ } } ``` ### Response Format -All API responses must use the standardized format: +All API responses use standardized format: ```php -// Success response +// Success return $this->respond([ 'status' => 'success', 'message' => 'Operation completed', 'data' => $data ], 200); -// Error response +// Error return $this->respond([ 'status' => 'failed', 'message' => 'Error description', @@ -167,10 +114,12 @@ return $this->respond([ ], 400); ``` +**Note**: Custom `ResponseTrait` automatically converts empty strings to `null`. + ### Error Handling -- Use try-catch for JWT operations and external calls +- Use try-catch for JWT and external calls +- Log errors: `log_message('error', $message)` - Return structured error responses with appropriate HTTP status codes -- Log errors using CodeIgniter's logging: `log_message('error', $message)` ```php try { @@ -183,9 +132,9 @@ try { ``` ### Database Operations -- Use CodeIgniter's Query Builder or Model methods -- Prefer parameterized queries over raw SQL -- Use transactions for multi-table operations +- Use CodeIgniter Query Builder or Model methods +- Use `helper('utc')` for UTC date conversion +- Wrap multi-table operations in transactions ```php $this->db->transStart(); @@ -197,9 +146,32 @@ if ($this->db->transStatus() === false) { } ``` +### Model Patterns +- Extend `BaseModel` for automatic UTC date handling +- Use `checkDbError()` for database error detection + +```php +error(); + if (!empty($error['code'])) { + throw new \Exception("{$context} failed: {$error['code']} - {$error['message']}"); + } + } +} +``` + ### Testing Guidelines -#### Test Structure ```php withBodyFormat('json') - ->post($this->endpoint, $payload); - + $result = $this->withBodyFormat('json')->post($this->endpoint, $payload); $result->assertStatus(201); } } ``` -#### Test Naming -- Use descriptive method names: `test` -- Example: `testCreatePatientValidationFail`, `testCreatePatientSuccess` +**Test Naming**: `test` (e.g., `testCreatePatientValidationFail`) -#### Test Status Codes -- 200: Success (GET, PATCH) -- 201: Created (POST) -- 400: Validation Error -- 401: Unauthorized -- 404: Not Found -- 500: Server Error +**Test Status Codes**: 200 (GET/PATCH), 201 (POST), 400 (Validation), 401 (Unauthorized), 404 (Not Found), 500 (Server Error) ### API Design - **Base URL**: `/api/` - **Authentication**: JWT token via HttpOnly cookie - **Content-Type**: `application/json` -- **HTTP Methods**: - - `GET` - Read - - `POST` - Create - - `PATCH` - Update (partial) - - `DELETE` - Delete +- **Methods**: GET (read), POST (create), PATCH (partial update), DELETE (delete) ### Routes Pattern ```php @@ -258,45 +218,34 @@ $routes->group('api/patient', function ($routes) { }); ``` -### Security Guidelines -- Always use the `auth` filter for protected routes -- Sanitize all user inputs -- Use parameterized queries to prevent SQL injection -- Store JWT secret in `.env` file -- Never commit `.env` files +### Security +- Use `auth` filter for protected routes +- Sanitize user inputs +- Use parameterized queries +- Store secrets in `.env`, never commit --- ## Project-Specific Conventions ### Legacy Field Naming -Database uses PascalCase for column names (legacy convention): -- `PatientID`, `NameFirst`, `NameLast` -- `Birthdate`, `CreatedAt`, `UpdatedAt` +Database uses PascalCase columns: `PatientID`, `NameFirst`, `Birthdate`, `CreatedAt`, `UpdatedAt` ### ValueSet System -Use the `App\Libraries\Lookups` class for static dropdown values: ```php use App\Libraries\Lookups; $genders = Lookups::get('gender'); +$label = Lookups::getLabel('gender', '1'); // Returns 'Female' $options = Lookups::getOptions('gender'); +$labeled = Lookups::transformLabels($data, ['Sex' => 'gender']); ``` -### Models -Extend `BaseModel` for automatic UTC date handling: -```php -modelRefTxt = new \App\Models\RefRange\RefTxtModel; $this->rules = [ - 'TestSiteCode' => 'required|min_length[3]|max_length[6]', + 'TestSiteCode' => 'required', 'TestSiteName' => 'required', 'TestType' => 'required', 'SiteID' => 'required' @@ -154,8 +155,10 @@ class TestsController extends BaseController if (!empty($row['testdeftech'])) { $techData = $row['testdeftech'][0]; $refType = $techData['RefType']; + $resultType = $techData['ResultType'] ?? ''; - if ($refType === '1' || $refType === 'NMRC' || $refType === '3' || $refType === 'THOLD') { + // Use TestValidationService to determine reference table + if (TestValidationService::usesRefNum($resultType, $refType)) { $refnumData = $this->modelRefNum ->where('TestSiteID', $id) ->where('EndDate IS NULL') @@ -186,7 +189,7 @@ class TestsController extends BaseController } - if ($refType === '2' || $refType === 'TEXT' || $refType === '4' || $refType === 'VSET') { + if (TestValidationService::usesRefTxt($resultType, $refType)) { $reftxtData = $this->modelRefTxt ->where('TestSiteID', $id) ->where('EndDate IS NULL') @@ -225,6 +228,28 @@ class TestsController extends BaseController return $this->failValidationErrors($this->validator->getErrors()); } + // Validate TestType, ResultType, and RefType combinations + $testType = $input['TestType'] ?? ''; + $details = $input['details'] ?? $input; + $resultType = $details['ResultType'] ?? ''; + $refType = $details['RefType'] ?? ''; + + // Set defaults for CALC, GROUP, TITLE types + if (TestValidationService::isCalc($testType)) { + $resultType = 'NMRIC'; + $refType = $refType ?: 'RANGE'; + } elseif (TestValidationService::isGroup($testType) || TestValidationService::isTitle($testType)) { + $resultType = 'NORES'; + $refType = 'NOREF'; + } + + if ($resultType && $refType) { + $validation = TestValidationService::validate($testType, $resultType, $refType); + if (!$validation['valid']) { + return $this->failValidationErrors(['type_validation' => $validation['error']]); + } + } + $this->db->transStart(); try { @@ -284,6 +309,28 @@ class TestsController extends BaseController return $this->failNotFound('Test not found'); } + // Validate TestType, ResultType, and RefType combinations if provided + $testType = $input['TestType'] ?? $existing['TestType'] ?? ''; + $details = $input['details'] ?? $input; + $resultType = $details['ResultType'] ?? $existing['ResultType'] ?? ''; + $refType = $details['RefType'] ?? $existing['RefType'] ?? ''; + + // Set defaults for CALC, GROUP, TITLE types + if (TestValidationService::isCalc($testType)) { + $resultType = 'NMRIC'; + $refType = $refType ?: 'RANGE'; + } elseif (TestValidationService::isGroup($testType) || TestValidationService::isTitle($testType)) { + $resultType = 'NORES'; + $refType = 'NOREF'; + } + + if ($resultType && $refType) { + $validation = TestValidationService::validate($testType, $resultType, $refType); + if (!$validation['valid']) { + return $this->failValidationErrors(['type_validation' => $validation['error']]); + } + } + $this->db->transStart(); try { @@ -362,15 +409,15 @@ class TestsController extends BaseController $testType = $existing['TestType']; $typeCode = $testType; - if ($typeCode === 'CALC') { + if (TestValidationService::isCalc($typeCode)) { $this->db->table('testdefcal') ->where('TestSiteID', $id) ->update(['EndDate' => $now]); - } elseif ($typeCode === 'GROUP') { + } elseif (TestValidationService::isGroup($typeCode)) { $this->db->table('testdefgrp') ->where('TestSiteID', $id) ->update(['EndDate' => $now]); - } elseif (in_array($typeCode, ['TEST', 'PARAM'])) { + } elseif (TestValidationService::isTechnicalTest($typeCode)) { $this->modelRefNum->where('TestSiteID', $id)->set('EndDate', $now)->update(); $this->modelRefTxt->where('TestSiteID', $id)->set('EndDate', $now)->update(); @@ -437,19 +484,21 @@ class TestsController extends BaseController if (in_array($typeCode, ['TEST', 'PARAM']) && isset($details['RefType'])) { $refType = (string) $details['RefType']; + $resultType = $details['ResultType'] ?? ''; - if (($refType === '1' || $refType === 'NMRC' || $refType === '3' || $refType === 'THOLD') && isset($input['refnum']) && is_array($input['refnum'])) { + // Use TestValidationService to determine which reference table to use + if (TestValidationService::usesRefNum($resultType, $refType) && isset($input['refnum']) && is_array($input['refnum'])) { $this->saveRefNumRanges($testSiteID, $input['refnum'], $action, $input['SiteID'] ?? 1); } - if (($refType === '2' || $refType === 'TEXT' || $refType === '4' || $refType === 'VSET') && isset($input['reftxt']) && is_array($input['reftxt'])) { + if (TestValidationService::usesRefTxt($resultType, $refType) && isset($input['reftxt']) && is_array($input['reftxt'])) { $this->saveRefTxtRanges($testSiteID, $input['reftxt'], $action, $input['SiteID'] ?? 1); } } break; } - if (in_array($typeCode, ['TEST', 'CALC']) && isset($input['testmap']) && is_array($input['testmap'])) { + if ((TestValidationService::isTechnicalTest($typeCode) || TestValidationService::isCalc($typeCode)) && isset($input['testmap']) && is_array($input['testmap'])) { $this->saveTestMap($testSiteID, $input['testmap'], $action); } } @@ -538,7 +587,8 @@ class TestsController extends BaseController 'DepartmentID' => $data['DepartmentID'] ?? null, 'FormulaInput' => $data['FormulaInput'] ?? null, 'FormulaCode' => $data['FormulaCode'] ?? $data['Formula'] ?? null, - 'RefType' => $data['RefType'] ?? 'NMRC', + 'ResultType' => 'NMRIC', // CALC always has NMRIC result type + 'RefType' => $data['RefType'] ?? 'RANGE', 'Unit1' => $data['Unit1'] ?? $data['ResultUnit'] ?? null, 'Factor' => $data['Factor'] ?? null, 'Unit2' => $data['Unit2'] ?? null, diff --git a/app/Database/Migrations/2026-02-20-000011_CreateAuditLogs.php b/app/Database/Migrations/2026-02-20-000011_CreateAuditLogs.php new file mode 100644 index 0000000..388780f --- /dev/null +++ b/app/Database/Migrations/2026-02-20-000011_CreateAuditLogs.php @@ -0,0 +1,162 @@ +forge->dropTable('patreglog', true); + $this->forge->dropTable('patvisitlog', true); + $this->forge->dropTable('specimenlog', true); + + // Create data_audit_log table + $this->forge->addField([ + 'id' => ['type' => 'BIGINT', 'constraint' => 20, 'unsigned' => true, 'auto_increment' => true], + 'operation' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'entity_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'entity_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => false], + 'table_name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'field_name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'previous_value' => ['type' => 'JSON', 'null' => true], + 'new_value' => ['type' => 'JSON', 'null' => true], + 'mechanism' => ['type' => 'ENUM', 'constraint' => ['MANUAL', 'AUTOMATIC'], 'null' => false, 'default' => 'MANUAL'], + 'application_id' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'web_page' => ['type' => 'VARCHAR', 'constraint' => 500, 'null' => true], + 'session_id' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'event_type' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'site_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true], + 'workstation_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true], + 'pc_name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => true], + 'user_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => false], + 'created_at' => ['type' => 'DATETIME', 'null' => false], + 'reason' => ['type' => 'TEXT', 'null' => true], + 'context' => ['type' => 'JSON', 'null' => true] + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('idx_operation_created', ['operation', 'created_at']); + $this->forge->addKey('idx_entity', ['entity_type', 'entity_id', 'created_at']); + $this->forge->addKey('idx_user_created', ['user_id', 'created_at']); + $this->forge->addKey('idx_mechanism', ['mechanism', 'created_at']); + $this->forge->addKey('idx_table', ['table_name', 'created_at']); + $this->forge->addKey('idx_site', ['site_id', 'created_at']); + $this->forge->addKey('idx_created', 'created_at'); + $this->forge->addKey('idx_session', ['session_id', 'created_at']); + $this->forge->createTable('data_audit_log', true); + + // Create service_audit_log table + $this->forge->addField([ + 'id' => ['type' => 'BIGINT', 'constraint' => 20, 'unsigned' => true, 'auto_increment' => true], + 'operation' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'entity_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'entity_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => false], + 'service_class' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'resource_type' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'resource_details' => ['type' => 'JSON', 'null' => true], + 'previous_value' => ['type' => 'JSON', 'null' => true], + 'new_value' => ['type' => 'JSON', 'null' => true], + 'mechanism' => ['type' => 'ENUM', 'constraint' => ['MANUAL', 'AUTOMATIC'], 'null' => false, 'default' => 'AUTOMATIC'], + 'application_id' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'service_name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'session_id' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'event_type' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'site_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true], + 'workstation_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true], + 'pc_name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => true], + 'port' => ['type' => 'INT', 'null' => true], + 'user_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => false], + 'created_at' => ['type' => 'DATETIME', 'null' => false], + 'reason' => ['type' => 'TEXT', 'null' => true], + 'context' => ['type' => 'JSON', 'null' => true] + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('idx_operation_created', ['operation', 'created_at']); + $this->forge->addKey('idx_entity', ['entity_type', 'entity_id', 'created_at']); + $this->forge->addKey('idx_service_class', ['service_class', 'created_at']); + $this->forge->addKey('idx_user_created', ['user_id', 'created_at']); + $this->forge->addKey('idx_mechanism', ['mechanism', 'created_at']); + $this->forge->addKey('idx_site', ['site_id', 'created_at']); + $this->forge->addKey('idx_created', 'created_at'); + $this->forge->createTable('service_audit_log', true); + + // Create security_audit_log table + $this->forge->addField([ + 'id' => ['type' => 'BIGINT', 'constraint' => 20, 'unsigned' => true, 'auto_increment' => true], + 'operation' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'entity_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'entity_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => false], + 'security_class' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'resource_path' => ['type' => 'VARCHAR', 'constraint' => 500, 'null' => true], + 'previous_value' => ['type' => 'JSON', 'null' => true], + 'new_value' => ['type' => 'JSON', 'null' => true], + 'mechanism' => ['type' => 'ENUM', 'constraint' => ['MANUAL', 'AUTOMATIC'], 'null' => false, 'default' => 'MANUAL'], + 'application_id' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'web_page' => ['type' => 'VARCHAR', 'constraint' => 500, 'null' => true], + 'session_id' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'event_type' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'site_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true], + 'workstation_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true], + 'pc_name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => true], + 'user_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => false], + 'created_at' => ['type' => 'DATETIME', 'null' => false], + 'reason' => ['type' => 'TEXT', 'null' => true], + 'context' => ['type' => 'JSON', 'null' => true] + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('idx_operation_created', ['operation', 'created_at']); + $this->forge->addKey('idx_entity', ['entity_type', 'entity_id', 'created_at']); + $this->forge->addKey('idx_security_class', ['security_class', 'created_at']); + $this->forge->addKey('idx_user_created', ['user_id', 'created_at']); + $this->forge->addKey('idx_event_type', ['event_type', 'created_at']); + $this->forge->addKey('idx_site', ['site_id', 'created_at']); + $this->forge->addKey('idx_created', 'created_at'); + $this->forge->addKey('idx_session', ['session_id', 'created_at']); + $this->forge->createTable('security_audit_log', true); + + // Create error_audit_log table + $this->forge->addField([ + 'id' => ['type' => 'BIGINT', 'constraint' => 20, 'unsigned' => true, 'auto_increment' => true], + 'operation' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'entity_type' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => false], + 'entity_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => false], + 'error_code' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'error_message' => ['type' => 'TEXT', 'null' => true], + 'error_details' => ['type' => 'JSON', 'null' => true], + 'previous_value' => ['type' => 'JSON', 'null' => true], + 'new_value' => ['type' => 'JSON', 'null' => true], + 'mechanism' => ['type' => 'ENUM', 'constraint' => ['MANUAL', 'AUTOMATIC'], 'null' => false, 'default' => 'MANUAL'], + 'application_id' => ['type' => 'VARCHAR', 'constraint' => 50, 'null' => true], + 'web_page' => ['type' => 'VARCHAR', 'constraint' => 500, 'null' => true], + 'session_id' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'event_type' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'site_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true], + 'workstation_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => true], + 'pc_name' => ['type' => 'VARCHAR', 'constraint' => 100, 'null' => true], + 'ip_address' => ['type' => 'VARCHAR', 'constraint' => 45, 'null' => true], + 'user_id' => ['type' => 'VARCHAR', 'constraint' => 36, 'null' => false], + 'created_at' => ['type' => 'DATETIME', 'null' => false], + 'reason' => ['type' => 'TEXT', 'null' => true], + 'context' => ['type' => 'JSON', 'null' => true] + ]); + $this->forge->addKey('id', true); + $this->forge->addKey('idx_operation_created', ['operation', 'created_at']); + $this->forge->addKey('idx_entity', ['entity_type', 'entity_id', 'created_at']); + $this->forge->addKey('idx_error_code', ['error_code', 'created_at']); + $this->forge->addKey('idx_event_type', ['event_type', 'created_at']); + $this->forge->addKey('idx_user_created', ['user_id', 'created_at']); + $this->forge->addKey('idx_site', ['site_id', 'created_at']); + $this->forge->addKey('idx_created', 'created_at'); + $this->forge->createTable('error_audit_log', true); + } + + public function down() { + $this->forge->dropTable('error_audit_log'); + $this->forge->dropTable('security_audit_log'); + $this->forge->dropTable('service_audit_log'); + $this->forge->dropTable('data_audit_log'); + } +} diff --git a/app/Database/Seeds/TestSeeder.php b/app/Database/Seeds/TestSeeder.php index 7323497..b845faf 100644 --- a/app/Database/Seeds/TestSeeder.php +++ b/app/Database/Seeds/TestSeeder.php @@ -29,72 +29,72 @@ class TestSeeder extends Seeder // TEST TYPE - Actual Laboratory Tests // ======================================== // Hematology Tests - Technical details merged into testdefsite - $data = ['SiteID' => '1', 'TestSiteCode' => 'HB', 'TestSiteName' => 'Hemoglobin', 'TestType' => 'TEST', 'Description' => '', 'SeqScr' => '2', 'SeqRpt' => '2', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'HB', 'TestSiteName' => 'Hemoglobin', 'TestType' => 'TEST', 'Description' => '', 'SeqScr' => '2', 'SeqRpt' => '2', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['HB'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'HCT', 'TestSiteName' => 'Hematocrit', 'TestType' => 'TEST', 'Description' => '', 'SeqScr' => '3', 'SeqRpt' => '3', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => '%', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'HCT', 'TestSiteName' => 'Hematocrit', 'TestType' => 'TEST', 'Description' => '', 'SeqScr' => '3', 'SeqRpt' => '3', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => '%', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['HCT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'RBC', 'TestSiteName' => 'Red Blood Cell', 'TestType' => 'TEST', 'Description' => 'Eritrosit', 'SeqScr' => '4', 'SeqRpt' => '4', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^6/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'RBC', 'TestSiteName' => 'Red Blood Cell', 'TestType' => 'TEST', 'Description' => 'Eritrosit', 'SeqScr' => '4', 'SeqRpt' => '4', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^6/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['RBC'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'WBC', 'TestSiteName' => 'White Blood Cell', 'TestType' => 'TEST', 'Description' => 'Leukosit', 'SeqScr' => '5', 'SeqRpt' => '5', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'WBC', 'TestSiteName' => 'White Blood Cell', 'TestType' => 'TEST', 'Description' => 'Leukosit', 'SeqScr' => '5', 'SeqRpt' => '5', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['WBC'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'PLT', 'TestSiteName' => 'Platelet', 'TestType' => 'TEST', 'Description' => 'Trombosit', 'SeqScr' => '6', 'SeqRpt' => '6', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'PLT', 'TestSiteName' => 'Platelet', 'TestType' => 'TEST', 'Description' => 'Trombosit', 'SeqScr' => '6', 'SeqRpt' => '6', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['PLT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'MCV', 'TestSiteName' => 'MCV', 'TestType' => 'TEST', 'Description' => 'Mean Corpuscular Volume', 'SeqScr' => '7', 'SeqRpt' => '7', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'fL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'MCV', 'TestSiteName' => 'MCV', 'TestType' => 'TEST', 'Description' => 'Mean Corpuscular Volume', 'SeqScr' => '7', 'SeqRpt' => '7', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'fL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['MCV'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'MCH', 'TestSiteName' => 'MCH', 'TestType' => 'TEST', 'Description' => 'Mean Corpuscular Hemoglobin', 'SeqScr' => '8', 'SeqRpt' => '8', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'pg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'MCH', 'TestSiteName' => 'MCH', 'TestType' => 'TEST', 'Description' => 'Mean Corpuscular Hemoglobin', 'SeqScr' => '8', 'SeqRpt' => '8', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'pg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['MCH'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'MCHC', 'TestSiteName' => 'MCHC', 'TestType' => 'TEST', 'Description' => 'Mean Corpuscular Hemoglobin Concentration', 'SeqScr' => '9', 'SeqRpt' => '9', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'MCHC', 'TestSiteName' => 'MCHC', 'TestType' => 'TEST', 'Description' => 'Mean Corpuscular Hemoglobin Concentration', 'SeqScr' => '9', 'SeqRpt' => '9', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['MCHC'] = $this->db->insertID(); // Chemistry Tests - $data = ['SiteID' => '1', 'TestSiteCode' => 'GLU', 'TestSiteName' => 'Glucose', 'TestType' => 'TEST', 'Description' => 'Glukosa Sewaktu', 'SeqScr' => '11', 'SeqRpt' => '11', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '0.0555', 'Unit2' => 'mmol/L', 'Decimal' => '0', 'Method' => 'Hexokinase', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'GLU', 'TestSiteName' => 'Glucose', 'TestType' => 'TEST', 'Description' => 'Glukosa Sewaktu', 'SeqScr' => '11', 'SeqRpt' => '11', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '0.0555', 'Unit2' => 'mmol/L', 'Decimal' => '0', 'Method' => 'Hexokinase', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['GLU'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'CREA', 'TestSiteName' => 'Creatinine', 'TestType' => 'TEST', 'Description' => 'Kreatinin', 'SeqScr' => '12', 'SeqRpt' => '12', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '88.4', 'Unit2' => 'umol/L', 'Decimal' => '2', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'CREA', 'TestSiteName' => 'Creatinine', 'TestType' => 'TEST', 'Description' => 'Kreatinin', 'SeqScr' => '12', 'SeqRpt' => '12', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '88.4', 'Unit2' => 'umol/L', 'Decimal' => '2', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['CREA'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'UREA', 'TestSiteName' => 'Blood Urea Nitrogen', 'TestType' => 'TEST', 'Description' => 'BUN', 'SeqScr' => '13', 'SeqRpt' => '13', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Urease-GLDH', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'UREA', 'TestSiteName' => 'Blood Urea Nitrogen', 'TestType' => 'TEST', 'Description' => 'BUN', 'SeqScr' => '13', 'SeqRpt' => '13', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Urease-GLDH', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['UREA'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'SGOT', 'TestSiteName' => 'AST (SGOT)', 'TestType' => 'TEST', 'Description' => 'Aspartate Aminotransferase', 'SeqScr' => '14', 'SeqRpt' => '14', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'SGOT', 'TestSiteName' => 'AST (SGOT)', 'TestType' => 'TEST', 'Description' => 'Aspartate Aminotransferase', 'SeqScr' => '14', 'SeqRpt' => '14', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['SGOT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'SGPT', 'TestSiteName' => 'ALT (SGPT)', 'TestType' => 'TEST', 'Description' => 'Alanine Aminotransferase', 'SeqScr' => '15', 'SeqRpt' => '15', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'SGPT', 'TestSiteName' => 'ALT (SGPT)', 'TestType' => 'TEST', 'Description' => 'Alanine Aminotransferase', 'SeqScr' => '15', 'SeqRpt' => '15', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['SGPT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'CHOL', 'TestSiteName' => 'Total Cholesterol', 'TestType' => 'TEST', 'Description' => 'Kolesterol Total', 'SeqScr' => '16', 'SeqRpt' => '16', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'CHOL', 'TestSiteName' => 'Total Cholesterol', 'TestType' => 'TEST', 'Description' => 'Kolesterol Total', 'SeqScr' => '16', 'SeqRpt' => '16', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['CHOL'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'TG', 'TestSiteName' => 'Triglycerides', 'TestType' => 'TEST', 'Description' => 'Trigliserida', 'SeqScr' => '17', 'SeqRpt' => '17', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'GPO-PAP', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'TG', 'TestSiteName' => 'Triglycerides', 'TestType' => 'TEST', 'Description' => 'Trigliserida', 'SeqScr' => '17', 'SeqRpt' => '17', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'GPO-PAP', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['TG'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'HDL', 'TestSiteName' => 'HDL Cholesterol', 'TestType' => 'TEST', 'Description' => 'Kolesterol HDL', 'SeqScr' => '18', 'SeqRpt' => '18', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'HDL', 'TestSiteName' => 'HDL Cholesterol', 'TestType' => 'TEST', 'Description' => 'Kolesterol HDL', 'SeqScr' => '18', 'SeqRpt' => '18', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['HDL'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'LDL', 'TestSiteName' => 'LDL Cholesterol', 'TestType' => 'TEST', 'Description' => 'Kolesterol LDL', 'SeqScr' => '19', 'SeqRpt' => '19', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'LDL', 'TestSiteName' => 'LDL Cholesterol', 'TestType' => 'TEST', 'Description' => 'Kolesterol LDL', 'SeqScr' => '19', 'SeqRpt' => '19', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['LDL'] = $this->db->insertID(); @@ -127,25 +127,25 @@ class TestSeeder extends Seeder $data = ['SiteID' => '1', 'TestSiteCode' => 'BMI', 'TestSiteName' => 'Body Mass Index', 'TestType' => 'CALC', 'Description' => 'Indeks Massa Tubuh - weight/(height^2)', 'SeqScr' => '45', 'SeqRpt' => '45', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '0', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['BMI'] = $this->db->insertID(); - $data = ['TestSiteID' => $tIDs['BMI'], 'DisciplineID' => '10', 'DepartmentID' => '', 'FormulaInput' => 'WEIGHT,HEIGHT', 'FormulaCode' => 'WEIGHT / ((HEIGHT/100) * (HEIGHT/100))', 'Unit1' => 'kg/m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['BMI'], 'DisciplineID' => '10', 'DepartmentID' => '', 'FormulaInput' => 'WEIGHT,HEIGHT', 'FormulaCode' => 'WEIGHT / ((HEIGHT/100) * (HEIGHT/100))', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'Unit1' => 'kg/m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'CreateDate' => "$now"]; $this->db->table('testdefcal')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'EGFR', 'TestSiteName' => 'eGFR (CKD-EPI)', 'TestType' => 'CALC', 'Description' => 'Estimated Glomerular Filtration Rate', 'SeqScr' => '20', 'SeqRpt' => '20', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '0', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['EGFR'] = $this->db->insertID(); - $data = ['TestSiteID' => $tIDs['EGFR'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CREA,AGE,GENDER', 'FormulaCode' => 'CKD_EPI(CREA,AGE,GENDER)', 'Unit1' => 'mL/min/1.73m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['EGFR'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CREA,AGE,GENDER', 'FormulaCode' => 'CKD_EPI(CREA,AGE,GENDER)', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'Unit1' => 'mL/min/1.73m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"]; $this->db->table('testdefcal')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'LDLCALC', 'TestSiteName' => 'LDL Cholesterol (Calculated)', 'TestType' => 'CALC', 'Description' => 'Friedewald formula: TC - HDL - (TG/5)', 'SeqScr' => '21', 'SeqRpt' => '21', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '0', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['LDLCALC'] = $this->db->insertID(); - $data = ['TestSiteID' => $tIDs['LDLCALC'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CHOL,HDL,TG', 'FormulaCode' => 'CHOL - HDL - (TG/5)', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['LDLCALC'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CHOL,HDL,TG', 'FormulaCode' => 'CHOL - HDL - (TG/5)', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"]; $this->db->table('testdefcal')->insert($data); // ======================================== // GROUP TYPE - Panel/Profile Tests // ======================================== - $data = ['SiteID' => '1', 'TestSiteCode' => 'CBC', 'TestSiteName' => 'Complete Blood Count', 'TestType' => 'GROUP', 'Description' => 'Darah Lengkap', 'SeqScr' => '50', 'SeqRpt' => '50', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'CBC', 'TestSiteName' => 'Complete Blood Count', 'TestType' => 'GROUP', 'Description' => 'Darah Lengkap', 'SeqScr' => '50', 'SeqRpt' => '50', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'ResultType' => 'NORES', 'RefType' => 'NOREF', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['CBC'] = $this->db->insertID(); $this->db->table('testdefgrp')->insertBatch([ @@ -159,7 +159,7 @@ class TestSeeder extends Seeder ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCHC'], 'CreateDate' => "$now"] ]); - $data = ['SiteID' => '1', 'TestSiteCode' => 'LIPID', 'TestSiteName' => 'Lipid Profile', 'TestType' => 'GROUP', 'Description' => 'Profil Lipid', 'SeqScr' => '51', 'SeqRpt' => '51', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'LIPID', 'TestSiteName' => 'Lipid Profile', 'TestType' => 'GROUP', 'Description' => 'Profil Lipid', 'SeqScr' => '51', 'SeqRpt' => '51', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'ResultType' => 'NORES', 'RefType' => 'NOREF', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['LIPID'] = $this->db->insertID(); $this->db->table('testdefgrp')->insertBatch([ @@ -170,7 +170,7 @@ class TestSeeder extends Seeder ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDLCALC'], 'CreateDate' => "$now"] ]); - $data = ['SiteID' => '1', 'TestSiteCode' => 'LFT', 'TestSiteName' => 'Liver Function Test', 'TestType' => 'GROUP', 'Description' => 'Fungsi Hati', 'SeqScr' => '52', 'SeqRpt' => '52', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'LFT', 'TestSiteName' => 'Liver Function Test', 'TestType' => 'GROUP', 'Description' => 'Fungsi Hati', 'SeqScr' => '52', 'SeqRpt' => '52', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'ResultType' => 'NORES', 'RefType' => 'NOREF', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['LFT'] = $this->db->insertID(); $this->db->table('testdefgrp')->insertBatch([ @@ -178,7 +178,7 @@ class TestSeeder extends Seeder ['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGPT'], 'CreateDate' => "$now"] ]); - $data = ['SiteID' => '1', 'TestSiteCode' => 'RFT', 'TestSiteName' => 'Renal Function Test', 'TestType' => 'GROUP', 'Description' => 'Fungsi Ginjal', 'SeqScr' => '53', 'SeqRpt' => '53', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'RFT', 'TestSiteName' => 'Renal Function Test', 'TestType' => 'GROUP', 'Description' => 'Fungsi Ginjal', 'SeqScr' => '53', 'SeqRpt' => '53', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'ResultType' => 'NORES', 'RefType' => 'NOREF', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['RFT'] = $this->db->insertID(); $this->db->table('testdefgrp')->insertBatch([ @@ -188,19 +188,19 @@ class TestSeeder extends Seeder ]); // Urinalysis Tests (with valueset result type) - $data = ['SiteID' => '1', 'TestSiteCode' => 'UCOLOR', 'TestSiteName' => 'Urine Color', 'TestType' => 'TEST', 'Description' => 'Warna Urine', 'SeqScr' => '31', 'SeqRpt' => '31', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'VSET', 'RefType' => 'TEXT', 'VSet' => '1001', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Visual', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'UCOLOR', 'TestSiteName' => 'Urine Color', 'TestType' => 'TEST', 'Description' => 'Warna Urine', 'SeqScr' => '31', 'SeqRpt' => '31', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'VSET', 'RefType' => 'VSET', 'VSet' => '1001', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Visual', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['UCOLOR'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'UGLUC', 'TestSiteName' => 'Urine Glucose', 'TestType' => 'TEST', 'Description' => 'Glukosa Urine', 'SeqScr' => '32', 'SeqRpt' => '32', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'VSET', 'RefType' => 'TEXT', 'VSet' => '1002', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'UGLUC', 'TestSiteName' => 'Urine Glucose', 'TestType' => 'TEST', 'Description' => 'Glukosa Urine', 'SeqScr' => '32', 'SeqRpt' => '32', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'VSET', 'RefType' => 'VSET', 'VSet' => '1002', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['UGLUC'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'UPROT', 'TestSiteName' => 'Urine Protein', 'TestType' => 'TEST', 'Description' => 'Protein Urine', 'SeqScr' => '33', 'SeqRpt' => '33', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'VSET', 'RefType' => 'TEXT', 'VSet' => '1003', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'UPROT', 'TestSiteName' => 'Urine Protein', 'TestType' => 'TEST', 'Description' => 'Protein Urine', 'SeqScr' => '33', 'SeqRpt' => '33', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'VSET', 'RefType' => 'VSET', 'VSet' => '1003', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['UPROT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'PH', 'TestSiteName' => 'Urine pH', 'TestType' => 'TEST', 'Description' => 'pH Urine', 'SeqScr' => '34', 'SeqRpt' => '34', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; + $data = ['SiteID' => '1', 'TestSiteCode' => 'PH', 'TestSiteName' => 'Urine pH', 'TestType' => 'TEST', 'Description' => 'pH Urine', 'SeqScr' => '34', 'SeqRpt' => '34', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['PH'] = $this->db->insertID(); } diff --git a/app/Libraries/TestValidationService.php b/app/Libraries/TestValidationService.php new file mode 100644 index 0000000..06f0ea3 --- /dev/null +++ b/app/Libraries/TestValidationService.php @@ -0,0 +1,259 @@ + ['NMRIC', 'RANGE', 'TEXT', 'VSET'], + 'PARAM' => ['NMRIC', 'RANGE', 'TEXT', 'VSET'], + 'CALC' => ['NMRIC'], + 'GROUP' => ['NORES'], + 'TITLE' => ['NORES'], + ]; + + /** + * Valid ResultType to RefType mappings + */ + private const RESULT_TYPE_REF_TYPES = [ + 'NMRIC' => ['RANGE', 'THOLD'], + 'RANGE' => ['RANGE', 'THOLD'], + 'VSET' => ['VSET'], + 'TEXT' => ['TEXT'], + 'NORES' => ['NOREF'], + ]; + + /** + * Reference table mapping based on ResultType and RefType + */ + private const REFERENCE_TABLES = [ + 'NMRIC' => [ + 'RANGE' => 'refnum', + 'THOLD' => 'refnum', + ], + 'RANGE' => [ + 'RANGE' => 'refnum', + 'THOLD' => 'refnum', + ], + 'VSET' => [ + 'VSET' => 'reftxt', + ], + 'TEXT' => [ + 'TEXT' => 'reftxt', + ], + 'NORES' => [ + 'NOREF' => null, + ], + ]; + + /** + * Validate TestType and ResultType combination + * + * @param string $testType + * @param string $resultType + * @return array ['valid' => bool, 'error' => string|null] + */ + public static function validateTestTypeResultType(string $testType, string $resultType): array + { + $testType = strtoupper($testType); + $resultType = strtoupper($resultType); + + if (!isset(self::TEST_TYPE_RESULT_TYPES[$testType])) { + return [ + 'valid' => false, + 'error' => "Invalid TestType '{$testType}'. Allowed: TEST, PARAM, CALC, GROUP, TITLE" + ]; + } + + $validResultTypes = self::TEST_TYPE_RESULT_TYPES[$testType]; + + if (!in_array($resultType, $validResultTypes, true)) { + return [ + 'valid' => false, + 'error' => "Invalid ResultType '{$resultType}' for TestType '{$testType}'. Allowed: " . implode(', ', $validResultTypes) + ]; + } + + return ['valid' => true, 'error' => null]; + } + + /** + * Validate ResultType and RefType combination + * + * @param string $resultType + * @param string $refType + * @return array ['valid' => bool, 'error' => string|null] + */ + public static function validateResultTypeRefType(string $resultType, string $refType): array + { + $resultType = strtoupper($resultType); + $refType = strtoupper($refType); + + if (!isset(self::RESULT_TYPE_REF_TYPES[$resultType])) { + return [ + 'valid' => false, + 'error' => "Invalid ResultType '{$resultType}'. Allowed: NMRIC, RANGE, TEXT, VSET, NORES" + ]; + } + + $validRefTypes = self::RESULT_TYPE_REF_TYPES[$resultType]; + + if (!in_array($refType, $validRefTypes, true)) { + return [ + 'valid' => false, + 'error' => "Invalid RefType '{$refType}' for ResultType '{$resultType}'. Allowed: " . implode(', ', $validRefTypes) + ]; + } + + return ['valid' => true, 'error' => null]; + } + + /** + * Validate complete test type combination + * + * @param string $testType + * @param string $resultType + * @param string $refType + * @return array ['valid' => bool, 'error' => string|null] + */ + public static function validate(string $testType, string $resultType, string $refType): array + { + // First validate TestType and ResultType + $testResultValidation = self::validateTestTypeResultType($testType, $resultType); + if (!$testResultValidation['valid']) { + return $testResultValidation; + } + + // Then validate ResultType and RefType + return self::validateResultTypeRefType($resultType, $refType); + } + + /** + * Get valid ResultTypes for a TestType + * + * @param string $testType + * @return array + */ + public static function getValidResultTypes(string $testType): array + { + $testType = strtoupper($testType); + return self::TEST_TYPE_RESULT_TYPES[$testType] ?? []; + } + + /** + * Get valid RefTypes for a ResultType + * + * @param string $resultType + * @return array + */ + public static function getValidRefTypes(string $resultType): array + { + $resultType = strtoupper($resultType); + return self::RESULT_TYPE_REF_TYPES[$resultType] ?? []; + } + + /** + * Get reference table name based on ResultType and RefType + * + * @param string $resultType + * @param string $refType + * @return string|null Returns table name or null if no reference table needed + */ + public static function getReferenceTable(string $resultType, string $refType): ?string + { + $resultType = strtoupper($resultType); + $refType = strtoupper($refType); + + return self::REFERENCE_TABLES[$resultType][$refType] ?? null; + } + + /** + * Check if a test needs reference ranges + * + * @param string $resultType + * @return bool + */ + public static function needsReferenceRanges(string $resultType): bool + { + $resultType = strtoupper($resultType); + return $resultType !== 'NORES'; + } + + /** + * Check if a test uses refnum table + * + * @param string $resultType + * @param string $refType + * @return bool + */ + public static function usesRefNum(string $resultType, string $refType): bool + { + return self::getReferenceTable($resultType, $refType) === 'refnum'; + } + + /** + * Check if a test uses reftxt table + * + * @param string $resultType + * @param string $refType + * @return bool + */ + public static function usesRefTxt(string $resultType, string $refType): bool + { + return self::getReferenceTable($resultType, $refType) === 'reftxt'; + } + + /** + * Check if TestType is CALC + * + * @param string $testType + * @return bool + */ + public static function isCalc(string $testType): bool + { + return strtoupper($testType) === 'CALC'; + } + + /** + * Check if TestType is GROUP + * + * @param string $testType + * @return bool + */ + public static function isGroup(string $testType): bool + { + return strtoupper($testType) === 'GROUP'; + } + + /** + * Check if TestType is TITLE + * + * @param string $testType + * @return bool + */ + public static function isTitle(string $testType): bool + { + return strtoupper($testType) === 'TITLE'; + } + + /** + * Check if TestType is TEST or PARAM (technical tests) + * + * @param string $testType + * @return bool + */ + public static function isTechnicalTest(string $testType): bool + { + $testType = strtoupper($testType); + return in_array($testType, ['TEST', 'PARAM'], true); + } +} \ No newline at end of file diff --git a/app/Models/Patient/PatientModel.php b/app/Models/Patient/PatientModel.php index 6b54590..09e4691 100644 --- a/app/Models/Patient/PatientModel.php +++ b/app/Models/Patient/PatientModel.php @@ -8,6 +8,8 @@ use App\Models\Patient\PatAttModel; use App\Models\Patient\PatComModel; use App\Models\Patient\PatIdtModel; +use App\Services\AuditService; + class PatientModel extends BaseModel { protected $table = 'patient'; protected $primaryKey = 'InternalPID'; @@ -145,9 +147,22 @@ class PatientModel extends BaseModel { $db->transBegin(); try { + $previousData = []; $this->insert($input); $newInternalPID = $this->getInsertID(); $this->checkDbError($db, 'Insert patient'); + + AuditService::logData( + 'CREATE', + 'patient', + (string) $newInternalPID, + 'patient', + null, + $previousData, + $input, + 'Patient registration', + ['PatientID' => $input['PatientID'] ?? null] + ); if (!empty($patIdt)) { $modelPatIdt->createPatIdt($patIdt, $newInternalPID); @@ -198,10 +213,22 @@ class PatientModel extends BaseModel { $db->transBegin(); try { - $InternalPID = $input['InternalPID']; + $previousData = $this->find($InternalPID); $this->where('InternalPID',$InternalPID)->set($input)->update(); $this->checkDbError($db, 'Update patient'); + + AuditService::logData( + 'UPDATE', + 'patient', + (string) $InternalPID, + 'patient', + null, + $previousData, + $input, + 'Patient data updated', + ['changed_fields' => array_keys(array_diff_assoc($previousData, $input))] + ); if (!empty($input['PatIdt'])) { $modelPatIdt->updatePatIdt($input['PatIdt'], $InternalPID); @@ -330,6 +357,39 @@ class PatientModel extends BaseModel { } } + public function deletePatient($InternalPID) { + $db = \Config\Database::connect(); + $db->transBegin(); + + try { + $previousData = $this->find($InternalPID); + if (!$previousData) { + throw new \Exception('Patient not found'); + } + + $this->delete($InternalPID); + $this->checkDbError($db, 'Delete patient'); + + AuditService::logData( + 'DELETE', + 'patient', + (string) $InternalPID, + 'patient', + null, + $previousData, + [], + 'Patient deleted', + ['PatientID' => $previousData['PatientID'] ?? null] + ); + + $db->transCommit(); + return $InternalPID; + } catch (\Exception $e) { + $db->transRollback(); + throw $e; + } + } + private function isValidDateTime($datetime) { if (empty($datetime) || $datetime=="") {return null; } try { diff --git a/app/Models/Test/TestDefSiteModel.php b/app/Models/Test/TestDefSiteModel.php index c72a9b0..e6dae91 100644 --- a/app/Models/Test/TestDefSiteModel.php +++ b/app/Models/Test/TestDefSiteModel.php @@ -4,6 +4,7 @@ namespace App\Models\Test; use App\Models\BaseModel; use App\Libraries\ValueSet; +use App\Libraries\TestValidationService; class TestDefSiteModel extends BaseModel { protected $table = 'testdefsite'; @@ -92,7 +93,7 @@ class TestDefSiteModel extends BaseModel { $typeCode = $row['TestType'] ?? ''; - if ($typeCode === 'CALC') { + if (TestValidationService::isCalc($typeCode)) { $row['testdefcal'] = $db->table('testdefcal') ->select('testdefcal.*, d.DisciplineName, dept.DepartmentName') ->join('discipline d', 'd.DisciplineID=testdefcal.DisciplineID', 'left') @@ -104,7 +105,7 @@ class TestDefSiteModel extends BaseModel { $testMapModel = new \App\Models\Test\TestMapModel(); $row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll(); - } elseif ($typeCode === 'GROUP') { + } elseif (TestValidationService::isGroup($typeCode)) { $row['testdefgrp'] = $db->table('testdefgrp') ->select('testdefgrp.*, t.TestSiteCode, t.TestSiteName, t.TestType') ->join('testdefsite t', 't.TestSiteID=testdefgrp.Member', 'left') @@ -120,11 +121,11 @@ class TestDefSiteModel extends BaseModel { $testMapModel = new \App\Models\Test\TestMapModel(); $row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll(); - } elseif ($typeCode === 'TITLE') { + } elseif (TestValidationService::isTitle($typeCode)) { $testMapModel = new \App\Models\Test\TestMapModel(); $row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll(); - } elseif (in_array($typeCode, ['TEST', 'PARAM'])) { + } elseif (TestValidationService::isTechnicalTest($typeCode)) { // Technical details are now flattened into the main row if ($row['DisciplineID']) { $discipline = $db->table('discipline')->where('DisciplineID', $row['DisciplineID'])->get()->getRowArray(); @@ -141,4 +142,40 @@ class TestDefSiteModel extends BaseModel { return $row; } + + /** + * Validate test type combination + * + * @param string $testType + * @param string $resultType + * @param string $refType + * @return array ['valid' => bool, 'error' => string|null] + */ + public function validateTypes(string $testType, string $resultType, string $refType): array + { + return TestValidationService::validate($testType, $resultType, $refType); + } + + /** + * Check if test needs reference ranges + * + * @param string $resultType + * @return bool + */ + public function needsReferenceRanges(string $resultType): bool + { + return TestValidationService::needsReferenceRanges($resultType); + } + + /** + * Get reference table name + * + * @param string $resultType + * @param string $refType + * @return string|null + */ + public function getReferenceTable(string $resultType, string $refType): ?string + { + return TestValidationService::getReferenceTable($resultType, $refType); + } } diff --git a/app/Services/AuditService.php b/app/Services/AuditService.php new file mode 100644 index 0000000..7ef7cd2 --- /dev/null +++ b/app/Services/AuditService.php @@ -0,0 +1,196 @@ +db = \Config\Database::connect(); + } + + public static function logData( + string $operation, + string $entityType, + string $entityId, + ?string $tableName = null, + ?string $fieldName = null, + ?array $previousValue = null, + ?array $newValue = null, + ?string $reason = null, + ?array $context = null + ): void { + self::log('data_audit_log', [ + 'operation' => $operation, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'table_name' => $tableName, + 'field_name' => $fieldName, + 'previous_value' => $previousValue, + 'new_value' => $newValue, + 'mechanism' => 'MANUAL', + 'application_id' => 'CLQMS-WEB', + 'web_page' => self::getUri(), + 'session_id' => self::getSessionId(), + 'event_type' => strtoupper($entityType) . '_' . strtoupper($operation), + 'site_id' => self::getSiteId(), + 'workstation_id' => self::getWorkstationId(), + 'pc_name' => self::getPcName(), + 'ip_address' => self::getIpAddress(), + 'user_id' => self::getUserId(), + 'reason' => $reason, + 'context' => $context, + 'created_at' => date('Y-m-d H:i:s') + ]); + } + + public static function logService( + string $operation, + string $entityType, + string $entityId, + string $serviceClass, + ?string $resourceType = null, + ?array $resourceDetails = null, + ?array $previousValue = null, + ?array $newValue = null, + ?string $serviceName = null, + ?array $context = null + ): void { + self::log('service_audit_log', [ + 'operation' => $operation, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'service_class' => $serviceClass, + 'resource_type' => $resourceType, + 'resource_details' => $resourceDetails, + 'previous_value' => $previousValue, + 'new_value' => $newValue, + 'mechanism' => 'AUTOMATIC', + 'application_id' => $serviceName ?? 'SYSTEM-SERVICE', + 'service_name' => $serviceName, + 'session_id' => self::getSessionId() ?: 'service_session', + 'event_type' => strtoupper($serviceClass) . '_' . strtoupper($operation), + 'site_id' => self::getSiteId(), + 'workstation_id' => self::getWorkstationId(), + 'pc_name' => self::getPcName(), + 'ip_address' => self::getIpAddress(), + 'port' => $resourceDetails['port'] ?? null, + 'user_id' => 'SYSTEM', + 'reason' => null, + 'context' => $context, + 'created_at' => date('Y-m-d H:i:s') + ]); + } + + public static function logSecurity( + string $operation, + string $entityType, + string $entityId, + string $securityClass, + ?string $eventType = 'SUCCESS', + ?string $resourcePath = null, + ?array $previousValue = null, + ?array $newValue = null, + ?string $reason = null, + ?array $context = null + ): void { + self::log('security_audit_log', [ + 'operation' => $operation, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'security_class' => $securityClass, + 'resource_path' => $resourcePath, + 'previous_value' => $previousValue, + 'new_value' => $newValue, + 'mechanism' => 'MANUAL', + 'application_id' => 'CLQMS-WEB', + 'web_page' => self::getUri(), + 'session_id' => self::getSessionId(), + 'event_type' => $eventType, + 'site_id' => self::getSiteId(), + 'workstation_id' => self::getWorkstationId(), + 'pc_name' => self::getPcName(), + 'ip_address' => self::getIpAddress(), + 'user_id' => self::getUserId() ?? 'UNKNOWN', + 'reason' => $reason, + 'context' => $context, + 'created_at' => date('Y-m-d H:i:s') + ]); + } + + public static function logError( + string $entityType, + string $entityId, + string $errorCode, + string $errorMessage, + string $eventType, + ?array $errorDetails = null, + ?array $previousValue = null, + ?array $newValue = null, + ?string $reason = null, + ?array $context = null + ): void { + self::log('error_audit_log', [ + 'operation' => 'ERROR', + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'error_code' => $errorCode, + 'error_message' => $errorMessage, + 'error_details' => $errorDetails, + 'previous_value' => $previousValue, + 'new_value' => $newValue, + 'mechanism' => 'AUTOMATIC', + 'application_id' => 'CLQMS-WEB', + 'web_page' => self::getUri(), + 'session_id' => self::getSessionId() ?: 'system', + 'event_type' => $eventType, + 'site_id' => self::getSiteId(), + 'workstation_id' => self::getWorkstationId(), + 'pc_name' => self::getPcName(), + 'ip_address' => self::getIpAddress(), + 'user_id' => self::getUserId() ?? 'SYSTEM', + 'reason' => $reason, + 'context' => $context, + 'created_at' => date('Y-m-d H:i:s') + ]); + } + + private static function log(string $table, array $data): void { + $db = \Config\Database::connect(); + $db->table($table)->insert($data); + } + + private static function getUri(): ?string { + return $_SERVER['REQUEST_URI'] ?? null; + } + + private static function getSessionId(): ?string { + $session = session(); + return $session->get('session_id'); + } + + private static function getSiteId(): ?string { + $session = session(); + return $session->get('site_id'); + } + + private static function getWorkstationId(): ?string { + $session = session(); + return $session->get('workstation_id'); + } + + private static function getPcName(): ?string { + return gethostname(); + } + + private static function getIpAddress(): ?string { + return $_SERVER['REMOTE_ADDR'] ?? null; + } + + private static function getUserId(): ?string { + $session = session(); + return $session->get('user_id'); + } +} diff --git a/docs/FRONTEND_TEST_MANAGEMENT_PROMPT.md b/docs/FRONTEND_TEST_MANAGEMENT_PROMPT.md new file mode 100644 index 0000000..4f51eb0 --- /dev/null +++ b/docs/FRONTEND_TEST_MANAGEMENT_PROMPT.md @@ -0,0 +1,1581 @@ +# CLQMS Master Data - Test Management Frontend Development Prompt + +## 📋 Project Overview + +Build a modern, responsive Svelte 5 frontend for CLQMS (Clinical Laboratory Quality Management System) Test Management module. The frontend will consume the existing REST API backend (`/api/tests`) and provide a comprehensive interface for managing laboratory test definitions across all test types. + +--- + +## 🎯 Objective + +Create a user-friendly, type-safe frontend application that enables laboratory administrators to: +- Browse and search all test definitions with filtering +- Create new tests of any type (TEST, PARAM, CALC, GROUP, TITLE) +- Edit existing tests with type-specific configurations +- Manage reference ranges (numeric and text-based) +- Configure test mappings for external systems +- Organize tests into groups and panels +- Manage calculated test formulas + +--- + +## 🛠️ Technology Stack (Svelte 5) + +### Core Requirements +- **Framework**: Svelte 5 with runes (`$state`, `$derived`, `$effect`) +- **Meta-Framework**: SvelteKit (for routing and server-side features) +- **Language**: TypeScript (strict mode enabled) +- **Styling**: Tailwind CSS +- **UI Components**: Skeleton UI (or Melt UI) +- **Forms**: Headless form components with validation +- **HTTP Client**: Axios (with request/response interceptors) +- **State Management**: Svelte 5 runes (no external store library required) +- **Build Tool**: Vite (comes with SvelteKit) + +### Recommended Dependencies +```json +{ + "dependencies": { + "svelte": "^5.0.0", + "@sveltejs/kit": "^2.0.0", + "axios": "^1.6.0", + "zod": "^3.22.0", + "date-fns": "^3.0.0" + }, + "devDependencies": { + "skeleton": "^2.8.0", + "@skeletonlabs/tw-plugin": "^0.3.0", + "tailwindcss": "^3.4.0", + "autoprefixer": "^10.4.0", + "postcss": "^8.4.0", + "typescript": "^5.3.0" + } +} +``` + +--- + +## 📁 Project Structure + +``` +src/ +├── lib/ +│ ├── components/ +│ │ ├── test/ +│ │ │ ├── TestList.svelte # Main test listing page +│ │ │ ├── TestForm.svelte # Main form container with tabs +│ │ │ ├── TestCard.svelte # Single test card/row +│ │ │ ├── TestFilterPanel.svelte # Search/filter panel +│ │ │ ├── SidebarTabs.svelte # Left navigation tabs +│ │ │ ├── tabs/ +│ │ │ │ ├── BasicInfoTab.svelte # Basic test info +│ │ │ │ ├── TechDetailsTab.svelte # Technical specifications +│ │ │ │ ├── CalcDetailsTab.svelte # Calculated test formula +│ │ │ │ ├── GroupMembersTab.svelte # Group member management +│ │ │ │ ├── MappingsTab.svelte # System mappings +│ │ │ │ ├── RefNumTab.svelte # Numeric reference ranges +│ │ │ │ └── RefTxtTab.svelte # Text reference ranges +│ │ │ └── modals/ +│ │ │ ├── RefNumModal.svelte # Edit reference range modal +│ │ │ ├── RefTxtModal.svelte # Edit text reference modal +│ │ │ ├── MappingModal.svelte # Edit mapping modal +│ │ │ └── MemberModal.svelte # Add group member modal +│ │ └── ui/ # Reusable UI components +│ │ ├── Button.svelte +│ │ ├── Input.svelte +│ │ ├── Select.svelte +│ │ ├── Checkbox.svelte +│ │ ├── Table.svelte +│ │ ├── Modal.svelte +│ │ ├── Badge.svelte +│ │ ├── Tabs.svelte +│ │ ├── Alert.svelte +│ │ └── Spinner.svelte +│ ├── stores/ +│ │ ├── testStore.ts # Test form state with runes +│ │ ├── valueSetStore.ts # ValueSet/dropdown data +│ │ ├── authStore.ts # Authentication state +│ │ └── uiStore.ts # UI state (modals, tabs) +│ ├── services/ +│ │ ├── api.ts # Axios instance with interceptors +│ │ ├── testService.ts # Test API operations +│ │ ├── valueSetService.ts # ValueSet API calls +│ │ └── validationService.ts # Frontend validation logic +│ ├── types/ +│ │ ├── test.types.ts # All test-related types +│ │ ├── api.types.ts # API response/request types +│ │ ├── valueset.types.ts # ValueSet types +│ │ └── index.ts # Type exports +│ └── utils/ +│ ├── validation.ts # Validation helpers +│ ├── format.ts # Formatters (dates, numbers) +│ ├── constants.ts # App constants +│ └── helpers.ts # Utility functions +├── routes/ +│ ├── +layout.svelte # Root layout with nav +│ ├── +page.svelte # Landing page +│ ├── tests/ +│ │ ├── +page.svelte # Test list page +│ │ └── [id]/ +│ │ └── +page.svelte # Test detail/edit page +│ └── login/ +│ └── +page.svelte # Login page +├── app.html # HTML template +└── app.css # Global styles +``` + +--- + +## 🔌 API Integration + +### Base Configuration + +**API Base URL**: `http://localhost:8080/api` (configurable via environment variable) + +**Authentication**: JWT token via HTTP header +``` +Authorization: Bearer {token} +``` + +### Endpoints + +#### 1. List Tests +``` +GET /api/tests +Query Parameters: + - SiteID (optional): Filter by site + - TestType (optional): Filter by test type (TEST, PARAM, CALC, GROUP, TITLE) + - VisibleScr (optional): Filter by screen visibility (0/1) + - VisibleRpt (optional): Filter by report visibility (0/1) + - TestSiteName (optional): Search by test name (partial match) +``` + +**Response**: +```typescript +{ + status: "success"; + message: string; + data: TestSummary[]; +} + +interface TestSummary { + TestSiteID: number; + TestSiteCode: string; + TestSiteName: string; + TestType: string; + TestTypeLabel: string; + SeqScr: number; + SeqRpt: number; + VisibleScr: number; + VisibleRpt: number; + CountStat: number; + StartDate: string; + DisciplineID?: number; + DepartmentID?: number; + DisciplineName?: string; + DepartmentName?: string; +} +``` + +#### 2. Get Single Test +``` +GET /api/tests/:id +``` + +**Response**: +```typescript +{ + status: "success"; + message: string; + data: TestDetail; +} + +interface TestDetail { + // Base fields + TestSiteID: number; + TestSiteCode: string; + TestSiteName: string; + TestType: string; + TestTypeLabel: string; + Description?: string; + SiteID: number; + SeqScr: number; + SeqRpt: number; + VisibleScr: number; + VisibleRpt: number; + CountStat: number; + StartDate: string; + EndDate?: string; + + // Technical details (TEST/PARAM/CALC) + DisciplineID?: number; + DepartmentID?: number; + DisciplineName?: string; + DepartmentName?: string; + ResultType?: string; + RefType?: string; + VSet?: string; + Unit1?: string; + Factor?: number; + Unit2?: string; + Decimal?: number; + ReqQty?: number; + ReqQtyUnit?: string; + CollReq?: string; + Method?: string; + ExpectedTAT?: number; + + // Nested data based on TestType + testdefcal?: Calculation[]; // For CALC type + testdefgrp?: GroupMember[]; // For GROUP type + testmap?: TestMapping[]; // For all types + testdeftech?: TechDetail[]; // For TEST/PARAM + refnum?: RefNumRange[]; // For TEST/PARAM (numeric ref) + reftxt?: RefTxtRange[]; // For TEST/PARAM (text ref) +} + +interface RefNumRange { + RefNumID: number; + NumRefType: string; // REF, CRTC, VAL, RERUN + NumRefTypeLabel: string; + RangeType: string; // RANGE, THOLD + RangeTypeLabel: string; + Sex: string; // 0=All, 1=Female, 2=Male + SexLabel: string; + AgeStart: number; + AgeEnd: number; + LowSign?: string; // =, <, <= + LowSignLabel?: string; + Low?: number; + HighSign?: string; // =, >, >= + HighSignLabel?: string; + High?: number; + Flag?: string; // H, L, A, etc. + Interpretation?: string; +} + +interface RefTxtRange { + RefTxtID: number; + TxtRefType: string; // Normal, Abnormal, Critical + TxtRefTypeLabel: string; + Sex: string; + SexLabel: string; + AgeStart: number; + AgeEnd: number; + RefTxt: string; + Flag?: string; +} +``` + +#### 3. Create Test +``` +POST /api/tests +Content-Type: application/json +Body: CreateTestPayload +``` + +**Request**: +```typescript +interface CreateTestPayload { + SiteID: number; + TestSiteCode: string; + TestSiteName: string; + TestType: 'TEST' | 'PARAM' | 'CALC' | 'GROUP' | 'TITLE'; + Description?: string; + SeqScr?: number; + SeqRpt?: number; + VisibleScr?: number; + VisibleRpt?: number; + CountStat?: number; + StartDate?: string; + + // Nested details (based on TestType) + details?: { + // Technical (TEST/PARAM/CALC) + DisciplineID?: number; + DepartmentID?: number; + ResultType?: string; + RefType?: string; + VSet?: string; + Unit1?: string; + Factor?: number; + Unit2?: string; + Decimal?: number; + ReqQty?: number; + ReqQtyUnit?: string; + CollReq?: string; + Method?: string; + ExpectedTAT?: number; + + // CALC only + FormulaInput?: string; + FormulaCode?: string; + + // GROUP only + members?: number[]; // Array of TestSiteIDs + }; + + // Reference ranges (TEST/PARAM) + refnum?: Omit[]; + reftxt?: Omit[]; + + // Mappings (all types) + testmap?: TestMapping[]; +} +``` + +**Response**: +```typescript +{ + status: "created"; + message: "Test created successfully"; + data: { TestSiteId: number }; +} +``` + +#### 4. Update Test +``` +PATCH /api/tests +Content-Type: application/json +Body: CreateTestPayload & { TestSiteID: number } +``` + +**Response**: +```typescript +{ + status: "success"; + message: "Test updated successfully"; + data: { TestSiteId: number }; +} +``` + +#### 5. Delete Test (Soft Delete) +``` +DELETE /api/tests +Content-Type: application/json +Body: { TestSiteID: number } +``` + +**Response**: +```typescript +{ + status: "success"; + message: "Test disabled successfully"; + data: { TestSiteId: number; EndDate: string }; +} +``` + +--- + +## 🎨 UI/UX Design Specifications + +### Layout Architecture + +**Page Layout**: Fixed sidebar + scrollable content area + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Header: CLQMS Test Management [User] [Logout] │ +├────────────┬────────────────────────────────────────────────┤ +│ │ │ +│ Sidebar │ Main Content Area │ +│ (Left) │ │ +│ │ │ +│ Tab 1 │ ┌──────────────────────────────────────────┐ │ +│ Tab 2 │ │ │ │ +│ Tab 3 │ │ Dynamic Content │ │ +│ ... │ │ │ │ +│ │ │ │ │ +│ │ │ │ │ +│ │ └──────────────────────────────────────────┘ │ +│ │ │ +│ │ [Save] [Cancel] [Delete] │ +│ │ │ +└────────────┴────────────────────────────────────────────────┘ +``` + +### Test List Page + +**Components**: +1. **Filter Panel** (top of page): + - Site dropdown (multi-select) + - Test Type dropdown (checkboxes) + - Visibility toggles (Screen/Report) + - Search input (debounced, 300ms) + - Clear filters button + +2. **Test Table/Card Grid**: + - Columns: Code, Name, Type, Discipline, Department, SeqScr, SeqRpt, Visibility, Actions + - Sortable headers (Code, Name, SeqScr, SeqRpt) + - Row actions: View, Edit, Delete (with confirmation) + - Row hover effect + - Test type badge with color coding + +3. **Pagination**: + - Show 20 items per page + - Page navigation buttons + - Page size selector (10, 20, 50, 100) + - Total count display + +**Table Design**: +``` +┌─────────┬────────────────────┬───────┬────────────┬────────┬────────┬────────┬────────────┬─────────┐ +│ Code │ Name │ Type │ Discipline │ Dept │ ScrVis │ RptVis │ Actions │ │ +├─────────┼────────────────────┼───────┼────────────┼────────┼────────┼────────┼────────────┼─────────┤ +│ CBC │ Complete Blood │ TEST │ Hematology │ Hema │ ☑ │ ☑ │ 👁️ ✏️ 🗑️ │ │ +│ │ Count │ │ │ │ │ │ │ │ +│ HGB │ Hemoglobin │ PARAM │ Hematology │ Hema │ ☑ │ ☑ │ 👁️ ✏️ 🗑️ │ │ +│ CALC_A1C│ A1C Calculated │ CALC │ Chemistry │ Chem │ ☑ │ ☑ │ 👁️ ✏️ 🗑️ │ │ +│ CMP_GRP │ Comprehensive │ GROUP │ - │ - │ ☑ │ ☐ │ 👁️ ✏️ 🗑️ │ │ +│ │ Panel │ │ │ │ │ │ │ │ +│ HEADER1 │ Chemistry Results │ TITLE │ - │ - │ ☐ │ ☑ │ 👁️ ✏️ 🗑️ │ │ +└─────────┴────────────────────┴───────┴────────────┴────────┴────────┴────────┴────────────┴─────────┘ +``` + +### Test Form Page + +**Left Sidebar Tabs** (Navigation): +``` +┌──────────────┐ +│ Basic Info │ +├──────────────┤ +│ Tech Details │ +├──────────────┤ +│ Calculations │ (CALC only) +├──────────────┤ +│ Group Memb │ (GROUP only) +├──────────────┤ +│ Mappings │ +├──────────────┤ +│ Ref Num │ (TEST/PARAM/CALC) +├──────────────┤ +│ Ref Txt │ (TEST/PARAM) +└──────────────┘ +``` + +**Tab Visibility Rules**: +| Tab | TEST | PARAM | CALC | GROUP | TITLE | +|-----|------|-------|------|-------|-------| +| Basic Info | ✅ | ✅ | ✅ | ✅ | ✅ | +| Tech Details | ✅ | ✅ | ❌ | ❌ | ❌ | +| Calculations | ❌ | ❌ | ✅ | ❌ | ❌ | +| Group Members | ❌ | ❌ | ❌ | ✅ | ❌ | +| Mappings | ✅ | ✅ | ✅ | ✅ | ✅ | +| Ref Num | ✅ | ✅ | ✅ | ❌ | ❌ | +| Ref Txt | ✅ | ✅ | ❌ | ❌ | ❌ | + +**Active Tab Styling**: +- Left border accent color (primary theme color) +- Light background highlight +- Bold text +- Icon indicator + +### Tab Content Specifications + +#### 1. Basic Info Tab + +**Form Layout** (Two-column grid): +``` +┌──────────────────────────────────────────────────────────────────┐ +│ Test Code: [CBC_______] *Required │ +│ Test Name: [Complete Blood Count________] *Required │ +│ Test Type: [TEST ▼] (dropdown) │ +│ Description: [Standard hematology test_______________] │ +│ │ +│ Site: [Main Lab ▼] │ +│ │ +│ Screen Seq: [1___] Report Seq: [1___] │ +│ │ +│ [☑] Visible on Screen [☑] Visible on Report │ +│ │ +│ [☑] Count in Statistics │ +│ │ +│ Start Date: [2024-01-01_______] │ +└──────────────────────────────────────────────────────────────────┘ +``` + +**Fields**: +- TestSiteCode (required, 3-6 chars, uppercase, unique validation) +- TestSiteName (required, 3-255 chars) +- TestType (required, dropdown: TEST, PARAM, CALC, GROUP, TITLE) +- Description (optional, textarea, max 500 chars) +- SiteID (required, dropdown from sites API) +- SeqScr (optional, number, default 0) +- SeqRpt (optional, number, default 0) +- VisibleScr (checkbox, default true) +- VisibleRpt (checkbox, default true) +- CountStat (checkbox, default true) +- StartDate (optional, datetime, default current) + +**Dynamic Behavior**: +- When TestType changes → Show/hide relevant tabs +- When TestType = CALC/PARAM/TEST → Auto-populate defaults +- TestType change triggers confirmation if form has unsaved changes + +#### 2. Tech Details Tab + +**Form Layout** (Three sections): + +**Section 1: Categorization** +``` +┌──────────────────────────────────────────────────────────────┐ +│ Discipline: [Hematology ▼] │ +│ Department: [CBC Dept ▼] │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Section 2: Result Configuration** +``` +┌──────────────────────────────────────────────────────────────┐ +│ Result Type: [Numeric ▼] (dynamic based on TestType) │ +│ Ref Type: [Range ▼] (dynamic based on ResultType) │ +│ Value Set: [____________] (if ResultType = VSET) │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Section 3: Units & Conversion** +``` +┌──────────────────────────────────────────────────────────────┐ +│ Unit 1: [g/dL ▼] │ +│ Factor: [1.0__] (conversion factor) │ +│ Unit 2: [g/L ▼] │ +│ Decimal: [2__] (decimal places) │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Section 4: Sample Requirements** +``` +┌──────────────────────────────────────────────────────────────┐ +│ Required Qty: [5.0__] │ +│ Qty Unit: [mL ▼] │ +│ Collection Req: [Fasting required_______________] │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Section 5: Method & TAT** +``` +┌──────────────────────────────────────────────────────────────┐ +│ Method: [Automated Analyzer_______________] │ +│ Expected TAT: [60__] (minutes) │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Fields**: +- DisciplineID (dropdown, optional) +- DepartmentID (dropdown, optional) +- ResultType (dropdown, dynamic options based on TestType) +- RefType (dropdown, dynamic options based on ResultType) +- VSet (text input, shown only if ResultType = VSET) +- Unit1 (dropdown from units valueset) +- Factor (number, optional) +- Unit2 (dropdown from units valueset) +- Decimal (number, default 2, min 0, max 6) +- ReqQty (number, optional) +- ReqQtyUnit (dropdown) +- CollReq (textarea, optional) +- Method (text input, optional) +- ExpectedTAT (number, optional) + +**Dynamic Dropdown Logic**: +```typescript +// TestType → ResultType mapping +const getResultTypeOptions = (testType: string) => { + switch (testType) { + case 'CALC': return ['NMRIC']; + case 'GROUP': + case 'TITLE': return ['NORES']; + default: return ['NMRIC', 'RANGE', 'TEXT', 'VSET']; + } +}; + +// ResultType → RefType mapping +const getRefTypeOptions = (resultType: string) => { + switch (resultType) { + case 'NMRIC': + case 'RANGE': return ['RANGE', 'THOLD']; + case 'VSET': return ['VSET']; + case 'TEXT': return ['TEXT']; + case 'NORES': return ['NOREF']; + default: return []; + } +}; +``` + +#### 3. Calculations Tab (CALC only) + +**Form Layout**: +``` +┌──────────────────────────────────────────────────────────────┐ +│ Formula Input: [HGB + MCV + MCHC________] │ +│ │ +│ Formula Code: [{HGB} + {MCV} + {MCHC}_____________] │ +│ │ +│ Discipline: [Hematology ▼] │ +│ Department: [CBC Dept ▼] │ +│ │ +│ Method: [Calculated from components_______] │ +│ │ +│ Unit 1: [g/dL ▼] │ +│ Factor: [1.0__] │ +│ Unit 2: [g/L ▼] │ +│ Decimal: [2__] │ +│ │ +│ Ref Type: [Range ▼] │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Fields**: +- FormulaInput (text input, description of calculation) +- FormulaCode (text input, actual formula with placeholders like {A}, {B}) +- DisciplineID (dropdown) +- DepartmentID (dropdown) +- Method (text input) +- Unit1, Factor, Unit2, Decimal (same as Tech Details) +- RefType (dropdown: RANGE, THOLD) + +**Validation**: +- FormulaCode must contain valid syntax +- FormulaCode must reference valid test codes +- Test codes in formula must exist in system + +#### 4. Group Members Tab (GROUP only) + +**Form Layout**: +``` +┌──────────────────────────────────────────────────────────────┐ +│ Group: CBC - Complete Blood Count │ +│ │ +│ Current Members: │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Code │ Name │ Type │ Seq │ Actions │ │ +│ ├──────┼─────────────────┼────────┼─────┼───────────────┤ │ +│ │ HGB │ Hemoglobin │ PARAM │ 1 │ [↑] [↓] [✕] │ │ +│ │ RBC │ Red Blood Cells │ TEST │ 2 │ [↑] [↓] [✕] │ │ +│ │ WBC │ White Blood Cell│ TEST │ 3 │ [↑] [↓] [✕] │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ Add Member: [HGB ▼] [Add Member +] │ +│ │ +│ Available Tests: (searchable dropdown) │ +│ - HGB - Hemoglobin │ +│ - RBC - Red Blood Cells │ +│ - WBC - White Blood Cells │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Features**: +- List current members with Code, Name, Type +- Reorder members (drag-and-drop or ↑↓ buttons) +- Remove member button (with confirmation) +- Add member dropdown (searchable, excludes current group and members) +- Prevent circular references (group cannot contain itself) +- Prevent duplicate members + +**Member Selection**: +- Dropdown with search +- Group by TestType (TEST, PARAM, CALC) +- Show TestSiteCode - TestSiteName format +- Filter out already added members +- Filter out current group itself + +#### 5. Mappings Tab (All test types) + +**Form Layout**: +``` +┌──────────────────────────────────────────────────────────────┐ +│ Test: CBC - Complete Blood Count │ +│ │ +│ Current Mappings: │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Host │ Host Code │ Client │ Client Code │ Actions │ │ +│ ├──────┼───────────┼────────┼────────────┼───────────┤ │ +│ │ HIS │ CBC │ WST-1 │ CBC01 │ [✏️] [✕] │ │ +│ │ SITE │ CBC │ INST-1 │ CBC │ [✏️] [✕] │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ [Add Mapping +] │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Add/Edit Mapping Modal**: +``` +┌──────────────────────────────────────────────────────────────┐ +│ Add/Edit Mapping │ +│ │ +│ Host System: │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Type: [HIS ▼] │ │ +│ │ ID: [1____] │ │ +│ │ Data Src: [DB____] │ │ +│ │ Test Code:[CBC____] │ │ +│ │ Test Name:[Complete Blood Count___________] │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ Client System: │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Type: [WST ▼] │ │ +│ │ ID: [1____] │ │ +│ │ Data Src: [API____] │ │ +│ │ Container: [Tube1 ▼] │ │ +│ │ Test Code: [CBC01____] │ │ +│ │ Test Name: [CBC_____________] │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ [Save] [Cancel] │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Fields**: +- HostType: dropdown (HIS, SITE, WST, INST) +- HostID: text/number input +- HostDataSource: text input +- HostTestCode: text input +- HostTestName: text input +- ClientType: dropdown (HIS, SITE, WST, INST) +- ClientID: text/number input +- ClientDataSource: text input +- ConDefID: dropdown (container definitions) +- ClientTestCode: text input +- ClientTestName: text input + +**Validation**: +- At least one of Host or Client must be specified +- Test codes must be unique per Host/Client combination + +#### 6. Ref Num Tab (Numeric Reference Ranges) + +**Form Layout**: +``` +┌──────────────────────────────────────────────────────────────┐ +│ Test: CBC - Complete Blood Count │ +│ Numeric Reference Ranges │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │Type │Range │Sex │ Age │ Low │High │Flag │ │ +│ │ │ │ │ │Bound │Bound │ │ │ +│ ├───────┼──────┼────┼───────┼──────────┼─────────┼─────┤ │ +│ │Ref │RANGE │All │0-150 │[≥ 4.0] │[< 5.5] │ N │ │ +│ │ │ │ │ │ │ │ │ │ +│ │Crtc │THOLD │All │0-150 │[< 3.5] │[> 6.0] │ H/L │ │ +│ │ │ │ │ │ │ │ │ │ +│ │Ref │RANGE │F │18-150 │[≥ 4.5] │[< 5.0] │ N │ │ +│ │ │ │ │ │ │ │ │ │ +│ │Crtc │THOLD │M │18-150 │[< 3.8] │[> 5.8] │ H/L │ │ +│ │ │ │ │ │ │ │ │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ [Add Range] [Delete Selected] [Copy from Similar Test] │ +│ │ +│ Selected Ranges: 0 │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Add/Edit Reference Range Modal**: +``` +┌──────────────────────────────────────────────────────────────┐ +│ Numeric Reference Range │ +│ │ +│ Reference Type: [Reference ▼] │ +│ (Reference, Critical, Validation, Rerun) │ +│ │ +│ Range Type: [Range ▼] │ +│ (Range, Threshold) │ +│ │ +│ Sex: [All ▼] │ +│ (All, Female, Male) │ +│ │ +│ Age Start: [0__] Age End: [150__] │ +│ │ +│ Low Bound: │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Sign: [≥ ▼] Value: [4.0__] │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ High Bound: │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │ Sign: [< ▼] Value: [5.5__] │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ Flag: [H/L/A/N___] (High/Low/Abnormal/Normal) │ +│ │ +│ Interpretation:[Normal range for hemoglobin_______________] │ +│ │ +│ [Save] [Cancel] │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Fields**: +- NumRefType: dropdown (REF, CRTC, VAL, RERUN) +- RangeType: dropdown (RANGE, THOLD) +- Sex: dropdown (0=All, 1=Female, 2=Male) +- AgeStart: number input (min 0, max 150) +- AgeEnd: number input (min 0, max 150) +- LowSign: dropdown (=, <, <=) +- Low: number input (optional) +- HighSign: dropdown (=, >, >=) +- High: number input (optional) +- Flag: text input (single char: H, L, A, N) +- Interpretation: textarea (optional) + +**Validation**: +- AgeStart must be less than AgeEnd +- If both Low and High are present: Low must be less than High +- LowSign must be appropriate for Low value (e.g., if Low = 4.0, LowSign should be >=) +- HighSign must be appropriate for High value (e.g., if High = 5.5, HighSign should be <=) + +**Reference Range Logic**: +- Reference (REF): Normal ranges for reporting +- Critical (CRTC): Critical values requiring immediate notification +- Validation (VAL): Validation checks for result entry +- Rerun (RERUN): Conditions triggering automatic rerun + +**Range Type**: +- RANGE: Standard range (Low to High) +- THOLD: Threshold (single value with comparison) + +#### 7. Ref Txt Tab (Text Reference Ranges) + +**Form Layout**: +``` +┌──────────────────────────────────────────────────────────────┐ +│ Test: URINE - Urinalysis │ +│ Text Reference Ranges │ +│ │ +│ ┌────────────────────────────────────────────────────────┐ │ +│ │Type │Sex │ Age │ Reference Text │ Flag │ │ +│ ├───────┼────┼───────┼─────────────────┼─────────────┤ │ +│ │Normal │All │0-150 │Clear │ N │ │ +│ │ │ │ │ │ │ │ +│ │Abnml │All │0-150 │Cloudy │ A │ │ +│ │ │ │ │ │ │ │ +│ │Crtc │All │0-150 │Bloody │ C │ │ +│ │ │ │ │ │ │ │ +│ └────────────────────────────────────────────────────────┘ │ +│ │ +│ [Add Range] [Delete Selected] [Copy from Similar Test] │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Add/Edit Text Reference Modal**: +``` +┌──────────────────────────────────────────────────────────────┐ +│ Text Reference Range │ +│ │ +│ Reference Type: [Normal ▼] │ +│ (Normal, Abnormal, Critical) │ +│ │ +│ Sex: [All ▼] │ +│ (All, Female, Male) │ +│ │ +│ Age Start: [0__] Age End: [150__] │ +│ │ +│ Reference Text: [Clear_______________] │ +│ │ +│ Flag: [N/A/C___] (Normal/Abnormal/Critical) │ +│ │ +│ [Save] [Cancel] │ +└──────────────────────────────────────────────────────────────┘ +``` + +**Fields**: +- TxtRefType: dropdown (Normal, Abnormal, Critical) +- Sex: dropdown (0=All, 1=Female, 2=Male) +- AgeStart: number input +- AgeEnd: number input +- RefTxt: text input +- Flag: text input (N, A, C) + +**Validation**: +- AgeStart must be less than AgeEnd +- RefTxt is required +- Flag must match TxtRefType (Normal=N, Abnormal=A, Critical=C) + +--- + +## 🏗️ Component Implementation + +### Core Component Requirements + +#### 1. State Management with Svelte 5 Runes + +**testStore.ts**: +```typescript +import { writable } from 'svelte/store'; + +interface TestFormState { + TestSiteID?: number; + TestSiteCode: string; + TestSiteName: string; + TestType: 'TEST' | 'PARAM' | 'CALC' | 'GROUP' | 'TITLE'; + Description?: string; + SiteID: number; + SeqScr: number; + SeqRpt: number; + VisibleScr: number; + VisibleRpt: number; + CountStat: number; + StartDate?: string; + details?: { + DisciplineID?: number; + DepartmentID?: number; + ResultType?: string; + RefType?: string; + VSet?: string; + Unit1?: string; + Factor?: number; + Unit2?: string; + Decimal?: number; + ReqQty?: number; + ReqQtyUnit?: string; + CollReq?: string; + Method?: string; + ExpectedTAT?: number; + FormulaInput?: string; + FormulaCode?: string; + members?: number[]; + }; + refnum?: RefNumRange[]; + reftxt?: RefTxtRange[]; + testmap?: TestMapping[]; +} + +export const testStore = writable({ + TestSiteCode: '', + TestSiteName: '', + TestType: 'TEST', + SiteID: 1, + SeqScr: 0, + SeqRpt: 0, + VisibleScr: 1, + VisibleRpt: 1, + CountStat: 1, + refnum: [], + reftxt: [], + testmap: [], +}); +``` + +**Derived Stores**: +```typescript +// Derive visible tabs based on TestType +export const visibleTabs = derived(testStore, ($store) => { + const type = $store.TestType; + return allTabs.filter(tab => tab.isVisible(type)); +}); + +// Derive valid ResultType options +export const validResultTypes = derived(testStore, ($store) => { + const type = $store.TestType; + // Return options based on type +}); + +// Derive valid RefType options +export const validRefTypes = derived([testStore, validResultTypes], ([$store, $resultTypes]) => { + const resultType = $store.details?.ResultType; + // Return options based on resultType +}); +``` + +#### 2. Reusable UI Components + +**Button.svelte**: +```svelte + + + +``` + +**Input.svelte**: +```svelte + + +
+ {#if label} + + {/if} + + {#if error} + {error} + {/if} +
+``` + +**Select.svelte**: +```svelte + + +
+ {#if label} + + {/if} + +
+``` + +#### 3. Test Form Component + +**TestForm.svelte** (Main container): +```svelte + + +
+ + +
+ {#if isLoading} + + {:else} +

+ {$testStore.TestSiteID ? 'Edit Test' : 'New Test'} +

+ + {#if currentTab === 'basic'} + + {:else if currentTab === 'tech'} + + {:else if currentTab === 'calc'} + + {:else if currentTab === 'group'} + + {:else if currentTab === 'mappings'} + + {:else if currentTab === 'refnum'} + + {:else if currentTab === 'reftxt'} + + {/if} + +
+ + + {#if $testStore.TestSiteID} + + {/if} +
+ {/if} +
+
+``` + +--- + +## ✅ Validation Requirements + +### Frontend Validation + +#### TestSiteCode +- Required +- 3-6 characters +- Uppercase only +- Alphanumeric only +- Unique (check via API) +- Regex: `^[A-Z0-9]{3,6}$` + +#### TestSiteName +- Required +- 3-255 characters +- No special characters (except hyphen, space, parenthesis) +- Regex: `^[a-zA-Z0-9\s\-\(\)]{3,255}$` + +#### TestType +- Required +- Must be one of: TEST, PARAM, CALC, GROUP, TITLE + +#### Type Combination Validation +```typescript +const validateTypeCombination = (testType: string, resultType: string, refType: string) => { + const valid = TestValidationService.validate(testType, resultType, refType); + if (!valid.valid) { + throw new Error(valid.error); + } +}; +``` + +#### Reference Range Validation + +**Numeric Ranges**: +- AgeStart < AgeEnd (both 0-150) +- If Low and High both present: Low < High +- LowSign appropriate for Low value +- HighSign appropriate for High value +- Flag is single character (H, L, A, N) + +**Text Ranges**: +- AgeStart < AgeEnd +- RefTxt is required +- Flag matches TxtRefType + +#### Group Validation +- Group cannot contain itself +- No circular references (Group A contains Group B, Group B contains Group A) +- No duplicate members +- Minimum 1 member for GROUP type + +--- + +## 🎨 Styling & Design System + +### Color Palette +```css +:root { + /* Primary */ + --primary-50: #e0f2fe; + --primary-100: #bae6fd; + --primary-500: #0ea5e9; + --primary-600: #0284c7; + --primary-700: #0369a1; + + /* Secondary */ + --secondary-500: #64748b; + --secondary-600: #475569; + + /* Success */ + --success-500: #22c55e; + --success-600: #16a34a; + + /* Danger */ + --danger-500: #ef4444; + --danger-600: #dc2626; + + /* Warning */ + --warning-500: #f59e0b; + --warning-600: #d97706; + + /* Neutral */ + --gray-50: #f9fafb; + --gray-100: #f3f4f6; + --gray-200: #e5e7eb; + --gray-300: #d1d5db; + --gray-500: #6b7280; + --gray-700: #374151; + --gray-900: #111827; +} +``` + +### Typography +```css +/* Font sizes */ +--text-xs: 0.75rem; /* 12px */ +--text-sm: 0.875rem; /* 14px */ +--text-base: 1rem; /* 16px */ +--text-lg: 1.125rem; /* 18px */ +--text-xl: 1.25rem; /* 20px */ +--text-2xl: 1.5rem; /* 24px */ +--text-3xl: 1.875rem; /* 30px */ +``` + +### Spacing +```css +--space-1: 0.25rem; /* 4px */ +--space-2: 0.5rem; /* 8px */ +--space-3: 0.75rem; /* 12px */ +--space-4: 1rem; /* 16px */ +--space-6: 1.5rem; /* 24px */ +--space-8: 2rem; /* 32px */ +``` + +### Components + +**Buttons**: +```css +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: var(--space-2) var(--space-4); + border-radius: 0.375rem; + font-weight: 500; + transition: all 0.2s; + cursor: pointer; +} + +.btn-primary { + background-color: var(--primary-600); + color: white; +} + +.btn-primary:hover:not(:disabled) { + background-color: var(--primary-700); +} + +.btn-secondary { + background-color: var(--gray-200); + color: var(--gray-700); +} + +.btn-danger { + background-color: var(--danger-600); + color: white; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} +``` + +**Inputs**: +```css +.input-field { + width: 100%; + padding: var(--space-2) var(--space-3); + border: 1px solid var(--gray-300); + border-radius: 0.375rem; + font-size: var(--text-base); + transition: border-color 0.2s; +} + +.input-field:focus { + outline: none; + border-color: var(--primary-500); + box-shadow: 0 0 0 3px rgba(14, 165, 233, 0.1); +} + +.input-field.has-error { + border-color: var(--danger-500); +} +``` + +**Sidebar Tabs**: +```css +.sidebar-tab { + padding: var(--space-3) var(--space-4); + border-left: 3px solid transparent; + cursor: pointer; + transition: all 0.2s; +} + +.sidebar-tab:hover { + background-color: var(--gray-100); +} + +.sidebar-tab.active { + background-color: var(--primary-50); + border-left-color: var(--primary-600); + font-weight: 600; +} +``` + +--- + +## 📱 Responsive Design + +### Breakpoints +```css +--breakpoint-sm: 640px; +--breakpoint-md: 768px; +--breakpoint-lg: 1024px; +--breakpoint-xl: 1280px; +``` + +### Responsive Behavior + +**Desktop (> 1024px)**: +- Sidebar: Fixed width, visible +- Content: Full width +- Form: 2-column grid layout + +**Tablet (768px - 1024px)**: +- Sidebar: Collapsible (hamburger menu) +- Content: Full width +- Form: Single column layout +- Table: Horizontal scroll + +**Mobile (< 768px)**: +- Sidebar: Off-canvas drawer +- Content: Full width +- Form: Single column, stacked +- Table: Card view instead of table + +--- + +## 🚀 Implementation Checklist + +### Phase 1: Project Setup & Infrastructure +- [ ] Initialize SvelteKit project with TypeScript +- [ ] Install and configure Tailwind CSS +- [ ] Set up Skeleton UI or Melt UI +- [ ] Configure Axios with interceptors +- [ ] Create type definitions (test.types.ts, api.types.ts) +- [ ] Set up API service layer +- [ ] Create auth store and testStore +- [ ] Set up routing structure + +### Phase 2: Reusable Components +- [ ] Button component +- [ ] Input component +- [ ] Select component +- [ ] Checkbox component +- [ ] Table component +- [ ] Modal component +- [ ] Badge component +- [ ] Alert component +- [ ] Spinner component +- [ ] Tabs component + +### Phase 3: Test List Page +- [ ] Test list page layout +- [ ] Filter panel component +- [ ] Test table component +- [ ] Pagination component +- [ ] Search functionality +- [ ] Filter functionality +- [ ] Sort functionality +- [ ] Load test data from API + +### Phase 4: Test Form - Basic & Tech +- [ ] Test form container with sidebar tabs +- [ ] Basic Info tab +- [ ] Tech Details tab +- [ ] Dynamic dropdown logic +- [ ] Form validation +- [ ] Save functionality +- [ ] Update functionality + +### Phase 5: Type-Specific Tabs +- [ ] Calculations tab (CALC) +- [ ] Group Members tab (GROUP) +- [ ] Mappings tab (all types) +- [ ] Member selection dropdown +- [ ] Mapping add/edit modal + +### Phase 6: Reference Ranges +- [ ] RefNum tab with table +- [ ] RefTxt tab with table +- [ ] Reference range modal +- [ ] Reference range validation +- [ ] Add/Edit/Delete operations + +### Phase 7: Polish & Testing +- [ ] Responsive design +- [ ] Loading states +- [ ] Error handling +- [ ] Form dirty state tracking +- [ ] Confirmation dialogs +- [ ] Toast notifications +- [ ] Accessibility (ARIA labels) +- [ ] Keyboard navigation +- [ ] Cross-browser testing + +### Phase 8: Documentation +- [ ] Component documentation +- [ ] API integration guide +- [ ] User guide +- [ ] Deployment instructions + +--- + +## 📚 Additional Notes + +### ValueSet Integration +- Use backend API `/api/valueset` to fetch dropdown options +- Cache valuesets locally to reduce API calls +- Transform labels: API returns both code and label (e.g., `TestType` and `TestTypeLabel`) +- Display labels in UI, use codes for API calls + +### Authentication +- JWT token stored in localStorage +- Include token in Authorization header for all API calls +- Handle token expiration and refresh +- Redirect to login if unauthorized + +### Error Handling +- Display user-friendly error messages +- Log technical errors to console +- Retry logic for failed requests (with backoff) +- Show appropriate feedback for network errors + +### Performance +- Implement debounced search (300ms) +- Lazy load test data (pagination) +- Optimize re-renders with Svelte 5 runes +- Memoize expensive computations + +### Accessibility +- ARIA labels for form inputs +- Keyboard navigation support +- Screen reader compatibility +- Focus management in modals +- Color contrast compliance (WCAG AA) + +--- + +## 🔗 References + +### Backend Documentation +- API Endpoints: `/api/tests` +- Models: `TestDefSiteModel`, `TestDefCalModel`, `TestDefGrpModel`, `TestMapModel` +- Validation: `TestValidationService` + +### Type System +- Test types: TEST, PARAM, CALC, GROUP, TITLE +- Result types: NMRIC, RANGE, TEXT, VSET, NORES +- Reference types: RANGE, THOLD, TEXT, VSET, NOREF + +### Business Rules +- Soft deletes only (set EndDate) +- Test type + ResultType + RefType must be valid combination +- Reference ranges are type-specific (numeric vs text) +- Calculations use formula placeholders like {A}, {B} + +--- + +## 🎯 Success Criteria + +The frontend is considered complete when: + +1. **Functional Requirements** + - All CRUD operations work for all test types + - Reference ranges can be managed (add/edit/delete) + - Group members can be added/removed/reordered + - Mappings can be configured for external systems + - Form validation prevents invalid data submission + +2. **User Experience** + - Intuitive navigation with sidebar tabs + - Clear visual feedback for actions + - Responsive design works on all devices + - Loading states indicate progress + - Error messages are helpful and actionable + +3. **Code Quality** + - TypeScript strict mode with no errors + - Component reusability and modularity + - Proper error handling throughout + - Clean, readable code with comments + - Efficient state management with Svelte 5 runes + +4. **Performance** + - Page load time < 2 seconds + - Search results appear within 300ms + - Form submissions complete within 1 second + - No memory leaks or performance degradation + +5. **Testing** + - Unit tests for components + - Integration tests for API calls + - E2E tests for critical user flows + - Cross-browser compatibility verified + +--- + +## 📞 Support + +For questions or issues during development: +1. Review backend API documentation in `README.md` +2. Check model definitions in `app/Models/Test/` +3. Refer to validation service in `app/Libraries/TestValidationService.php` +4. Test API endpoints directly using tools like Postman + +--- + +**Last Updated**: February 2025 +**Version**: 1.0 diff --git a/docs/audit-logging-plan.md b/docs/audit-logging-plan.md index 27c820b..3fd5093 100644 --- a/docs/audit-logging-plan.md +++ b/docs/audit-logging-plan.md @@ -1,189 +1,464 @@ # Audit Logging Architecture Plan for CLQMS -> **Clinical Laboratory Quality Management System (CLQMS)** - A comprehensive audit trail strategy for tracking changes across master data, patient records, and laboratory operations. +> **Clinical Laboratory Quality Management System (CLQMS)** - Comprehensive audit trail implementation based on section 4.2.1.20 Error Management requirements, implementing 5W1H audit principles across four specialized log types. --- ## Executive Summary -This document outlines a unified audit logging architecture for CLQMS, designed to provide complete traceability of data changes while maintaining optimal performance and maintainability. The approach separates audit logs into three domain-specific tables, utilizing JSON for flexible value storage. +This document defines the audit logging architecture for CLQMS, implementing the **5W1H audit principle** (What, When, Who, How, Where, Why) across four specialized log tables. The design supports both **manual** (user-initiated) and **automatic** (instrument/service-initiated) operations with complete traceability. --- -## 1. Current State Analysis +## 1. Requirements Analysis (Section 4.2.1.20) -### Existing Audit Infrastructure +### 5W1H Audit Principles -| Aspect | Current Status | -|--------|---------------| -| **Database Tables** | 3 tables exist in migrations (patreglog, patvisitlog, specimenlog) | -| **Implementation** | Tables created but not actively used | -| **Structure** | Fixed column approach (FldName, FldValuePrev) | -| **Code Coverage** | No models or controllers implemented | -| **Application Logging** | Basic CodeIgniter file logging for debug/errors | +| Dimension | Description | Captured Fields | +|-----------|-------------|-----------------| +| **What** | Data changed, operation performed | `operation`, `table_name`, `field_name`, `previous_value`, `new_value` | +| **When** | Timestamp of activity | `created_at` | +| **Who** | User performing operation | `user_id` | +| **How** | Mechanism, application, session | `mechanism`, `application_id`, `web_page`, `session_id`, `event_type` | +| **Where** | Location of operation | `site_id`, `workstation_id`, `pc_name`, `ip_address` | +| **Why** | Reason for operation | `reason` | -### Pain Points Identified +### Four Log Types -- ❌ **3 separate tables** with nearly identical schemas -- ❌ **Fixed column structure** - rigid and requires schema changes for new entities -- ❌ **No implementation** - audit tables exist but aren't populated -- ❌ **Maintenance overhead** - adding new entities requires new migrations +| Log Type | Description | Examples | +|----------|-------------|----------| +| **Data Log** | Events related to data operations | Patient demographics, visits, test orders, samples, results, user data, master data, archiving, transaction errors | +| **Service Log** | Background service events | Host communication, instrument communication, printing, messaging, resource access, system errors | +| **Security Log** | Security and access events | Logins/logouts, file access, permission changes, password failures, system changes | +| **Error Log** | Error events by entity | Instrument errors, integration errors, validation errors | + +### Mechanism Types + +- **MANUAL**: User-initiated actions via web interface +- **AUTOMATIC**: System/instrument-initiated (duplo/repeated operations) --- -## 2. Proposed Architecture +## 2. Table Architecture -### 2.1 Domain Separation +### 2.1 Overview -We categorize audit logs by **data domain** and **access patterns**: +Four separate tables optimized for different volumes and retention: -| Table | Domain | Volume | Retention | Use Case | -|-------|--------|--------|-----------|----------| -| `master_audit_log` | Reference Data | Low | Permanent | Organizations, Users, ValueSets | -| `patient_audit_log` | Patient Records | Medium | 7 years | Demographics, Contacts, Insurance | -| `order_audit_log` | Operations | High | 2 years | Orders, Tests, Specimens, Results | - -### 2.2 Unified Table Structure - -#### Master Audit Log - -```sql -CREATE TABLE master_audit_log ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - entity_type VARCHAR(50) NOT NULL, -- 'organization', 'user', 'valueset' - entity_id VARCHAR(36) NOT NULL, -- UUID or primary key - action ENUM('CREATE', 'UPDATE', 'DELETE', 'PATCH') NOT NULL, - - old_values JSON NULL, -- Complete snapshot before change - new_values JSON NULL, -- Complete snapshot after change - changed_fields JSON, -- Array of modified field names - - -- Context - user_id VARCHAR(36), - site_id VARCHAR(36), - ip_address VARCHAR(45), - user_agent VARCHAR(500), - app_version VARCHAR(20), - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - - INDEX idx_entity (entity_type, entity_id), - INDEX idx_created (created_at), - INDEX idx_user (user_id, created_at) -) ENGINE=InnoDB; -``` - -#### Patient Audit Log - -```sql -CREATE TABLE patient_audit_log ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - entity_type VARCHAR(50) NOT NULL, -- 'patient', 'contact', 'insurance' - entity_id VARCHAR(36) NOT NULL, - patient_id VARCHAR(36), -- Context FK for patient - - action ENUM('CREATE', 'UPDATE', 'DELETE', 'MERGE', 'UNMERGE') NOT NULL, - - old_values JSON NULL, - new_values JSON NULL, - changed_fields JSON, - reason TEXT, -- Why the change was made - - -- Context - user_id VARCHAR(36), - site_id VARCHAR(36), - ip_address VARCHAR(45), - session_id VARCHAR(100), - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - - INDEX idx_entity (entity_type, entity_id), - INDEX idx_patient (patient_id, created_at), - INDEX idx_created (created_at), - INDEX idx_user (user_id, created_at) -) ENGINE=InnoDB; -``` - -#### Order/Test Audit Log - -```sql -CREATE TABLE order_audit_log ( - id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, - entity_type VARCHAR(50) NOT NULL, -- 'order', 'test', 'specimen', 'result' - entity_id VARCHAR(36) NOT NULL, - - -- Context FKs - patient_id VARCHAR(36), - visit_id VARCHAR(36), - order_id VARCHAR(36), - - action ENUM('CREATE', 'UPDATE', 'DELETE', 'CANCEL', 'REORDER', 'COLLECT', 'RESULT') NOT NULL, - - old_values JSON NULL, - new_values JSON NULL, - changed_fields JSON, - status_transition VARCHAR(100), -- e.g., 'pending->collected' - - -- Context - user_id VARCHAR(36), - site_id VARCHAR(36), - device_id VARCHAR(36), -- Instrument/edge device - ip_address VARCHAR(45), - session_id VARCHAR(100), - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - - INDEX idx_entity (entity_type, entity_id), - INDEX idx_order (order_id, created_at), - INDEX idx_patient (patient_id, created_at), - INDEX idx_created (created_at), - INDEX idx_user (user_id, created_at) -) ENGINE=InnoDB; -``` +| Table | Volume | Retention | Partitioning | +|-------|--------|-----------|--------------| +| `data_audit_log` | Medium | 7 years | Monthly | +| `service_audit_log` | Very High | 2 years | Monthly | +| `security_audit_log` | Low | Permanent | No | +| `error_audit_log` | Variable | 5 years | Monthly | --- -## 3. JSON Value Structure +### 2.2 Table: data_audit_log -### Example Audit Entry +```sql +CREATE TABLE data_audit_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + -- WHAT: Operation and data details + operation VARCHAR(50) NOT NULL, -- 'CREATE', 'UPDATE', 'DELETE', 'ARCHIVE', etc. + entity_type VARCHAR(50) NOT NULL, -- 'patient', 'visit', 'test_order', 'sample', 'user', etc. + entity_id VARCHAR(36) NOT NULL, -- ID of affected entity + table_name VARCHAR(100), -- Database table name + field_name VARCHAR(100), -- Specific field changed (NULL if multiple) + previous_value JSON, -- Value before change + new_value JSON, -- Value after change + + -- HOW: Mechanism details + mechanism ENUM('MANUAL', 'AUTOMATIC') NOT NULL DEFAULT 'MANUAL', + application_id VARCHAR(50), -- Application identifier + web_page VARCHAR(500), -- URL/endpoint accessed + session_id VARCHAR(100), -- Session identifier + event_type VARCHAR(100), -- Event classification + + -- WHERE: Location information + site_id VARCHAR(36), -- Site/location ID + workstation_id VARCHAR(36), -- Workstation ID + pc_name VARCHAR(100), -- Computer name + ip_address VARCHAR(45), -- IP address (IPv6 compatible) + + -- WHO: User information + user_id VARCHAR(36) NOT NULL, -- User ID or 'SYSTEM' for automatic + + -- WHEN: Timestamp + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + -- WHY: Reason + reason TEXT, -- User-provided reason + + -- Context: Additional flexible data + context JSON, -- Log-type-specific extra data + + -- Indexes + INDEX idx_operation_created (operation, created_at), + INDEX idx_entity (entity_type, entity_id, created_at), + INDEX idx_user_created (user_id, created_at), + INDEX idx_mechanism (mechanism, created_at), + INDEX idx_table (table_name, created_at), + INDEX idx_site (site_id, created_at), + INDEX idx_created (created_at), + INDEX idx_session (session_id, created_at) + +) ENGINE=InnoDB +PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) ( + PARTITION p202601 VALUES LESS THAN (202602), + PARTITION p202602 VALUES LESS THAN (202603), + PARTITION p_future VALUES LESS THAN MAXVALUE +); +``` + +**Data Log Examples:** +- Patient registration: `operation='CREATE'`, `entity_type='patient'`, `mechanism='MANUAL'` +- Sample result from instrument: `operation='UPDATE'`, `entity_type='result'`, `mechanism='AUTOMATIC'` +- User profile update: `operation='UPDATE'`, `entity_type='user'`, `mechanism='MANUAL'` + +--- + +### 2.3 Table: service_audit_log + +```sql +CREATE TABLE service_audit_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + -- WHAT: Service operation details + operation VARCHAR(50) NOT NULL, -- 'COMMUNICATION', 'PRINT', 'BACKUP', 'MESSAGE', etc. + entity_type VARCHAR(50) NOT NULL, -- 'host', 'instrument', 'database', 'network', etc. + entity_id VARCHAR(36) NOT NULL, -- Service identifier + service_class VARCHAR(50), -- 'communication', 'printing', 'messaging', 'resource' + resource_type VARCHAR(100), -- 'database_access', 'backup', 'network', 'internet' + resource_details JSON, -- IP, port, connection details + previous_value JSON, -- State before + new_value JSON, -- State after + + -- HOW: Mechanism and context + mechanism ENUM('MANUAL', 'AUTOMATIC') NOT NULL DEFAULT 'AUTOMATIC', + application_id VARCHAR(50), -- Service application ID + service_name VARCHAR(100), -- Background service name + session_id VARCHAR(100), -- Service session + event_type VARCHAR(100), -- Event classification + + -- WHERE: Location and resources + site_id VARCHAR(36), + workstation_id VARCHAR(36), + pc_name VARCHAR(100), + ip_address VARCHAR(45), + port INT, -- Port number for network + + -- WHO: System or user + user_id VARCHAR(36) NOT NULL, -- 'SYSTEM' for automatic services + + -- WHEN: Timestamp + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + -- WHY: Reason if manual + reason TEXT, + + -- Context: Service-specific data + context JSON, -- Communication details, error codes, etc. + + -- Indexes + INDEX idx_operation_created (operation, created_at), + INDEX idx_entity (entity_type, entity_id, created_at), + INDEX idx_service_class (service_class, created_at), + INDEX idx_user_created (user_id, created_at), + INDEX idx_mechanism (mechanism, created_at), + INDEX idx_site (site_id, created_at), + INDEX idx_created (created_at) + +) ENGINE=InnoDB +PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) ( + PARTITION p202601 VALUES LESS THAN (202602), + PARTITION p202602 VALUES LESS THAN (202603), + PARTITION p_future VALUES LESS THAN MAXVALUE +); +``` + +**Service Log Examples:** +- Instrument communication: `operation='COMMUNICATION'`, `entity_type='instrument'`, `service_class='communication'` +- Database backup: `operation='BACKUP'`, `entity_type='database'`, `service_class='resource'` +- Automatic print: `operation='PRINT'`, `service_class='printing'`, `mechanism='AUTOMATIC'` + +--- + +### 2.4 Table: security_audit_log + +```sql +CREATE TABLE security_audit_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + -- WHAT: Security event details + operation VARCHAR(50) NOT NULL, -- 'LOGIN', 'LOGOUT', 'ACCESS_DENIED', 'PASSWORD_FAIL', etc. + entity_type VARCHAR(50) NOT NULL, -- 'user', 'file', 'folder', 'setting', 'application' + entity_id VARCHAR(36) NOT NULL, -- Target entity ID + security_class VARCHAR(50), -- 'authentication', 'authorization', 'system_change' + resource_path VARCHAR(500), -- File/folder path accessed + previous_value JSON, -- Previous security state + new_value JSON, -- New security state + + -- HOW: Access details + mechanism ENUM('MANUAL', 'AUTOMATIC') NOT NULL DEFAULT 'MANUAL', + application_id VARCHAR(50), + web_page VARCHAR(500), + session_id VARCHAR(100), + event_type VARCHAR(100), -- 'SUCCESS', 'FAILURE', 'WARNING' + + -- WHERE: Access location + site_id VARCHAR(36), + workstation_id VARCHAR(36), + pc_name VARCHAR(100), + ip_address VARCHAR(45), + + -- WHO: User attempting action + user_id VARCHAR(36) NOT NULL, -- User ID or 'UNKNOWN' for failed attempts + + -- WHEN: Timestamp + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + -- WHY: Reason if provided + reason TEXT, + + -- Context: Security-specific data + context JSON, -- Permission changes, failure counts, etc. + + -- Indexes + INDEX idx_operation_created (operation, created_at), + INDEX idx_entity (entity_type, entity_id, created_at), + INDEX idx_security_class (security_class, created_at), + INDEX idx_user_created (user_id, created_at), + INDEX idx_event_type (event_type, created_at), + INDEX idx_site (site_id, created_at), + INDEX idx_created (created_at), + INDEX idx_session (session_id, created_at) + +) ENGINE=InnoDB; +``` + +**Security Log Examples:** +- User login: `operation='LOGIN'`, `entity_type='user'`, `security_class='authentication'`, `event_type='SUCCESS'` +- Failed password: `operation='PASSWORD_FAIL'`, `entity_type='user'`, `security_class='authentication'`, `event_type='FAILURE'` +- Permission change: `operation='UPDATE'`, `entity_type='user'`, `security_class='authorization'` +- File access: `operation='ACCESS'`, `entity_type='file'`, `security_class='authorization'` + +--- + +### 2.5 Table: error_audit_log + +```sql +CREATE TABLE error_audit_log ( + id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, + + -- WHAT: Error details + operation VARCHAR(50) NOT NULL, -- 'ERROR', 'WARNING', 'CRITICAL' + entity_type VARCHAR(50) NOT NULL, -- 'instrument', 'integration', 'database', 'validation' + entity_id VARCHAR(36) NOT NULL, -- Entity where error occurred + error_code VARCHAR(50), -- Specific error code + error_message TEXT, -- Error message + error_details JSON, -- Stack trace, context + previous_value JSON, -- State before error + new_value JSON, -- State after error (if recovered) + + -- HOW: Error context + mechanism ENUM('MANUAL', 'AUTOMATIC') NOT NULL DEFAULT 'MANUAL', + application_id VARCHAR(50), + web_page VARCHAR(500), + session_id VARCHAR(100), + event_type VARCHAR(100), -- 'TRANSACTION_ERROR', 'SYSTEM_ERROR', 'VALIDATION_ERROR' + + -- WHERE: Error location + site_id VARCHAR(36), + workstation_id VARCHAR(36), + pc_name VARCHAR(100), + ip_address VARCHAR(45), + + -- WHO: User or system + user_id VARCHAR(36) NOT NULL, -- User ID or 'SYSTEM' + + -- WHEN: Timestamp + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + + -- WHY: Error context + reason TEXT, -- Why the error occurred + + -- Context: Additional error data + context JSON, -- Related IDs, transaction info, etc. + + -- Indexes + INDEX idx_operation_created (operation, created_at), + INDEX idx_entity (entity_type, entity_id, created_at), + INDEX idx_error_code (error_code, created_at), + INDEX idx_event_type (event_type, created_at), + INDEX idx_user_created (user_id, created_at), + INDEX idx_site (site_id, created_at), + INDEX idx_created (created_at) + +) ENGINE=InnoDB +PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) ( + PARTITION p202601 VALUES LESS THAN (202602), + PARTITION p202602 VALUES LESS THAN (202603), + PARTITION p_future VALUES LESS THAN MAXVALUE +); +``` + +**Error Log Examples:** +- Transaction error: `operation='ERROR'`, `entity_type='database'`, `event_type='TRANSACTION_ERROR'` +- Instrument error: `operation='ERROR'`, `entity_type='instrument'`, `event_type='SYSTEM_ERROR'` +- Integration error: `operation='ERROR'`, `entity_type='integration'`, `event_type='SYSTEM_ERROR'` +- Validation error: `operation='ERROR'`, `entity_type='validation'`, `event_type='VALIDATION_ERROR'` + +--- + +## 3. Example Audit Entries + +### 3.1 Data Log Entry (Patient Update) ```json { "id": 15243, + "operation": "UPDATE", "entity_type": "patient", "entity_id": "PAT-2026-001234", - "action": "UPDATE", - - "old_values": { + "table_name": "patients", + "field_name": null, + "previous_value": { "NameFirst": "John", "NameLast": "Doe", - "Gender": "M", - "BirthDate": "1990-01-15", "Phone": "+1-555-0100" }, - - "new_values": { + "new_value": { "NameFirst": "Johnny", "NameLast": "Doe-Smith", - "Gender": "M", - "BirthDate": "1990-01-15", "Phone": "+1-555-0199" }, - - "changed_fields": ["NameFirst", "NameLast", "Phone"], - - "user_id": "USR-001", + "mechanism": "MANUAL", + "application_id": "CLQMS-WEB", + "web_page": "/api/patient/PAT-2026-001234", + "session_id": "sess_abc123", + "event_type": "PATIENT_UPDATE", "site_id": "SITE-001", - "created_at": "2026-02-19T14:30:00Z" + "workstation_id": "WS-001", + "pc_name": "LAB-PC-01", + "ip_address": "192.168.1.100", + "user_id": "USR-001", + "created_at": "2026-02-19T14:30:00Z", + "reason": "Patient requested name change after marriage", + "context": { + "changed_fields": ["NameFirst", "NameLast", "Phone"], + "validation_status": "PASSED" + } } ``` -### Benefits of JSON Approach +### 3.2 Service Log Entry (Instrument Communication) -✅ **Schema Evolution** - Add new fields without migrations -✅ **Complete Snapshots** - Reconstruct full record state at any point -✅ **Flexible Queries** - MySQL 8.0+ supports JSON indexing and extraction -✅ **Audit Integrity** - Store exactly what changed, no data loss +```json +{ + "id": 89345, + "operation": "COMMUNICATION", + "entity_type": "instrument", + "entity_id": "INST-001", + "service_class": "communication", + "resource_type": "instrument_communication", + "resource_details": { + "protocol": "HL7", + "port": 2575, + "direction": "INBOUND" + }, + "previous_value": { "status": "IDLE" }, + "new_value": { "status": "RECEIVING" }, + "mechanism": "AUTOMATIC", + "application_id": "INSTRUMENT-SERVICE", + "service_name": "instrument-listener", + "session_id": "svc_inst_001", + "event_type": "RESULT_RECEIVED", + "site_id": "SITE-001", + "workstation_id": "WS-LAB-01", + "pc_name": "LAB-SERVER-01", + "ip_address": "192.168.1.10", + "port": 2575, + "user_id": "SYSTEM", + "created_at": "2026-02-19T14:35:22Z", + "reason": null, + "context": { + "sample_id": "SMP-2026-004567", + "test_count": 5, + "bytes_received": 2048 + } +} +``` + +### 3.3 Security Log Entry (Failed Login) + +```json +{ + "id": 4521, + "operation": "PASSWORD_FAIL", + "entity_type": "user", + "entity_id": "USR-999", + "security_class": "authentication", + "resource_path": "/api/auth/login", + "previous_value": { "failed_attempts": 2 }, + "new_value": { "failed_attempts": 3 }, + "mechanism": "MANUAL", + "application_id": "CLQMS-WEB", + "web_page": "/login", + "session_id": "sess_fail_789", + "event_type": "FAILURE", + "site_id": "SITE-002", + "workstation_id": "WS-RECEPTION", + "pc_name": "RECEPTION-PC-02", + "ip_address": "203.0.113.45", + "user_id": "USR-999", + "created_at": "2026-02-19T15:10:05Z", + "reason": null, + "context": { + "lockout_threshold": 5, + "remaining_attempts": 2, + "username_attempted": "john.doe" + } +} +``` + +### 3.4 Error Log Entry (Database Transaction Failure) + +```json +{ + "id": 1203, + "operation": "ERROR", + "entity_type": "database", + "entity_id": "DB-PRIMARY", + "error_code": "DB_TXN_001", + "error_message": "Transaction rollback due to deadlock", + "error_details": { + "sql_state": "40001", + "error_number": 1213, + "deadlock_victim": true + }, + "previous_value": { "transaction_status": "ACTIVE" }, + "new_value": { "transaction_status": "ROLLED_BACK" }, + "mechanism": "AUTOMATIC", + "application_id": "CLQMS-WEB", + "web_page": "/api/orders/batch-update", + "session_id": "sess_xyz789", + "event_type": "TRANSACTION_ERROR", + "site_id": "SITE-001", + "workstation_id": "WS-001", + "pc_name": "LAB-PC-01", + "ip_address": "192.168.1.100", + "user_id": "USR-001", + "created_at": "2026-02-19T15:15:30Z", + "reason": "Deadlock detected during batch update", + "context": { + "affected_tables": ["orders", "order_tests"], + "retry_count": 0, + "transaction_id": "txn_20260219151530" + } +} +``` --- @@ -199,275 +474,289 @@ namespace App\Services; class AuditService { /** - * Log an audit event to the appropriate table + * Log a DATA audit event */ - public static function log( - string $category, // 'master', 'patient', 'order' - string $entityType, // e.g., 'patient', 'order' + public static function logData( + string $operation, + string $entityType, string $entityId, - string $action, - ?array $oldValues = null, - ?array $newValues = null, + ?string $tableName = null, + ?string $fieldName = null, + ?array $previousValue = null, + ?array $newValue = null, ?string $reason = null, ?array $context = null ): void { - $changedFields = self::calculateChangedFields($oldValues, $newValues); - - $data = [ + self::log('data_audit_log', [ + 'operation' => $operation, 'entity_type' => $entityType, 'entity_id' => $entityId, - 'action' => $action, - 'old_values' => $oldValues ? json_encode($oldValues) : null, - 'new_values' => $newValues ? json_encode($newValues) : null, - 'changed_fields' => json_encode($changedFields), + 'table_name' => $tableName, + 'field_name' => $fieldName, + 'previous_value' => $previousValue ? json_encode($previousValue) : null, + 'new_value' => $newValue ? json_encode($newValue) : null, + 'mechanism' => 'MANUAL', + 'application_id' => 'CLQMS-WEB', + 'web_page' => $_SERVER['REQUEST_URI'] ?? null, + 'session_id' => session_id(), + 'event_type' => strtoupper($entityType) . '_' . strtoupper($operation), + 'site_id' => session('site_id'), + 'workstation_id' => session('workstation_id'), + 'pc_name' => gethostname(), + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, 'user_id' => auth()->id() ?? 'SYSTEM', - 'site_id' => session('site_id') ?? 'MAIN', + 'reason' => $reason, + 'context' => $context ? json_encode($context) : null, 'created_at' => date('Y-m-d H:i:s') - ]; - - // Route to appropriate table - $table = match($category) { - 'master' => 'master_audit_log', - 'patient' => 'patient_audit_log', - 'order' => 'order_audit_log', - default => throw new \InvalidArgumentException("Unknown category: $category") - }; - - // Async logging recommended for high-volume operations - self::dispatchAuditJob($table, $data); + ]); } - private static function calculateChangedFields(?array $old, ?array $new): array - { - if (!$old || !$new) return []; - - $changes = []; - $allKeys = array_unique(array_merge(array_keys($old), array_keys($new))); - - foreach ($allKeys as $key) { - if (($old[$key] ?? null) !== ($new[$key] ?? null)) { - $changes[] = $key; - } - } - - return $changes; - } -} -``` - -### 4.2 Model Integration - -```php -getPatientId(), - action: $action, - oldValues: $oldValues, - newValues: $newValues - ); + self::log('service_audit_log', [ + 'operation' => $operation, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'service_class' => $serviceClass, + 'resource_type' => $resourceType, + 'resource_details' => $resourceDetails ? json_encode($resourceDetails) : null, + 'previous_value' => $previousValue ? json_encode($previousValue) : null, + 'new_value' => $newValue ? json_encode($newValue) : null, + 'mechanism' => 'AUTOMATIC', + 'application_id' => $serviceName ?? 'SYSTEM-SERVICE', + 'service_name' => $serviceName, + 'session_id' => session_id() ?: 'service_session', + 'event_type' => strtoupper($serviceClass) . '_' . strtoupper($operation), + 'site_id' => session('site_id'), + 'workstation_id' => session('workstation_id'), + 'pc_name' => gethostname(), + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, + 'port' => $resourceDetails['port'] ?? null, + 'user_id' => 'SYSTEM', + 'reason' => null, + 'context' => $context ? json_encode($context) : null, + 'created_at' => date('Y-m-d H:i:s') + ]); } - // Override save method to auto-log - public function save($data): bool + /** + * Log a SECURITY audit event + */ + public static function logSecurity( + string $operation, + string $entityType, + string $entityId, + string $securityClass, + ?string $eventType = 'SUCCESS', + ?string $resourcePath = null, + ?array $previousValue = null, + ?array $newValue = null, + ?string $reason = null, + ?array $context = null + ): void { + self::log('security_audit_log', [ + 'operation' => $operation, + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'security_class' => $securityClass, + 'resource_path' => $resourcePath, + 'previous_value' => $previousValue ? json_encode($previousValue) : null, + 'new_value' => $newValue ? json_encode($newValue) : null, + 'mechanism' => 'MANUAL', + 'application_id' => 'CLQMS-WEB', + 'web_page' => $_SERVER['REQUEST_URI'] ?? null, + 'session_id' => session_id(), + 'event_type' => $eventType, + 'site_id' => session('site_id'), + 'workstation_id' => session('workstation_id'), + 'pc_name' => gethostname(), + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, + 'user_id' => auth()->id() ?? 'UNKNOWN', + 'reason' => $reason, + 'context' => $context ? json_encode($context) : null, + 'created_at' => date('Y-m-d H:i:s') + ]); + } + + /** + * Log an ERROR audit event + */ + public static function logError( + string $entityType, + string $entityId, + string $errorCode, + string $errorMessage, + string $eventType, + ?array $errorDetails = null, + ?array $previousValue = null, + ?array $newValue = null, + ?string $reason = null, + ?array $context = null + ): void { + self::log('error_audit_log', [ + 'operation' => 'ERROR', + 'entity_type' => $entityType, + 'entity_id' => $entityId, + 'error_code' => $errorCode, + 'error_message' => $errorMessage, + 'error_details' => $errorDetails ? json_encode($errorDetails) : null, + 'previous_value' => $previousValue ? json_encode($previousValue) : null, + 'new_value' => $newValue ? json_encode($newValue) : null, + 'mechanism' => 'AUTOMATIC', + 'application_id' => 'CLQMS-WEB', + 'web_page' => $_SERVER['REQUEST_URI'] ?? null, + 'session_id' => session_id() ?: 'system', + 'event_type' => $eventType, + 'site_id' => session('site_id'), + 'workstation_id' => session('workstation_id'), + 'pc_name' => gethostname(), + 'ip_address' => $_SERVER['REMOTE_ADDR'] ?? null, + 'user_id' => auth()->id() ?? 'SYSTEM', + 'reason' => $reason, + 'context' => $context ? json_encode($context) : null, + 'created_at' => date('Y-m-d H:i:s') + ]); + } + + /** + * Generic log method with async support + */ + private static function log(string $table, array $data): void { - $oldData = $this->find($data['PatientID'] ?? null); - - $result = parent::save($data); - - if ($result) { - $this->logAudit( - $oldData ? 'UPDATE' : 'CREATE', - $oldData?->toArray(), - $this->find($data['PatientID'])->toArray() - ); + // For high-volume operations, dispatch to queue + if (in_array($table, ['service_audit_log', 'error_audit_log'])) { + self::dispatchAuditJob($table, $data); + } else { + // Direct insert for data and security logs + \Config\Database::connect()->table($table)->insert($data); } - - return $result; + } + + private static function dispatchAuditJob(string $table, array $data): void + { + // Implementation: Queue the audit entry for async processing + // This prevents blocking user operations during high-volume logging } } ``` --- -## 5. Query Patterns & Performance +## 5. Query Patterns -### 5.1 Common Queries +### 5.1 Common Audit Queries ```sql --- View entity history -SELECT * FROM patient_audit_log +-- View patient history (DATA log) +SELECT * FROM data_audit_log WHERE entity_type = 'patient' AND entity_id = 'PAT-2026-001234' ORDER BY created_at DESC; -- User activity report -SELECT entity_type, action, COUNT(*) as count -FROM patient_audit_log +SELECT operation, entity_type, COUNT(*) as count +FROM data_audit_log WHERE user_id = 'USR-001' AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY) -GROUP BY entity_type, action; +GROUP BY operation, entity_type; --- Find all changes to a specific field -SELECT * FROM order_audit_log -WHERE JSON_CONTAINS(changed_fields, '"result_value"') -AND patient_id = 'PAT-001' -AND created_at > '2026-01-01'; -``` +-- Instrument communication history (SERVICE log) +SELECT * FROM service_audit_log +WHERE entity_type = 'instrument' +AND entity_id = 'INST-001' +AND operation = 'COMMUNICATION' +ORDER BY created_at DESC; -### 5.2 Partitioning Strategy (Order/Test) +-- Failed login attempts (SECURITY log) +SELECT * FROM security_audit_log +WHERE operation IN ('PASSWORD_FAIL', 'ACCESS_DENIED') +AND event_type = 'FAILURE' +AND created_at > DATE_SUB(NOW(), INTERVAL 24 HOUR) +ORDER BY created_at DESC; -For high-volume tables, implement monthly partitioning: +-- Recent errors (ERROR log) +SELECT * FROM error_audit_log +WHERE created_at > DATE_SUB(NOW(), INTERVAL 1 HOUR) +AND event_type = 'CRITICAL' +ORDER BY created_at DESC; -```sql -CREATE TABLE order_audit_log ( - -- ... columns - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -) ENGINE=InnoDB -PARTITION BY RANGE (YEAR(created_at) * 100 + MONTH(created_at)) ( - PARTITION p202601 VALUES LESS THAN (202602), - PARTITION p202602 VALUES LESS THAN (202603), - PARTITION p_future VALUES LESS THAN MAXVALUE -); +-- Find changes to specific field +SELECT * FROM data_audit_log +WHERE table_name = 'patients' +AND field_name = 'Phone' +AND entity_id = 'PAT-2026-001234' +ORDER BY created_at DESC; ``` --- -## 6. Soft Delete Handling - -Soft deletes ARE captured as audit entries with complete snapshots: - -```php -// When soft deleting a patient: -AuditService::log( - category: 'patient', - entityType: 'patient', - entityId: $patientId, - action: 'DELETE', - oldValues: $fullRecordBeforeDelete, // Complete last known state - newValues: null, - reason: 'Patient requested data removal' -); -``` - -This ensures: -- ✅ Full audit trail even for deleted records -- ✅ Compliance with "right to be forgotten" (GDPR) -- ✅ Ability to restore accidentally deleted records - ---- - -## 7. Migration Plan +## 6. Migration Plan ### Phase 1: Foundation (Week 1) - [ ] Drop existing unused tables (patreglog, patvisitlog, specimenlog) -- [ ] Create new audit tables with JSON columns +- [ ] Create 4 new audit tables with partitioning - [ ] Create AuditService class - [ ] Add database indexes ### Phase 2: Core Implementation (Week 2) -- [ ] Integrate AuditService into Patient model -- [ ] Integrate AuditService into Order model -- [ ] Integrate AuditService into Master data models -- [ ] Add audit trail to authentication events +- [ ] Integrate data_audit_log into Patient model +- [ ] Integrate data_audit_log into Order/Test models +- [ ] Integrate data_audit_log into Master data models +- [ ] Integrate security_audit_log into authentication -### Phase 3: API & UI (Week 3) -- [ ] Create API endpoints for querying audit logs -- [ ] Build admin interface for audit review -- [ ] Add audit export functionality (CSV/PDF) +### Phase 3: Service & Error Logging (Week 3) +- [ ] Implement service_audit_log for instrument communication +- [ ] Implement service_audit_log for printing/messaging +- [ ] Implement error_audit_log for database errors +- [ ] Implement error_audit_log for instrument errors +- [ ] Implement error_audit_log for integration errors -### Phase 4: Optimization (Week 4) +### Phase 4: API & Optimization (Week 4) +- [ ] Create unified API endpoint for querying all log types +- [ ] Add filters by log_type, date range, user, entity - [ ] Implement async logging queue -- [ ] Add table partitioning for order_audit_log -- [ ] Set up retention policies and archiving -- [ ] Performance testing and tuning +- [ ] Add log export functionality (CSV/PDF) --- -## 8. Retention & Archiving Strategy +## 7. Retention Strategy (TBD) -| Table | Retention Period | Archive Action | -|-------|---------------|----------------| -| `master_audit_log` | Permanent | None (keep forever) | -| `patient_audit_log` | 7 years | Move to cold storage after 7 years | -| `order_audit_log` | 2 years | Partition rotation: drop old partitions | - -### Automated Maintenance - -```sql --- Monthly job: Archive old patient audit logs -INSERT INTO patient_audit_log_archive -SELECT * FROM patient_audit_log -WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 YEAR); - -DELETE FROM patient_audit_log -WHERE created_at < DATE_SUB(NOW(), INTERVAL 7 YEAR); - --- Monthly job: Drop old order partitions -ALTER TABLE order_audit_log DROP PARTITION p202501; -``` +| Table | Proposed Retention | Notes | +|-------|-------------------|-------| +| `data_audit_log` | 7 years | Patient data compliance | +| `service_audit_log` | 2 years | High volume, operational only | +| `security_audit_log` | Permanent | Compliance and forensics | +| `error_audit_log` | 5 years | Debugging and incident analysis | --- -## 9. Questions for Stakeholders - -Before implementation, please confirm: - -1. **Retention Policy**: Are the proposed retention periods (master=forever, patient=7 years, order=2 years) compliant with your regulatory requirements? - -2. **Async vs Sync**: Should audit logging be synchronous (block on failure) or asynchronous (queue-based)? Recommended: async for order/test operations. - -3. **Archive Storage**: Where should archived audit logs be stored? Options: separate database, file storage (S3), or compressed tables. - -4. **User Access**: Which user roles need access to audit trails? Should users see their own audit history? - -5. **Compliance**: Do you need specific compliance features (e.g., HIPAA audit trail requirements, 21 CFR Part 11 for FDA)? - ---- - -## 10. Key Design Decisions Summary +## 8. Key Design Decisions | Decision | Choice | Rationale | |----------|--------|-----------| -| **Table Count** | 3 tables | Separates concerns, optimizes queries, different retention | -| **JSON vs Columns** | JSON for values | Flexible, handles schema changes, complete snapshots | -| **Full vs Diff** | Full snapshots | Easier to reconstruct history, no data loss | -| **Soft Deletes** | Captured in audit | Compliance and restore capability | -| **Partitioning** | Order table only | High volume, time-based queries | -| **Async Logging** | Recommended | Don't block user operations | +| **Table Count** | 4 tables | Separates by log type, different retention needs | +| **5W1H** | All 6 dimensions captured | Complete audit trail per section 4.2.1.20 | +| **Mechanism** | MANUAL vs AUTOMATIC | Distinguishes user vs instrument operations | +| **User for AUTO** | 'SYSTEM' | Clear identification of automatic operations | +| **JSON Storage** | previous_value, new_value, context | Flexible schema evolution | +| **Partitioning** | Monthly for high-volume tables | Manage service and error log growth | +| **Async Logging** | Yes for service/error logs | Don't block user operations | --- -## Conclusion - -This unified audit logging architecture provides: - -✅ **Complete traceability** across all data domains -✅ **Regulatory compliance** with proper retention -✅ **Performance optimization** through domain separation -✅ **Flexibility** via JSON value storage -✅ **Maintainability** with centralized service - -The approach balances audit integrity with system performance, ensuring CLQMS can scale while maintaining comprehensive audit trails. - ---- - -*Document Version: 1.0* -*Author: CLQMS Development Team* -*Date: February 19, 2026* +*Document Version: 2.0* +*Based on: Section 4.2.1.20 Error Management* +*Date: February 20, 2026* diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index 69f3bfe..9eb5b25 100644 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -2674,14 +2674,18 @@ paths: type: string enum: - NMRIC + - RANGE + - TEXT - VSET + - NORES RefType: type: string enum: - - NMRC - - TEXT + - RANGE - THOLD - VSET + - TEXT + - NOREF VSet: type: integer ReqQty: @@ -2794,14 +2798,18 @@ paths: type: string enum: - NMRIC + - RANGE + - TEXT - VSET + - NORES RefType: type: string enum: - - NMRC - - TEXT + - RANGE - THOLD - VSET + - TEXT + - NOREF VSet: type: integer ReqQty: @@ -4139,22 +4147,46 @@ components: type: string enum: - NMRIC + - RANGE + - TEXT - VSET + - NORES description: | - NMRIC: Numeric result - VSET: Value set result + Result type determines the format of test results: + - NMRIC: Single numeric value + - RANGE: Numeric range (min-max) + - TEXT: Free text result + - VSET: Value set/enum result + - NORES: No result (for GROUP and TITLE types) + + TestType to ResultType mapping: + - TEST: NMRIC | RANGE | TEXT | VSET + - PARAM: NMRIC | RANGE | TEXT | VSET + - CALC: NMRIC (calculated result is always numeric) + - GROUP: NORES (no result, container only) + - TITLE: NORES (no result, header only) RefType: type: string enum: - - NMRC - - TEXT + - RANGE - THOLD - VSET + - TEXT + - NOREF description: | - NMRC: Numeric reference range - TEXT: Text reference - THOLD: Threshold reference - VSET: Value set reference + Reference type determines which reference range table to use: + - RANGE: Numeric reference range + - THOLD: Threshold/panic range + - VSET: Value set reference + - TEXT: Free text reference + - NOREF: No reference (for NORES result type) + + ResultType to RefType mapping: + - NMRIC: RANGE | THOLD → refnum table + - RANGE: RANGE | THOLD → refnum table + - VSET: VSET → reftxt table + - TEXT: TEXT → reftxt table + - NORES: NOREF → (no reference table) VSet: type: integer description: Value set ID for VSET result type diff --git a/public/components/schemas/tests.yaml b/public/components/schemas/tests.yaml index 1621859..3f38389 100644 --- a/public/components/schemas/tests.yaml +++ b/public/components/schemas/tests.yaml @@ -30,18 +30,38 @@ TestDefinition: type: string ResultType: type: string - enum: [NMRIC, VSET] + enum: [NMRIC, RANGE, TEXT, VSET, NORES] description: | - NMRIC: Numeric result - VSET: Value set result + Result type determines the format of test results: + - NMRIC: Single numeric value + - RANGE: Numeric range (min-max) + - TEXT: Free text result + - VSET: Value set/enum result + - NORES: No result (for GROUP and TITLE types) + + TestType to ResultType mapping: + - TEST: NMRIC | RANGE | TEXT | VSET + - PARAM: NMRIC | RANGE | TEXT | VSET + - CALC: NMRIC (calculated result is always numeric) + - GROUP: NORES (no result, container only) + - TITLE: NORES (no result, header only) RefType: type: string - enum: [NMRC, TEXT, THOLD, VSET] + enum: [RANGE, THOLD, VSET, TEXT, NOREF] description: | - NMRC: Numeric reference range - TEXT: Text reference - THOLD: Threshold reference - VSET: Value set reference + Reference type determines which reference range table to use: + - RANGE: Numeric reference range + - THOLD: Threshold/panic range + - VSET: Value set reference + - TEXT: Free text reference + - NOREF: No reference (for NORES result type) + + ResultType to RefType mapping: + - NMRIC: RANGE | THOLD → refnum table + - RANGE: RANGE | THOLD → refnum table + - VSET: VSET → reftxt table + - TEXT: TEXT → reftxt table + - NORES: NOREF → (no reference table) VSet: type: integer description: Value set ID for VSET result type diff --git a/public/paths/tests.yaml b/public/paths/tests.yaml index 7cb9474..02c6f3a 100644 --- a/public/paths/tests.yaml +++ b/public/paths/tests.yaml @@ -99,10 +99,10 @@ type: integer ResultType: type: string - enum: [NMRIC, VSET] + enum: [NMRIC, RANGE, TEXT, VSET, NORES] RefType: type: string - enum: [NMRC, TEXT, THOLD, VSET] + enum: [RANGE, THOLD, VSET, TEXT, NOREF] VSet: type: integer ReqQty: @@ -208,10 +208,10 @@ type: integer ResultType: type: string - enum: [NMRIC, VSET] + enum: [NMRIC, RANGE, TEXT, VSET, NORES] RefType: type: string - enum: [NMRC, TEXT, THOLD, VSET] + enum: [RANGE, THOLD, VSET, TEXT, NOREF] VSet: type: integer ReqQty: diff --git a/tests/feature/TestsControllerTest.php b/tests/feature/TestsControllerTest.php index 70b50f5..35c999c 100644 --- a/tests/feature/TestsControllerTest.php +++ b/tests/feature/TestsControllerTest.php @@ -115,4 +115,303 @@ class TestsControllerTest extends CIUnitTestCase $this->assertEquals(5.5, $showData['data']['refnum'][0]['Low']); $this->assertEquals('High', $showData['data']['refnum'][0]['Interpretation']); } + + /** + * Test valid TestType and ResultType combinations + * @dataProvider validTestTypeResultTypeProvider + */ + public function testValidTestTypeResultTypeCombinations($testType, $resultType, $refType, $shouldSucceed) + { + $testData = [ + 'TestSiteCode' => 'TT' . substr(time(), -4) . rand(10, 99), + 'TestSiteName' => 'Type Test ' . time(), + 'TestType' => $testType, + 'SiteID' => 1, + 'details' => [ + 'ResultType' => $resultType, + 'RefType' => $refType + ] + ]; + + // Add reference data if needed + if ($refType === 'RANGE' || $refType === 'THOLD') { + $testData['refnum'] = [ + [ + 'NumRefType' => $refType, + 'RangeType' => 'VALUE', + 'Sex' => '1', + 'AgeStart' => 0, + 'AgeEnd' => 100, + 'LowSign' => '>', + 'Low' => 5.5, + 'Interpretation' => 'Normal' + ] + ]; + } elseif ($refType === 'VSET' || $refType === 'TEXT') { + $testData['reftxt'] = [ + [ + 'TxtRefType' => $refType, + 'Sex' => '1', + 'AgeStart' => 0, + 'AgeEnd' => 100, + 'RefTxt' => 'Normal range text', + 'Flag' => 'N' + ] + ]; + } + + $result = $this->withHeaders(['Cookie' => 'token=' . $this->token]) + ->withBody(json_encode($testData)) + ->call('post', 'api/tests'); + + if ($shouldSucceed) { + $result->assertStatus(201); + $data = json_decode($result->getJSON(), true); + $this->assertEquals('created', $data['status']); + } else { + // Invalid combinations should fail validation or return error + $this->assertGreaterThanOrEqual(400, $result->getStatusCode()); + } + } + + public function validTestTypeResultTypeProvider() + { + return [ + // TEST type - can have NMRIC, RANGE, TEXT, VSET + 'TEST with NMRIC' => ['TEST', 'NMRIC', 'RANGE', true], + 'TEST with RANGE' => ['TEST', 'RANGE', 'RANGE', true], + 'TEST with TEXT' => ['TEST', 'TEXT', 'TEXT', true], + 'TEST with VSET' => ['TEST', 'VSET', 'VSET', true], + 'TEST with THOLD' => ['TEST', 'NMRIC', 'THOLD', true], + + // PARAM type - can have NMRIC, RANGE, TEXT, VSET + 'PARAM with NMRIC' => ['PARAM', 'NMRIC', 'RANGE', true], + 'PARAM with RANGE' => ['PARAM', 'RANGE', 'RANGE', true], + 'PARAM with TEXT' => ['PARAM', 'TEXT', 'TEXT', true], + 'PARAM with VSET' => ['PARAM', 'VSET', 'VSET', true], + + // CALC type - only NMRIC + 'CALC with NMRIC' => ['CALC', 'NMRIC', 'RANGE', true], + + // GROUP type - only NORES + 'GROUP with NORES' => ['GROUP', 'NORES', 'NOREF', true], + + // TITLE type - only NORES + 'TITLE with NORES' => ['TITLE', 'NORES', 'NOREF', true], + ]; + } + + /** + * Test ResultType to RefType mapping + * @dataProvider resultTypeToRefTypeProvider + */ + public function testResultTypeToRefTypeMapping($resultType, $refType, $expectedRefTable) + { + $testData = [ + 'TestSiteCode' => 'RT' . substr(time(), -4) . rand(10, 99), + 'TestSiteName' => 'RefType Test ' . time(), + 'TestType' => 'TEST', + 'SiteID' => 1, + 'details' => [ + 'ResultType' => $resultType, + 'RefType' => $refType + ] + ]; + + // Add appropriate reference data + if ($expectedRefTable === 'refnum') { + $testData['refnum'] = [ + [ + 'NumRefType' => $refType, + 'RangeType' => 'VALUE', + 'Sex' => '1', + 'AgeStart' => 18, + 'AgeEnd' => 99, + 'LowSign' => 'GE', + 'Low' => 10, + 'HighSign' => 'LE', + 'High' => 20, + 'Interpretation' => 'Normal' + ] + ]; + } elseif ($expectedRefTable === 'reftxt') { + $testData['reftxt'] = [ + [ + 'TxtRefType' => $refType, + 'Sex' => '1', + 'AgeStart' => 18, + 'AgeEnd' => 99, + 'RefTxt' => 'Reference text', + 'Flag' => 'N' + ] + ]; + } + + $result = $this->withHeaders(['Cookie' => 'token=' . $this->token]) + ->withBody(json_encode($testData)) + ->call('post', 'api/tests'); + + $result->assertStatus(201); + $data = json_decode($result->getJSON(), true); + $id = $data['data']['TestSiteId']; + + // Verify the reference data was stored in correct table + $showResult = $this->callProtected('get', "api/tests/$id"); + $showData = json_decode($showResult->getJSON(), true); + + if ($expectedRefTable === 'refnum') { + $this->assertArrayHasKey('refnum', $showData['data']); + $this->assertNotEmpty($showData['data']['refnum']); + } elseif ($expectedRefTable === 'reftxt') { + $this->assertArrayHasKey('reftxt', $showData['data']); + $this->assertNotEmpty($showData['data']['reftxt']); + } + } + + public function resultTypeToRefTypeProvider() + { + return [ + // NMRIC with RANGE → refnum table + 'NMRIC with RANGE uses refnum' => ['NMRIC', 'RANGE', 'refnum'], + // NMRIC with THOLD → refnum table + 'NMRIC with THOLD uses refnum' => ['NMRIC', 'THOLD', 'refnum'], + // RANGE with RANGE → refnum table + 'RANGE with RANGE uses refnum' => ['RANGE', 'RANGE', 'refnum'], + // RANGE with THOLD → refnum table + 'RANGE with THOLD uses refnum' => ['RANGE', 'THOLD', 'refnum'], + // VSET with VSET → reftxt table + 'VSET with VSET uses reftxt' => ['VSET', 'VSET', 'reftxt'], + // TEXT with TEXT → reftxt table + 'TEXT with TEXT uses reftxt' => ['TEXT', 'TEXT', 'reftxt'], + ]; + } + + /** + * Test CALC type always has NMRIC result type + */ + public function testCalcTypeAlwaysHasNmricResultType() + { + $testData = [ + 'TestSiteCode' => 'CALC' . substr(time(), -4), + 'TestSiteName' => 'Calc Test ' . time(), + 'TestType' => 'CALC', + 'SiteID' => 1, + 'details' => [ + 'DisciplineID' => 1, + 'DepartmentID' => 1, + 'FormulaInput' => 'WEIGHT,HEIGHT', + 'FormulaCode' => 'WEIGHT/(HEIGHT/100)^2', + 'Unit1' => 'kg/m2', + 'Decimal' => 1 + ] + ]; + + $result = $this->withHeaders(['Cookie' => 'token=' . $this->token]) + ->withBody(json_encode($testData)) + ->call('post', 'api/tests'); + + $result->assertStatus(201); + $data = json_decode($result->getJSON(), true); + $id = $data['data']['TestSiteId']; + + // Verify CALC test was created + $showResult = $this->callProtected('get', "api/tests/$id"); + $showData = json_decode($showResult->getJSON(), true); + + $this->assertEquals('CALC', $showData['data']['TestType']); + $this->assertArrayHasKey('testdefcal', $showData['data']); + } + + /** + * Test GROUP type has no result (NORES) + */ + public function testGroupTypeHasNoResult() + { + // First create member tests + $member1Data = [ + 'TestSiteCode' => 'M1' . substr(time(), -4), + 'TestSiteName' => 'Member 1 ' . time(), + 'TestType' => 'TEST', + 'SiteID' => 1, + 'details' => [ + 'ResultType' => 'NMRIC', + 'RefType' => 'RANGE' + ], + 'refnum' => [ + [ + 'NumRefType' => 'RANGE', + 'RangeType' => 'VALUE', + 'Sex' => '1', + 'AgeStart' => 0, + 'AgeEnd' => 100, + 'LowSign' => '>', + 'Low' => 5.5, + 'Interpretation' => 'Normal' + ] + ] + ]; + + $result1 = $this->withHeaders(['Cookie' => 'token=' . $this->token]) + ->withBody(json_encode($member1Data)) + ->call('post', 'api/tests'); + $data1 = json_decode($result1->getJSON(), true); + $member1Id = $data1['data']['TestSiteId']; + + $member2Data = [ + 'TestSiteCode' => 'M2' . substr(time(), -4), + 'TestSiteName' => 'Member 2 ' . time(), + 'TestType' => 'TEST', + 'SiteID' => 1, + 'details' => [ + 'ResultType' => 'NMRIC', + 'RefType' => 'RANGE' + ], + 'refnum' => [ + [ + 'NumRefType' => 'RANGE', + 'RangeType' => 'VALUE', + 'Sex' => '1', + 'AgeStart' => 0, + 'AgeEnd' => 100, + 'LowSign' => '>', + 'Low' => 5.5, + 'Interpretation' => 'Normal' + ] + ] + ]; + + $result2 = $this->withHeaders(['Cookie' => 'token=' . $this->token]) + ->withBody(json_encode($member2Data)) + ->call('post', 'api/tests'); + $data2 = json_decode($result2->getJSON(), true); + $member2Id = $data2['data']['TestSiteId']; + + // Create group test + $groupData = [ + 'TestSiteCode' => 'GRP' . substr(time(), -4), + 'TestSiteName' => 'Group Test ' . time(), + 'TestType' => 'GROUP', + 'SiteID' => 1, + 'details' => [ + 'ResultType' => 'NORES', + 'RefType' => 'NOREF' + ], + 'members' => [$member1Id, $member2Id] + ]; + + $result = $this->withHeaders(['Cookie' => 'token=' . $this->token]) + ->withBody(json_encode($groupData)) + ->call('post', 'api/tests'); + + $result->assertStatus(201); + $data = json_decode($result->getJSON(), true); + $id = $data['data']['TestSiteId']; + + // Verify GROUP test was created with members + $showResult = $this->callProtected('get', "api/tests/$id"); + $showData = json_decode($showResult->getJSON(), true); + + $this->assertEquals('GROUP', $showData['data']['TestType']); + $this->assertArrayHasKey('testdefgrp', $showData['data']); + } } diff --git a/tests/unit/TestDef/TestDefModelsTest.php b/tests/unit/TestDef/TestDefModelsTest.php index 662356d..346d64b 100644 --- a/tests/unit/TestDef/TestDefModelsTest.php +++ b/tests/unit/TestDef/TestDefModelsTest.php @@ -65,6 +65,8 @@ class TestDefModelsTest extends CIUnitTestCase $this->assertContains('CreateDate', $allowedFields); $this->assertContains('StartDate', $allowedFields); $this->assertContains('EndDate', $allowedFields); + $this->assertContains('ResultType', $allowedFields); + $this->assertContains('RefType', $allowedFields); } /**