Add audit logging plan documentation and update test infrastructure

- Add audit-logging-plan.md with comprehensive logging implementation guide

- Update AGENTS.md with project guidelines

- Refactor test models: remove RefTHoldModel, RefVSetModel, TestDefTechModel

- Update TestDefSiteModel and related migrations

- Update seeder and test data files

- Update API documentation (OpenAPI specs)
This commit is contained in:
mahdahar 2026-02-19 13:20:24 +07:00
parent 30c0c538d6
commit ece101b6d2
15 changed files with 2007 additions and 572 deletions

3
.gitignore vendored
View File

@ -126,5 +126,4 @@ _modules/*
/phpunit*.xml
/public/.htaccess
/.serena
AGENTS.md
/.serena

328
AGENTS.md Normal file
View File

@ -0,0 +1,328 @@
# AGENTS.md - Code Guidelines for CLQMS
> **CLQMS (Clinical Laboratory Quality Management System)** - A headless REST API backend for clinical laboratory workflows built with CodeIgniter 4.
---
## Build, Test & Lint Commands
### Running Tests
```bash
# Run all tests
./vendor/bin/phpunit
# Run a specific test file
./vendor/bin/phpunit tests/feature/Patients/PatientCreateTest.php
# Run a specific test method
./vendor/bin/phpunit --filter testCreatePatientSuccess tests/feature/Patients/PatientCreateTest.php
# Run tests with coverage
./vendor/bin/phpunit --coverage-html build/logs/html
# Run tests by suite
./vendor/bin/phpunit --testsuite App
```
### CodeIgniter CLI Commands
```bash
# Run spark commands
php spark <command>
# Generate migration
php spark make:migration <name>
# Generate model
php spark make:model <name>
# Generate controller
php spark make:controller <name>
# Generate seeder
php spark make:seeder <name>
# Run 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
### PHP Standards
- **PHP Version**: 8.1+
- **PSR-4 Autoloading**: `App\` maps to `app/`, `Config\` maps to `app/Config/`
- **PSR-12 Coding Style** (follow where applicable)
### Naming Conventions
| Element | Convention | Example |
|---------|-----------|---------|
| Classes | PascalCase | `PatientController` |
| Methods | camelCase | `createPatient()` |
| Properties | snake_case (legacy) / camelCase (new) | `$patient_id` / `$patientId` |
| Constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
| Tables | snake_case | `patient_visits` |
| 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
- Group imports: Framework first, then App, then external
- Use statements must be in alphabetical order within groups
```php
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\ResponseInterface;
use App\Traits\ResponseTrait;
use Firebase\JWT\JWT;
```
### Controller Structure
```php
<?php
namespace App\Controllers;
use App\Traits\ResponseTrait;
class ExampleController extends BaseController
{
use ResponseTrait;
protected $model;
public function __construct()
{
$this->model = new \App\Models\ExampleModel();
}
// GET /example
public function index()
{
// Implementation
}
// POST /example
public function create()
{
// Implementation
}
}
```
### Response Format
All API responses must use the standardized format:
```php
// Success response
return $this->respond([
'status' => 'success',
'message' => 'Operation completed',
'data' => $data
], 200);
// Error response
return $this->respond([
'status' => 'failed',
'message' => 'Error description',
'data' => []
], 400);
```
### Error Handling
- Use try-catch for JWT operations and external calls
- Return structured error responses with appropriate HTTP status codes
- Log errors using CodeIgniter's logging: `log_message('error', $message)`
```php
try {
$decoded = JWT::decode($token, new Key($key, 'HS256'));
} catch (\Firebase\JWT\ExpiredException $e) {
return $this->respond(['status' => 'failed', 'message' => 'Token expired'], 401);
} catch (\Exception $e) {
return $this->respond(['status' => 'failed', 'message' => 'Invalid token'], 401);
}
```
### Database Operations
- Use CodeIgniter's Query Builder or Model methods
- Prefer parameterized queries over raw SQL
- Use transactions for multi-table operations
```php
$this->db->transStart();
// ... database operations
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->respond(['status' => 'error', 'message' => 'Transaction failed'], 500);
}
```
### Testing Guidelines
#### Test Structure
```php
<?php
namespace Tests\Feature\Patients;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class PatientCreateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
public function testCreatePatientSuccess()
{
$payload = [...];
$result = $this->withBodyFormat('json')
->post($this->endpoint, $payload);
$result->assertStatus(201);
}
}
```
#### Test Naming
- Use descriptive method names: `test<Action><Scenario><ExpectedResult>`
- Example: `testCreatePatientValidationFail`, `testCreatePatientSuccess`
#### Test Status Codes
- 200: Success (GET, PATCH)
- 201: Created (POST)
- 400: Validation Error
- 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
### Routes Pattern
```php
$routes->group('api/patient', function ($routes) {
$routes->get('/', 'Patient\PatientController::index');
$routes->post('/', 'Patient\PatientController::create');
$routes->get('(:num)', 'Patient\PatientController::show/$1');
$routes->patch('/', 'Patient\PatientController::update');
$routes->delete('/', 'Patient\PatientController::delete');
});
```
### 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
---
## Project-Specific Conventions
### Legacy Field Naming
Database uses PascalCase for column names (legacy convention):
- `PatientID`, `NameFirst`, `NameLast`
- `Birthdate`, `CreatedAt`, `UpdatedAt`
### ValueSet System
Use the `App\Libraries\Lookups` class for static dropdown values:
```php
use App\Libraries\Lookups;
$genders = Lookups::get('gender');
$options = Lookups::getOptions('gender');
```
### Models
Extend `BaseModel` for automatic UTC date handling:
```php
<?php
namespace App\Models;
class PatientModel extends BaseModel
{
protected $table = 'patients';
protected $primaryKey = 'PatientID';
protected $allowedFields = ['NameFirst', 'NameLast', ...];
}
```
---
## Environment Configuration
### Database (`.env`)
```ini
database.default.hostname = localhost
database.default.database = clqms01
database.default.username = root
database.default.password = adminsakti
database.default.DBDriver = MySQLi
```
### JWT Secret (`.env`)
```ini
JWT_SECRET = '5pandaNdutNdut'
```
---
## Additional Notes
- **API-Only**: No view layer - this is a headless REST API
- **Frontend Agnostic**: Any client can consume these APIs
- **Stateless**: JWT-based authentication per request
- **UTC Dates**: All dates stored in UTC, converted for display
*© 2025 5Panda Team. Engineering Precision in Clinical Diagnostics.*

View File

@ -13,7 +13,7 @@ class TestsController extends BaseController
protected $rules;
protected $model;
protected $modelCal;
protected $modelTech;
protected $modelGrp;
protected $modelMap;
protected $modelRefNum;
@ -24,7 +24,7 @@ class TestsController extends BaseController
$this->db = \Config\Database::connect();
$this->model = new \App\Models\Test\TestDefSiteModel;
$this->modelCal = new \App\Models\Test\TestDefCalModel;
$this->modelTech = new \App\Models\Test\TestDefTechModel;
$this->modelGrp = new \App\Models\Test\TestDefGrpModel;
$this->modelMap = new \App\Models\Test\TestMapModel;
$this->modelRefNum = new \App\Models\RefRange\RefNumModel;
@ -50,13 +50,12 @@ class TestsController extends BaseController
->select("testdefsite.TestSiteID, testdefsite.TestSiteCode, testdefsite.TestSiteName, testdefsite.TestType,
testdefsite.SeqScr, testdefsite.SeqRpt, testdefsite.VisibleScr, testdefsite.VisibleRpt,
testdefsite.CountStat, testdefsite.StartDate, testdefsite.EndDate,
COALESCE(tech.DisciplineID, cal.DisciplineID) as DisciplineID,
COALESCE(tech.DepartmentID, cal.DepartmentID) as DepartmentID,
COALESCE(testdefsite.DisciplineID, cal.DisciplineID) as DisciplineID,
COALESCE(testdefsite.DepartmentID, cal.DepartmentID) as DepartmentID,
d.DisciplineName, dept.DepartmentName")
->join('testdeftech tech', 'tech.TestSiteID = testdefsite.TestSiteID AND tech.EndDate IS NULL', 'left')
->join('testdefcal cal', 'cal.TestSiteID = testdefsite.TestSiteID AND cal.EndDate IS NULL', 'left')
->join('discipline d', 'd.DisciplineID = COALESCE(tech.DisciplineID, cal.DisciplineID)', 'left')
->join('department dept', 'dept.DepartmentID = COALESCE(tech.DepartmentID, cal.DepartmentID)', 'left')
->join('discipline d', 'd.DisciplineID = COALESCE(testdefsite.DisciplineID, cal.DisciplineID)', 'left')
->join('department dept', 'dept.DepartmentID = COALESCE(testdefsite.DepartmentID, cal.DepartmentID)', 'left')
->where('testdefsite.EndDate IS NULL');
if ($siteId) {
@ -141,12 +140,13 @@ class TestsController extends BaseController
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
} else {
$row['testdeftech'] = $this->db->table('testdeftech')
->select('testdeftech.*, d.DisciplineName, dept.DepartmentName')
->join('discipline d', 'd.DisciplineID=testdeftech.DisciplineID', 'left')
->join('department dept', 'dept.DepartmentID=testdeftech.DepartmentID', 'left')
->where('testdeftech.TestSiteID', $id)
->where('testdeftech.EndDate IS NULL')
// Technical details are now stored directly in testdefsite
$row['testdeftech'] = $this->db->table('testdefsite')
->select('testdefsite.*, d.DisciplineName, dept.DepartmentName')
->join('discipline d', 'd.DisciplineID=testdefsite.DisciplineID', 'left')
->join('department dept', 'dept.DepartmentID=testdefsite.DepartmentID', 'left')
->where('testdefsite.TestSiteID', $id)
->where('testdefsite.EndDate IS NULL')
->get()->getResultArray();
$row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll();
@ -371,9 +371,6 @@ class TestsController extends BaseController
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
} elseif (in_array($typeCode, ['TEST', 'PARAM'])) {
$this->db->table('testdeftech')
->where('TestSiteID', $id)
->update(['EndDate' => $now]);
$this->modelRefNum->where('TestSiteID', $id)->set('EndDate', $now)->update();
$this->modelRefTxt->where('TestSiteID', $id)->set('EndDate', $now)->update();
@ -459,8 +456,8 @@ class TestsController extends BaseController
private function saveTechDetails($testSiteID, $data, $action, $typeCode)
{
// Technical details are now stored directly in testdefsite table
$techData = [
'TestSiteID' => $testSiteID,
'DisciplineID' => $data['DisciplineID'] ?? null,
'DepartmentID' => $data['DepartmentID'] ?? null,
'ResultType' => $data['ResultType'] ?? null,
@ -477,20 +474,8 @@ class TestsController extends BaseController
'ExpectedTAT' => $data['ExpectedTAT'] ?? null
];
if ($action === 'update') {
$exists = $this->db->table('testdeftech')
->where('TestSiteID', $testSiteID)
->where('EndDate IS NULL')
->get()->getRowArray();
if ($exists) {
$this->modelTech->update($exists['TestTechID'], $techData);
} else {
$this->modelTech->insert($techData);
}
} else {
$this->modelTech->insert($techData);
}
// Update the testdefsite record directly
$this->model->update($testSiteID, $techData);
}
private function saveRefNumRanges($testSiteID, $ranges, $action, $siteID)

View File

@ -13,23 +13,7 @@ class CreateTestDefinitions extends Migration {
'TestSiteName' => ['type' => 'varchar', 'constraint'=> 100, 'null' => false],
'TestType' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => false],
'Description' => ['type' => 'varchar', 'constraint'=> 255, 'null' => true],
'SeqScr' => ['type' => 'int', 'null' => true],
'SeqRpt' => ['type' => 'int', 'null' => true],
'IndentLeft' => ['type' => 'int', 'null' => true, 'default' => 0],
'FontStyle' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'VisibleScr' => ['type' => 'int', 'null' => true, 'default' => 1],
'VisibleRpt' => ['type' => 'int', 'null' => true, 'default' => 1],
'CountStat' => ['type' => 'int', 'null' => true, 'default' => 1],
'CreateDate' => ['type' => 'Datetime', 'null' => true],
'StartDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true],
]);
$this->forge->addKey('TestSiteID', true);
$this->forge->createTable('testdefsite');
$this->forge->addField([
'TestTechID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
// Technical details merged from testdeftech
'DisciplineID' => ['type' => 'INT', 'null' => true],
'DepartmentID' => ['type' => 'INT', 'null' => true],
'ResultType' => ['type' => 'VARCHAR', 'constraint'=> 20, 'null' => true],
@ -44,12 +28,22 @@ class CreateTestDefinitions extends Migration {
'CollReq' => ['type' => 'varchar', 'constraint'=> 255, 'null' => true],
'Method' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'ExpectedTAT' => ['type' => 'INT', 'null' => true],
'SeqScr' => ['type' => 'int', 'null' => true],
'SeqRpt' => ['type' => 'int', 'null' => true],
'IndentLeft' => ['type' => 'int', 'null' => true, 'default' => 0],
'FontStyle' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true],
'VisibleScr' => ['type' => 'int', 'null' => true, 'default' => 1],
'VisibleRpt' => ['type' => 'int', 'null' => true, 'default' => 1],
'CountStat' => ['type' => 'int', 'null' => true, 'default' => 1],
'Level' => ['type' => 'int', 'null' => true],
'CreateDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true]
'StartDate' => ['type' => 'Datetime', 'null' => true],
'EndDate' => ['type' => 'Datetime', 'null' => true],
]);
$this->forge->addKey('TestTechID', true);
$this->forge->addForeignKey('TestSiteID', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE');
$this->forge->createTable('testdeftech');
$this->forge->addKey('TestSiteID', true);
$this->forge->createTable('testdefsite');
// testdeftech table removed - merged into testdefsite
$this->forge->addField([
'TestCalID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true],
@ -166,7 +160,7 @@ class CreateTestDefinitions extends Migration {
$this->forge->dropTable('testmap');
$this->forge->dropTable('testdefgrp');
$this->forge->dropTable('testdefcal');
$this->forge->dropTable('testdeftech');
// testdeftech table removed - merged into testdefsite
$this->forge->dropTable('testdefsite');
}
}

View File

@ -28,142 +28,98 @@ class TestSeeder extends Seeder
// ========================================
// TEST TYPE - Actual Laboratory Tests
// ========================================
// Hematology Tests
$data = ['SiteID' => '1', 'TestSiteCode' => 'HB', 'TestSiteName' => 'Hemoglobin', 'TestType' => 'TEST', 'Description' => '', 'SeqScr' => '2', 'SeqRpt' => '2', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'CreateDate' => "$now"];
// 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"];
$this->db->table('testdefsite')->insert($data);
$tIDs['HB'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['HB'], '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"];
$this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'HCT', 'TestSiteName' => 'Hematocrit', 'TestType' => 'TEST', 'Description' => '', 'SeqScr' => '3', 'SeqRpt' => '3', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['HCT'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => '%', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['RBC'], '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"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['WBC'], '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"];
$this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'PLT', 'TestSiteName' => 'Platelet', 'TestType' => 'TEST', 'Description' => 'Trombosit', 'SeqScr' => '6', 'SeqRpt' => '6', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['PLT'], '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"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['MCV'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'fL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['MCH'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'pg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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();
$data = ['TestSiteID' => $tIDs['MCHC'], '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"];
$this->db->table('testdeftech')->insert($data);
// 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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['GLU'], '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"];
$this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'CREA', 'TestSiteName' => 'Creatinine', 'TestType' => 'TEST', 'Description' => 'Kreatinin', 'SeqScr' => '12', 'SeqRpt' => '12', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['CREA'], '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"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['UREA'], '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"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['SGOT'], '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"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['SGPT'], '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"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['CHOL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Enzymatic', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'TG', 'TestSiteName' => 'Triglycerides', 'TestType' => 'TEST', 'Description' => 'Trigliserida', 'SeqScr' => '17', 'SeqRpt' => '17', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['TG'], '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"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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 = ['TestSiteID' => $tIDs['HDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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();
$data = ['TestSiteID' => $tIDs['LDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
// ========================================
// PARAM TYPE - Parameters (non-lab values)
// ========================================
$data = ['SiteID' => '1', 'TestSiteCode' => 'HEIGHT', 'TestSiteName' => 'Height', 'TestType' => 'PARAM', 'Description' => 'Tinggi Badan', 'SeqScr' => '40', 'SeqRpt' => '40', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'CreateDate' => "$now"];
$data = ['SiteID' => '1', 'TestSiteCode' => 'HEIGHT', 'TestSiteName' => 'Height', 'TestType' => 'PARAM', 'Description' => 'Tinggi Badan', 'SeqScr' => '40', 'SeqRpt' => '40', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'cm', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['HEIGHT'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['HEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'cm', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'WEIGHT', 'TestSiteName' => 'Weight', 'TestType' => 'PARAM', 'Description' => 'Berat Badan', 'SeqScr' => '41', 'SeqRpt' => '41', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'CreateDate' => "$now"];
$data = ['SiteID' => '1', 'TestSiteCode' => 'WEIGHT', 'TestSiteName' => 'Weight', 'TestType' => 'PARAM', 'Description' => 'Berat Badan', 'SeqScr' => '41', 'SeqRpt' => '41', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'kg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['WEIGHT'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['WEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'kg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'AGE', 'TestSiteName' => 'Age', 'TestType' => 'PARAM', 'Description' => 'Usia', 'SeqScr' => '42', 'SeqRpt' => '42', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'CreateDate' => "$now"];
$data = ['SiteID' => '1', 'TestSiteCode' => 'AGE', 'TestSiteName' => 'Age', 'TestType' => 'PARAM', 'Description' => 'Usia', 'SeqScr' => '42', 'SeqRpt' => '42', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'years', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['AGE'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['AGE'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'years', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'SYSTL', 'TestSiteName' => 'Systolic BP', 'TestType' => 'PARAM', 'Description' => 'Tekanan Darah Sistolik', 'SeqScr' => '43', 'SeqRpt' => '43', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'CreateDate' => "$now"];
$data = ['SiteID' => '1', 'TestSiteCode' => 'SYSTL', 'TestSiteName' => 'Systolic BP', 'TestType' => 'PARAM', 'Description' => 'Tekanan Darah Sistolik', 'SeqScr' => '43', 'SeqRpt' => '43', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['SYSTL'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['SYSTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'DIASTL', 'TestSiteName' => 'Diastolic BP', 'TestType' => 'PARAM', 'Description' => 'Tekanan Darah Diastolik', 'SeqScr' => '44', 'SeqRpt' => '44', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'CreateDate' => "$now"];
$data = ['SiteID' => '1', 'TestSiteCode' => 'DIASTL', 'TestSiteName' => 'Diastolic BP', 'TestType' => 'PARAM', 'Description' => 'Tekanan Darah Diastolik', 'SeqScr' => '44', 'SeqRpt' => '44', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['DIASTL'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['DIASTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
// ========================================
// CALC TYPE - Calculated Tests
@ -232,28 +188,20 @@ 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', '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' => 'TEXT', '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 = ['TestSiteID' => $tIDs['UCOLOR'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'VSET', 'RefType' => 'TEXT', 'VSet' => '1001', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Visual', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'TEXT', '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 = ['TestSiteID' => $tIDs['UGLUC'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'VSET', 'RefType' => 'TEXT', 'VSet' => '1002', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'TEXT', '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 = ['TestSiteID' => $tIDs['UPROT'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'VSET', 'RefType' => 'TEXT', 'VSet' => '1003', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
$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', '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' => 'NMRC', '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();
$data = ['TestSiteID' => $tIDs['PH'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => 'NMRIC', 'RefType' => 'NMRC', 'VSet' => '', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Dipstick', 'CreateDate' => "$now"];
$this->db->table('testdeftech')->insert($data);
}
}

View File

@ -5,6 +5,7 @@
{"key": "NMRIC", "value": "Numeric"},
{"key": "RANGE", "value": "Range"},
{"key": "TEXT", "value": "Text"},
{"key": "VSET", "value": "Value set"}
{"key": "VSET", "value": "Value set"},
{"key": "NORES", "value": "No Result"}
]
}

View File

@ -1,20 +0,0 @@
<?php
namespace App\Models\RefRange;
use App\Models\BaseModel;
class RefTHoldModel extends BaseModel {
protected $table = 'refthold';
protected $primaryKey = 'RefTHoldID';
protected $allowedFields = ['SiteID', 'TestSiteID', 'SpcType', 'Sex', 'AgeStart', 'AgeEnd',
'Threshold', 'BelowTxt', 'AboveTxt', 'GrayzoneLow', 'GrayzoneHigh', 'GrayzoneTxt',
'CreateDate', 'EndDate'];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = "EndDate";
}

View File

@ -1,19 +0,0 @@
<?php
namespace App\Models\RefRange;
use App\Models\BaseModel;
class RefVSetModel extends BaseModel {
protected $table = 'refvset';
protected $primaryKey = 'RefVSetID';
protected $allowedFields = ['SiteID', 'TestSiteID', 'SpcType', 'Sex', 'AgeStart', 'AgeEnd',
'RefTxt', 'CreateDate', 'EndDate'];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = "EndDate";
}

View File

@ -10,20 +10,14 @@ class TestDefSiteModel extends BaseModel {
protected $primaryKey = 'TestSiteID';
protected $allowedFields = [
'SiteID',
'TestSiteCode',
'TestSiteName',
'TestType',
'Description',
'SeqScr',
'SeqRpt',
'IndentLeft',
'FontStyle',
'VisibleScr',
'VisibleRpt',
'CountStat',
'CreateDate',
'StartDate',
'EndDate'
'TestSiteCode', 'TestSiteName', 'TestType', 'Description',
'DisciplineID', 'DepartmentID',
'ResultType', 'RefType', 'Vset',
'Unit1', 'Factor', 'Unit2', 'Decimal',
'ReqQty', 'ReqQtyUnit', 'CollReq', 'Method', 'ExpectedTAT',
'SeqScr', 'SeqRpt', 'IndentLeft', 'FontStyle', 'VisibleScr', 'VisibleRpt',
'CountStat', 'Level',
'CreateDate', 'StartDate','EndDate'
];
protected $useTimestamps = true;
@ -131,13 +125,15 @@ class TestDefSiteModel extends BaseModel {
$row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll();
} elseif (in_array($typeCode, ['TEST', 'PARAM'])) {
$row['testdeftech'] = $db->table('testdeftech')
->select('testdeftech.*, d.DisciplineName, dept.DepartmentName')
->join('discipline d', 'd.DisciplineID=testdeftech.DisciplineID', 'left')
->join('department dept', 'dept.DepartmentID=testdeftech.DepartmentID', 'left')
->where('testdeftech.TestSiteID', $TestSiteID)
->where('testdeftech.EndDate IS NULL')
->get()->getResultArray();
// Technical details are now flattened into the main row
if ($row['DisciplineID']) {
$discipline = $db->table('discipline')->where('DisciplineID', $row['DisciplineID'])->get()->getRowArray();
$row['DisciplineName'] = $discipline['DisciplineName'] ?? null;
}
if ($row['DepartmentID']) {
$department = $db->table('department')->where('DepartmentID', $row['DepartmentID'])->get()->getRowArray();
$row['DepartmentName'] = $department['DepartmentName'] ?? null;
}
$testMapModel = new \App\Models\Test\TestMapModel();
$row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll();

View File

@ -1,82 +0,0 @@
<?php
namespace App\Models\Test;
use App\Models\BaseModel;
class TestDefTechModel extends BaseModel {
protected $table = 'testdeftech';
protected $primaryKey = 'TestTechID';
protected $allowedFields = [
'TestSiteID',
'DisciplineID',
'DepartmentID',
'ResultType',
'RefType',
'VSet',
'ReqQty',
'ReqQtyUnit',
'Unit1',
'Factor',
'Unit2',
'Decimal',
'CollReq',
'Method',
'ExpectedTAT',
'CreateDate',
'EndDate'
];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = "EndDate";
/**
* Get technical details for a test
*/
public function getTechDetails($testSiteID) {
$db = \Config\Database::connect();
return $db->table('testdeftech')
->select('testdeftech.*, d.DisciplineName, dept.DepartmentName')
->join('discipline d', 'd.DisciplineID=testdeftech.DisciplineID', 'left')
->join('department dept', 'dept.DepartmentID=testdeftech.DepartmentID', 'left')
->where('testdeftech.TestSiteID', $testSiteID)
->where('testdeftech.EndDate IS NULL')
->get()->getResultArray();
}
/**
* Get tests by discipline
*/
public function getTestsByDiscipline($disciplineID, $siteID = null) {
$builder = $this->select('testdeftech.*, testdefsite.TestSiteCode, testdefsite.TestSiteName')
->join('testdefsite', 'testdefsite.TestSiteID=testdeftech.TestSiteID', 'left')
->where('testdeftech.DisciplineID', $disciplineID)
->where('testdeftech.EndDate IS NULL');
if ($siteID) {
$builder->where('testdefsite.SiteID', $siteID);
}
return $builder->findAll();
}
/**
* Get tests by department
*/
public function getTestsByDepartment($departmentID, $siteID = null) {
$builder = $this->select('testdeftech.*, testdefsite.TestSiteCode, testdefsite.TestSiteName')
->join('testdefsite', 'testdefsite.TestSiteID=testdeftech.TestSiteID', 'left')
->where('testdeftech.DepartmentID', $departmentID)
->where('testdeftech.EndDate IS NULL');
if ($siteID) {
$builder->where('testdefsite.SiteID', $siteID);
}
return $builder->findAll();
}
}

