Support test lifecycle updates in order patch

- add Tests delta handling to order patch endpoint\n- create/edit/soft-delete patres rows in transaction\n- update OpenAPI request schema for Tests payload\n- add feature coverage for create/edit/delete lifecycle
This commit is contained in:
mahdahar 2026-04-23 10:45:23 +07:00
parent e869ec4ac4
commit 6b4ffe017c
5 changed files with 549 additions and 129 deletions

View File

@ -220,22 +220,63 @@ class OrderTestController extends Controller {
return $this->failValidationErrors(['OrderID' => 'OrderID in URL does not match body']); return $this->failValidationErrors(['OrderID' => 'OrderID in URL does not match body']);
} }
$transactionStarted = false;
try { try {
$input['OrderID'] = $OrderID;
$order = $this->model->getOrder($OrderID); $order = $this->model->getOrder($OrderID);
if (!$order) { if (!$order) {
return $this->failNotFound('Order not found'); return $this->failNotFound('Order not found');
} }
$updateData = []; $updateData = [];
if (isset($input['Priority'])) $updateData['Priority'] = $input['Priority']; if (array_key_exists('Priority', $input)) {
if (isset($input['OrderStatus'])) $updateData['OrderStatus'] = $input['OrderStatus']; $updateData['Priority'] = $input['Priority'];
if (isset($input['OrderingProvider'])) $updateData['OrderingProvider'] = $input['OrderingProvider']; }
if (isset($input['DepartmentID'])) $updateData['DepartmentID'] = $input['DepartmentID']; if (array_key_exists('OrderStatus', $input)) {
if (isset($input['WorkstationID'])) $updateData['WorkstationID'] = $input['WorkstationID']; $updateData['OrderStatus'] = $input['OrderStatus'];
}
if (array_key_exists('OrderingProvider', $input)) {
$updateData['OrderingProvider'] = $input['OrderingProvider'];
}
if (array_key_exists('DepartmentID', $input)) {
$updateData['DepartmentID'] = $input['DepartmentID'];
}
if (array_key_exists('WorkstationID', $input)) {
$updateData['WorkstationID'] = $input['WorkstationID'];
}
if (!empty($updateData)) { $testsDelta = null;
$this->model->update($order['InternalOID'], $updateData); if (array_key_exists('Tests', $input)) {
$testsDelta = $this->normalizeTestsDelta($input['Tests']);
}
if (empty($updateData) && $testsDelta === null) {
return $this->failValidationErrors(['error' => 'Update payload is required']);
}
$hasUpdates = !empty($updateData) || $testsDelta !== null;
if ($hasUpdates) {
$this->db->transStart();
$transactionStarted = true;
if (!empty($updateData)) {
$updated = $this->model->update($order['InternalOID'], $updateData);
if (!$updated) {
throw new \RuntimeException('Failed to update order');
}
}
if ($testsDelta !== null) {
$this->model->applyTestDelta((int) $order['InternalOID'], $testsDelta, $order);
}
$this->db->transComplete();
if ($this->db->transStatus() === false) {
throw new \RuntimeException('Transaction failed');
}
$transactionStarted = false;
} }
$updatedOrder = $this->model->getOrder($OrderID); $updatedOrder = $this->model->getOrder($OrderID);
@ -247,11 +288,51 @@ class OrderTestController extends Controller {
'message' => 'Order updated successfully', 'message' => 'Order updated successfully',
'data' => $updatedOrder 'data' => $updatedOrder
], 200); ], 200);
} catch (\InvalidArgumentException $e) {
if ($transactionStarted) {
$this->db->transRollback();
}
return $this->failValidationErrors(['Tests' => $e->getMessage()]);
} catch (\Exception $e) { } catch (\Exception $e) {
if ($transactionStarted) {
$this->db->transRollback();
}
return $this->failServerError('Something went wrong: ' . $e->getMessage()); return $this->failServerError('Something went wrong: ' . $e->getMessage());
} }
} }
private function normalizeTestsDelta($tests): ?array {
if (!is_array($tests)) {
throw new \InvalidArgumentException('Tests must be an object');
}
$delta = [
'created' => [],
'edited' => [],
'deleted' => [],
];
foreach (array_keys($delta) as $key) {
if (!array_key_exists($key, $tests)) {
continue;
}
if (!is_array($tests[$key])) {
throw new \InvalidArgumentException(ucfirst($key) . ' tests must be an array');
}
$delta[$key] = $tests[$key];
}
if (empty($delta['created']) && empty($delta['edited']) && empty($delta['deleted'])) {
throw new \InvalidArgumentException('Tests delta is required');
}
return $delta;
}
public function delete() { public function delete() {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
$orderID = $input['OrderID'] ?? null; $orderID = $input['OrderID'] ?? null;

View File

@ -231,6 +231,168 @@ class OrderTestModel extends BaseModel {
} }
} }
public function applyTestDelta(int $orderOID, array $testsDelta, ?array $order = null): array {
$order ??= $this->find($orderOID);
if (!$order) {
throw new \InvalidArgumentException('Order not found');
}
$summary = [
'created' => [],
'edited' => [],
'deleted' => [],
];
foreach (($testsDelta['created'] ?? []) as $item) {
if (!is_array($item)) {
throw new \InvalidArgumentException('Invalid created test payload');
}
$summary['created'][] = $this->createTestRow($order, $item);
}
foreach (($testsDelta['edited'] ?? []) as $item) {
if (!is_array($item)) {
throw new \InvalidArgumentException('Invalid edited test payload');
}
$summary['edited'][] = $this->editTestRow($orderOID, $item);
}
foreach (($testsDelta['deleted'] ?? []) as $item) {
$testSiteID = is_array($item) ? (int) ($item['TestSiteID'] ?? 0) : (int) $item;
if ($testSiteID <= 0) {
throw new \InvalidArgumentException('TestSiteID is required for deleted tests');
}
$summary['deleted'][] = $this->deleteTestRow($orderOID, $testSiteID);
}
return $summary;
}
private function createTestRow(array $order, array $item): array {
$testSiteID = (int) ($item['TestSiteID'] ?? 0);
if ($testSiteID <= 0) {
throw new \InvalidArgumentException('TestSiteID is required');
}
$testSite = $this->db->table('testdefsite')
->select('TestSiteID, TestSiteCode')
->where('TestSiteID', $testSiteID)
->where('EndDate', null)
->get()
->getRowArray();
if (!$testSite) {
throw new \InvalidArgumentException('Test site not found');
}
$payload = [
'OrderID' => (int) $order['InternalOID'],
'TestSiteID' => $testSiteID,
'TestSiteCode' => $testSite['TestSiteCode'],
'SID' => (string) ($order['OrderID'] ?? ''),
'SampleID' => (string) ($order['OrderID'] ?? ''),
'ResultDateTime' => $item['ResultDateTime'] ?? ($order['TrnDate'] ?? date('Y-m-d H:i:s')),
'CreateDate' => date('Y-m-d H:i:s'),
];
$payload['Result'] = $item['Result'] ?? $item['ResultValue'] ?? null;
$extraFields = array_intersect_key($item, array_flip([
'InternalSID',
'AspCnt',
'ResultCode',
'SampleType',
'WorkstationID',
'EquipmentID',
'RefNumID',
'RefTxtID',
]));
$payload = array_merge($payload, $extraFields);
if (!$this->db->table('patres')->insert($payload)) {
throw new \RuntimeException('Failed to create test row');
}
return [
'ResultID' => (int) $this->db->insertID(),
'TestSiteID' => $testSiteID,
];
}
private function editTestRow(int $orderOID, array $item): array {
$testSiteID = (int) ($item['TestSiteID'] ?? 0);
if ($testSiteID <= 0) {
throw new \InvalidArgumentException('TestSiteID is required');
}
$row = $this->getLatestActiveTestRow($orderOID, $testSiteID);
if (!$row) {
throw new \InvalidArgumentException('Test row not found');
}
$updateData = array_intersect_key($item, array_flip([
'InternalSID',
'AspCnt',
'Result',
'ResultCode',
'SampleType',
'ResultDateTime',
'WorkstationID',
'EquipmentID',
'RefNumID',
'RefTxtID',
]));
if (empty($updateData)) {
throw new \InvalidArgumentException('Edit payload is empty');
}
if (!$this->db->table('patres')
->where('ResultID', $row['ResultID'])
->update($updateData)) {
throw new \RuntimeException('Failed to update test row');
}
return [
'ResultID' => (int) $row['ResultID'],
'TestSiteID' => $testSiteID,
];
}
private function deleteTestRow(int $orderOID, int $testSiteID): array {
$row = $this->getLatestActiveTestRow($orderOID, $testSiteID);
if (!$row) {
throw new \InvalidArgumentException('Test row not found');
}
if (!$this->db->table('patres')
->where('ResultID', $row['ResultID'])
->update(['DelDate' => date('Y-m-d H:i:s')])) {
throw new \RuntimeException('Failed to delete test row');
}
return [
'ResultID' => (int) $row['ResultID'],
'TestSiteID' => $testSiteID,
];
}
private function getLatestActiveTestRow(int $orderOID, int $testSiteID): ?array {
$row = $this->db->table('patres')
->where('OrderID', $orderOID)
->where('TestSiteID', $testSiteID)
->where('DelDate', null)
->orderBy('ResultID', 'DESC')
->get()
->getRowArray();
return $row ?: null;
}
private function getContainerRequirement($testSiteID, $testMapDetailModel, $containerDefModel): array { private function getContainerRequirement($testSiteID, $testMapDetailModel, $containerDefModel): array {
// Try to find container requirement from test mapping // Try to find container requirement from test mapping
$containerDef = $this->db->table('testmapdetail tmd') $containerDef = $this->db->table('testmapdetail tmd')

View File

@ -1502,6 +1502,44 @@ paths:
type: integer type: integer
WorkstationID: WorkstationID:
type: integer type: integer
Tests:
type: object
properties:
created:
type: array
description: New tests to create for this order
items:
type: object
properties:
TestSiteID:
type: integer
Result:
type: string
nullable: true
ResultDateTime:
type: string
format: date-time
nullable: true
edited:
type: array
description: Existing tests to edit by OrderID + TestSiteID
items:
type: object
properties:
TestSiteID:
type: integer
Result:
type: string
nullable: true
ResultDateTime:
type: string
format: date-time
nullable: true
deleted:
type: array
description: TestSiteID values to soft delete by latest active row
items:
type: integer
responses: responses:
'200': '200':
description: Order updated description: Order updated

View File

@ -252,6 +252,44 @@
type: integer type: integer
WorkstationID: WorkstationID:
type: integer type: integer
Tests:
type: object
properties:
created:
type: array
description: New tests to create for this order
items:
type: object
properties:
TestSiteID:
type: integer
Result:
type: string
nullable: true
ResultDateTime:
type: string
format: date-time
nullable: true
edited:
type: array
description: Existing tests to edit by OrderID + TestSiteID
items:
type: object
properties:
TestSiteID:
type: integer
Result:
type: string
nullable: true
ResultDateTime:
type: string
format: date-time
nullable: true
deleted:
type: array
description: TestSiteID values to soft delete by latest active row
items:
type: integer
responses: responses:
'200': '200':
description: Order updated description: Order updated

View File

@ -1,121 +1,222 @@
<?php <?php
namespace Tests\Feature\OrderTest; declare(strict_types=1);
use CodeIgniter\Test\FeatureTestTrait; namespace Tests\Feature\OrderTest;
use CodeIgniter\Test\CIUnitTestCase;
use Firebase\JWT\JWT; use App\Models\OrderTest\OrderTestModel;
use App\Models\PatResultModel;
class OrderTestPatchTest extends CIUnitTestCase use App\Models\Patient\PatientModel;
{ use App\Models\Test\TestDefSiteModel;
use FeatureTestTrait; use CodeIgniter\Test\CIUnitTestCase;
use CodeIgniter\Test\FeatureTestTrait;
protected string $token; use Firebase\JWT\JWT;
protected string $endpoint = 'api/ordertest';
class OrderTestPatchTest extends CIUnitTestCase
protected function setUp(): void {
{ use FeatureTestTrait;
parent::setUp();
$key = getenv('JWT_SECRET') ?: 'my-secret-key'; protected string $token;
$payload = [ protected string $endpoint = 'api/ordertest';
'iss' => 'localhost',
'aud' => 'localhost', protected function setUp(): void
'iat' => time(), {
'nbf' => time(), parent::setUp();
'exp' => time() + 3600,
'uid' => 1, $key = getenv('JWT_SECRET') ?: 'my-secret-key';
'email' => 'admin@admin.com', $payload = [
]; 'iss' => 'localhost',
$this->token = JWT::encode($payload, $key, 'HS256'); 'aud' => 'localhost',
} 'iat' => time(),
'nbf' => time(),
private function authHeaders(): array 'exp' => time() + 3600,
{ 'uid' => 1,
return ['Cookie' => 'token=' . $this->token]; 'email' => 'admin@admin.com',
} ];
$this->token = JWT::encode($payload, $key, 'HS256');
private function createOrderTest(array $data = []): array }
{
$payload = array_merge([ private function authHeaders(): array
'OrderCode' => 'ORD_' . uniqid(), {
'OrderName' => 'Test Order ' . uniqid(), return ['Cookie' => 'token=' . $this->token];
], $data); }
$response = $this->withHeaders($this->authHeaders()) private function findResultByOrderAndSite(int $internalOID, int $testSiteID): ?array
->withBodyFormat('json') {
->call('post', $this->endpoint, $payload); $resultModel = new PatResultModel();
$response->assertStatus(201); return $resultModel->where('OrderID', $internalOID)
$decoded = json_decode($response->getJSON(), true); ->where('TestSiteID', $testSiteID)
return $decoded['data']; ->where('DelDate', null)
} ->orderBy('ResultID', 'DESC')
->first();
public function testPartialUpdateOrderTestSuccess() }
{
$order = $this->createOrderTest(); private function findRowBySite(array $rows, int $testSiteID): ?array
$id = $order['OrderID']; {
foreach ($rows as $row) {
$patch = $this->withHeaders($this->authHeaders()) if ((int) ($row['TestSiteID'] ?? 0) === $testSiteID) {
->withBodyFormat('json') return $row;
->call('patch', "{$this->endpoint}/{$id}", ['OrderName' => 'Updated Order']); }
}
$patch->assertStatus(200);
$patchData = json_decode($patch->getJSON(), true); return null;
$this->assertEquals('success', $patchData['status']); }
$show = $this->withHeaders($this->authHeaders())->call('get', "{$this->endpoint}/{$id}"); private function createOrderWithTest(): array
$show->assertStatus(200); {
$showData = json_decode($show->getJSON(), true)['data']; $patientModel = new PatientModel();
$patient = $patientModel->where('DelDate', null)->first();
$this->assertEquals('Updated Order', $showData['OrderName']); $this->assertNotEmpty($patient, 'No active patient found for order patch test.');
$this->assertEquals($order['OrderCode'], $showData['OrderCode']);
} $testModel = new TestDefSiteModel();
$tests = $testModel->where('EndDate', null)
public function testPartialUpdateOrderTestNotFound() ->where('TestType', 'TEST')
{ ->findAll(2);
$patch = $this->withHeaders($this->authHeaders())
->withBodyFormat('json') if (count($tests) < 2) {
->call('patch', "{$this->endpoint}/999999", ['OrderName' => 'Updated']); $tests = $testModel->where('EndDate', null)->findAll(2);
}
$patch->assertStatus(404);
} $this->assertGreaterThanOrEqual(2, count($tests), 'Need at least 2 test definitions for lifecycle patch test.');
public function testPartialUpdateOrderTestInvalidId() $response = $this->withHeaders($this->authHeaders())
{ ->withBodyFormat('json')
$patch = $this->withHeaders($this->authHeaders()) ->call('post', $this->endpoint, [
->withBodyFormat('json') 'InternalPID' => (int) $patient['InternalPID'],
->call('patch', "{$this->endpoint}/invalid", ['OrderName' => 'Updated']); 'SiteID' => (int) ($tests[0]['SiteID'] ?? 1),
'Tests' => [
$patch->assertStatus(400); [
} 'TestSiteID' => (int) $tests[0]['TestSiteID'],
],
public function testPartialUpdateOrderTestEmptyPayload() ],
{ ]);
$order = $this->createOrderTest();
$id = $order['OrderID']; $response->assertStatus(201);
$decoded = json_decode($response->getJSON(), true);
$patch = $this->withHeaders($this->authHeaders()) $this->assertSame('success', $decoded['status'] ?? null);
->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$id}", []); return [
'order' => $decoded['data'],
$patch->assertStatus(400); 'baseTest' => $tests[0],
} 'extraTest' => $tests[1],
];
public function testPartialUpdateOrderTestSingleField() }
{
$order = $this->createOrderTest(); public function testPatchTestsLifecycleCreatedEditedDeleted(): void
$id = $order['OrderID']; {
$fixture = $this->createOrderWithTest();
$patch = $this->withHeaders($this->authHeaders()) $order = $fixture['order'];
->withBodyFormat('json') $baseTest = $fixture['baseTest'];
->call('patch', "{$this->endpoint}/{$id}", ['OrderCode' => 'NEW_' . uniqid()]); $extraTest = $fixture['extraTest'];
$orderID = $order['OrderID'];
$patch->assertStatus(200); $internalOID = (int) $order['InternalOID'];
$showData = json_decode($this->withHeaders($this->authHeaders()) $baseSiteID = (int) $baseTest['TestSiteID'];
->call('get', "{$this->endpoint}/{$id}") $extraSiteID = (int) $extraTest['TestSiteID'];
->getJSON(), true)['data'];
$createResponse = $this->withHeaders($this->authHeaders())
$this->assertNotEquals($order['OrderCode'], $showData['OrderCode']); ->withBodyFormat('json')
$this->assertEquals($order['OrderName'], $showData['OrderName']); ->call('patch', "{$this->endpoint}/{$orderID}", [
} 'Tests' => [
} 'created' => [
[
'TestSiteID' => $extraSiteID,
'Result' => 'CREATED',
],
],
],
]);
$createResponse->assertStatus(200);
$createJson = json_decode($createResponse->getJSON(), true);
$this->assertSame('success', $createJson['status'] ?? null);
$createdRow = $this->findRowBySite($createJson['data']['Tests'] ?? [], $extraSiteID);
$this->assertNotNull($createdRow, 'Created test not returned by patch response.');
$this->assertSame('CREATED', $createdRow['Result'] ?? null);
$createdResultID = (int) ($createdRow['ResultID'] ?? 0);
$this->assertGreaterThan(0, $createdResultID, 'Created test missing ResultID.');
$createdDbRow = $this->findResultByOrderAndSite($internalOID, $extraSiteID);
$this->assertNotNull($createdDbRow, 'Created test not found in DB.');
$this->assertSame('CREATED', $createdDbRow['Result'] ?? null);
$editResponse = $this->withHeaders($this->authHeaders())
->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$orderID}", [
'Tests' => [
'edited' => [
[
'TestSiteID' => $baseSiteID,
'Result' => 'EDITED',
],
],
],
]);
$editResponse->assertStatus(200);
$editJson = json_decode($editResponse->getJSON(), true);
$this->assertSame('success', $editJson['status'] ?? null);
$editedRow = $this->findRowBySite($editJson['data']['Tests'] ?? [], $baseSiteID);
$this->assertNotNull($editedRow, 'Edited test not returned by patch response.');
$this->assertSame('EDITED', $editedRow['Result'] ?? null);
$deleteResponse = $this->withHeaders($this->authHeaders())
->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$orderID}", [
'Tests' => [
'deleted' => [
$extraSiteID,
],
],
]);
$deleteResponse->assertStatus(200);
$deleteJson = json_decode($deleteResponse->getJSON(), true);
$this->assertSame('success', $deleteJson['status'] ?? null);
$this->assertNull($this->findRowBySite($deleteJson['data']['Tests'] ?? [], $extraSiteID), 'Deleted test still returned by patch response.');
$deletedDbRow = (new PatResultModel())->find($createdResultID);
$this->assertNotNull($deletedDbRow, 'Deleted test row missing from DB.');
$this->assertNotNull($deletedDbRow['DelDate'] ?? null, 'Deleted test row not soft deleted.');
}
public function testPatchTestsNotFound(): void
{
$response = $this->withHeaders($this->authHeaders())
->withBodyFormat('json')
->call('patch', "{$this->endpoint}/999999999", [
'Tests' => [
'deleted' => [1],
],
]);
$response->assertStatus(404);
}
public function testPatchTestsInvalidId(): void
{
$response = $this->withHeaders($this->authHeaders())
->withBodyFormat('json')
->call('patch', "{$this->endpoint}/invalid", [
'Tests' => [
'deleted' => [1],
],
]);
$response->assertStatus(404);
}
public function testPatchTestsEmptyPayload(): void
{
$fixture = $this->createOrderWithTest();
$orderID = $fixture['order']['OrderID'];
$response = $this->withHeaders($this->authHeaders())
->withBodyFormat('json')
->call('patch', "{$this->endpoint}/{$orderID}", []);
$response->assertStatus(400);
}
}