feat: expand audit logging service and docs
This commit is contained in:
parent
6ece30302f
commit
7600989bed
Binary file not shown.
Binary file not shown.
137
app/Database/Migrations/2026-03-25-000050_CreateAuditLogs.php
Normal file
137
app/Database/Migrations/2026-03-25-000050_CreateAuditLogs.php
Normal file
@ -0,0 +1,137 @@
|
||||
<?php
|
||||
|
||||
namespace App\Database\Migrations;
|
||||
|
||||
use CodeIgniter\Database\Migration;
|
||||
|
||||
class CreateAuditLogs extends Migration
|
||||
{
|
||||
private array $logTables = [
|
||||
'logpatient' => 'LogPatientID',
|
||||
'logorder' => 'LogOrderID',
|
||||
'logmaster' => 'LogMasterID',
|
||||
'logsystem' => 'LogSystemID',
|
||||
];
|
||||
|
||||
public function up(): void
|
||||
{
|
||||
foreach ($this->logTables as $table => $pk) {
|
||||
$this->createLogTable($table, $pk);
|
||||
}
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
foreach (array_reverse($this->logTables) as $table => $pk) {
|
||||
$this->forge->dropTable($table, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function createLogTable(string $table, string $primaryKey): void
|
||||
{
|
||||
$fields = [
|
||||
$primaryKey => [
|
||||
'type' => 'BIGINT',
|
||||
'constraint' => 20,
|
||||
'unsigned' => true,
|
||||
'auto_increment' => true,
|
||||
],
|
||||
'TblName' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 64,
|
||||
],
|
||||
'RecID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 64,
|
||||
],
|
||||
'FldName' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 128,
|
||||
'null' => true,
|
||||
],
|
||||
'FldValuePrev' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'FldValueNew' => [
|
||||
'type' => 'TEXT',
|
||||
'null' => true,
|
||||
],
|
||||
'UserID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 64,
|
||||
],
|
||||
'SiteID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 32,
|
||||
],
|
||||
'DIDType' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 32,
|
||||
'null' => true,
|
||||
],
|
||||
'DID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 128,
|
||||
'null' => true,
|
||||
],
|
||||
'MachineID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 128,
|
||||
'null' => true,
|
||||
],
|
||||
'SessionID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 128,
|
||||
],
|
||||
'AppID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 64,
|
||||
],
|
||||
'ProcessID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 128,
|
||||
'null' => true,
|
||||
],
|
||||
'WebPageID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 128,
|
||||
'null' => true,
|
||||
],
|
||||
'EventID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 80,
|
||||
],
|
||||
'ActivityID' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 24,
|
||||
],
|
||||
'Reason' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 512,
|
||||
'null' => true,
|
||||
],
|
||||
'LogDate' => [
|
||||
'type' => 'DATETIME',
|
||||
'constraint' => 3,
|
||||
],
|
||||
'Context' => [
|
||||
'type' => 'JSON',
|
||||
],
|
||||
'IpAddress' => [
|
||||
'type' => 'VARCHAR',
|
||||
'constraint' => 45,
|
||||
'null' => true,
|
||||
],
|
||||
];
|
||||
|
||||
$this->forge->addField($fields);
|
||||
$this->forge->addKey($primaryKey, true);
|
||||
$this->forge->addKey(['LogDate'], false, false, "idx_{$table}_logdate");
|
||||
$this->forge->addKey(['RecID', 'LogDate'], false, false, "idx_{$table}_recid_logdate");
|
||||
$this->forge->addKey(['UserID', 'LogDate'], false, false, "idx_{$table}_userid_logdate");
|
||||
$this->forge->addKey(['EventID', 'LogDate'], false, false, "idx_{$table}_eventid_logdate");
|
||||
$this->forge->addKey(['SiteID', 'LogDate'], false, false, "idx_{$table}_site_logdate");
|
||||
$this->forge->createTable($table, true);
|
||||
}
|
||||
}
|
||||
@ -9,7 +9,8 @@
|
||||
{"file": "marital_status.json","VSName": "Marital Status"},
|
||||
{"file": "death_indicator.json","VSName": "Death Indicator"},
|
||||
{"file": "identifier_type.json","VSName": "Identifier Type"},
|
||||
{"file": "operation.json","VSName": "Operation (CRUD)"},
|
||||
{"file": "operation.json","VSName": "Operation (CRUD)"},
|
||||
{"file": "event_id.json","VSName": "Audit Event ID"},
|
||||
{"file": "did_type.json","VSName": "DID Type"},
|
||||
{"file": "requested_entity.json","VSName": "Requested Entity"},
|
||||
{"file": "order_priority.json","VSName": "Order Priority"},
|
||||
|
||||
79
app/Libraries/Data/event_id.json
Normal file
79
app/Libraries/Data/event_id.json
Normal file
@ -0,0 +1,79 @@
|
||||
{
|
||||
"name": "event_id",
|
||||
"VSName": "Audit Event ID",
|
||||
"VCategory": "System",
|
||||
"values": [
|
||||
{"key": "PATIENT_REGISTERED", "value": "Patient registered"},
|
||||
{"key": "PATIENT_DEMOGRAPHICS_UPDATED", "value": "Patient demographics updated"},
|
||||
{"key": "PATIENT_MERGED", "value": "Patient merged"},
|
||||
{"key": "PATIENT_UNMERGED", "value": "Patient unmerged"},
|
||||
{"key": "PATIENT_IDENTIFIER_UPDATED", "value": "Patient identifier updated"},
|
||||
{"key": "PATIENT_CONSENT_UPDATED", "value": "Patient consent updated"},
|
||||
{"key": "PATIENT_INSURANCE_UPDATED", "value": "Patient insurance updated"},
|
||||
{"key": "PATIENT_DELETED", "value": "Patient deleted"},
|
||||
{"key": "VISIT_ADMITTED", "value": "Visit admitted"},
|
||||
{"key": "VISIT_TRANSFERRED", "value": "Visit transferred"},
|
||||
{"key": "VISIT_DISCHARGED", "value": "Visit discharged"},
|
||||
{"key": "VISIT_STATUS_UPDATED", "value": "Visit status updated"},
|
||||
{"key": "ORDER_CREATED", "value": "Order created"},
|
||||
{"key": "ORDER_CANCELLED", "value": "Order cancelled"},
|
||||
{"key": "ORDER_REOPENED", "value": "Order reopened"},
|
||||
{"key": "ORDER_TEST_ADDED", "value": "Order test added"},
|
||||
{"key": "ORDER_TEST_REMOVED", "value": "Order test removed"},
|
||||
{"key": "SPECIMEN_COLLECTED", "value": "Specimen collected"},
|
||||
{"key": "SPECIMEN_RECEIVED", "value": "Specimen received"},
|
||||
{"key": "SPECIMEN_REJECTED", "value": "Specimen rejected"},
|
||||
{"key": "SPECIMEN_ALIQUOTED", "value": "Specimen aliquoted"},
|
||||
{"key": "SPECIMEN_DISPOSED", "value": "Specimen disposed"},
|
||||
{"key": "RESULT_ENTERED", "value": "Result entered"},
|
||||
{"key": "RESULT_UPDATED", "value": "Result updated"},
|
||||
{"key": "RESULT_VERIFIED", "value": "Result verified"},
|
||||
{"key": "RESULT_AMENDED", "value": "Result amended"},
|
||||
{"key": "RESULT_RELEASED", "value": "Result released"},
|
||||
{"key": "RESULT_RETRACTED", "value": "Result retracted"},
|
||||
{"key": "RESULT_CORRECTED", "value": "Result corrected"},
|
||||
{"key": "QC_RECORDED", "value": "QC recorded"},
|
||||
{"key": "QC_FAILED", "value": "QC failed"},
|
||||
{"key": "QC_OVERRIDE_APPLIED", "value": "QC override applied"},
|
||||
{"key": "VALUESET_ITEM_CREATED", "value": "Value set item created"},
|
||||
{"key": "VALUESET_ITEM_UPDATED", "value": "Value set item updated"},
|
||||
{"key": "VALUESET_ITEM_RETIRED", "value": "Value set item retired"},
|
||||
{"key": "TEST_DEFINITION_UPDATED", "value": "Test definition updated"},
|
||||
{"key": "REFERENCE_RANGE_UPDATED", "value": "Reference range updated"},
|
||||
{"key": "TEST_PANEL_MEMBERSHIP_UPDATED", "value": "Test panel membership updated"},
|
||||
{"key": "ANALYZER_CONFIG_UPDATED", "value": "Analyzer config updated"},
|
||||
{"key": "INTEGRATION_CONFIG_UPDATED", "value": "Integration config updated"},
|
||||
{"key": "CODING_SYSTEM_UPDATED", "value": "Coding system updated"},
|
||||
{"key": "USER_CREATED", "value": "User created"},
|
||||
{"key": "USER_DISABLED", "value": "User disabled"},
|
||||
{"key": "USER_PASSWORD_RESET", "value": "User password reset"},
|
||||
{"key": "USER_ROLE_CHANGED", "value": "User role changed"},
|
||||
{"key": "USER_PERMISSION_CHANGED", "value": "User permission changed"},
|
||||
{"key": "SITE_CREATED", "value": "Site created"},
|
||||
{"key": "SITE_UPDATED", "value": "Site updated"},
|
||||
{"key": "WORKSTATION_UPDATED", "value": "Workstation updated"},
|
||||
{"key": "AUTH_LOGIN_SUCCESS", "value": "Auth login success"},
|
||||
{"key": "AUTH_LOGOUT_SUCCESS", "value": "Auth logout success"},
|
||||
{"key": "AUTH_LOGIN_FAILED", "value": "Auth login failed"},
|
||||
{"key": "AUTH_LOCKOUT_TRIGGERED", "value": "Auth lockout triggered"},
|
||||
{"key": "TOKEN_ISSUED", "value": "Token issued"},
|
||||
{"key": "TOKEN_REFRESHED", "value": "Token refreshed"},
|
||||
{"key": "TOKEN_REVOKED", "value": "Token revoked"},
|
||||
{"key": "AUTHORIZATION_FAILED", "value": "Authorization failed"},
|
||||
{"key": "IMPORT_JOB_STARTED", "value": "Import job started"},
|
||||
{"key": "IMPORT_JOB_FINISHED", "value": "Import job finished"},
|
||||
{"key": "EXPORT_JOB_STARTED", "value": "Export job started"},
|
||||
{"key": "EXPORT_JOB_FINISHED", "value": "Export job finished"},
|
||||
{"key": "JOB_STARTED", "value": "Job started"},
|
||||
{"key": "JOB_FINISHED", "value": "Job finished"},
|
||||
{"key": "INTEGRATION_SYNC_STARTED", "value": "Integration sync started"},
|
||||
{"key": "INTEGRATION_SYNC_FINISHED", "value": "Integration sync finished"},
|
||||
{"key": "AUDIT_WRITE_FAILED", "value": "Audit write failed"},
|
||||
{"key": "AUDIT_ARCHIVE_EXECUTED", "value": "Audit archive executed"},
|
||||
{"key": "AUDIT_PURGE_EXECUTED", "value": "Audit purge executed"},
|
||||
{"key": "AUDIT_CHECKSUM_CREATED", "value": "Audit checksum created"},
|
||||
{"key": "AUDIT_CHECKSUM_FAILED", "value": "Audit checksum failed"},
|
||||
{"key": "LEGAL_HOLD_APPLIED", "value": "Legal hold applied"},
|
||||
{"key": "LEGAL_HOLD_RELEASED", "value": "Legal hold released"}
|
||||
]
|
||||
}
|
||||
@ -152,17 +152,24 @@ class PatientModel extends BaseModel {
|
||||
$newInternalPID = $this->getInsertID();
|
||||
$this->checkDbError($db, 'Insert patient');
|
||||
|
||||
AuditService::logData(
|
||||
'CREATE',
|
||||
'patient',
|
||||
(string) $newInternalPID,
|
||||
'patient',
|
||||
null,
|
||||
$previousData,
|
||||
$input,
|
||||
'Patient registration',
|
||||
['PatientID' => $input['PatientID'] ?? null]
|
||||
);
|
||||
$auditDiff = $this->buildAuditDiff([], $input);
|
||||
AuditService::logData(
|
||||
'PATIENT_REGISTERED',
|
||||
'CREATE',
|
||||
'patient',
|
||||
(string) $newInternalPID,
|
||||
'patient',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
'Patient registration',
|
||||
[
|
||||
'diff' => $auditDiff,
|
||||
'patient_id' => $input['PatientID'] ?? null,
|
||||
'validation_profile' => 'patient.create',
|
||||
],
|
||||
['entity_version' => 1]
|
||||
);
|
||||
|
||||
if (!empty($patIdt)) {
|
||||
$modelPatIdt->createPatIdt($patIdt, $newInternalPID);
|
||||
@ -219,16 +226,23 @@ class PatientModel extends BaseModel {
|
||||
$this->checkDbError($db, 'Update patient');
|
||||
|
||||
$changedFields = array_keys(array_diff_assoc((array) $previousData, (array) $input));
|
||||
$auditDiff = $this->buildAuditDiff((array) $previousData, $input);
|
||||
AuditService::logData(
|
||||
'PATIENT_DEMOGRAPHICS_UPDATED',
|
||||
'UPDATE',
|
||||
'patient',
|
||||
(string) $InternalPID,
|
||||
'patient',
|
||||
null,
|
||||
(array) $previousData,
|
||||
$input,
|
||||
null,
|
||||
null,
|
||||
'Patient data updated',
|
||||
['changed_fields' => $changedFields]
|
||||
[
|
||||
'diff' => $auditDiff,
|
||||
'changed_fields' => $changedFields,
|
||||
'validation_profile' => 'patient.update',
|
||||
],
|
||||
['entity_version' => 1]
|
||||
);
|
||||
|
||||
if (!empty($input['PatIdt'])) {
|
||||
@ -335,25 +349,43 @@ class PatientModel extends BaseModel {
|
||||
}
|
||||
}
|
||||
|
||||
private function formatedDateForDisplay($dateString) {
|
||||
$date = \DateTime::createFromFormat('Y-m-d H:i', $dateString);
|
||||
|
||||
if (!$date) {
|
||||
$timestamp = strtotime($dateString);
|
||||
if ($timestamp) {
|
||||
return date('j M Y', $timestamp);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date->format('j M Y');
|
||||
}
|
||||
|
||||
private function checkDbError($db, string $context) {
|
||||
$error = $db->error();
|
||||
if (!empty($error['code'])) {
|
||||
throw new \Exception(
|
||||
"{$context} failed: {$error['code']} - {$error['message']}"
|
||||
private function formatedDateForDisplay($dateString) {
|
||||
$date = \DateTime::createFromFormat('Y-m-d H:i', $dateString);
|
||||
|
||||
if (!$date) {
|
||||
$timestamp = strtotime($dateString);
|
||||
if ($timestamp) {
|
||||
return date('j M Y', $timestamp);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return $date->format('j M Y');
|
||||
}
|
||||
|
||||
private function buildAuditDiff(array $before, array $after): array {
|
||||
$diff = [];
|
||||
$fields = array_unique(array_merge(array_keys($before), array_keys($after)));
|
||||
foreach ($fields as $field) {
|
||||
$prev = $before[$field] ?? null;
|
||||
$next = $after[$field] ?? null;
|
||||
if ($prev === $next) {
|
||||
continue;
|
||||
}
|
||||
$diff[] = [
|
||||
'field' => $field,
|
||||
'previous' => $prev,
|
||||
'new' => $next,
|
||||
];
|
||||
}
|
||||
return $diff;
|
||||
}
|
||||
|
||||
private function checkDbError($db, string $context) {
|
||||
$error = $db->error();
|
||||
if (!empty($error['code'])) {
|
||||
throw new \Exception(
|
||||
"{$context} failed: {$error['code']} - {$error['message']}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -371,17 +403,23 @@ class PatientModel extends BaseModel {
|
||||
$this->delete($InternalPID);
|
||||
$this->checkDbError($db, 'Delete patient');
|
||||
|
||||
AuditService::logData(
|
||||
'DELETE',
|
||||
'patient',
|
||||
(string) $InternalPID,
|
||||
'patient',
|
||||
null,
|
||||
$previousData,
|
||||
[],
|
||||
'Patient deleted',
|
||||
['PatientID' => $previousData['PatientID'] ?? null]
|
||||
);
|
||||
$auditDiff = $this->buildAuditDiff((array) $previousData, []);
|
||||
AuditService::logData(
|
||||
'PATIENT_DELETED',
|
||||
'DELETE',
|
||||
'patient',
|
||||
(string) $InternalPID,
|
||||
'patient',
|
||||
null,
|
||||
$previousData,
|
||||
null,
|
||||
'Patient deleted',
|
||||
[
|
||||
'diff' => $auditDiff,
|
||||
'patient_id' => $previousData['PatientID'] ?? null,
|
||||
],
|
||||
['entity_version' => 1]
|
||||
);
|
||||
|
||||
$db->transCommit();
|
||||
return $InternalPID;
|
||||
|
||||
@ -1,209 +1,346 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
|
||||
class AuditService {
|
||||
protected BaseConnection $db;
|
||||
|
||||
public function __construct() {
|
||||
$this->db = \Config\Database::connect();
|
||||
}
|
||||
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Libraries\ValueSet;
|
||||
use CodeIgniter\Database\BaseConnection;
|
||||
use CodeIgniter\HTTP\IncomingRequest;
|
||||
use Config\Services;
|
||||
use DateTime;
|
||||
use DateTimeZone;
|
||||
|
||||
class AuditService
|
||||
{
|
||||
private const TABLE_MAP = [
|
||||
'logpatient' => 'logpatient',
|
||||
'patient' => 'logpatient',
|
||||
'visit' => 'logpatient',
|
||||
'logorder' => 'logorder',
|
||||
'order' => 'logorder',
|
||||
'specimen' => 'logorder',
|
||||
'result' => 'logorder',
|
||||
'logmaster' => 'logmaster',
|
||||
'master' => 'logmaster',
|
||||
'config' => 'logmaster',
|
||||
'valueset' => 'logmaster',
|
||||
'logsystem' => 'logsystem',
|
||||
'system' => 'logsystem',
|
||||
'auth' => 'logsystem',
|
||||
'job' => 'logsystem',
|
||||
];
|
||||
|
||||
private const DEFAULT_APP_ID = 'clqms-api';
|
||||
private const ENTITY_VERSION_DEFAULT = 1;
|
||||
|
||||
private static ?BaseConnection $db = null;
|
||||
private static $session = null;
|
||||
private static ?IncomingRequest $request = null;
|
||||
private static ?array $eventIdCache = null;
|
||||
private static ?string $cachedRequestId = null;
|
||||
|
||||
public static function logData(
|
||||
string $operation,
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
?string $tableName = null,
|
||||
?string $fieldName = null,
|
||||
?array $previousValue = null,
|
||||
?array $newValue = null,
|
||||
?string $reason = null,
|
||||
?array $context = null
|
||||
): void {
|
||||
self::log('data_audit_log', [
|
||||
'operation' => $operation,
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'table_name' => $tableName,
|
||||
'field_name' => $fieldName,
|
||||
'previous_value' => self::normalizeAuditValue($previousValue),
|
||||
'new_value' => self::normalizeAuditValue($newValue),
|
||||
'mechanism' => 'MANUAL',
|
||||
'application_id' => 'CLQMS-WEB',
|
||||
'web_page' => self::getUri(),
|
||||
'session_id' => self::getSessionId(),
|
||||
'event_type' => strtoupper($entityType) . '_' . strtoupper($operation),
|
||||
'site_id' => self::getSiteId(),
|
||||
'workstation_id' => self::getWorkstationId(),
|
||||
'pc_name' => self::getPcName(),
|
||||
'ip_address' => self::getIpAddress(),
|
||||
'user_id' => self::getUserId(),
|
||||
'reason' => $reason,
|
||||
'context' => self::normalizeAuditValue($context),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
}
|
||||
|
||||
public static function logService(
|
||||
string $operation,
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $serviceClass,
|
||||
?string $resourceType = null,
|
||||
?array $resourceDetails = null,
|
||||
?array $previousValue = null,
|
||||
?array $newValue = null,
|
||||
?string $serviceName = null,
|
||||
?array $context = null
|
||||
): void {
|
||||
self::log('service_audit_log', [
|
||||
'operation' => $operation,
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'service_class' => $serviceClass,
|
||||
'resource_type' => $resourceType,
|
||||
'resource_details' => self::normalizeAuditValue($resourceDetails),
|
||||
'previous_value' => self::normalizeAuditValue($previousValue),
|
||||
'new_value' => self::normalizeAuditValue($newValue),
|
||||
'mechanism' => 'AUTOMATIC',
|
||||
'application_id' => $serviceName ?? 'SYSTEM-SERVICE',
|
||||
'service_name' => $serviceName,
|
||||
'session_id' => self::getSessionId() ?: 'service_session',
|
||||
'event_type' => strtoupper($serviceClass) . '_' . strtoupper($operation),
|
||||
'site_id' => self::getSiteId(),
|
||||
'workstation_id' => self::getWorkstationId(),
|
||||
'pc_name' => self::getPcName(),
|
||||
'ip_address' => self::getIpAddress(),
|
||||
'port' => $resourceDetails['port'] ?? null,
|
||||
'user_id' => 'SYSTEM',
|
||||
'reason' => null,
|
||||
'context' => self::normalizeAuditValue($context),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
}
|
||||
|
||||
public static function logSecurity(
|
||||
string $operation,
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $securityClass,
|
||||
?string $eventType = 'SUCCESS',
|
||||
?string $resourcePath = null,
|
||||
?array $previousValue = null,
|
||||
?array $newValue = null,
|
||||
?string $reason = null,
|
||||
?array $context = null
|
||||
): void {
|
||||
self::log('security_audit_log', [
|
||||
'operation' => $operation,
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'security_class' => $securityClass,
|
||||
'resource_path' => $resourcePath,
|
||||
'previous_value' => self::normalizeAuditValue($previousValue),
|
||||
'new_value' => self::normalizeAuditValue($newValue),
|
||||
'mechanism' => 'MANUAL',
|
||||
'application_id' => 'CLQMS-WEB',
|
||||
'web_page' => self::getUri(),
|
||||
'session_id' => self::getSessionId(),
|
||||
'event_type' => $eventType,
|
||||
'site_id' => self::getSiteId(),
|
||||
'workstation_id' => self::getWorkstationId(),
|
||||
'pc_name' => self::getPcName(),
|
||||
'ip_address' => self::getIpAddress(),
|
||||
'user_id' => self::getUserId() ?? 'UNKNOWN',
|
||||
'reason' => $reason,
|
||||
'context' => self::normalizeAuditValue($context),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
}
|
||||
|
||||
public static function logError(
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $errorCode,
|
||||
string $errorMessage,
|
||||
string $eventType,
|
||||
?array $errorDetails = null,
|
||||
?array $previousValue = null,
|
||||
?array $newValue = null,
|
||||
?string $reason = null,
|
||||
?array $context = null
|
||||
): void {
|
||||
self::log('error_audit_log', [
|
||||
'operation' => 'ERROR',
|
||||
'entity_type' => $entityType,
|
||||
'entity_id' => $entityId,
|
||||
'error_code' => $errorCode,
|
||||
'error_message' => $errorMessage,
|
||||
'error_details' => self::normalizeAuditValue($errorDetails),
|
||||
'previous_value' => self::normalizeAuditValue($previousValue),
|
||||
'new_value' => self::normalizeAuditValue($newValue),
|
||||
'mechanism' => 'AUTOMATIC',
|
||||
'application_id' => 'CLQMS-WEB',
|
||||
'web_page' => self::getUri(),
|
||||
'session_id' => self::getSessionId() ?: 'system',
|
||||
'event_type' => $eventType,
|
||||
'site_id' => self::getSiteId(),
|
||||
'workstation_id' => self::getWorkstationId(),
|
||||
'pc_name' => self::getPcName(),
|
||||
'ip_address' => self::getIpAddress(),
|
||||
'user_id' => self::getUserId() ?? 'SYSTEM',
|
||||
'reason' => $reason,
|
||||
'context' => self::normalizeAuditValue($context),
|
||||
'created_at' => date('Y-m-d H:i:s')
|
||||
]);
|
||||
}
|
||||
|
||||
private static function log(string $table, array $data): void {
|
||||
$db = \Config\Database::connect();
|
||||
if (!$db->tableExists($table)) {
|
||||
return;
|
||||
string $eventId,
|
||||
string $activityId,
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $tableName,
|
||||
?string $fldName = null,
|
||||
$previousValue = null,
|
||||
$newValue = null,
|
||||
?string $reason = null,
|
||||
?array $context = null,
|
||||
array $options = []
|
||||
): void {
|
||||
$sourceTable = $tableName ?: $entityType;
|
||||
$logTable = self::resolveLogTable($sourceTable);
|
||||
if ($logTable === null) {
|
||||
log_message('warning', "AuditService cannot resolve log table for {$sourceTable}");
|
||||
return;
|
||||
}
|
||||
|
||||
$record = self::buildRecord(
|
||||
$logTable,
|
||||
self::normalizeEventId($eventId),
|
||||
strtoupper($activityId),
|
||||
$entityType,
|
||||
$entityId,
|
||||
$sourceTable,
|
||||
$fldName,
|
||||
$previousValue,
|
||||
$newValue,
|
||||
$reason,
|
||||
$context,
|
||||
$options
|
||||
);
|
||||
|
||||
if ($record === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
self::getDb()->table($logTable)->insert($record);
|
||||
} catch (\Throwable $e) {
|
||||
log_message('error', "AuditService failed to insert into {$logTable}: {$e->getMessage()}");
|
||||
}
|
||||
}
|
||||
$db->table($table)->insert($data);
|
||||
}
|
||||
|
||||
private static function normalizeAuditValue($value)
|
||||
{
|
||||
if ($value === null || is_scalar($value)) {
|
||||
return $value;
|
||||
}
|
||||
private static function buildRecord(
|
||||
string $logTable,
|
||||
string $eventId,
|
||||
string $activityId,
|
||||
string $entityType,
|
||||
string $entityId,
|
||||
string $tblName,
|
||||
?string $fldName,
|
||||
$previousValue,
|
||||
$newValue,
|
||||
?string $reason,
|
||||
?array $context,
|
||||
array $options
|
||||
): ?array {
|
||||
$contextJson = self::buildContext($context, $options, $entityType);
|
||||
if ($contextJson === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $json !== false ? $json : null;
|
||||
}
|
||||
|
||||
private static function getUri(): ?string {
|
||||
return $_SERVER['REQUEST_URI'] ?? null;
|
||||
}
|
||||
|
||||
private static function getSessionId(): ?string {
|
||||
$session = session();
|
||||
return $session->get('session_id');
|
||||
}
|
||||
|
||||
private static function getSiteId(): ?string {
|
||||
$session = session();
|
||||
return $session->get('site_id');
|
||||
}
|
||||
|
||||
private static function getWorkstationId(): ?string {
|
||||
$session = session();
|
||||
return $session->get('workstation_id');
|
||||
}
|
||||
|
||||
private static function getPcName(): ?string {
|
||||
return gethostname();
|
||||
}
|
||||
|
||||
private static function getIpAddress(): ?string {
|
||||
return $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
}
|
||||
|
||||
private static function getUserId(): ?string {
|
||||
$session = session();
|
||||
return $session->get('user_id');
|
||||
}
|
||||
}
|
||||
return [
|
||||
'TblName' => $tblName,
|
||||
'RecID' => (string) $entityId,
|
||||
'FldName' => $fldName,
|
||||
'FldValuePrev' => self::serializeValue($previousValue),
|
||||
'FldValueNew' => self::serializeValue($newValue),
|
||||
'UserID' => self::resolveUserId($options),
|
||||
'SiteID' => self::resolveSiteId($options),
|
||||
'DIDType' => $options['did_type'] ?? null,
|
||||
'DID' => $options['did'] ?? null,
|
||||
'MachineID' => $options['machine_id'] ?? gethostname(),
|
||||
'SessionID' => self::resolveSessionId($options),
|
||||
'AppID' => $options['app_id'] ?? self::DEFAULT_APP_ID,
|
||||
'ProcessID' => $options['process_id'] ?? null,
|
||||
'WebPageID' => $options['web_page_id'] ?? self::resolveRoute($options),
|
||||
'EventID' => $eventId,
|
||||
'ActivityID' => $activityId,
|
||||
'Reason' => $reason,
|
||||
'LogDate' => self::nowWithMillis(),
|
||||
'Context' => $contextJson,
|
||||
'IpAddress' => self::resolveIpAddress(),
|
||||
];
|
||||
}
|
||||
|
||||
private static function serializeValue($value): ?string
|
||||
{
|
||||
if ($value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
|
||||
$json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $json !== false ? $json : null;
|
||||
}
|
||||
|
||||
private static function buildContext(?array $context, array $options, string $entityType): ?string
|
||||
{
|
||||
$route = $options['route'] ?? self::resolveRoute($options);
|
||||
$payload = array_merge(
|
||||
[
|
||||
'request_id' => $options['request_id'] ?? self::resolveRequestId(),
|
||||
'route' => $route,
|
||||
'timestamp_utc' => $options['timestamp_utc'] ?? self::timestampUtc(),
|
||||
'entity_type' => $options['entity_type'] ?? $entityType,
|
||||
'entity_version' => $options['entity_version'] ?? self::ENTITY_VERSION_DEFAULT,
|
||||
],
|
||||
$context ?? []
|
||||
);
|
||||
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
||||
return $json !== false ? $json : null;
|
||||
}
|
||||
|
||||
private static function resolveLogTable(?string $source): ?string
|
||||
{
|
||||
if ($source === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$key = strtolower(trim($source));
|
||||
return self::TABLE_MAP[$key] ?? null;
|
||||
}
|
||||
|
||||
private static function resolveUserId(array $options): string
|
||||
{
|
||||
return $options['user_id'] ?? self::getSessionValue('user_id') ?? 'SYSTEM';
|
||||
}
|
||||
|
||||
private static function resolveSiteId(array $options): string
|
||||
{
|
||||
return $options['site_id'] ?? self::getSessionValue('site_id') ?? 'GLOBAL';
|
||||
}
|
||||
|
||||
private static function resolveSessionId(array $options): string
|
||||
{
|
||||
if (!empty($options['session_id'])) {
|
||||
return $options['session_id'];
|
||||
}
|
||||
|
||||
$session = self::getSession();
|
||||
if ($session !== null && method_exists($session, 'getId')) {
|
||||
$id = $session->getId();
|
||||
if (!empty($id)) {
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
|
||||
if (session_status() === PHP_SESSION_ACTIVE) {
|
||||
$id = session_id();
|
||||
if (!empty($id)) {
|
||||
return $id;
|
||||
}
|
||||
}
|
||||
|
||||
return self::generateUniqueId('sess');
|
||||
}
|
||||
|
||||
private static function resolveRoute(array $options): string
|
||||
{
|
||||
if (!empty($options['route'])) {
|
||||
return $options['route'];
|
||||
}
|
||||
|
||||
$request = self::getRequest();
|
||||
if ($request !== null) {
|
||||
return trim(sprintf('%s %s', $request->getMethod(), $request->getUri()->getPath()));
|
||||
}
|
||||
|
||||
return 'cli';
|
||||
}
|
||||
|
||||
private static function resolveIpAddress(): ?string
|
||||
{
|
||||
$request = self::getRequest();
|
||||
if ($request !== null) {
|
||||
return $request->getIPAddress();
|
||||
}
|
||||
|
||||
return $_SERVER['REMOTE_ADDR'] ?? null;
|
||||
}
|
||||
|
||||
private static function normalizeEventId(string $eventId): string
|
||||
{
|
||||
$normalized = strtoupper(trim($eventId));
|
||||
if (empty($normalized)) {
|
||||
log_message('warning', 'AuditService received empty EventID');
|
||||
return $eventId;
|
||||
}
|
||||
|
||||
if (!self::isKnownEvent($normalized)) {
|
||||
log_message('warning', "AuditService unknown EventID: {$normalized}");
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
private static function isKnownEvent(string $eventId): bool
|
||||
{
|
||||
if (self::$eventIdCache === null) {
|
||||
$raw = ValueSet::getRaw('event_id') ?? [];
|
||||
self::$eventIdCache = array_filter(array_map(fn ($item) => $item['key'] ?? null, $raw));
|
||||
}
|
||||
|
||||
return in_array($eventId, self::$eventIdCache, true);
|
||||
}
|
||||
|
||||
private static function resolveRequestId(): string
|
||||
{
|
||||
if (self::$cachedRequestId !== null) {
|
||||
return self::$cachedRequestId;
|
||||
}
|
||||
|
||||
$request = self::getRequest();
|
||||
if ($request !== null) {
|
||||
$value = $request->getHeaderLine('X-Request-ID');
|
||||
if (!empty($value)) {
|
||||
self::$cachedRequestId = $value;
|
||||
return $value;
|
||||
}
|
||||
}
|
||||
|
||||
foreach (['HTTP_X_REQUEST_ID', 'REQUEST_ID'] as $header) {
|
||||
if (!empty($_SERVER[$header])) {
|
||||
self::$cachedRequestId = $_SERVER[$header];
|
||||
return self::$cachedRequestId;
|
||||
}
|
||||
}
|
||||
|
||||
return self::$cachedRequestId = self::generateUniqueId('req');
|
||||
}
|
||||
|
||||
private static function nowWithMillis(): string
|
||||
{
|
||||
$dt = new DateTime('now', new DateTimeZone('UTC'));
|
||||
return $dt->format('Y-m-d H:i:s.v');
|
||||
}
|
||||
|
||||
private static function timestampUtc(): string
|
||||
{
|
||||
$dt = new DateTime('now', new DateTimeZone('UTC'));
|
||||
return $dt->format('Y-m-d\TH:i:s.v\Z');
|
||||
}
|
||||
|
||||
private static function getSessionValue(string $key): ?string
|
||||
{
|
||||
$session = self::getSession();
|
||||
if ($session === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!method_exists($session, 'get')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$value = $session->get($key);
|
||||
return $value !== null ? (string) $value : null;
|
||||
}
|
||||
|
||||
private static function getSession(): ?object
|
||||
{
|
||||
if (self::$session !== null) {
|
||||
return self::$session;
|
||||
}
|
||||
|
||||
try {
|
||||
return self::$session = Services::session();
|
||||
} catch (\Throwable $e) {
|
||||
return self::$session = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function getRequest(): ?IncomingRequest
|
||||
{
|
||||
if (self::$request !== null) {
|
||||
return self::$request;
|
||||
}
|
||||
|
||||
try {
|
||||
return self::$request = Services::request();
|
||||
} catch (\Throwable $e) {
|
||||
return self::$request = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static function getDb(): BaseConnection
|
||||
{
|
||||
return self::$db ??= \Config\Database::connect();
|
||||
}
|
||||
|
||||
private static function generateUniqueId(string $prefix): string
|
||||
{
|
||||
try {
|
||||
return $prefix . '_' . bin2hex(random_bytes(8));
|
||||
} catch (\Throwable $e) {
|
||||
return uniqid("{$prefix}_", true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,194 +1,352 @@
|
||||
# Audit Logging Strategy
|
||||
# Audit Logging Strategy (Implementation Ready)
|
||||
|
||||
## Overview
|
||||
## 1) Purpose, Scope, and Non-Goals
|
||||
|
||||
This document defines how CLQMS should capture audit and operational logs across four tables:
|
||||
This document defines the production audit logging contract for CLQMS.
|
||||
|
||||
- `logpatient` — patient, visit, and ADT activity
|
||||
- `logorder` — orders, tests, specimens, results, and QC
|
||||
- `logmaster` — master data and configuration changes
|
||||
- `logsystem` — sessions, security, import/export, and system operations
|
||||
### Purpose
|
||||
|
||||
The intent is to audit all domains, including master data changes, and to standardize event capture so reporting and compliance are consistent.
|
||||
- Provide a single, normalized audit model for compliance, investigations, and operations.
|
||||
- Ensure every protected workflow writes consistent, queryable audit records.
|
||||
- Make behavior deterministic across API controllers, services, jobs, and integrations.
|
||||
|
||||
## Table Ownership
|
||||
### Scope
|
||||
|
||||
| Event | Table |
|
||||
| --- | --- |
|
||||
| Patient registered/updated/merged | `logpatient` |
|
||||
| Insurance/consent changed | `logpatient` |
|
||||
| Patient visit (admit/transfer/discharge) | `logpatient` |
|
||||
| Order created/cancelled | `logorder` |
|
||||
| Sample received/rejected | `logorder` |
|
||||
| Result entered/verified/amended | `logorder` |
|
||||
| Result released/retracted/corrected | `logorder` |
|
||||
| QC result recorded | `logorder` |
|
||||
| Test panel added/removed | `logmaster` |
|
||||
| Reference range changed | `logmaster` |
|
||||
| Analyzer config updated | `logmaster` |
|
||||
| User role changed | `logmaster` |
|
||||
| User login/logout | `logsystem` |
|
||||
| Import/export job start/end | `logsystem` |
|
||||
This applies to four log tables:
|
||||
|
||||
## Standard Log Schema (Shared Columns)
|
||||
- `logpatient` - patient identity, demographics, consent, insurance, and visit/ADT events.
|
||||
- `logorder` - orders, specimen lifecycle, results lifecycle, and QC.
|
||||
- `logmaster` - test/master configuration, value sets, role/permission updates, infrastructure configuration.
|
||||
- `logsystem` - authentication, authorization, import/export, jobs, and system integrity operations.
|
||||
|
||||
Use a shared schema for all four tables to keep instrumentation and reporting consistent. The legacy names below match existing patterns and can be reused.
|
||||
### Non-goals
|
||||
|
||||
| Column | Description |
|
||||
| --- | --- |
|
||||
| `LogID` (PK) | Auto increment primary key per table (e.g., `LogPatientID`) |
|
||||
| `TblName` | Source table name |
|
||||
| `RecID` | Record ID of the entity |
|
||||
| `FldName` | Field name that changed (nullable for bulk events) |
|
||||
| `FldValuePrev` | Previous value (string or JSON) |
|
||||
| `FldValueNew` | New value (string or JSON) |
|
||||
| `UserID` | Acting user ID (nullable for system actions) |
|
||||
| `SiteID` | Site context |
|
||||
| `DIDType` | Device identifier type |
|
||||
| `DID` | Device identifier |
|
||||
| `MachineID` | Workstation or host identifier |
|
||||
| `SessionID` | Session identifier |
|
||||
| `AppID` | Client application ID |
|
||||
| `ProcessID` | Process/workflow identifier |
|
||||
| `WebPageID` | UI page/context (nullable) |
|
||||
| `EventID` | Event code (see catalog) |
|
||||
| `ActivityID` | Action code (create/update/delete/read/etc.) |
|
||||
| `Reason` | User/system reason |
|
||||
| `LogDate` | Timestamp of event |
|
||||
| `Context` | JSON metadata (optional but recommended) |
|
||||
| `IpAddress` | Remote IP (optional but recommended) |
|
||||
- This is not a replacement for metrics/tracing systems (Prometheus, APM, etc.).
|
||||
- This is not a full immutable ledger; tamper evidence is implemented with controls described below.
|
||||
|
||||
Recommended: keep a JSON string in `Context` for extra details (e.g., route, request id, batch id, error message). Use size limits to avoid oversized rows.
|
||||
## 2) Table Ownership
|
||||
|
||||
## Event Catalog
|
||||
Use this mapping to choose the target table and minimum event shape.
|
||||
|
||||
### logpatient
|
||||
| Event family | Table | Minimum keys in `Context` | Example `EventID` |
|
||||
| --- | --- | --- | --- |
|
||||
| Patient create/update/merge | `logpatient` | `route`, `request_id`, `entity_version` | `PATIENT_REGISTERED` |
|
||||
| Consent/insurance changes | `logpatient` | `consent_type` or `payer_id` | `PATIENT_CONSENT_UPDATED` |
|
||||
| Visit ADT transitions | `logpatient` | `visit_id`, `from_status`, `to_status` | `VISIT_TRANSFERRED` |
|
||||
| Order create/cancel/reopen | `logorder` | `order_id`, `priority`, `source` | `ORDER_CREATED` |
|
||||
| Specimen lifecycle | `logorder` | `specimen_id`, `specimen_status` | `SPECIMEN_RECEIVED` |
|
||||
| Result lifecycle | `logorder` | `result_id`, `verification_state` | `RESULT_AMENDED` |
|
||||
| QC lifecycle | `logorder` | `qc_run_id`, `instrument_id` | `QC_RECORDED` |
|
||||
| Value sets/test definitions | `logmaster` | `config_group`, `change_ticket` | `VALUESET_ITEM_RETIRED` |
|
||||
| Roles/permissions/users | `logmaster` | `target_user_id`, `target_role` | `USER_ROLE_CHANGED` |
|
||||
| Login/logout/token/auth failures | `logsystem` | `auth_flow`, `failure_reason` (on failure) | `AUTH_LOGIN_FAILED` |
|
||||
| Import/export/jobs/integration | `logsystem` | `batch_id`, `record_count`, `job_name` | `IMPORT_JOB_FINISHED` |
|
||||
| Purge/archive/legal hold | `logsystem` | `archive_id`, `policy_name`, `approved_by` | `AUDIT_PURGE_EXECUTED` |
|
||||
|
||||
**Patient core**
|
||||
## 3) Canonical Schema (All Four Tables)
|
||||
|
||||
- Register patient
|
||||
- Update demographics
|
||||
- Merge/unmerge/split
|
||||
- Identity changes (MRN, external identifiers)
|
||||
- Consent grant/revoke/update
|
||||
- Insurance add/update/remove
|
||||
- Patient record view (if required by compliance)
|
||||
All four tables MUST implement the same logical columns. Physical PK name may vary (`LogPatientID`, `LogOrderID`, etc.).
|
||||
|
||||
**Visit/ADT**
|
||||
### 3.1 Column contract
|
||||
|
||||
- Admit, transfer, discharge
|
||||
- Bed/ward/unit changes
|
||||
- Visit status updates
|
||||
| Column | Type | Required | Max length | Description | Example |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| `LogID` (or table-specific PK) | `BIGINT UNSIGNED AUTO_INCREMENT` | Yes | N/A | Surrogate key per table | `987654` |
|
||||
| `TblName` | `VARCHAR(64)` | Yes | 64 | Source business table | `patient` |
|
||||
| `RecID` | `VARCHAR(64)` | Yes | 64 | Primary identifier of affected entity | `PAT000123` |
|
||||
| `FldName` | `VARCHAR(128)` | Conditional | 128 | Changed field name, null for multi-field/bulk | `NameLast` |
|
||||
| `FldValuePrev` | `TEXT` | Conditional | 65535 | Previous value (string or JSON) | `{"status":"PENDING"}` |
|
||||
| `FldValueNew` | `TEXT` | Conditional | 65535 | New value (string or JSON) | `{"status":"VERIFIED"}` |
|
||||
| `UserID` | `VARCHAR(64)` | Yes | 64 | Actor user id, or `SYSTEM` for non-user actions | `USR001` |
|
||||
| `SiteID` | `VARCHAR(32)` | Yes | 32 | Facility/site context | `SITE01` |
|
||||
| `DIDType` | `VARCHAR(32)` | No | 32 | Device identifier type | `UUID` |
|
||||
| `DID` | `VARCHAR(128)` | No | 128 | Device identifier value | `6b8f...` |
|
||||
| `MachineID` | `VARCHAR(128)` | No | 128 | Host/workstation identifier | `WS-LAB-07` |
|
||||
| `SessionID` | `VARCHAR(128)` | Yes | 128 | Auth or workflow session identifier | `sess_abc123` |
|
||||
| `AppID` | `VARCHAR(64)` | Yes | 64 | Calling client/application id | `clqms-api` |
|
||||
| `ProcessID` | `VARCHAR(128)` | No | 128 | Process/workflow/job id | `job_20260325_01` |
|
||||
| `WebPageID` | `VARCHAR(128)` | No | 128 | UI route/page id if user-driven | `patient-detail` |
|
||||
| `EventID` | `VARCHAR(80)` | Yes | 80 | Canonical event code | `RESULT_RELEASED` |
|
||||
| `ActivityID` | `VARCHAR(24)` | Yes | 24 | Canonical action enum | `UPDATE` |
|
||||
| `Reason` | `VARCHAR(512)` | No | 512 | User/system reason or ticket reference | `Critical value corrected` |
|
||||
| `LogDate` | `DATETIME(3)` | Yes | N/A | Event time in UTC | `2026-03-25 04:45:12.551` |
|
||||
| `Context` | `JSON` (preferred) or `LONGTEXT` | Yes | N/A | Structured metadata payload | See section 5 |
|
||||
| `IpAddress` | `VARCHAR(45)` | No | 45 | IPv4/IPv6 remote address | `10.10.2.44` |
|
||||
|
||||
**Other**
|
||||
### 3.2 Required/conditional rules
|
||||
|
||||
- Patient notes/attachments added/removed
|
||||
- Patient alerts/flags changes
|
||||
- `FldName`, `FldValuePrev`, and `FldValueNew` are required for single-field changes.
|
||||
- For multi-field changes, set `FldName = NULL` and store a compact JSON diff under `Context.diff`.
|
||||
- For non-mutating events (`READ`, `LOGIN`, `EXPORT`, `IMPORT`), `FldValuePrev` and `FldValueNew` may be null.
|
||||
- `Context` is required for all rows. At minimum include `request_id` and `route` (or `job_name` for non-HTTP jobs).
|
||||
|
||||
### logorder
|
||||
## 4) DDL Template and Indexing
|
||||
|
||||
**Orders/tests**
|
||||
Use this template when creating a log table. Replace `${TABLE}` and `${PK}`.
|
||||
|
||||
- Create/cancel/reopen order
|
||||
- Add/remove tests
|
||||
- Priority changes
|
||||
- Order comments added/removed
|
||||
```sql
|
||||
CREATE TABLE `${TABLE}` (
|
||||
`${PK}` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`TblName` VARCHAR(64) NOT NULL,
|
||||
`RecID` VARCHAR(64) NOT NULL,
|
||||
`FldName` VARCHAR(128) NULL,
|
||||
`FldValuePrev` TEXT NULL,
|
||||
`FldValueNew` TEXT NULL,
|
||||
`UserID` VARCHAR(64) NOT NULL,
|
||||
`SiteID` VARCHAR(32) NOT NULL,
|
||||
`DIDType` VARCHAR(32) NULL,
|
||||
`DID` VARCHAR(128) NULL,
|
||||
`MachineID` VARCHAR(128) NULL,
|
||||
`SessionID` VARCHAR(128) NOT NULL,
|
||||
`AppID` VARCHAR(64) NOT NULL,
|
||||
`ProcessID` VARCHAR(128) NULL,
|
||||
`WebPageID` VARCHAR(128) NULL,
|
||||
`EventID` VARCHAR(80) NOT NULL,
|
||||
`ActivityID` VARCHAR(24) NOT NULL,
|
||||
`Reason` VARCHAR(512) NULL,
|
||||
`LogDate` DATETIME(3) NOT NULL,
|
||||
`Context` JSON NOT NULL,
|
||||
`IpAddress` VARCHAR(45) NULL,
|
||||
PRIMARY KEY (`${PK}`),
|
||||
INDEX `idx_${TABLE}_logdate` (`LogDate`),
|
||||
INDEX `idx_${TABLE}_recid_logdate` (`RecID`, `LogDate`),
|
||||
INDEX `idx_${TABLE}_userid_logdate` (`UserID`, `LogDate`),
|
||||
INDEX `idx_${TABLE}_eventid_logdate` (`EventID`, `LogDate`),
|
||||
INDEX `idx_${TABLE}_site_logdate` (`SiteID`, `LogDate`)
|
||||
);
|
||||
```
|
||||
|
||||
**Specimen lifecycle**
|
||||
Optional JSON path index (DB engine specific):
|
||||
|
||||
- Collected, labeled, received, rejected
|
||||
- Centrifuged, aliquoted, stored
|
||||
- Disposed/expired
|
||||
- `Context.request_id`
|
||||
- `Context.batch_id`
|
||||
- `Context.job_name`
|
||||
|
||||
**Results**
|
||||
## 5) Context JSON Contract
|
||||
|
||||
- Result entered/updated
|
||||
- Verified/amended
|
||||
- Released/retracted/corrected
|
||||
- Result comments/interpretation changes
|
||||
- Auto-verification override
|
||||
`Context` MUST be valid JSON. Keep payload compact and predictable.
|
||||
|
||||
**QC**
|
||||
### 5.1 Required keys for all events
|
||||
|
||||
- QC result recorded
|
||||
- QC failure/override
|
||||
```json
|
||||
{
|
||||
"request_id": "a4f5b6c7",
|
||||
"route": "PATCH /api/patient/123",
|
||||
"timestamp_utc": "2026-03-25T04:45:12.551Z",
|
||||
"entity_type": "patient",
|
||||
"entity_version": 7
|
||||
}
|
||||
```
|
||||
|
||||
### logmaster
|
||||
### 5.2 Additional keys by event class
|
||||
|
||||
**Value sets**
|
||||
- Patient/order/result mutation: `diff` (array of changed fields), `validation_profile`.
|
||||
- Import/export/jobs: `batch_id`, `record_count`, `success_count`, `failure_count`, `job_name`.
|
||||
- Auth/security events: `auth_flow`, `failure_reason`, `token_type` (never token value).
|
||||
- Retention operations: `policy_name`, `archive_id`, `approved_by`, `window_start`, `window_end`.
|
||||
|
||||
- Create/update/retire value set items
|
||||
### 5.3 Size and shape limits
|
||||
|
||||
**Test definitions**
|
||||
- Maximum serialized `Context` size: 16 KB.
|
||||
- `diff` array should include only audited fields, not entire entity snapshots.
|
||||
- Store references (`file_id`, `blob_ref`) instead of large payloads.
|
||||
|
||||
- Test definition updates (units, methods, ranges)
|
||||
- Reference range changes
|
||||
- Formula/delta check changes
|
||||
- Test panel membership add/remove
|
||||
## 6) Activity and Event Catalog Governance
|
||||
|
||||
**Infrastructure**
|
||||
`EventID` values MUST come from the ValueSet library, not hardcoded inline strings.
|
||||
|
||||
- Analyzer/instrument config changes
|
||||
- Host app integration config
|
||||
- Coding system changes
|
||||
- Source file: `app/Libraries/Data/event_id.json`
|
||||
- Runtime access: `\App\Libraries\ValueSet::getRaw('event_id')`
|
||||
- Optional label lookup for reporting: `\App\Libraries\ValueSet::getLabel('event_id', $eventId)`
|
||||
|
||||
**Users/roles**
|
||||
### 6.1 Allowed `ActivityID`
|
||||
|
||||
- User create/disable/reset
|
||||
- Role changes
|
||||
- Permission changes
|
||||
`CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`, `AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT`, `LOCK`, `UNLOCK`, `RESET`
|
||||
|
||||
**Sites/workstations**
|
||||
### 6.2 `EventID` naming pattern
|
||||
|
||||
- Site/location/workstation CRUD
|
||||
- Format: `<DOMAIN>_<OBJECT>_<ACTION>`
|
||||
- Character set: uppercase A-Z, numbers, underscore.
|
||||
- Max length: 80.
|
||||
- Examples: `PATIENT_DEMOGRAPHICS_UPDATED`, `ORDER_CANCELLED`, `AUTH_LOGIN_FAILED`.
|
||||
|
||||
### logsystem
|
||||
### 6.3 Catalog lifecycle
|
||||
|
||||
**Sessions & security**
|
||||
- New `EventID` requires docs update and test coverage.
|
||||
- New `EventID` must be added to `app/Libraries/Data/event_id.json` and deployed with cache refresh (`ValueSet::clearCache()`).
|
||||
- Never repurpose an existing `EventID` to mean something else.
|
||||
- Deprecated `EventID` remains queryable and documented for historical data.
|
||||
|
||||
- Login/logout
|
||||
- Failed login attempts
|
||||
- Lockouts/password resets
|
||||
- Token issue/refresh/revoke
|
||||
- Authorization failures
|
||||
## 7) Minimum Event Coverage (Must Implement)
|
||||
|
||||
**Import/export**
|
||||
### 7.1 `logpatient`
|
||||
|
||||
- Import/export job start/end
|
||||
- Batch ID, source, record counts, status
|
||||
- `PATIENT_REGISTERED`, `PATIENT_DEMOGRAPHICS_UPDATED`, `PATIENT_MERGED`, `PATIENT_UNMERGED`
|
||||
- `PATIENT_IDENTIFIER_UPDATED`, `PATIENT_CONSENT_UPDATED`, `PATIENT_INSURANCE_UPDATED`
|
||||
- `VISIT_ADMITTED`, `VISIT_TRANSFERRED`, `VISIT_DISCHARGED`, `VISIT_STATUS_UPDATED`
|
||||
|
||||
**System operations**
|
||||
### 7.2 `logorder`
|
||||
|
||||
- Background jobs start/end
|
||||
- Integration sync runs
|
||||
- System config changes
|
||||
- Service errors that affect data integrity
|
||||
- `ORDER_CREATED`, `ORDER_CANCELLED`, `ORDER_REOPENED`, `ORDER_TEST_ADDED`, `ORDER_TEST_REMOVED`
|
||||
- `SPECIMEN_COLLECTED`, `SPECIMEN_RECEIVED`, `SPECIMEN_REJECTED`, `SPECIMEN_ALIQUOTED`, `SPECIMEN_DISPOSED`
|
||||
- `RESULT_ENTERED`, `RESULT_UPDATED`, `RESULT_VERIFIED`, `RESULT_AMENDED`, `RESULT_RELEASED`, `RESULT_RETRACTED`, `RESULT_CORRECTED`
|
||||
- `QC_RECORDED`, `QC_FAILED`, `QC_OVERRIDE_APPLIED`
|
||||
|
||||
## Activity & Event Codes
|
||||
### 7.3 `logmaster`
|
||||
|
||||
Use consistent `ActivityID` and `EventID` values. Recommended defaults:
|
||||
- `VALUESET_ITEM_CREATED`, `VALUESET_ITEM_UPDATED`, `VALUESET_ITEM_RETIRED`
|
||||
- `TEST_DEFINITION_UPDATED`, `REFERENCE_RANGE_UPDATED`, `TEST_PANEL_MEMBERSHIP_UPDATED`
|
||||
- `ANALYZER_CONFIG_UPDATED`, `INTEGRATION_CONFIG_UPDATED`, `CODING_SYSTEM_UPDATED`
|
||||
- `USER_CREATED`, `USER_DISABLED`, `USER_PASSWORD_RESET`, `USER_ROLE_CHANGED`, `USER_PERMISSION_CHANGED`
|
||||
- `SITE_CREATED`, `SITE_UPDATED`, `WORKSTATION_UPDATED`
|
||||
|
||||
- `ActivityID`: `CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`, `AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT`
|
||||
- `EventID`: domain-specific codes (e.g., `PATIENT_REGISTERED`, `ORDER_CREATED`, `RESULT_VERIFIED`, `QC_RECORDED`)
|
||||
### 7.4 `logsystem`
|
||||
|
||||
## Capture Guidelines
|
||||
- `AUTH_LOGIN_SUCCESS`, `AUTH_LOGOUT_SUCCESS`, `AUTH_LOGIN_FAILED`, `AUTH_LOCKOUT_TRIGGERED`
|
||||
- `TOKEN_ISSUED`, `TOKEN_REFRESHED`, `TOKEN_REVOKED`, `AUTHORIZATION_FAILED`
|
||||
- `IMPORT_JOB_STARTED`, `IMPORT_JOB_FINISHED`, `EXPORT_JOB_STARTED`, `EXPORT_JOB_FINISHED`
|
||||
- `JOB_STARTED`, `JOB_FINISHED`, `INTEGRATION_SYNC_STARTED`, `INTEGRATION_SYNC_FINISHED`
|
||||
- `AUDIT_ARCHIVE_EXECUTED`, `AUDIT_PURGE_EXECUTED`, `LEGAL_HOLD_APPLIED`, `LEGAL_HOLD_RELEASED`
|
||||
|
||||
- Always capture `UserID`, `SessionID`, `SiteID`, and `LogDate` when available.
|
||||
- If the action is system-driven, set `UserID` to `SYSTEM` (or null) and add context in `Context`.
|
||||
- Store payload diffs in `FldValuePrev` and `FldValueNew` for single-field changes; for multi-field changes, put a JSON diff in `Context` and leave `FldName` null.
|
||||
- For bulk operations, store batch metadata in `Context` (`batch_id`, `record_count`, `source`).
|
||||
- Do not log secrets, tokens, or full PHI when not required. Mask or omit sensitive fields.
|
||||
## 8) Capture Rules (Application Behavior)
|
||||
|
||||
## Retention & Governance
|
||||
### 8.1 Write timing
|
||||
|
||||
- Define retention policy per table (e.g., 7 years for patient/order, 2 years for system).
|
||||
- Archive before purge; record purge activity in `logsystem`.
|
||||
- Restrict write/delete permissions to service accounts only.
|
||||
- For mutating transactions, write audit record in the same DB transaction where feasible.
|
||||
- If asynchronous logging is required, enqueue within transaction and process with at-least-once delivery.
|
||||
|
||||
## Implementation Checklist
|
||||
### 8.2 Failure policy
|
||||
|
||||
1. Create the four tables with shared schema (or migrate existing log tables to match).
|
||||
2. Add a single audit service with helpers to build a normalized payload.
|
||||
3. Instrument controllers/services for each event category above.
|
||||
4. Add automated tests for representative audit writes.
|
||||
5. Document `EventID` codes used by each endpoint/service.
|
||||
- Compliance-critical writes (patient, order, result, role/permission): fail request if audit write fails.
|
||||
- Operational-only writes (non-critical job checkpoints): continue request, emit error log, retry in background.
|
||||
- All audit write failures must produce `logsystem` event `AUDIT_WRITE_FAILED` with sanitized details.
|
||||
|
||||
### 8.3 Diff policy
|
||||
|
||||
- Single-field change: set `FldName`, `FldValuePrev`, `FldValueNew`.
|
||||
- Multi-field change: set `FldName = NULL`, keep prev/new null or compact summary, place canonical diff in `Context.diff`.
|
||||
- Bulk operations: include `batch_id`, `record_count`, sample `affected_ids` (capped), and source.
|
||||
|
||||
## 9) Security and Privacy Controls
|
||||
|
||||
### 9.1 Never log
|
||||
|
||||
- Passwords, raw JWTs, API secrets, private keys, OTP values.
|
||||
- Full clinical free text unless explicitly required by policy.
|
||||
|
||||
### 9.2 Masking rules
|
||||
|
||||
- Identifiers with high sensitivity should be masked in `FldValuePrev/New` when not required.
|
||||
- Token-like strings should be fully removed and replaced with `[REDACTED]`.
|
||||
- Use deterministic masking where correlation is needed (e.g., hash + prefix).
|
||||
|
||||
### 9.3 Access control
|
||||
|
||||
- Insert permissions only for API/service accounts.
|
||||
- No update/delete privileges for regular runtime users.
|
||||
- Read access to logs is role-restricted and audited.
|
||||
|
||||
### 9.4 Tamper evidence
|
||||
|
||||
- Enable DB audit on DDL changes to log tables.
|
||||
- Store periodic checksum snapshots of recent log ranges in secure storage.
|
||||
- Record checksum run outcomes in `logsystem` (`AUDIT_CHECKSUM_CREATED`, `AUDIT_CHECKSUM_FAILED`).
|
||||
|
||||
## 10) Retention, Archive, and Purge
|
||||
|
||||
### 10.1 Default retention
|
||||
|
||||
- `logpatient`: 7 years
|
||||
- `logorder`: 7 years
|
||||
- `logmaster`: 5 years
|
||||
- `logsystem`: 2 years
|
||||
|
||||
If regional policy requires longer periods, policy overrides these defaults.
|
||||
|
||||
### 10.2 Archive workflow
|
||||
|
||||
1. Select eligible rows by `LogDate` and legal-hold status.
|
||||
2. Export to immutable archive format (compressed JSONL or parquet).
|
||||
3. Verify checksums and row counts.
|
||||
4. Write `AUDIT_ARCHIVE_EXECUTED` entry in `logsystem`.
|
||||
|
||||
### 10.3 Purge workflow
|
||||
|
||||
1. Require approval reference (`approved_by`, `change_ticket`).
|
||||
2. Purge archived rows only.
|
||||
3. Write `AUDIT_PURGE_EXECUTED` entry with table, date window, count, and archive reference.
|
||||
|
||||
## 11) Operational Monitoring
|
||||
|
||||
Track these SLIs/SLOs:
|
||||
|
||||
- Audit write success rate >= 99.9% for critical domains.
|
||||
- P95 audit insert latency < 50 ms.
|
||||
- Queue backlog age < 5 minutes (if async path is used).
|
||||
- Zero unreviewed `AUDIT_WRITE_FAILED` older than 24 hours.
|
||||
|
||||
Alert on:
|
||||
|
||||
- Sustained write failures.
|
||||
- Sudden drop in expected event volume.
|
||||
- Purge/archive jobs without corresponding `logsystem` records.
|
||||
|
||||
## 12) Migration Strategy for Existing Logs
|
||||
|
||||
1. Inventory current columns and event vocabulary in all four tables.
|
||||
2. Add missing canonical columns with nullable defaults.
|
||||
3. Backfill required values (`AppID`, `SessionID`, `Context` minimum keys) where derivable.
|
||||
4. Introduce canonical `EventID` mapping table for legacy names.
|
||||
5. Enforce NOT NULL constraints only after backfill validation succeeds.
|
||||
|
||||
## 13) Testing Requirements
|
||||
|
||||
### 13.1 Automated tests
|
||||
|
||||
- Feature tests for representative endpoints must assert audit row creation.
|
||||
- Assert table target, `ActivityID`, `EventID`, `RecID`, and required `Context` keys.
|
||||
- Assert `EventID` exists in `\App\Libraries\ValueSet::getRaw('event_id')`.
|
||||
- Add negative tests for audit failure policy (critical path blocks, non-critical path retries).
|
||||
|
||||
### 13.2 Test matrix minimum
|
||||
|
||||
- One success and one failure scenario per major domain (`patient`, `order`, `master`, `system`).
|
||||
- One bulk operation scenario validating `batch_id` and counts.
|
||||
- One security scenario validating redaction of sensitive fields.
|
||||
|
||||
## 14) Implementation Checklist (Phased)
|
||||
|
||||
### Phase 1 - Schema and constants
|
||||
|
||||
1. Create/align all four log tables to canonical schema.
|
||||
2. Add shared enums/constants for `ActivityID` and `EventID`.
|
||||
3. Add and maintain `app/Libraries/Data/event_id.json` as the `EventID` source of truth.
|
||||
4. Add DB indexes listed in section 4.
|
||||
|
||||
### Phase 2 - Audit service
|
||||
|
||||
1. Implement centralized audit writer service.
|
||||
2. Add helpers to normalize actor/device/session/context.
|
||||
3. Add diff builder utility for single and multi-field changes.
|
||||
|
||||
### Phase 3 - Instrumentation
|
||||
|
||||
1. Instrument patient and order flows first (compliance-critical).
|
||||
2. Instrument master and system flows.
|
||||
3. Add fallback/retry path and `AUDIT_WRITE_FAILED` emission.
|
||||
|
||||
### Phase 4 - Validation and rollout
|
||||
|
||||
1. Add feature tests and failure-path tests.
|
||||
2. Validate dashboards/queries for each table.
|
||||
3. Release with runbook updates and retention job schedule.
|
||||
|
||||
## 15) Acceptance Criteria
|
||||
|
||||
The implementation is complete when all statements below are true:
|
||||
|
||||
- Every protected endpoint emits at least one canonical audit row.
|
||||
- Each row has valid `ActivityID`, `EventID` (present in ValueSet `event_id`), `LogDate` (UTC), and non-empty `Context` with required keys.
|
||||
- Sensitive values are redacted/masked per section 9.
|
||||
- Archive and purge operations are fully traceable in `logsystem`.
|
||||
- Tests cover critical success/failure paths and pass in CI.
|
||||
|
||||
@ -4447,16 +4447,55 @@ paths:
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '#/components/schemas/TestDefinition'
|
||||
$ref: '#/components/schemas/TestDefinitionListItem'
|
||||
pagination:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of records matching the query
|
||||
examples:
|
||||
list_flat:
|
||||
summary: Flat list response from testdefsite
|
||||
value:
|
||||
status: success
|
||||
message: Data fetched successfully
|
||||
data:
|
||||
- TestSiteID: 21
|
||||
TestSiteCode: GLU
|
||||
TestSiteName: Glucose
|
||||
TestType: TEST
|
||||
SeqScr: 11
|
||||
SeqRpt: 11
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
StartDate: '2026-01-01 00:00:00'
|
||||
EndDate: null
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
DisciplineName: Clinical Chemistry
|
||||
DepartmentName: Laboratory
|
||||
- TestSiteID: 22
|
||||
TestSiteCode: CREA
|
||||
TestSiteName: Creatinine
|
||||
TestType: TEST
|
||||
SeqScr: 12
|
||||
SeqRpt: 12
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
StartDate: '2026-01-01 00:00:00'
|
||||
EndDate: null
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
DisciplineName: Clinical Chemistry
|
||||
DepartmentName: Laboratory
|
||||
post:
|
||||
tags:
|
||||
- Test
|
||||
@ -4546,62 +4585,26 @@ paths:
|
||||
type: integer
|
||||
CountStat:
|
||||
type: integer
|
||||
details:
|
||||
testdefcal:
|
||||
type: object
|
||||
description: |
|
||||
Type-specific details. For CALC and GROUP types, include members array.
|
||||
|
||||
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
|
||||
Invalid TestSiteIDs will result in a 400 error.
|
||||
description: Calculated test metadata persisted in the `testdefcal` table.
|
||||
properties:
|
||||
DisciplineID:
|
||||
type: integer
|
||||
DepartmentID:
|
||||
type: integer
|
||||
ResultType:
|
||||
type: string
|
||||
enum:
|
||||
- NMRIC
|
||||
- RANGE
|
||||
- TEXT
|
||||
- VSET
|
||||
- NORES
|
||||
RefType:
|
||||
type: string
|
||||
enum:
|
||||
- RANGE
|
||||
- THOLD
|
||||
- VSET
|
||||
- TEXT
|
||||
- NOREF
|
||||
FormulaCode:
|
||||
type: string
|
||||
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
|
||||
Unit1:
|
||||
type: string
|
||||
Factor:
|
||||
type: number
|
||||
Unit2:
|
||||
type: string
|
||||
Decimal:
|
||||
type: integer
|
||||
default: 2
|
||||
Method:
|
||||
type: string
|
||||
ExpectedTAT:
|
||||
type: integer
|
||||
description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}")
|
||||
testdefgrp:
|
||||
type: object
|
||||
description: Group member payload stored in the `testdefgrp` table.
|
||||
properties:
|
||||
members:
|
||||
type: array
|
||||
description: |
|
||||
Array of member tests for CALC and GROUP types.
|
||||
Each member object must contain `TestSiteID` (the actual test ID).
|
||||
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
|
||||
description: Array of member TestSiteIDs for CALC/GROUP definitions.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
type: integer
|
||||
description: The actual TestSiteID of the member test (required)
|
||||
description: Foreign key referencing the member test's TestSiteID.
|
||||
required:
|
||||
- TestSiteID
|
||||
refnum:
|
||||
@ -4634,11 +4637,10 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
Unit1: mg/dL
|
||||
Method: CBC Analyzer
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
Unit1: mg/dL
|
||||
Method: CBC Analyzer
|
||||
PARAM_no_ref:
|
||||
summary: Parameter without reference or map
|
||||
value:
|
||||
@ -4651,11 +4653,10 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 0
|
||||
CountStat: 0
|
||||
details:
|
||||
DisciplineID: 10
|
||||
DepartmentID: 0
|
||||
Unit1: cm
|
||||
Method: Manual entry
|
||||
DisciplineID: 10
|
||||
DepartmentID: 0
|
||||
Unit1: cm
|
||||
Method: Manual entry
|
||||
TEST_range_single:
|
||||
summary: Technical test with numeric range reference (single)
|
||||
value:
|
||||
@ -4668,13 +4669,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: RANGE
|
||||
Unit1: mg/dL
|
||||
Method: Hexokinase
|
||||
refnum:
|
||||
- NumRefType: NMRC
|
||||
RangeType: REF
|
||||
@ -4686,6 +4680,12 @@ paths:
|
||||
AgeStart: 18
|
||||
AgeEnd: 99
|
||||
Flag: 'N'
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: RANGE
|
||||
Unit1: mg/dL
|
||||
Method: Hexokinase
|
||||
TEST_range_multiple_map:
|
||||
summary: Numeric reference with multiple ranges and test map
|
||||
value:
|
||||
@ -4698,13 +4698,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: RANGE
|
||||
Unit1: mg/dL
|
||||
Method: Hexokinase
|
||||
refnum:
|
||||
- NumRefType: NMRC
|
||||
RangeType: REF
|
||||
@ -4752,6 +4745,12 @@ paths:
|
||||
ConDefID: 3
|
||||
ClientTestCode: HB_C
|
||||
ClientTestName: Hemoglobin Client
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: RANGE
|
||||
Unit1: mg/dL
|
||||
Method: Hexokinase
|
||||
TEST_threshold:
|
||||
summary: Technical test with threshold reference
|
||||
value:
|
||||
@ -4764,13 +4763,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: THOLD
|
||||
Unit1: mmol/L
|
||||
Method: Auto Analyzer
|
||||
refnum:
|
||||
- NumRefType: THOLD
|
||||
RangeType: PANIC
|
||||
@ -4780,6 +4772,12 @@ paths:
|
||||
AgeStart: 0
|
||||
AgeEnd: 125
|
||||
Flag: H
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: THOLD
|
||||
Unit1: mmol/L
|
||||
Method: Auto Analyzer
|
||||
TEST_threshold_map:
|
||||
summary: Threshold reference plus test map
|
||||
value:
|
||||
@ -4792,13 +4790,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: THOLD
|
||||
Unit1: mmol/L
|
||||
Method: Auto Analyzer
|
||||
refnum:
|
||||
- NumRefType: THOLD
|
||||
RangeType: PANIC
|
||||
@ -4832,6 +4823,12 @@ paths:
|
||||
ConDefID: 1
|
||||
ClientTestCode: GLU_C
|
||||
ClientTestName: Glucose Client
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: THOLD
|
||||
Unit1: mmol/L
|
||||
Method: Auto Analyzer
|
||||
TEST_text:
|
||||
summary: Technical test with text reference
|
||||
value:
|
||||
@ -4844,12 +4841,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 1
|
||||
DepartmentID: 1
|
||||
ResultType: TEXT
|
||||
RefType: TEXT
|
||||
Method: Morphology
|
||||
reftxt:
|
||||
- SpcType: GEN
|
||||
TxtRefType: TEXT
|
||||
@ -4858,6 +4849,11 @@ paths:
|
||||
AgeEnd: 99
|
||||
RefTxt: NORM=Normal;HIGH=High
|
||||
Flag: 'N'
|
||||
DisciplineID: 1
|
||||
DepartmentID: 1
|
||||
ResultType: TEXT
|
||||
RefType: TEXT
|
||||
Method: Morphology
|
||||
TEST_text_map:
|
||||
summary: Text reference plus test map
|
||||
value:
|
||||
@ -4870,11 +4866,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 1
|
||||
DepartmentID: 1
|
||||
ResultType: TEXT
|
||||
RefType: TEXT
|
||||
reftxt:
|
||||
- SpcType: GEN
|
||||
TxtRefType: TEXT
|
||||
@ -4901,6 +4892,10 @@ paths:
|
||||
ConDefID: 4
|
||||
ClientTestCode: STAGE_C
|
||||
ClientTestName: Disease Stage Client
|
||||
DisciplineID: 1
|
||||
DepartmentID: 1
|
||||
ResultType: TEXT
|
||||
RefType: TEXT
|
||||
TEST_valueset:
|
||||
summary: Technical test using a value set result
|
||||
value:
|
||||
@ -4913,12 +4908,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
Method: Visual
|
||||
reftxt:
|
||||
- SpcType: GEN
|
||||
TxtRefType: VSET
|
||||
@ -4927,6 +4916,11 @@ paths:
|
||||
AgeEnd: 120
|
||||
RefTxt: NORM=Normal;MACRO=Macro
|
||||
Flag: 'N'
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
Method: Visual
|
||||
TEST_valueset_map:
|
||||
summary: Value set reference with test map
|
||||
value:
|
||||
@ -4939,11 +4933,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
reftxt:
|
||||
- SpcType: GEN
|
||||
TxtRefType: VSET
|
||||
@ -4963,6 +4952,10 @@ paths:
|
||||
ConDefID: 12
|
||||
ClientTestCode: UCOLOR_C
|
||||
ClientTestName: Urine Color Client
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
TEST_valueset_map_no_reftxt:
|
||||
summary: Value set result with mapping but without explicit text reference entries
|
||||
value:
|
||||
@ -4975,11 +4968,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
testmap:
|
||||
- HostType: SITE
|
||||
HostID: '1'
|
||||
@ -4991,6 +4979,10 @@ paths:
|
||||
ConDefID: 12
|
||||
ClientTestCode: UGLUC_C
|
||||
ClientTestName: Urine Glucose Client
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
CALC_basic:
|
||||
summary: Calculated test with members (no references)
|
||||
value:
|
||||
@ -5003,10 +4995,11 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 0
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
testdefcal:
|
||||
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
|
||||
testdefgrp:
|
||||
members:
|
||||
- TestSiteID: 21
|
||||
- TestSiteID: 22
|
||||
@ -5022,13 +5015,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 0
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
|
||||
members:
|
||||
- TestSiteID: 21
|
||||
- TestSiteID: 22
|
||||
refnum:
|
||||
- NumRefType: NMRC
|
||||
RangeType: REF
|
||||
@ -5051,6 +5037,14 @@ paths:
|
||||
ConDefID: 1
|
||||
ClientTestCode: EGFR_C
|
||||
ClientTestName: eGFR Client
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
testdefcal:
|
||||
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
|
||||
testdefgrp:
|
||||
members:
|
||||
- TestSiteID: 21
|
||||
- TestSiteID: 22
|
||||
GROUP_with_members:
|
||||
summary: Group/profile test with members and mapping
|
||||
value:
|
||||
@ -5063,10 +5057,6 @@ paths:
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
members:
|
||||
- TestSiteID: 169
|
||||
- TestSiteID: 170
|
||||
testmap:
|
||||
- HostType: SITE
|
||||
HostID: '1'
|
||||
@ -5078,6 +5068,10 @@ paths:
|
||||
ConDefID: 1
|
||||
ClientTestCode: LIPID_C
|
||||
ClientTestName: Lipid Client
|
||||
testdefgrp:
|
||||
members:
|
||||
- TestSiteID: 169
|
||||
- TestSiteID: 170
|
||||
responses:
|
||||
'201':
|
||||
description: Test definition created
|
||||
@ -5195,62 +5189,26 @@ paths:
|
||||
type: integer
|
||||
CountStat:
|
||||
type: integer
|
||||
details:
|
||||
testdefcal:
|
||||
type: object
|
||||
description: |
|
||||
Type-specific details. For CALC and GROUP types, include members array.
|
||||
|
||||
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
|
||||
Invalid TestSiteIDs will result in a 400 error.
|
||||
description: Calculated test metadata persisted in the `testdefcal` table.
|
||||
properties:
|
||||
DisciplineID:
|
||||
type: integer
|
||||
DepartmentID:
|
||||
type: integer
|
||||
ResultType:
|
||||
type: string
|
||||
enum:
|
||||
- NMRIC
|
||||
- RANGE
|
||||
- TEXT
|
||||
- VSET
|
||||
- NORES
|
||||
RefType:
|
||||
type: string
|
||||
enum:
|
||||
- RANGE
|
||||
- THOLD
|
||||
- VSET
|
||||
- TEXT
|
||||
- NOREF
|
||||
FormulaCode:
|
||||
type: string
|
||||
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
|
||||
Unit1:
|
||||
type: string
|
||||
Factor:
|
||||
type: number
|
||||
Unit2:
|
||||
type: string
|
||||
Decimal:
|
||||
type: integer
|
||||
default: 2
|
||||
Method:
|
||||
type: string
|
||||
ExpectedTAT:
|
||||
type: integer
|
||||
description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}")
|
||||
testdefgrp:
|
||||
type: object
|
||||
description: Group member payload stored in the `testdefgrp` table.
|
||||
properties:
|
||||
members:
|
||||
type: array
|
||||
description: |
|
||||
Array of member tests for CALC and GROUP types.
|
||||
Each member object must contain `TestSiteID` (the actual test ID).
|
||||
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
|
||||
description: Array of member TestSiteIDs for CALC/GROUP definitions.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
type: integer
|
||||
description: The actual TestSiteID of the member test (required)
|
||||
description: Foreign key referencing the member test's TestSiteID.
|
||||
required:
|
||||
- TestSiteID
|
||||
refnum:
|
||||
@ -8122,6 +8080,58 @@ components:
|
||||
type: string
|
||||
format: date-time
|
||||
description: Soft delete timestamp
|
||||
TestDefinitionListItem:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
type: integer
|
||||
TestSiteCode:
|
||||
type: string
|
||||
TestSiteName:
|
||||
type: string
|
||||
TestType:
|
||||
type: string
|
||||
enum:
|
||||
- TEST
|
||||
- PARAM
|
||||
- CALC
|
||||
- GROUP
|
||||
- TITLE
|
||||
SeqScr:
|
||||
type: integer
|
||||
SeqRpt:
|
||||
type: integer
|
||||
VisibleScr:
|
||||
type: integer
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
VisibleRpt:
|
||||
type: integer
|
||||
enum:
|
||||
- 0
|
||||
- 1
|
||||
CountStat:
|
||||
type: integer
|
||||
StartDate:
|
||||
type: string
|
||||
format: date-time
|
||||
EndDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
DisciplineID:
|
||||
type: integer
|
||||
nullable: true
|
||||
DepartmentID:
|
||||
type: integer
|
||||
nullable: true
|
||||
DisciplineName:
|
||||
type: string
|
||||
nullable: true
|
||||
DepartmentName:
|
||||
type: string
|
||||
nullable: true
|
||||
ValueSetListItem:
|
||||
type: object
|
||||
description: Library/system value set summary (from JSON files)
|
||||
|
||||
@ -1,4 +1,48 @@
|
||||
TestDefinition:
|
||||
TestDefinitionListItem:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
type: integer
|
||||
TestSiteCode:
|
||||
type: string
|
||||
TestSiteName:
|
||||
type: string
|
||||
TestType:
|
||||
type: string
|
||||
enum: [TEST, PARAM, CALC, GROUP, TITLE]
|
||||
SeqScr:
|
||||
type: integer
|
||||
SeqRpt:
|
||||
type: integer
|
||||
VisibleScr:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
VisibleRpt:
|
||||
type: integer
|
||||
enum: [0, 1]
|
||||
CountStat:
|
||||
type: integer
|
||||
StartDate:
|
||||
type: string
|
||||
format: date-time
|
||||
EndDate:
|
||||
type: string
|
||||
format: date-time
|
||||
nullable: true
|
||||
DisciplineID:
|
||||
type: integer
|
||||
nullable: true
|
||||
DepartmentID:
|
||||
type: integer
|
||||
nullable: true
|
||||
DisciplineName:
|
||||
type: string
|
||||
nullable: true
|
||||
DepartmentName:
|
||||
type: string
|
||||
nullable: true
|
||||
|
||||
TestDefinition:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
|
||||
@ -53,18 +53,57 @@
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../components/schemas/tests.yaml#/TestDefinition'
|
||||
pagination:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of records matching the query
|
||||
status:
|
||||
type: string
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
type: array
|
||||
items:
|
||||
$ref: '../components/schemas/tests.yaml#/TestDefinitionListItem'
|
||||
pagination:
|
||||
type: object
|
||||
properties:
|
||||
total:
|
||||
type: integer
|
||||
description: Total number of records matching the query
|
||||
examples:
|
||||
list_flat:
|
||||
summary: Flat list response from testdefsite
|
||||
value:
|
||||
status: success
|
||||
message: Data fetched successfully
|
||||
data:
|
||||
- TestSiteID: 21
|
||||
TestSiteCode: GLU
|
||||
TestSiteName: Glucose
|
||||
TestType: TEST
|
||||
SeqScr: 11
|
||||
SeqRpt: 11
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
StartDate: '2026-01-01 00:00:00'
|
||||
EndDate: null
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
DisciplineName: Clinical Chemistry
|
||||
DepartmentName: Laboratory
|
||||
- TestSiteID: 22
|
||||
TestSiteCode: CREA
|
||||
TestSiteName: Creatinine
|
||||
TestType: TEST
|
||||
SeqScr: 12
|
||||
SeqRpt: 12
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
StartDate: '2026-01-01 00:00:00'
|
||||
EndDate: null
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
DisciplineName: Clinical Chemistry
|
||||
DepartmentName: Laboratory
|
||||
|
||||
post:
|
||||
tags: [Test]
|
||||
@ -137,56 +176,30 @@
|
||||
type: integer
|
||||
VisibleRpt:
|
||||
type: integer
|
||||
CountStat:
|
||||
type: integer
|
||||
details:
|
||||
type: object
|
||||
description: |
|
||||
Type-specific details. For CALC and GROUP types, include members array.
|
||||
|
||||
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
|
||||
Invalid TestSiteIDs will result in a 400 error.
|
||||
properties:
|
||||
DisciplineID:
|
||||
type: integer
|
||||
DepartmentID:
|
||||
type: integer
|
||||
ResultType:
|
||||
type: string
|
||||
enum: [NMRIC, RANGE, TEXT, VSET, NORES]
|
||||
RefType:
|
||||
type: string
|
||||
enum: [RANGE, THOLD, VSET, TEXT, NOREF]
|
||||
FormulaCode:
|
||||
type: string
|
||||
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
|
||||
Unit1:
|
||||
type: string
|
||||
Factor:
|
||||
type: number
|
||||
Unit2:
|
||||
type: string
|
||||
Decimal:
|
||||
type: integer
|
||||
default: 2
|
||||
Method:
|
||||
type: string
|
||||
ExpectedTAT:
|
||||
type: integer
|
||||
members:
|
||||
type: array
|
||||
description: |
|
||||
Array of member tests for CALC and GROUP types.
|
||||
Each member object must contain `TestSiteID` (the actual test ID).
|
||||
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
type: integer
|
||||
description: The actual TestSiteID of the member test (required)
|
||||
required:
|
||||
- TestSiteID
|
||||
CountStat:
|
||||
type: integer
|
||||
testdefcal:
|
||||
type: object
|
||||
description: Calculated test metadata persisted in the `testdefcal` table.
|
||||
properties:
|
||||
FormulaCode:
|
||||
type: string
|
||||
description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}")
|
||||
testdefgrp:
|
||||
type: object
|
||||
description: Group member payload stored in the `testdefgrp` table.
|
||||
properties:
|
||||
members:
|
||||
type: array
|
||||
description: Array of member TestSiteIDs for CALC/GROUP definitions.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
type: integer
|
||||
description: Foreign key referencing the member test's TestSiteID.
|
||||
required:
|
||||
- TestSiteID
|
||||
refnum:
|
||||
type: array
|
||||
items:
|
||||
@ -217,11 +230,10 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
Unit1: mg/dL
|
||||
Method: CBC Analyzer
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
Unit1: mg/dL
|
||||
Method: CBC Analyzer
|
||||
PARAM_no_ref:
|
||||
summary: Parameter without reference or map
|
||||
value:
|
||||
@ -234,11 +246,10 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 0
|
||||
CountStat: 0
|
||||
details:
|
||||
DisciplineID: 10
|
||||
DepartmentID: 0
|
||||
Unit1: cm
|
||||
Method: Manual entry
|
||||
DisciplineID: 10
|
||||
DepartmentID: 0
|
||||
Unit1: cm
|
||||
Method: Manual entry
|
||||
TEST_range_single:
|
||||
summary: Technical test with numeric range reference (single)
|
||||
value:
|
||||
@ -251,13 +262,6 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: RANGE
|
||||
Unit1: mg/dL
|
||||
Method: Hexokinase
|
||||
refnum:
|
||||
- NumRefType: NMRC
|
||||
RangeType: REF
|
||||
@ -269,6 +273,12 @@
|
||||
AgeStart: 18
|
||||
AgeEnd: 99
|
||||
Flag: N
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: RANGE
|
||||
Unit1: mg/dL
|
||||
Method: Hexokinase
|
||||
TEST_range_multiple_map:
|
||||
summary: Numeric reference with multiple ranges and test map
|
||||
value:
|
||||
@ -281,13 +291,6 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: RANGE
|
||||
Unit1: mg/dL
|
||||
Method: Hexokinase
|
||||
refnum:
|
||||
- NumRefType: NMRC
|
||||
RangeType: REF
|
||||
@ -304,7 +307,7 @@
|
||||
Sex: '1'
|
||||
LowSign: '>'
|
||||
Low: 75
|
||||
HighSign: '<'
|
||||
HighSign: <
|
||||
High: 105
|
||||
AgeStart: 18
|
||||
AgeEnd: 99
|
||||
@ -335,6 +338,12 @@
|
||||
ConDefID: 3
|
||||
ClientTestCode: HB_C
|
||||
ClientTestName: Hemoglobin Client
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: RANGE
|
||||
Unit1: mg/dL
|
||||
Method: Hexokinase
|
||||
TEST_threshold:
|
||||
summary: Technical test with threshold reference
|
||||
value:
|
||||
@ -347,13 +356,6 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: THOLD
|
||||
Unit1: mmol/L
|
||||
Method: Auto Analyzer
|
||||
refnum:
|
||||
- NumRefType: THOLD
|
||||
RangeType: PANIC
|
||||
@ -363,6 +365,12 @@
|
||||
AgeStart: 0
|
||||
AgeEnd: 125
|
||||
Flag: H
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: THOLD
|
||||
Unit1: mmol/L
|
||||
Method: Auto Analyzer
|
||||
TEST_threshold_map:
|
||||
summary: Threshold reference plus test map
|
||||
value:
|
||||
@ -375,13 +383,6 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: THOLD
|
||||
Unit1: mmol/L
|
||||
Method: Auto Analyzer
|
||||
refnum:
|
||||
- NumRefType: THOLD
|
||||
RangeType: PANIC
|
||||
@ -394,7 +395,7 @@
|
||||
- NumRefType: THOLD
|
||||
RangeType: PANIC
|
||||
Sex: '1'
|
||||
LowSign: '<'
|
||||
LowSign: <
|
||||
Low: 121
|
||||
AgeStart: 0
|
||||
AgeEnd: 125
|
||||
@ -415,6 +416,12 @@
|
||||
ConDefID: 1
|
||||
ClientTestCode: GLU_C
|
||||
ClientTestName: Glucose Client
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
ResultType: NMRIC
|
||||
RefType: THOLD
|
||||
Unit1: mmol/L
|
||||
Method: Auto Analyzer
|
||||
TEST_text:
|
||||
summary: Technical test with text reference
|
||||
value:
|
||||
@ -427,20 +434,19 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 1
|
||||
DepartmentID: 1
|
||||
ResultType: TEXT
|
||||
RefType: TEXT
|
||||
Method: Morphology
|
||||
reftxt:
|
||||
- SpcType: GEN
|
||||
TxtRefType: TEXT
|
||||
Sex: '2'
|
||||
AgeStart: 18
|
||||
AgeEnd: 99
|
||||
RefTxt: 'NORM=Normal;HIGH=High'
|
||||
RefTxt: NORM=Normal;HIGH=High
|
||||
Flag: N
|
||||
DisciplineID: 1
|
||||
DepartmentID: 1
|
||||
ResultType: TEXT
|
||||
RefType: TEXT
|
||||
Method: Morphology
|
||||
TEST_text_map:
|
||||
summary: Text reference plus test map
|
||||
value:
|
||||
@ -453,25 +459,20 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 1
|
||||
DepartmentID: 1
|
||||
ResultType: TEXT
|
||||
RefType: TEXT
|
||||
reftxt:
|
||||
- SpcType: GEN
|
||||
TxtRefType: TEXT
|
||||
Sex: '2'
|
||||
AgeStart: 18
|
||||
AgeEnd: 99
|
||||
RefTxt: 'NORM=Normal'
|
||||
RefTxt: NORM=Normal
|
||||
Flag: N
|
||||
- SpcType: GEN
|
||||
TxtRefType: TEXT
|
||||
Sex: '1'
|
||||
AgeStart: 18
|
||||
AgeEnd: 99
|
||||
RefTxt: 'ABN=Abnormal'
|
||||
RefTxt: ABN=Abnormal
|
||||
Flag: N
|
||||
testmap:
|
||||
- HostType: SITE
|
||||
@ -484,6 +485,10 @@
|
||||
ConDefID: 4
|
||||
ClientTestCode: STAGE_C
|
||||
ClientTestName: Disease Stage Client
|
||||
DisciplineID: 1
|
||||
DepartmentID: 1
|
||||
ResultType: TEXT
|
||||
RefType: TEXT
|
||||
TEST_valueset:
|
||||
summary: Technical test using a value set result
|
||||
value:
|
||||
@ -496,20 +501,19 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
Method: Visual
|
||||
reftxt:
|
||||
- SpcType: GEN
|
||||
TxtRefType: VSET
|
||||
Sex: '2'
|
||||
AgeStart: 0
|
||||
AgeEnd: 120
|
||||
RefTxt: 'NORM=Normal;MACRO=Macro'
|
||||
RefTxt: NORM=Normal;MACRO=Macro
|
||||
Flag: N
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
Method: Visual
|
||||
TEST_valueset_map:
|
||||
summary: Value set reference with test map
|
||||
value:
|
||||
@ -522,18 +526,13 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
reftxt:
|
||||
- SpcType: GEN
|
||||
TxtRefType: VSET
|
||||
Sex: '2'
|
||||
AgeStart: 0
|
||||
AgeEnd: 120
|
||||
RefTxt: 'NORM=Normal;ABN=Abnormal'
|
||||
RefTxt: NORM=Normal;ABN=Abnormal
|
||||
Flag: N
|
||||
testmap:
|
||||
- HostType: SITE
|
||||
@ -546,6 +545,10 @@
|
||||
ConDefID: 12
|
||||
ClientTestCode: UCOLOR_C
|
||||
ClientTestName: Urine Color Client
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
TEST_valueset_map_no_reftxt:
|
||||
summary: Value set result with mapping but without explicit text reference entries
|
||||
value:
|
||||
@ -558,11 +561,6 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
testmap:
|
||||
- HostType: SITE
|
||||
HostID: '1'
|
||||
@ -574,6 +572,10 @@
|
||||
ConDefID: 12
|
||||
ClientTestCode: UGLUC_C
|
||||
ClientTestName: Urine Glucose Client
|
||||
DisciplineID: 4
|
||||
DepartmentID: 4
|
||||
ResultType: VSET
|
||||
RefType: VSET
|
||||
CALC_basic:
|
||||
summary: Calculated test with members (no references)
|
||||
value:
|
||||
@ -586,10 +588,11 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 0
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
testdefcal:
|
||||
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
|
||||
testdefgrp:
|
||||
members:
|
||||
- TestSiteID: 21
|
||||
- TestSiteID: 22
|
||||
@ -605,13 +608,6 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 0
|
||||
details:
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
|
||||
members:
|
||||
- TestSiteID: 21
|
||||
- TestSiteID: 22
|
||||
refnum:
|
||||
- NumRefType: NMRC
|
||||
RangeType: REF
|
||||
@ -634,6 +630,14 @@
|
||||
ConDefID: 1
|
||||
ClientTestCode: EGFR_C
|
||||
ClientTestName: eGFR Client
|
||||
DisciplineID: 2
|
||||
DepartmentID: 2
|
||||
testdefcal:
|
||||
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
|
||||
testdefgrp:
|
||||
members:
|
||||
- TestSiteID: 21
|
||||
- TestSiteID: 22
|
||||
GROUP_with_members:
|
||||
summary: Group/profile test with members and mapping
|
||||
value:
|
||||
@ -646,10 +650,6 @@
|
||||
VisibleScr: 1
|
||||
VisibleRpt: 1
|
||||
CountStat: 1
|
||||
details:
|
||||
members:
|
||||
- TestSiteID: 169
|
||||
- TestSiteID: 170
|
||||
testmap:
|
||||
- HostType: SITE
|
||||
HostID: '1'
|
||||
@ -661,6 +661,11 @@
|
||||
ConDefID: 1
|
||||
ClientTestCode: LIPID_C
|
||||
ClientTestName: Lipid Client
|
||||
testdefgrp:
|
||||
members:
|
||||
- TestSiteID: 169
|
||||
- TestSiteID: 170
|
||||
|
||||
responses:
|
||||
'201':
|
||||
description: Test definition created
|
||||
@ -761,56 +766,30 @@
|
||||
type: integer
|
||||
VisibleRpt:
|
||||
type: integer
|
||||
CountStat:
|
||||
type: integer
|
||||
details:
|
||||
type: object
|
||||
description: |
|
||||
Type-specific details. For CALC and GROUP types, include members array.
|
||||
|
||||
**Important**: Members must use `TestSiteID` (the actual test ID), NOT `Member` or `SeqScr`.
|
||||
Invalid TestSiteIDs will result in a 400 error.
|
||||
properties:
|
||||
DisciplineID:
|
||||
type: integer
|
||||
DepartmentID:
|
||||
type: integer
|
||||
ResultType:
|
||||
type: string
|
||||
enum: [NMRIC, RANGE, TEXT, VSET, NORES]
|
||||
RefType:
|
||||
type: string
|
||||
enum: [RANGE, THOLD, VSET, TEXT, NOREF]
|
||||
FormulaCode:
|
||||
type: string
|
||||
description: Formula expression for CALC type (e.g., "{TBIL} - {DBIL}")
|
||||
Unit1:
|
||||
type: string
|
||||
Factor:
|
||||
type: number
|
||||
Unit2:
|
||||
type: string
|
||||
Decimal:
|
||||
type: integer
|
||||
default: 2
|
||||
Method:
|
||||
type: string
|
||||
ExpectedTAT:
|
||||
type: integer
|
||||
members:
|
||||
type: array
|
||||
description: |
|
||||
Array of member tests for CALC and GROUP types.
|
||||
Each member object must contain `TestSiteID` (the actual test ID).
|
||||
Do NOT use `Member` or `SeqScr` - these will be rejected with validation error.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
type: integer
|
||||
description: The actual TestSiteID of the member test (required)
|
||||
required:
|
||||
- TestSiteID
|
||||
CountStat:
|
||||
type: integer
|
||||
testdefcal:
|
||||
type: object
|
||||
description: Calculated test metadata persisted in the `testdefcal` table.
|
||||
properties:
|
||||
FormulaCode:
|
||||
type: string
|
||||
description: Formula expression for calculated tests (e.g., "{TBIL} - {DBIL}")
|
||||
testdefgrp:
|
||||
type: object
|
||||
description: Group member payload stored in the `testdefgrp` table.
|
||||
properties:
|
||||
members:
|
||||
type: array
|
||||
description: Array of member TestSiteIDs for CALC/GROUP definitions.
|
||||
items:
|
||||
type: object
|
||||
properties:
|
||||
TestSiteID:
|
||||
type: integer
|
||||
description: Foreign key referencing the member test's TestSiteID.
|
||||
required:
|
||||
- TestSiteID
|
||||
refnum:
|
||||
type: array
|
||||
items:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user