473
docs/audit-logging-plan.md Normal file
View File

@ -0,0 +1,473 @@
# 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.
---
## 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.
---
## 1. Current State Analysis
### Existing Audit Infrastructure
| 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 |
### Pain Points Identified
- ❌ **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
---
## 2. Proposed Architecture
### 2.1 Domain Separation
We categorize audit logs by **data domain** and **access patterns**:
| 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;
```
---
## 3. JSON Value Structure
### Example Audit Entry
```json
{
"id": 15243,
"entity_type": "patient",
"entity_id": "PAT-2026-001234",
"action": "UPDATE",
"old_values": {
"NameFirst": "John",
"NameLast": "Doe",
"Gender": "M",
"BirthDate": "1990-01-15",
"Phone": "+1-555-0100"
},
"new_values": {
"NameFirst": "Johnny",
"NameLast": "Doe-Smith",
"Gender": "M",
"BirthDate": "1990-01-15",
"Phone": "+1-555-0199"
},
"changed_fields": ["NameFirst", "NameLast", "Phone"],
"user_id": "USR-001",
"site_id": "SITE-001",
"created_at": "2026-02-19T14:30:00Z"
}
```
### Benefits of JSON Approach
**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
---
## 4. Implementation Strategy
### 4.1 Central Audit Service
```php
<?php
namespace App\Services;
class AuditService
{
/**
* Log an audit event to the appropriate table
*/
public static function log(
string $category, // 'master', 'patient', 'order'
string $entityType, // e.g., 'patient', 'order'
string $entityId,
string $action,
?array $oldValues = null,
?array $newValues = null,
?string $reason = null,
?array $context = null
): void {
$changedFields = self::calculateChangedFields($oldValues, $newValues);
$data = [
'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),
'user_id' => auth()->id() ?? 'SYSTEM',
'site_id' => session('site_id') ?? 'MAIN',
'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
<?php
namespace App\Models;
use App\Services\AuditService;
class PatientModel extends BaseModel
{
protected $table = 'patients';
protected $primaryKey = 'PatientID';
protected function logAudit(
string $action,
?array $oldValues = null,
?array $newValues = null
): void {
AuditService::log(
category: 'patient',
entityType: 'patient',
entityId: $this->getPatientId(),
action: $action,
oldValues: $oldValues,
newValues: $newValues
);
}
// Override save method to auto-log
public function save($data): bool
{
$oldData = $this->find($data['PatientID'] ?? null);
$result = parent::save($data);
if ($result) {
$this->logAudit(
$oldData ? 'UPDATE' : 'CREATE',
$oldData?->toArray(),
$this->find($data['PatientID'])->toArray()
);
}
return $result;
}
}
```
---
## 5. Query Patterns & Performance
### 5.1 Common Queries
```sql
-- View entity history
SELECT * FROM patient_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
WHERE user_id = 'USR-001'
AND created_at > DATE_SUB(NOW(), INTERVAL 7 DAY)
GROUP BY entity_type, action;
-- 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';
```
### 5.2 Partitioning Strategy (Order/Test)
For high-volume tables, implement monthly partitioning:
```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
);
```
---
## 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
### Phase 1: Foundation (Week 1)
- [ ] Drop existing unused tables (patreglog, patvisitlog, specimenlog)
- [ ] Create new audit tables with JSON columns
- [ ] 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
### 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 4: Optimization (Week 4)
- [ ] Implement async logging queue
- [ ] Add table partitioning for order_audit_log
- [ ] Set up retention policies and archiving
- [ ] Performance testing and tuning
---
## 8. Retention & Archiving Strategy
| 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;
```
---
## 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
| 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 |
---
## 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*

View File

@ -2644,10 +2644,118 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/TestDefinition'
type: object
properties:
SiteID:
type: integer
description: Site ID (required)
TestSiteCode:
type: string
description: Test code (required)
TestSiteName:
type: string
description: Test name (required)
TestType:
type: string
enum:
- TEST
- PARAM
- CALC
- GROUP
- TITLE
description: Test type (required)
Description:
type: string
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum:
- NMRIC
- VSET
RefType:
type: string
enum:
- NMRC
- TEXT
- THOLD
- VSET
VSet:
type: integer
ReqQty:
type: number
format: decimal
ReqQtyUnit:
type: string
Unit1:
type: string
Factor:
type: number
format: decimal
Unit2:
type: string
Decimal:
type: integer
CollReq:
type: string
Method:
type: string
ExpectedTAT:
type: integer
SeqScr:
type: integer
SeqRpt:
type: integer
IndentLeft:
type: integer
FontStyle:
type: string
VisibleScr:
type: integer
VisibleRpt:
type: integer
CountStat:
type: integer
details:
type: object
description: Type-specific details
refnum:
type: array
items:
type: object
reftxt:
type: array
items:
type: object
testmap:
type: array
items:
type: object
required:
- SiteID
- TestSiteCode
- TestSiteName
- TestType
responses:
'201':
description: Test definition created
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: created
message:
type: string
data:
type: object
properties:
TestSiteId:
type: integer
patch:
tags:
- Tests
@ -2659,10 +2767,112 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/TestDefinition'
type: object
properties:
TestSiteID:
type: integer
description: Test Site ID (required)
TestSiteCode:
type: string
TestSiteName:
type: string
TestType:
type: string
enum:
- TEST
- PARAM
- CALC
- GROUP
- TITLE
Description:
type: string
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum:
- NMRIC
- VSET
RefType:
type: string
enum:
- NMRC
- TEXT
- THOLD
- VSET
VSet:
type: integer
ReqQty:
type: number
format: decimal
ReqQtyUnit:
type: string
Unit1:
type: string
Factor:
type: number
format: decimal
Unit2:
type: string
Decimal:
type: integer
CollReq:
type: string
Method:
type: string
ExpectedTAT:
type: integer
SeqScr:
type: integer
SeqRpt:
type: integer
IndentLeft:
type: integer
FontStyle:
type: string
VisibleScr:
type: integer
VisibleRpt:
type: integer
CountStat:
type: integer
details:
type: object
description: Type-specific details
refnum:
type: array
items:
type: object
reftxt:
type: array
items:
type: object
testmap:
type: array
items:
type: object
required:
- TestSiteID
responses:
'200':
description: Test definition updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: object
properties:
TestSiteId:
type: integer
/api/tests/{id}:
get:
tags:
@ -2676,9 +2886,70 @@ paths:
required: true
schema:
type: integer
description: Test Site ID
responses:
'200':
description: Test definition details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '#/components/schemas/TestDefinition'
'404':
description: Test not found
delete:
tags:
- Tests
summary: Soft delete test definition
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Test Site ID to delete
requestBody:
content:
application/json:
schema:
type: object
properties:
TestSiteID:
type: integer
description: Optional - can be provided in body instead of path
responses:
'200':
description: Test disabled successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: object
properties:
TestSiteId:
type: integer
EndDate:
type: string
format: date-time
'404':
description: Test not found
'422':
description: Test already disabled
/api/valueset:
get:
tags:
@ -3832,11 +4103,13 @@ components:
TestDefinition:
type: object
properties:
id:
TestSiteID:
type: integer
TestCode:
SiteID:
type: integer
TestSiteCode:
type: string
TestName:
TestSiteName:
type: string
TestType:
type: string
@ -3852,6 +4125,8 @@ components:
CALC: Calculated
GROUP: Panel/Profile
TITLE: Section header
Description:
type: string
DisciplineID:
type: integer
DisciplineName:
@ -3860,10 +4135,112 @@ components:
type: integer
DepartmentName:
type: string
Unit:
ResultType:
type: string
Formula:
enum:
- NMRIC
- VSET
description: |
NMRIC: Numeric result
VSET: Value set result
RefType:
type: string
enum:
- NMRC
- TEXT
- THOLD
- VSET
description: |
NMRC: Numeric reference range
TEXT: Text reference
THOLD: Threshold reference
VSET: Value set reference
VSet:
type: integer
description: Value set ID for VSET result type
ReqQty:
type: number
format: decimal
description: Required sample quantity
ReqQtyUnit:
type: string
description: Unit for required quantity
Unit1:
type: string
description: Primary unit
Factor:
type: number
format: decimal
description: Conversion factor
Unit2:
type: string
description: Secondary unit (after conversion)
Decimal:
type: integer
description: Number of decimal places
CollReq:
type: string
description: Collection requirements
Method:
type: string
description: Test method
ExpectedTAT:
type: integer
description: Expected turnaround time
SeqScr:
type: integer
description: Screen sequence
SeqRpt:
type: integer
description: Report sequence
IndentLeft:
type: integer
default: 0
FontStyle:
type: string
VisibleScr:
type: integer
default: 1
description: Screen visibility (0=hidden, 1=visible)
VisibleRpt:
type: integer
default: 1
description: Report visibility (0=hidden, 1=visible)
CountStat:
type: integer
default: 1
Level:
type: integer
CreateDate:
type: string
format: date-time
StartDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
FormulaInput:
type: string
description: Input variables for calculated tests
FormulaCode:
type: string
description: Formula expression for calculated tests
testdefcal:
type: array
description: Calculated test details (only for CALC type)
items:
type: object
testdefgrp:
type: array
description: Group members (only for GROUP type)
items:
type: object
testmap:
type: array
description: Test mappings
items:
$ref: '#/components/schemas/TestMap'
refnum:
type: array
description: Numeric reference ranges (optional). Mutually exclusive with reftxt - a test can only have ONE reference type.
@ -3939,36 +4316,66 @@ components:
Flag:
type: string
examples:
$1:
Unit: mg/dL
refnum:
- RefNumID: 1
NumRefType: NMRC
NumRefTypeLabel: Numeric
RangeType: REF
RangeTypeLabel: Reference Range
Sex: '2'
SexLabel: Male
LowSign: GE
LowSignLabel: '>='
HighSign: LE
HighSignLabel: <=
Low: 70
High: 100
AgeStart: 18
AgeEnd: 99
Flag: 'N'
Interpretation: Normal
TEST_numeric_thold:
summary: Technical test - threshold reference (THOLD)
TEST_numeric:
summary: Technical test with numeric reference
value:
id: 1
TestCode: GLU
TestName: Glucose
TestSiteID: 1
SiteID: 1
TestSiteCode: GLU
TestSiteName: Glucose
TestType: TEST
DisciplineID: 1
DepartmentID: 1
Unit: mg/dL
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: NMRC
Unit1: mg/dL
ReqQty: 300
ReqQtyUnit: uL
Decimal: 0
Method: Hexokinase
SeqScr: 11
SeqRpt: 11
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
refnum:
- RefNumID: 1
NumRefType: NMRC
NumRefTypeLabel: Numeric
RangeType: REF
RangeTypeLabel: Reference Range
Sex: '2'
SexLabel: Male
LowSign: GE
LowSignLabel: '>='
HighSign: LE
HighSignLabel: <=
Low: 70
High: 100
AgeStart: 18
AgeEnd: 99
Flag: 'N'
Interpretation: Normal
TEST_threshold:
summary: Technical test with threshold reference (panic)
value:
TestSiteID: 2
SiteID: 1
TestSiteCode: GLU
TestSiteName: Glucose
TestType: TEST
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mg/dL
Decimal: 0
Method: Hexokinase
SeqScr: 11
SeqRpt: 11
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
refnum:
- RefNumID: 2
NumRefType: THOLD
@ -3984,136 +4391,179 @@ components:
AgeEnd: 120
Flag: L
Interpretation: Critical Low
$2:
Unit: null
reftxt:
- RefTxtID: 1
TxtRefType: TEXT
TxtRefTypeLabel: Text
Sex: '2'
SexLabel: Male
AgeStart: 18
AgeEnd: 99
RefTxt: NORM=Normal;HYPO=Hypochromic;MACRO=Macrocytic
Flag: 'N'
TEST_text_vset:
summary: Technical test - text reference (VSET)
TEST_text:
summary: Technical test with text reference
value:
id: 1
TestCode: STAGE
TestName: Disease Stage
TestSiteID: 3
SiteID: 1
TestSiteCode: STAGE
TestSiteName: Disease Stage
TestType: TEST
DisciplineID: 1
DepartmentID: 1
Unit: null
ResultType: VSET
RefType: TEXT
SeqScr: 50
SeqRpt: 50
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
reftxt:
- RefTxtID: 2
TxtRefType: VSET
TxtRefTypeLabel: Value Set
Sex: '1'
SexLabel: Female
AgeStart: 0
AgeEnd: 120
RefTxt: STG1=Stage 1;STG2=Stage 2;STG3=Stage 3;STG4=Stage 4
- RefTxtID: 1
TxtRefType: TEXT
TxtRefTypeLabel: Text
Sex: '2'
SexLabel: Male
AgeStart: 18
AgeEnd: 99
RefTxt: NORM=Normal;HYPO=Hypochromic;MACRO=Macrocytic
Flag: 'N'
PARAM:
summary: Parameter - no reference range allowed
summary: Parameter test
value:
id: 2
TestCode: GLU_FAST
TestName: Fasting Glucose
TestSiteID: 4
SiteID: 1
TestSiteCode: HEIGHT
TestSiteName: Height
TestType: PARAM
DisciplineID: 1
DepartmentID: 1
Unit: mg/dL
$3:
Unit: null
Formula: BUN / Creatinine
refnum:
- RefNumID: 5
NumRefType: NMRC
NumRefTypeLabel: Numeric
RangeType: REF
RangeTypeLabel: Reference Range
Sex: '1'
SexLabel: Female
LowSign: GE
LowSignLabel: '>='
HighSign: LE
HighSignLabel: <=
Low: 10
High: 20
AgeStart: 18
AgeEnd: 120
Flag: 'N'
Interpretation: Normal
$4:
Unit: null
Formula: BUN / Creatinine
refnum:
- RefNumID: 6
NumRefType: THOLD
NumRefTypeLabel: Threshold
RangeType: PANIC
RangeTypeLabel: Panic Range
Sex: '1'
SexLabel: Female
LowSign: GT
LowSignLabel: '>'
Low: 20
AgeStart: 18
AgeEnd: 120
Flag: H
Interpretation: Elevated - possible prerenal cause
DisciplineID: 10
ResultType: NMRIC
Unit1: cm
Decimal: 0
SeqScr: 40
SeqRpt: 40
VisibleScr: 1
VisibleRpt: 0
CountStat: 0
CALC:
summary: Calculated test with reference
value:
TestSiteID: 5
SiteID: 1
TestSiteCode: EGFR
TestSiteName: eGFR
TestType: CALC
DisciplineID: 2
DepartmentID: 2
Unit1: mL/min/1.73m2
Decimal: 0
SeqScr: 20
SeqRpt: 20
VisibleScr: 1
VisibleRpt: 1
CountStat: 0
testdefcal:
- TestCalID: 1
DisciplineID: 2
DepartmentID: 2
FormulaInput: CREA,AGE,GENDER
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
Unit1: mL/min/1.73m2
Decimal: 0
refnum:
- RefNumID: 5
NumRefType: NMRC
NumRefTypeLabel: Numeric
RangeType: REF
RangeTypeLabel: Reference Range
Sex: '1'
SexLabel: Female
LowSign: GE
LowSignLabel: '>='
HighSign: LE
HighSignLabel: <=
Low: 10
High: 20
AgeStart: 18
AgeEnd: 120
Flag: 'N'
Interpretation: Normal
GROUP:
summary: Panel/Profile - no reference range allowed
summary: Panel/Profile test
value:
id: 4
TestCode: LIPID
TestName: Lipid Panel
TestSiteID: 6
SiteID: 1
TestSiteCode: LIPID
TestSiteName: Lipid Panel
TestType: GROUP
DisciplineID: 1
DepartmentID: 1
Unit: null
DisciplineID: 2
DepartmentID: 2
SeqScr: 51
SeqRpt: 51
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
testdefgrp:
- TestGrpID: 1
Member: 100
TestSiteCode: CHOL
TestSiteName: Total Cholesterol
- TestGrpID: 2
Member: 101
TestSiteCode: TG
TestSiteName: Triglycerides
TITLE:
summary: Section header - no reference range allowed
summary: Section header
value:
id: 5
TestCode: CHEM_HEADER
TestName: '--- CHEMISTRY ---'
TestSiteID: 7
SiteID: 1
TestSiteCode: CHEM_HEADER
TestSiteName: '--- CHEMISTRY ---'
TestType: TITLE
DisciplineID: 1
DepartmentID: 1
Unit: null
DisciplineID: 2
DepartmentID: 2
SeqScr: 100
SeqRpt: 100
VisibleScr: 1
VisibleRpt: 1
CountStat: 0
TestMap:
type: object
properties:
id:
type: integer
TestMapID:
type: integer
TestCode:
type: string
HostCode:
type: string
HostName:
type: string
ClientCode:
type: string
ClientName:
type: string
TestSiteID:
type: integer
HostType:
type: string
description: Host type code
HostTypeLabel:
HostID:
type: string
description: Host type display text
description: Host identifier
HostDataSource:
type: string
description: Host data source
HostTestCode:
type: string
description: Test code in host system
HostTestName:
type: string
description: Test name in host system
ClientType:
type: string
description: Client type code
ClientTypeLabel:
ClientID:
type: string
description: Client type display text
description: Client identifier
ClientDataSource:
type: string
description: Client data source
ConDefID:
type: integer
description: Connection definition ID
ClientTestCode:
type: string
description: Test code in client system
ClientTestName:
type: string
description: Test name in client system
CreateDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
description: Soft delete timestamp
OrderTest:
type: object
properties:

View File

@ -1,11 +1,13 @@
TestDefinition:
type: object
properties:
id:
TestSiteID:
type: integer
TestCode:
SiteID:
type: integer
TestSiteCode:
type: string
TestName:
TestSiteName:
type: string
TestType:
type: string
@ -16,6 +18,8 @@ TestDefinition:
CALC: Calculated
GROUP: Panel/Profile
TITLE: Section header
Description:
type: string
DisciplineID:
type: integer
DisciplineName:
@ -24,10 +28,106 @@ TestDefinition:
type: integer
DepartmentName:
type: string
Unit:
ResultType:
type: string
Formula:
enum: [NMRIC, VSET]
description: |
NMRIC: Numeric result
VSET: Value set result
RefType:
type: string
enum: [NMRC, TEXT, THOLD, VSET]
description: |
NMRC: Numeric reference range
TEXT: Text reference
THOLD: Threshold reference
VSET: Value set reference
VSet:
type: integer
description: Value set ID for VSET result type
ReqQty:
type: number
format: decimal
description: Required sample quantity
ReqQtyUnit:
type: string
description: Unit for required quantity
Unit1:
type: string
description: Primary unit
Factor:
type: number
format: decimal
description: Conversion factor
Unit2:
type: string
description: Secondary unit (after conversion)
Decimal:
type: integer
description: Number of decimal places
CollReq:
type: string
description: Collection requirements
Method:
type: string
description: Test method
ExpectedTAT:
type: integer
description: Expected turnaround time
SeqScr:
type: integer
description: Screen sequence
SeqRpt:
type: integer
description: Report sequence
IndentLeft:
type: integer
default: 0
FontStyle:
type: string
VisibleScr:
type: integer
default: 1
description: Screen visibility (0=hidden, 1=visible)
VisibleRpt:
type: integer
default: 1
description: Report visibility (0=hidden, 1=visible)
CountStat:
type: integer
default: 1
Level:
type: integer
CreateDate:
type: string
format: date-time
StartDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
FormulaInput:
type: string
description: Input variables for calculated tests
FormulaCode:
type: string
description: Formula expression for calculated tests
testdefcal:
type: array
description: Calculated test details (only for CALC type)
items:
type: object
testdefgrp:
type: array
description: Group members (only for GROUP type)
items:
type: object
testmap:
type: array
description: Test mappings
items:
$ref: '#/TestMap'
refnum:
type: array
description: Numeric reference ranges (optional). Mutually exclusive with reftxt - a test can only have ONE reference type.
@ -99,9 +199,29 @@ TestDefinition:
Flag:
type: string
examples:
$1:
Unit: mg/dL
refnum:
TEST_numeric:
summary: Technical test with numeric reference
value:
TestSiteID: 1
SiteID: 1
TestSiteCode: GLU
TestSiteName: Glucose
TestType: TEST
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: NMRC
Unit1: mg/dL
ReqQty: 300
ReqQtyUnit: uL
Decimal: 0
Method: Hexokinase
SeqScr: 11
SeqRpt: 11
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
refnum:
- RefNumID: 1
NumRefType: NMRC
NumRefTypeLabel: Numeric
@ -119,16 +239,26 @@ TestDefinition:
AgeEnd: 99
Flag: N
Interpretation: Normal
TEST_numeric_thold:
summary: Technical test - threshold reference (THOLD)
TEST_threshold:
summary: Technical test with threshold reference (panic)
value:
id: 1
TestCode: GLU
TestName: Glucose
TestSiteID: 2
SiteID: 1
TestSiteCode: GLU
TestSiteName: Glucose
TestType: TEST
DisciplineID: 1
DepartmentID: 1
Unit: mg/dL
DisciplineID: 2
DepartmentID: 2
ResultType: NMRIC
RefType: THOLD
Unit1: mg/dL
Decimal: 0
Method: Hexokinase
SeqScr: 11
SeqRpt: 11
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
refnum:
- RefNumID: 2
NumRefType: THOLD
@ -144,9 +274,24 @@ TestDefinition:
AgeEnd: 120
Flag: L
Interpretation: Critical Low
$2:
Unit: null
reftxt:
TEST_text:
summary: Technical test with text reference
value:
TestSiteID: 3
SiteID: 1
TestSiteCode: STAGE
TestSiteName: Disease Stage
TestType: TEST
DisciplineID: 1
DepartmentID: 1
ResultType: VSET
RefType: TEXT
SeqScr: 50
SeqRpt: 50
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
reftxt:
- RefTxtID: 1
TxtRefType: TEXT
TxtRefTypeLabel: Text
@ -156,123 +301,150 @@ TestDefinition:
AgeEnd: 99
RefTxt: 'NORM=Normal;HYPO=Hypochromic;MACRO=Macrocytic'
Flag: N
TEST_text_vset:
summary: Technical test - text reference (VSET)
PARAM:
summary: Parameter test
value:
id: 1
TestCode: STAGE
TestName: Disease Stage
TestType: TEST
DisciplineID: 1
DepartmentID: 1
Unit: null
reftxt:
- RefTxtID: 2
TxtRefType: VSET
TxtRefTypeLabel: Value Set
TestSiteID: 4
SiteID: 1
TestSiteCode: HEIGHT
TestSiteName: Height
TestType: PARAM
DisciplineID: 10
ResultType: NMRIC
Unit1: cm
Decimal: 0
SeqScr: 40
SeqRpt: 40
VisibleScr: 1
VisibleRpt: 0
CountStat: 0
CALC:
summary: Calculated test with reference
value:
TestSiteID: 5
SiteID: 1
TestSiteCode: EGFR
TestSiteName: eGFR
TestType: CALC
DisciplineID: 2
DepartmentID: 2
Unit1: mL/min/1.73m2
Decimal: 0
SeqScr: 20
SeqRpt: 20
VisibleScr: 1
VisibleRpt: 1
CountStat: 0
testdefcal:
- TestCalID: 1
DisciplineID: 2
DepartmentID: 2
FormulaInput: CREA,AGE,GENDER
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
Unit1: mL/min/1.73m2
Decimal: 0
refnum:
- RefNumID: 5
NumRefType: NMRC
NumRefTypeLabel: Numeric
RangeType: REF
RangeTypeLabel: Reference Range
Sex: '1'
SexLabel: Female
AgeStart: 0
LowSign: GE
LowSignLabel: ">="
HighSign: LE
HighSignLabel: "<="
Low: 10
High: 20
AgeStart: 18
AgeEnd: 120
RefTxt: 'STG1=Stage 1;STG2=Stage 2;STG3=Stage 3;STG4=Stage 4'
Flag: N
PARAM:
summary: Parameter - no reference range allowed
value:
id: 2
TestCode: GLU_FAST
TestName: Fasting Glucose
TestType: PARAM
DisciplineID: 1
DepartmentID: 1
Unit: mg/dL
$3:
Unit: null
Formula: "BUN / Creatinine"
refnum:
- RefNumID: 5
NumRefType: NMRC
NumRefTypeLabel: Numeric
RangeType: REF
RangeTypeLabel: Reference Range
Sex: '1'
SexLabel: Female
LowSign: GE
LowSignLabel: ">="
HighSign: LE
HighSignLabel: "<="
Low: 10
High: 20
AgeStart: 18
AgeEnd: 120
Flag: N
Interpretation: Normal
$4:
Unit: null
Formula: "BUN / Creatinine"
refnum:
- RefNumID: 6
NumRefType: THOLD
NumRefTypeLabel: Threshold
RangeType: PANIC
RangeTypeLabel: Panic Range
Sex: '1'
SexLabel: Female
LowSign: GT
LowSignLabel: ">"
Low: 20
AgeStart: 18
AgeEnd: 120
Flag: H
Interpretation: Elevated - possible prerenal cause
Interpretation: Normal
GROUP:
summary: Panel/Profile - no reference range allowed
summary: Panel/Profile test
value:
id: 4
TestCode: LIPID
TestName: Lipid Panel
TestSiteID: 6
SiteID: 1
TestSiteCode: LIPID
TestSiteName: Lipid Panel
TestType: GROUP
DisciplineID: 1
DepartmentID: 1
Unit: null
DisciplineID: 2
DepartmentID: 2
SeqScr: 51
SeqRpt: 51
VisibleScr: 1
VisibleRpt: 1
CountStat: 1
testdefgrp:
- TestGrpID: 1
Member: 100
TestSiteCode: CHOL
TestSiteName: Total Cholesterol
- TestGrpID: 2
Member: 101
TestSiteCode: TG
TestSiteName: Triglycerides
TITLE:
summary: Section header - no reference range allowed
summary: Section header
value:
id: 5
TestCode: CHEM_HEADER
TestName: '--- CHEMISTRY ---'
TestSiteID: 7
SiteID: 1
TestSiteCode: CHEM_HEADER
TestSiteName: '--- CHEMISTRY ---'
TestType: TITLE
DisciplineID: 1
DepartmentID: 1
Unit: null
DisciplineID: 2
DepartmentID: 2
SeqScr: 100
SeqRpt: 100
VisibleScr: 1
VisibleRpt: 1
CountStat: 0
TestMap:
type: object
properties:
id:
type: integer
TestMapID:
type: integer
TestCode:
type: string
HostCode:
type: string
HostName:
type: string
ClientCode:
type: string
ClientName:
type: string
TestSiteID:
type: integer
HostType:
type: string
description: Host type code
HostTypeLabel:
HostID:
type: string
description: Host type display text
description: Host identifier
HostDataSource:
type: string
description: Host data source
HostTestCode:
type: string
description: Test code in host system
HostTestName:
type: string
description: Test name in host system
ClientType:
type: string
description: Client type code
ClientTypeLabel:
ClientID:
type: string
description: Client type display text
description: Client identifier
ClientDataSource:
type: string
description: Client data source
ConDefID:
type: integer
description: Connection definition ID
ClientTestCode:
type: string
description: Test code in client system
ClientTestName:
type: string
description: Test name in client system
CreateDate:
type: string
format: date-time
EndDate:
type: string
format: date-time
description: Soft delete timestamp

View File

@ -76,10 +76,107 @@
content:
application/json:
schema:
$ref: '../components/schemas/tests.yaml#/TestDefinition'
type: object
properties:
SiteID:
type: integer
description: Site ID (required)
TestSiteCode:
type: string
description: Test code (required)
TestSiteName:
type: string
description: Test name (required)
TestType:
type: string
enum: [TEST, PARAM, CALC, GROUP, TITLE]
description: Test type (required)
Description:
type: string
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum: [NMRIC, VSET]
RefType:
type: string
enum: [NMRC, TEXT, THOLD, VSET]
VSet:
type: integer
ReqQty:
type: number
format: decimal
ReqQtyUnit:
type: string
Unit1:
type: string
Factor:
type: number
format: decimal
Unit2:
type: string
Decimal:
type: integer
CollReq:
type: string
Method:
type: string
ExpectedTAT:
type: integer
SeqScr:
type: integer
SeqRpt:
type: integer
IndentLeft:
type: integer
FontStyle:
type: string
VisibleScr:
type: integer
VisibleRpt:
type: integer
CountStat:
type: integer
details:
type: object
description: Type-specific details
refnum:
type: array
items:
type: object
reftxt:
type: array
items:
type: object
testmap:
type: array
items:
type: object
required:
- SiteID
- TestSiteCode
- TestSiteName
- TestType
responses:
'201':
description: Test definition created
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: created
message:
type: string
data:
type: object
properties:
TestSiteId:
type: integer
patch:
tags: [Tests]
@ -91,10 +188,101 @@
content:
application/json:
schema:
$ref: '../components/schemas/tests.yaml#/TestDefinition'
type: object
properties:
TestSiteID:
type: integer
description: Test Site ID (required)
TestSiteCode:
type: string
TestSiteName:
type: string
TestType:
type: string
enum: [TEST, PARAM, CALC, GROUP, TITLE]
Description:
type: string
DisciplineID:
type: integer
DepartmentID:
type: integer
ResultType:
type: string
enum: [NMRIC, VSET]
RefType:
type: string
enum: [NMRC, TEXT, THOLD, VSET]
VSet:
type: integer
ReqQty:
type: number
format: decimal
ReqQtyUnit:
type: string
Unit1:
type: string
Factor:
type: number
format: decimal
Unit2:
type: string
Decimal:
type: integer
CollReq:
type: string
Method:
type: string
ExpectedTAT:
type: integer
SeqScr:
type: integer
SeqRpt:
type: integer
IndentLeft:
type: integer
FontStyle:
type: string
VisibleScr:
type: integer
VisibleRpt:
type: integer
CountStat:
type: integer
details:
type: object
description: Type-specific details
refnum:
type: array
items:
type: object
reftxt:
type: array
items:
type: object
testmap:
type: array
items:
type: object
required:
- TestSiteID
responses:
'200':
description: Test definition updated
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: object
properties:
TestSiteId:
type: integer
/api/tests/{id}:
get:
@ -108,6 +296,67 @@
required: true
schema:
type: integer
description: Test Site ID
responses:
'200':
description: Test definition details
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
$ref: '../components/schemas/tests.yaml#/TestDefinition'
'404':
description: Test not found
delete:
tags: [Tests]
summary: Soft delete test definition
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Test Site ID to delete
requestBody:
content:
application/json:
schema:
type: object
properties:
TestSiteID:
type: integer
description: Optional - can be provided in body instead of path
responses:
'200':
description: Test disabled successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: object
properties:
TestSiteId:
type: integer
EndDate:
type: string
format: date-time
'404':
description: Test not found
'422':
description: Test already disabled

View File

@ -4,7 +4,7 @@ namespace Tests\Unit\TestDef;
use CodeIgniter\Test\CIUnitTestCase;
use App\Models\Test\TestDefSiteModel;
use App\Models\Test\TestDefTechModel;
use App\Models\Test\TestDefCalModel;
use App\Models\Test\TestDefGrpModel;
use App\Models\Test\TestMapModel;
@ -12,7 +12,7 @@ use App\Models\Test\TestMapModel;
class TestDefModelsTest extends CIUnitTestCase
{
protected $testDefSiteModel;
protected $testDefTechModel;
protected $testDefCalModel;
protected $testDefGrpModel;
protected $testMapModel;
@ -21,7 +21,7 @@ class TestDefModelsTest extends CIUnitTestCase
{
parent::setUp();
$this->testDefSiteModel = new TestDefSiteModel();
$this->testDefTechModel = new TestDefTechModel();
$this->testDefCalModel = new TestDefCalModel();
$this->testDefGrpModel = new TestDefGrpModel();
$this->testMapModel = new TestMapModel();
@ -76,43 +76,6 @@ class TestDefModelsTest extends CIUnitTestCase
$this->assertEquals('EndDate', $this->testDefSiteModel->deletedField);
}
/**
* Test TestDefTechModel has correct table name
*/
public function testTestDefTechModelTable()
{
$this->assertEquals('testdeftech', $this->testDefTechModel->table);
}
/**
* Test TestDefTechModel has correct primary key
*/
public function testTestDefTechModelPrimaryKey()
{
$this->assertEquals('TestTechID', $this->testDefTechModel->primaryKey);
}
/**
* Test TestDefTechModel has correct allowed fields
*/
public function testTestDefTechModelAllowedFields()
{
$allowedFields = $this->testDefTechModel->allowedFields;
$this->assertContains('TestSiteID', $allowedFields);
$this->assertContains('DisciplineID', $allowedFields);
$this->assertContains('DepartmentID', $allowedFields);
$this->assertContains('ResultType', $allowedFields);
$this->assertContains('RefType', $allowedFields);
$this->assertContains('VSet', $allowedFields);
$this->assertContains('Unit1', $allowedFields);
$this->assertContains('Factor', $allowedFields);
$this->assertContains('Unit2', $allowedFields);
$this->assertContains('Decimal', $allowedFields);
$this->assertContains('Method', $allowedFields);
$this->assertContains('ExpectedTAT', $allowedFields);
}
/**
* Test TestDefCalModel has correct table name
*/
@ -218,7 +181,6 @@ class TestDefModelsTest extends CIUnitTestCase
*/
public function testAllModelsUseSoftDeletes()
{
$this->assertTrue($this->testDefTechModel->useSoftDeletes);
$this->assertTrue($this->testDefCalModel->useSoftDeletes);
$this->assertTrue($this->testDefGrpModel->useSoftDeletes);
$this->assertTrue($this->testMapModel->useSoftDeletes);
@ -229,7 +191,6 @@ class TestDefModelsTest extends CIUnitTestCase
*/
public function testAllModelsUseEndDateAsDeletedField()
{
$this->assertEquals('EndDate', $this->testDefTechModel->deletedField);
$this->assertEquals('EndDate', $this->testDefCalModel->deletedField);
$this->assertEquals('EndDate', $this->testDefGrpModel->deletedField);
$this->assertEquals('EndDate', $this->testMapModel->deletedField);