2026-01-05 16:55:34 +07:00
|
|
|
<?php
|
|
|
|
|
|
2026-03-02 07:02:51 +07:00
|
|
|
namespace App\Controllers\Test;
|
|
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
use App\Controllers\BaseController;
|
2026-02-20 13:47:47 +07:00
|
|
|
use App\Libraries\TestValidationService;
|
2026-03-02 07:02:51 +07:00
|
|
|
use App\Libraries\ValueSet;
|
|
|
|
|
use App\Traits\ResponseTrait;
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
class TestsController extends BaseController
|
|
|
|
|
{
|
|
|
|
|
use ResponseTrait;
|
|
|
|
|
|
|
|
|
|
protected $db;
|
|
|
|
|
protected $model;
|
|
|
|
|
protected $modelCal;
|
|
|
|
|
protected $modelGrp;
|
|
|
|
|
protected $modelMap;
|
2026-02-26 16:48:10 +07:00
|
|
|
protected $modelMapDetail;
|
2026-01-05 16:55:34 +07:00
|
|
|
protected $modelRefNum;
|
|
|
|
|
protected $modelRefTxt;
|
2026-03-02 07:02:51 +07:00
|
|
|
protected $rules;
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
public function __construct()
|
|
|
|
|
{
|
|
|
|
|
$this->db = \Config\Database::connect();
|
|
|
|
|
$this->model = new \App\Models\Test\TestDefSiteModel;
|
|
|
|
|
$this->modelCal = new \App\Models\Test\TestDefCalModel;
|
|
|
|
|
$this->modelGrp = new \App\Models\Test\TestDefGrpModel;
|
|
|
|
|
$this->modelMap = new \App\Models\Test\TestMapModel;
|
2026-02-26 16:48:10 +07:00
|
|
|
$this->modelMapDetail = new \App\Models\Test\TestMapDetailModel;
|
2026-01-05 16:55:34 +07:00
|
|
|
$this->modelRefNum = new \App\Models\RefRange\RefNumModel;
|
|
|
|
|
$this->modelRefTxt = new \App\Models\RefRange\RefTxtModel;
|
|
|
|
|
|
|
|
|
|
$this->rules = [
|
2026-02-20 13:47:47 +07:00
|
|
|
'TestSiteCode' => 'required',
|
2026-01-05 16:55:34 +07:00
|
|
|
'TestSiteName' => 'required',
|
2026-03-02 07:02:51 +07:00
|
|
|
'TestType' => 'required',
|
|
|
|
|
'SiteID' => 'required',
|
2026-01-05 16:55:34 +07:00
|
|
|
];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function index()
|
2026-03-02 07:02:51 +07:00
|
|
|
{
|
2026-03-03 06:03:27 +07:00
|
|
|
$filters = [
|
|
|
|
|
'SiteID' => $this->request->getGet('SiteID'),
|
|
|
|
|
'TestType' => $this->request->getGet('TestType'),
|
|
|
|
|
'VisibleScr' => $this->request->getGet('VisibleScr'),
|
|
|
|
|
'VisibleRpt' => $this->request->getGet('VisibleRpt'),
|
|
|
|
|
'TestSiteName' => $this->request->getGet('TestSiteName'),
|
|
|
|
|
'TestSiteCode' => $this->request->getGet('TestSiteCode'),
|
|
|
|
|
];
|
2026-01-05 16:55:34 +07:00
|
|
|
|
2026-03-03 06:03:27 +07:00
|
|
|
$rows = $this->model->getTestsWithRelations($filters);
|
2026-03-02 07:02:51 +07:00
|
|
|
|
|
|
|
|
if (empty($rows)) {
|
|
|
|
|
return $this->respond([
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'message' => 'No data.',
|
|
|
|
|
'data' => [],
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
2026-01-12 16:53:41 +07:00
|
|
|
|
2026-03-02 07:02:51 +07:00
|
|
|
$rows = ValueSet::transformLabels($rows, [
|
|
|
|
|
'TestType' => 'test_type',
|
|
|
|
|
]);
|
2026-01-12 16:53:41 +07:00
|
|
|
|
2026-03-02 07:02:51 +07:00
|
|
|
return $this->respond([
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'message' => 'Data fetched successfully',
|
|
|
|
|
'data' => $rows,
|
|
|
|
|
], 200);
|
|
|
|
|
}
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
public function show($id = null)
|
|
|
|
|
{
|
2026-03-02 07:02:51 +07:00
|
|
|
if (!$id) {
|
2026-01-05 16:55:34 +07:00
|
|
|
return $this->failValidationErrors('TestSiteID is required');
|
2026-03-02 07:02:51 +07:00
|
|
|
}
|
2026-01-05 16:55:34 +07:00
|
|
|
|
2026-03-02 07:02:51 +07:00
|
|
|
$row = $this->model->select('testdefsite.*')
|
|
|
|
|
->where('testdefsite.TestSiteID', $id)
|
2026-01-05 16:55:34 +07:00
|
|
|
->find($id);
|
|
|
|
|
|
|
|
|
|
if (!$row) {
|
2026-03-02 07:02:51 +07:00
|
|
|
return $this->respond([
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'message' => 'No data.',
|
|
|
|
|
'data' => null,
|
|
|
|
|
], 200);
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
|
2026-01-12 16:53:41 +07:00
|
|
|
$row = ValueSet::transformLabels([$row], [
|
|
|
|
|
'TestType' => 'test_type',
|
|
|
|
|
])[0];
|
|
|
|
|
|
|
|
|
|
$typeCode = $row['TestType'] ?? '';
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
if ($typeCode === 'CALC') {
|
2026-03-03 06:03:27 +07:00
|
|
|
$row['testdefcal'] = $this->modelCal->getByTestSiteID($id);
|
2026-01-05 16:55:34 +07:00
|
|
|
} elseif ($typeCode === 'GROUP') {
|
2026-03-03 06:03:27 +07:00
|
|
|
$row['testdefgrp'] = $this->modelGrp->getGroupMembers($id);
|
2026-01-05 16:55:34 +07:00
|
|
|
} elseif ($typeCode === 'TITLE') {
|
|
|
|
|
} else {
|
2026-02-19 13:20:24 +07:00
|
|
|
$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')
|
2026-01-05 16:55:34 +07:00
|
|
|
->get()->getResultArray();
|
|
|
|
|
|
|
|
|
|
if (!empty($row['testdeftech'])) {
|
|
|
|
|
$techData = $row['testdeftech'][0];
|
2026-01-12 16:53:41 +07:00
|
|
|
$refType = $techData['RefType'];
|
2026-02-20 13:47:47 +07:00
|
|
|
$resultType = $techData['ResultType'] ?? '';
|
2026-01-05 16:55:34 +07:00
|
|
|
|
2026-02-20 13:47:47 +07:00
|
|
|
if (TestValidationService::usesRefNum($resultType, $refType)) {
|
2026-03-03 06:03:27 +07:00
|
|
|
$refnumData = $this->modelRefNum->getActiveByTestSiteID($id);
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
$row['refnum'] = array_map(function ($r) {
|
|
|
|
|
return [
|
2026-03-02 07:02:51 +07:00
|
|
|
'RefNumID' => $r['RefNumID'],
|
|
|
|
|
'NumRefType' => $r['NumRefType'],
|
feat(api): transition to headless architecture and enhance order management
This commit marks a significant architectural shift, transitioning the CLQMS backend to a fully headless REST API. All view-related components have been removed to focus solely on providing a robust, stateless API for clinical laboratory workflows.
### Architectural Changes
- **Headless API Transition:**
- Removed all view files (`app/Views/v2`), associated page controllers (`PagesController`), and routes (`Routes.php`). The application no longer serves a front-end UI.
- The root endpoint (`/`) now returns a simple "Backend Running" status message.
- **Developer Tooling & Guidance:**
- Replaced `CLAUDE.md` with `GEMINI.md` to provide updated context and instructional guidelines for Gemini agents.
- Updated `.serena/project.yml` with project configuration.
### Feature Enhancements
- **Advanced Order Management (`OrderTestModel`):**
- **Test Expansion:** The `createOrder` process now automatically expands `GROUP` (panel) tests into their individual components and recursively includes all parameter dependencies for `CALC` (calculated) tests.
- **Order Comments:** Added support for attaching comments to an order via the `ordercom` table.
- **Status Tracking:** Order status updates are now correctly recorded in the `orderstatus` table.
- **Schema Alignment:** Switched from `OrderID` to `InternalOID` as the primary key for internal operations.
- **Reference Range Refactor (`TestsController`):**
- Simplified reference range logic by consolidating `refthold` and `refvset` into the main `refnum` and `reftxt` tables.
- Standardized `RefType` handling to support `NMRC`, `TEXT`, `THOLD`, and `VSET` codes from the `reference_type` ValueSet.
### Other Changes
- **Documentation:**
- `PRD.md`, `README.md`, and `TODO.md` were updated to reflect the headless architecture, refined scope, and current project priorities.
- **Database:**
- Removed obsolete `RefTHoldID` and `RefVSetID` columns from the `patres` table migration.
- **Testing:**
- Added new feature tests for `ContactController`, `OrganizationController`, and `TestsController`.
2026-01-31 09:27:32 +07:00
|
|
|
'NumRefTypeLabel' => $r['NumRefType'] ? ValueSet::getLabel('numeric_ref_type', $r['NumRefType']) : '',
|
2026-03-02 07:02:51 +07:00
|
|
|
'RangeType' => $r['RangeType'],
|
|
|
|
|
'RangeTypeLabel' => $r['RangeType'] ? ValueSet::getLabel('range_type', $r['RangeType']) : '',
|
|
|
|
|
'Sex' => $r['Sex'],
|
|
|
|
|
'SexLabel' => $r['Sex'] ? ValueSet::getLabel('gender', $r['Sex']) : '',
|
|
|
|
|
'LowSign' => $r['LowSign'],
|
|
|
|
|
'LowSignLabel' => $r['LowSign'] ? ValueSet::getLabel('math_sign', $r['LowSign']) : '',
|
|
|
|
|
'HighSign' => $r['HighSign'],
|
|
|
|
|
'HighSignLabel' => $r['HighSign'] ? ValueSet::getLabel('math_sign', $r['HighSign']) : '',
|
|
|
|
|
'High' => $r['High'] !== null ? (float) $r['High'] : null,
|
|
|
|
|
'Low' => $r['Low'] !== null ? (float) $r['Low'] : null,
|
|
|
|
|
'AgeStart' => (int) $r['AgeStart'],
|
|
|
|
|
'AgeEnd' => (int) $r['AgeEnd'],
|
|
|
|
|
'Flag' => $r['Flag'],
|
|
|
|
|
'Interpretation' => $r['Interpretation'],
|
2026-01-05 16:55:34 +07:00
|
|
|
];
|
|
|
|
|
}, $refnumData ?? []);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:47:47 +07:00
|
|
|
if (TestValidationService::usesRefTxt($resultType, $refType)) {
|
2026-03-03 06:03:27 +07:00
|
|
|
$reftxtData = $this->modelRefTxt->getActiveByTestSiteID($id);
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
$row['reftxt'] = array_map(function ($r) {
|
|
|
|
|
return [
|
2026-03-02 07:02:51 +07:00
|
|
|
'RefTxtID' => $r['RefTxtID'],
|
|
|
|
|
'TxtRefType' => $r['TxtRefType'],
|
|
|
|
|
'TxtRefTypeLabel'=> $r['TxtRefType'] ? ValueSet::getLabel('text_ref_type', $r['TxtRefType']) : '',
|
|
|
|
|
'Sex' => $r['Sex'],
|
|
|
|
|
'SexLabel' => $r['Sex'] ? ValueSet::getLabel('gender', $r['Sex']) : '',
|
|
|
|
|
'AgeStart' => (int) $r['AgeStart'],
|
|
|
|
|
'AgeEnd' => (int) $r['AgeEnd'],
|
|
|
|
|
'RefTxt' => $r['RefTxt'],
|
|
|
|
|
'Flag' => $r['Flag'],
|
2026-01-05 16:55:34 +07:00
|
|
|
];
|
|
|
|
|
}, $reftxtData ?? []);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-02 07:02:51 +07:00
|
|
|
return $this->respond([
|
|
|
|
|
'status' => 'success',
|
|
|
|
|
'message' => 'Data fetched successfully',
|
|
|
|
|
'data' => $row,
|
|
|
|
|
], 200);
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function create()
|
|
|
|
|
{
|
|
|
|
|
$input = $this->request->getJSON(true);
|
|
|
|
|
|
|
|
|
|
if (!$this->validateData($input, $this->rules)) {
|
|
|
|
|
return $this->failValidationErrors($this->validator->getErrors());
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:47:47 +07:00
|
|
|
$testType = $input['TestType'] ?? '';
|
|
|
|
|
$details = $input['details'] ?? $input;
|
|
|
|
|
$resultType = $details['ResultType'] ?? '';
|
|
|
|
|
$refType = $details['RefType'] ?? '';
|
|
|
|
|
|
|
|
|
|
if (TestValidationService::isCalc($testType)) {
|
|
|
|
|
$resultType = 'NMRIC';
|
|
|
|
|
$refType = $refType ?: 'RANGE';
|
|
|
|
|
} elseif (TestValidationService::isGroup($testType) || TestValidationService::isTitle($testType)) {
|
|
|
|
|
$resultType = 'NORES';
|
|
|
|
|
$refType = 'NOREF';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($resultType && $refType) {
|
|
|
|
|
$validation = TestValidationService::validate($testType, $resultType, $refType);
|
|
|
|
|
if (!$validation['valid']) {
|
|
|
|
|
return $this->failValidationErrors(['type_validation' => $validation['error']]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
$this->db->transStart();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$testSiteData = [
|
2026-03-02 07:02:51 +07:00
|
|
|
'SiteID' => $input['SiteID'],
|
|
|
|
|
'TestSiteCode'=> $input['TestSiteCode'],
|
|
|
|
|
'TestSiteName'=> $input['TestSiteName'],
|
|
|
|
|
'TestType' => $input['TestType'],
|
2026-01-05 16:55:34 +07:00
|
|
|
'Description' => $input['Description'] ?? null,
|
2026-03-02 07:02:51 +07:00
|
|
|
'SeqScr' => $input['SeqScr'] ?? 0,
|
|
|
|
|
'SeqRpt' => $input['SeqRpt'] ?? 0,
|
|
|
|
|
'IndentLeft' => $input['IndentLeft'] ?? 0,
|
|
|
|
|
'FontStyle' => $input['FontStyle'] ?? null,
|
|
|
|
|
'VisibleScr' => $input['VisibleScr'] ?? 1,
|
|
|
|
|
'VisibleRpt' => $input['VisibleRpt'] ?? 1,
|
|
|
|
|
'CountStat' => $input['CountStat'] ?? 1,
|
|
|
|
|
'StartDate' => $input['StartDate'] ?? date('Y-m-d H:i:s'),
|
2026-01-05 16:55:34 +07:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
$id = $this->model->insert($testSiteData);
|
|
|
|
|
if (!$id) {
|
2026-03-02 07:02:51 +07:00
|
|
|
throw new \Exception('Failed to insert main test definition');
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->handleDetails($id, $input, 'insert');
|
|
|
|
|
|
|
|
|
|
$this->db->transComplete();
|
|
|
|
|
|
|
|
|
|
if ($this->db->transStatus() === false) {
|
|
|
|
|
return $this->failServerError('Transaction failed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->respondCreated([
|
2026-03-02 07:02:51 +07:00
|
|
|
'status' => 'created',
|
|
|
|
|
'message' => 'Test created successfully',
|
|
|
|
|
'data' => ['TestSiteId' => $id],
|
2026-01-05 16:55:34 +07:00
|
|
|
]);
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
$this->db->transRollback();
|
2026-03-02 07:02:51 +07:00
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function update($id = null)
|
|
|
|
|
{
|
|
|
|
|
$input = $this->request->getJSON(true);
|
|
|
|
|
|
2026-03-02 07:02:51 +07:00
|
|
|
if (!$id && isset($input['TestSiteID'])) {
|
|
|
|
|
$id = $input['TestSiteID'];
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
if (!$id) {
|
|
|
|
|
return $this->failValidationErrors('TestSiteID is required.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$existing = $this->model->find($id);
|
|
|
|
|
if (!$existing) {
|
|
|
|
|
return $this->failNotFound('Test not found');
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:47:47 +07:00
|
|
|
$testType = $input['TestType'] ?? $existing['TestType'] ?? '';
|
|
|
|
|
$details = $input['details'] ?? $input;
|
|
|
|
|
$resultType = $details['ResultType'] ?? $existing['ResultType'] ?? '';
|
|
|
|
|
$refType = $details['RefType'] ?? $existing['RefType'] ?? '';
|
|
|
|
|
|
|
|
|
|
if (TestValidationService::isCalc($testType)) {
|
|
|
|
|
$resultType = 'NMRIC';
|
|
|
|
|
$refType = $refType ?: 'RANGE';
|
|
|
|
|
} elseif (TestValidationService::isGroup($testType) || TestValidationService::isTitle($testType)) {
|
|
|
|
|
$resultType = 'NORES';
|
|
|
|
|
$refType = 'NOREF';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if ($resultType && $refType) {
|
|
|
|
|
$validation = TestValidationService::validate($testType, $resultType, $refType);
|
|
|
|
|
if (!$validation['valid']) {
|
|
|
|
|
return $this->failValidationErrors(['type_validation' => $validation['error']]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
$this->db->transStart();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$testSiteData = [];
|
|
|
|
|
$allowedUpdateFields = [
|
|
|
|
|
'TestSiteCode',
|
|
|
|
|
'TestSiteName',
|
|
|
|
|
'TestType',
|
|
|
|
|
'Description',
|
|
|
|
|
'SeqScr',
|
|
|
|
|
'SeqRpt',
|
|
|
|
|
'IndentLeft',
|
|
|
|
|
'FontStyle',
|
|
|
|
|
'VisibleScr',
|
|
|
|
|
'VisibleRpt',
|
|
|
|
|
'CountStat',
|
2026-03-02 07:02:51 +07:00
|
|
|
'StartDate',
|
2026-01-05 16:55:34 +07:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
foreach ($allowedUpdateFields as $field) {
|
|
|
|
|
if (isset($input[$field])) {
|
|
|
|
|
$testSiteData[$field] = $input[$field];
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!empty($testSiteData)) {
|
|
|
|
|
$this->model->update($id, $testSiteData);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->handleDetails($id, $input, 'update');
|
|
|
|
|
|
|
|
|
|
$this->db->transComplete();
|
|
|
|
|
|
|
|
|
|
if ($this->db->transStatus() === false) {
|
|
|
|
|
return $this->failServerError('Transaction failed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->respond([
|
2026-03-02 07:02:51 +07:00
|
|
|
'status' => 'success',
|
|
|
|
|
'message' => 'Test updated successfully',
|
|
|
|
|
'data' => ['TestSiteId' => $id],
|
2026-01-05 16:55:34 +07:00
|
|
|
]);
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
$this->db->transRollback();
|
2026-03-02 07:02:51 +07:00
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function delete($id = null)
|
|
|
|
|
{
|
|
|
|
|
$input = $this->request->getJSON(true);
|
|
|
|
|
|
2026-03-02 07:02:51 +07:00
|
|
|
if (!$id && isset($input['TestSiteID'])) {
|
|
|
|
|
$id = $input['TestSiteID'];
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
if (!$id) {
|
|
|
|
|
return $this->failValidationErrors('TestSiteID is required.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$existing = $this->model->find($id);
|
|
|
|
|
if (!$existing) {
|
|
|
|
|
return $this->failNotFound('Test not found');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!empty($existing['EndDate'])) {
|
|
|
|
|
return $this->failValidationErrors('Test is already disabled');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$this->db->transStart();
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
$now = date('Y-m-d H:i:s');
|
|
|
|
|
|
|
|
|
|
$this->model->update($id, ['EndDate' => $now]);
|
|
|
|
|
|
|
|
|
|
$testType = $existing['TestType'];
|
2026-01-12 16:53:41 +07:00
|
|
|
$typeCode = $testType;
|
2026-01-05 16:55:34 +07:00
|
|
|
|
2026-02-20 13:47:47 +07:00
|
|
|
if (TestValidationService::isCalc($typeCode)) {
|
2026-03-03 06:03:27 +07:00
|
|
|
$this->modelCal->disableByTestSiteID($id);
|
2026-02-20 13:47:47 +07:00
|
|
|
} elseif (TestValidationService::isGroup($typeCode)) {
|
2026-03-03 06:03:27 +07:00
|
|
|
$this->modelGrp->disableByTestSiteID($id);
|
2026-02-20 13:47:47 +07:00
|
|
|
} elseif (TestValidationService::isTechnicalTest($typeCode)) {
|
2026-01-05 16:55:34 +07:00
|
|
|
$this->modelRefNum->where('TestSiteID', $id)->set('EndDate', $now)->update();
|
|
|
|
|
$this->modelRefTxt->where('TestSiteID', $id)->set('EndDate', $now)->update();
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 13:51:27 +07:00
|
|
|
// Disable testmap by test code
|
|
|
|
|
$testSiteCode = $existing['TestSiteCode'] ?? null;
|
|
|
|
|
if ($testSiteCode) {
|
|
|
|
|
$existingMaps = $this->modelMap->getMappingsByTestCode($testSiteCode);
|
|
|
|
|
foreach ($existingMaps as $existingMap) {
|
|
|
|
|
$this->modelMapDetail->where('TestMapID', $existingMap['TestMapID'])
|
|
|
|
|
->where('EndDate', null)
|
|
|
|
|
->set('EndDate', $now)
|
|
|
|
|
->update();
|
|
|
|
|
$this->modelMap->update($existingMap['TestMapID'], ['EndDate' => $now]);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
$this->db->transComplete();
|
|
|
|
|
|
|
|
|
|
if ($this->db->transStatus() === false) {
|
|
|
|
|
return $this->failServerError('Transaction failed');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $this->respond([
|
2026-03-02 07:02:51 +07:00
|
|
|
'status' => 'success',
|
|
|
|
|
'message' => 'Test disabled successfully',
|
|
|
|
|
'data' => ['TestSiteId' => $id, 'EndDate' => $now],
|
2026-01-05 16:55:34 +07:00
|
|
|
]);
|
|
|
|
|
} catch (\Exception $e) {
|
|
|
|
|
$this->db->transRollback();
|
2026-03-02 07:02:51 +07:00
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function handleDetails($testSiteID, $input, $action)
|
|
|
|
|
{
|
|
|
|
|
$testTypeID = $input['TestType'] ?? null;
|
2026-03-03 13:51:27 +07:00
|
|
|
$testSiteCode = null;
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
if (!$testTypeID && $action === 'update') {
|
|
|
|
|
$existing = $this->model->find($testSiteID);
|
|
|
|
|
$testTypeID = $existing['TestType'] ?? null;
|
2026-03-03 13:51:27 +07:00
|
|
|
$testSiteCode = $existing['TestSiteCode'] ?? null;
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
|
2026-03-02 07:02:51 +07:00
|
|
|
if (!$testTypeID) {
|
2026-01-05 16:55:34 +07:00
|
|
|
return;
|
2026-03-02 07:02:51 +07:00
|
|
|
}
|
2026-01-05 16:55:34 +07:00
|
|
|
|
2026-01-12 16:53:41 +07:00
|
|
|
$typeCode = $testTypeID;
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
$details = $input['details'] ?? $input;
|
|
|
|
|
$details['TestSiteID'] = $testSiteID;
|
|
|
|
|
$details['SiteID'] = $input['SiteID'] ?? 1;
|
|
|
|
|
|
|
|
|
|
switch ($typeCode) {
|
|
|
|
|
case 'CALC':
|
|
|
|
|
$this->saveCalcDetails($testSiteID, $details, $action);
|
2026-03-02 07:02:51 +07:00
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'GROUP':
|
|
|
|
|
$this->saveGroupDetails($testSiteID, $details, $input, $action);
|
2026-03-02 07:02:51 +07:00
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'TITLE':
|
|
|
|
|
if (isset($input['testmap']) && is_array($input['testmap'])) {
|
2026-03-03 13:51:27 +07:00
|
|
|
$this->saveTestMap($testSiteID, $testSiteCode, $input['testmap'], $action);
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
2026-03-02 07:02:51 +07:00
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
break;
|
|
|
|
|
|
|
|
|
|
case 'TEST':
|
|
|
|
|
case 'PARAM':
|
|
|
|
|
default:
|
|
|
|
|
$this->saveTechDetails($testSiteID, $details, $action, $typeCode);
|
|
|
|
|
|
|
|
|
|
if (in_array($typeCode, ['TEST', 'PARAM']) && isset($details['RefType'])) {
|
feat(api): transition to headless architecture and enhance order management
This commit marks a significant architectural shift, transitioning the CLQMS backend to a fully headless REST API. All view-related components have been removed to focus solely on providing a robust, stateless API for clinical laboratory workflows.
### Architectural Changes
- **Headless API Transition:**
- Removed all view files (`app/Views/v2`), associated page controllers (`PagesController`), and routes (`Routes.php`). The application no longer serves a front-end UI.
- The root endpoint (`/`) now returns a simple "Backend Running" status message.
- **Developer Tooling & Guidance:**
- Replaced `CLAUDE.md` with `GEMINI.md` to provide updated context and instructional guidelines for Gemini agents.
- Updated `.serena/project.yml` with project configuration.
### Feature Enhancements
- **Advanced Order Management (`OrderTestModel`):**
- **Test Expansion:** The `createOrder` process now automatically expands `GROUP` (panel) tests into their individual components and recursively includes all parameter dependencies for `CALC` (calculated) tests.
- **Order Comments:** Added support for attaching comments to an order via the `ordercom` table.
- **Status Tracking:** Order status updates are now correctly recorded in the `orderstatus` table.
- **Schema Alignment:** Switched from `OrderID` to `InternalOID` as the primary key for internal operations.
- **Reference Range Refactor (`TestsController`):**
- Simplified reference range logic by consolidating `refthold` and `refvset` into the main `refnum` and `reftxt` tables.
- Standardized `RefType` handling to support `NMRC`, `TEXT`, `THOLD`, and `VSET` codes from the `reference_type` ValueSet.
### Other Changes
- **Documentation:**
- `PRD.md`, `README.md`, and `TODO.md` were updated to reflect the headless architecture, refined scope, and current project priorities.
- **Database:**
- Removed obsolete `RefTHoldID` and `RefVSetID` columns from the `patres` table migration.
- **Testing:**
- Added new feature tests for `ContactController`, `OrganizationController`, and `TestsController`.
2026-01-31 09:27:32 +07:00
|
|
|
$refType = (string) $details['RefType'];
|
2026-02-20 13:47:47 +07:00
|
|
|
$resultType = $details['ResultType'] ?? '';
|
2026-01-05 16:55:34 +07:00
|
|
|
|
2026-02-20 13:47:47 +07:00
|
|
|
if (TestValidationService::usesRefNum($resultType, $refType) && isset($input['refnum']) && is_array($input['refnum'])) {
|
2026-01-05 16:55:34 +07:00
|
|
|
$this->saveRefNumRanges($testSiteID, $input['refnum'], $action, $input['SiteID'] ?? 1);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:47:47 +07:00
|
|
|
if (TestValidationService::usesRefTxt($resultType, $refType) && isset($input['reftxt']) && is_array($input['reftxt'])) {
|
2026-01-05 16:55:34 +07:00
|
|
|
$this->saveRefTxtRanges($testSiteID, $input['reftxt'], $action, $input['SiteID'] ?? 1);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-02 07:02:51 +07:00
|
|
|
|
2026-01-05 16:55:34 +07:00
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-20 13:47:47 +07:00
|
|
|
if ((TestValidationService::isTechnicalTest($typeCode) || TestValidationService::isCalc($typeCode)) && isset($input['testmap']) && is_array($input['testmap'])) {
|
2026-03-03 13:51:27 +07:00
|
|
|
$this->saveTestMap($testSiteID, $testSiteCode, $input['testmap'], $action);
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function saveTechDetails($testSiteID, $data, $action, $typeCode)
|
|
|
|
|
{
|
|
|
|
|
$techData = [
|
2026-03-02 07:02:51 +07:00
|
|
|
'DisciplineID' => $data['DisciplineID'] ?? null,
|
|
|
|
|
'DepartmentID' => $data['DepartmentID'] ?? null,
|
|
|
|
|
'ResultType' => $data['ResultType'] ?? null,
|
|
|
|
|
'RefType' => $data['RefType'] ?? null,
|
|
|
|
|
'VSet' => $data['VSet'] ?? null,
|
|
|
|
|
'ReqQty' => $data['ReqQty'] ?? null,
|
|
|
|
|
'ReqQtyUnit' => $data['ReqQtyUnit'] ?? null,
|
|
|
|
|
'Unit1' => $data['Unit1'] ?? null,
|
|
|
|
|
'Factor' => $data['Factor'] ?? null,
|
|
|
|
|
'Unit2' => $data['Unit2'] ?? null,
|
|
|
|
|
'Decimal' => $data['Decimal'] ?? 2,
|
|
|
|
|
'CollReq' => $data['CollReq'] ?? null,
|
|
|
|
|
'Method' => $data['Method'] ?? null,
|
|
|
|
|
'ExpectedTAT' => $data['ExpectedTAT'] ?? null,
|
2026-01-05 16:55:34 +07:00
|
|
|
];
|
|
|
|
|
|
2026-02-19 13:20:24 +07:00
|
|
|
$this->model->update($testSiteID, $techData);
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function saveRefNumRanges($testSiteID, $ranges, $action, $siteID)
|
|
|
|
|
{
|
|
|
|
|
if ($action === 'update') {
|
|
|
|
|
$this->modelRefNum->where('TestSiteID', $testSiteID)
|
|
|
|
|
->set('EndDate', date('Y-m-d H:i:s'))
|
|
|
|
|
->update();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($ranges as $index => $range) {
|
|
|
|
|
$this->modelRefNum->insert([
|
2026-03-02 07:02:51 +07:00
|
|
|
'TestSiteID' => $testSiteID,
|
|
|
|
|
'SiteID' => $siteID,
|
|
|
|
|
'NumRefType' => $range['NumRefType'],
|
|
|
|
|
'RangeType' => $range['RangeType'],
|
|
|
|
|
'Sex' => $range['Sex'],
|
|
|
|
|
'AgeStart' => (int) ($range['AgeStart'] ?? 0),
|
|
|
|
|
'AgeEnd' => (int) ($range['AgeEnd'] ?? 150),
|
|
|
|
|
'LowSign' => !empty($range['LowSign']) ? $range['LowSign'] : null,
|
|
|
|
|
'Low' => !empty($range['Low']) ? (float) $range['Low'] : null,
|
|
|
|
|
'HighSign' => !empty($range['HighSign']) ? $range['HighSign'] : null,
|
|
|
|
|
'High' => !empty($range['High']) ? (float) $range['High'] : null,
|
|
|
|
|
'Flag' => $range['Flag'] ?? null,
|
|
|
|
|
'Interpretation'=> $range['Interpretation'] ?? null,
|
|
|
|
|
'Display' => $index,
|
|
|
|
|
'CreateDate' => date('Y-m-d H:i:s'),
|
2026-01-05 16:55:34 +07:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function saveRefTxtRanges($testSiteID, $ranges, $action, $siteID)
|
|
|
|
|
{
|
|
|
|
|
if ($action === 'update') {
|
|
|
|
|
$this->modelRefTxt->where('TestSiteID', $testSiteID)
|
|
|
|
|
->set('EndDate', date('Y-m-d H:i:s'))
|
|
|
|
|
->update();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
foreach ($ranges as $range) {
|
|
|
|
|
$this->modelRefTxt->insert([
|
|
|
|
|
'TestSiteID' => $testSiteID,
|
2026-03-02 07:02:51 +07:00
|
|
|
'SiteID' => $siteID,
|
2026-01-12 16:53:41 +07:00
|
|
|
'TxtRefType' => $range['TxtRefType'],
|
2026-03-02 07:02:51 +07:00
|
|
|
'Sex' => $range['Sex'],
|
|
|
|
|
'AgeStart' => (int) ($range['AgeStart'] ?? 0),
|
|
|
|
|
'AgeEnd' => (int) ($range['AgeEnd'] ?? 150),
|
|
|
|
|
'RefTxt' => $range['RefTxt'] ?? '',
|
|
|
|
|
'Flag' => $range['Flag'] ?? null,
|
|
|
|
|
'CreateDate' => date('Y-m-d H:i:s'),
|
2026-01-05 16:55:34 +07:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function saveCalcDetails($testSiteID, $data, $action)
|
|
|
|
|
{
|
|
|
|
|
$calcData = [
|
2026-03-02 07:02:51 +07:00
|
|
|
'TestSiteID' => $testSiteID,
|
|
|
|
|
'DisciplineID' => $data['DisciplineID'] ?? null,
|
|
|
|
|
'DepartmentID' => $data['DepartmentID'] ?? null,
|
|
|
|
|
'FormulaInput' => $data['FormulaInput'] ?? null,
|
|
|
|
|
'FormulaCode' => $data['FormulaCode'] ?? $data['Formula'] ?? null,
|
|
|
|
|
'ResultType' => 'NMRIC',
|
|
|
|
|
'RefType' => $data['RefType'] ?? 'RANGE',
|
|
|
|
|
'Unit1' => $data['Unit1'] ?? $data['ResultUnit'] ?? null,
|
|
|
|
|
'Factor' => $data['Factor'] ?? null,
|
|
|
|
|
'Unit2' => $data['Unit2'] ?? null,
|
|
|
|
|
'Decimal' => $data['Decimal'] ?? 2,
|
|
|
|
|
'Method' => $data['Method'] ?? null,
|
2026-01-05 16:55:34 +07:00
|
|
|
];
|
|
|
|
|
|
|
|
|
|
if ($action === 'update') {
|
2026-03-03 06:03:27 +07:00
|
|
|
$exists = $this->modelCal->existsByTestSiteID($testSiteID);
|
2026-01-05 16:55:34 +07:00
|
|
|
|
|
|
|
|
if ($exists) {
|
|
|
|
|
$this->modelCal->update($exists['TestCalID'], $calcData);
|
|
|
|
|
} else {
|
|
|
|
|
$this->modelCal->insert($calcData);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
$this->modelCal->insert($calcData);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function saveGroupDetails($testSiteID, $data, $input, $action)
|
|
|
|
|
{
|
|
|
|
|
if ($action === 'update') {
|
2026-03-03 06:03:27 +07:00
|
|
|
$this->modelGrp->disableByTestSiteID($testSiteID);
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$members = $data['members'] ?? ($input['Members'] ?? []);
|
|
|
|
|
|
|
|
|
|
if (is_array($members)) {
|
|
|
|
|
foreach ($members as $m) {
|
|
|
|
|
$memberID = is_array($m) ? ($m['Member'] ?? ($m['TestSiteID'] ?? null)) : $m;
|
|
|
|
|
if ($memberID) {
|
|
|
|
|
$this->modelGrp->insert([
|
|
|
|
|
'TestSiteID' => $testSiteID,
|
2026-03-02 07:02:51 +07:00
|
|
|
'Member' => $memberID,
|
2026-01-05 16:55:34 +07:00
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-03 13:51:27 +07:00
|
|
|
private function saveTestMap($testSiteID, $testSiteCode, $mappings, $action)
|
2026-01-05 16:55:34 +07:00
|
|
|
{
|
2026-03-03 13:51:27 +07:00
|
|
|
if ($action === 'update' && $testSiteCode) {
|
|
|
|
|
// Find existing mappings by test code through testmapdetail
|
|
|
|
|
$existingMaps = $this->modelMap->getMappingsByTestCode($testSiteCode);
|
2026-03-02 07:02:51 +07:00
|
|
|
|
2026-02-26 16:48:10 +07:00
|
|
|
foreach ($existingMaps as $existingMap) {
|
|
|
|
|
$this->modelMapDetail->where('TestMapID', $existingMap['TestMapID'])
|
2026-03-03 06:03:27 +07:00
|
|
|
->where('EndDate', null)
|
2026-02-26 16:48:10 +07:00
|
|
|
->set('EndDate', date('Y-m-d H:i:s'))
|
|
|
|
|
->update();
|
|
|
|
|
}
|
2026-03-02 07:02:51 +07:00
|
|
|
|
2026-03-03 13:51:27 +07:00
|
|
|
// Soft delete the testmap headers
|
|
|
|
|
foreach ($existingMaps as $existingMap) {
|
|
|
|
|
$this->modelMap->update($existingMap['TestMapID'], ['EndDate' => date('Y-m-d H:i:s')]);
|
|
|
|
|
}
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (is_array($mappings)) {
|
|
|
|
|
foreach ($mappings as $map) {
|
|
|
|
|
$mapData = [
|
2026-03-02 07:02:51 +07:00
|
|
|
'HostType' => $map['HostType'] ?? null,
|
|
|
|
|
'HostID' => $map['HostID'] ?? null,
|
2026-01-05 16:55:34 +07:00
|
|
|
'ClientType' => $map['ClientType'] ?? null,
|
2026-03-02 07:02:51 +07:00
|
|
|
'ClientID' => $map['ClientID'] ?? null,
|
2026-01-05 16:55:34 +07:00
|
|
|
];
|
2026-02-26 16:48:10 +07:00
|
|
|
$testMapID = $this->modelMap->insert($mapData);
|
|
|
|
|
|
|
|
|
|
if ($testMapID && isset($map['details']) && is_array($map['details'])) {
|
|
|
|
|
foreach ($map['details'] as $detail) {
|
|
|
|
|
$detailData = [
|
2026-03-02 07:02:51 +07:00
|
|
|
'TestMapID' => $testMapID,
|
|
|
|
|
'HostTestCode' => $detail['HostTestCode'] ?? null,
|
|
|
|
|
'HostTestName' => $detail['HostTestName'] ?? null,
|
|
|
|
|
'ConDefID' => $detail['ConDefID'] ?? null,
|
2026-02-26 16:48:10 +07:00
|
|
|
'ClientTestCode' => $detail['ClientTestCode'] ?? null,
|
|
|
|
|
'ClientTestName' => $detail['ClientTestName'] ?? null,
|
|
|
|
|
];
|
|
|
|
|
$this->modelMapDetail->insert($detailData);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-01-05 16:55:34 +07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-03 06:03:27 +07:00
|
|
|
}
|