diff --git a/app/Controllers/OrderTestController.php b/app/Controllers/OrderTestController.php index 7ef36db..47d1d60 100755 --- a/app/Controllers/OrderTestController.php +++ b/app/Controllers/OrderTestController.php @@ -220,22 +220,63 @@ class OrderTestController extends Controller { return $this->failValidationErrors(['OrderID' => 'OrderID in URL does not match body']); } + $transactionStarted = false; + try { - $input['OrderID'] = $OrderID; $order = $this->model->getOrder($OrderID); if (!$order) { return $this->failNotFound('Order not found'); } $updateData = []; - if (isset($input['Priority'])) $updateData['Priority'] = $input['Priority']; - if (isset($input['OrderStatus'])) $updateData['OrderStatus'] = $input['OrderStatus']; - if (isset($input['OrderingProvider'])) $updateData['OrderingProvider'] = $input['OrderingProvider']; - if (isset($input['DepartmentID'])) $updateData['DepartmentID'] = $input['DepartmentID']; - if (isset($input['WorkstationID'])) $updateData['WorkstationID'] = $input['WorkstationID']; + if (array_key_exists('Priority', $input)) { + $updateData['Priority'] = $input['Priority']; + } + if (array_key_exists('OrderStatus', $input)) { + $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)) { - $this->model->update($order['InternalOID'], $updateData); + $testsDelta = null; + 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); @@ -247,11 +288,51 @@ class OrderTestController extends Controller { 'message' => 'Order updated successfully', 'data' => $updatedOrder ], 200); + } catch (\InvalidArgumentException $e) { + if ($transactionStarted) { + $this->db->transRollback(); + } + + return $this->failValidationErrors(['Tests' => $e->getMessage()]); } catch (\Exception $e) { + if ($transactionStarted) { + $this->db->transRollback(); + } + 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() { $input = $this->request->getJSON(true); $orderID = $input['OrderID'] ?? null; diff --git a/app/Models/OrderTest/OrderTestModel.php b/app/Models/OrderTest/OrderTestModel.php index 0a392e9..6ec2825 100755 --- a/app/Models/OrderTest/OrderTestModel.php +++ b/app/Models/OrderTest/OrderTestModel.php @@ -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 { // Try to find container requirement from test mapping $containerDef = $this->db->table('testmapdetail tmd') diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index 820bd02..0de7cf4 100755 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -1502,6 +1502,44 @@ paths: type: integer WorkstationID: 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: '200': description: Order updated diff --git a/public/paths/orders.yaml b/public/paths/orders.yaml index 9c8b74b..a34dc24 100755 --- a/public/paths/orders.yaml +++ b/public/paths/orders.yaml @@ -252,6 +252,44 @@ type: integer WorkstationID: 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: '200': description: Order updated diff --git a/tests/feature/OrderTest/OrderTestPatchTest.php b/tests/feature/OrderTest/OrderTestPatchTest.php index 025c87d..f03d39e 100755 --- a/tests/feature/OrderTest/OrderTestPatchTest.php +++ b/tests/feature/OrderTest/OrderTestPatchTest.php @@ -1,121 +1,222 @@ - 'localhost', - 'aud' => 'localhost', - 'iat' => time(), - 'nbf' => time(), - 'exp' => time() + 3600, - 'uid' => 1, - 'email' => 'admin@admin.com', - ]; - $this->token = JWT::encode($payload, $key, 'HS256'); - } - - private function authHeaders(): array - { - return ['Cookie' => 'token=' . $this->token]; - } - - private function createOrderTest(array $data = []): array - { - $payload = array_merge([ - 'OrderCode' => 'ORD_' . uniqid(), - 'OrderName' => 'Test Order ' . uniqid(), - ], $data); - - $response = $this->withHeaders($this->authHeaders()) - ->withBodyFormat('json') - ->call('post', $this->endpoint, $payload); - - $response->assertStatus(201); - $decoded = json_decode($response->getJSON(), true); - return $decoded['data']; - } - - public function testPartialUpdateOrderTestSuccess() - { - $order = $this->createOrderTest(); - $id = $order['OrderID']; - - $patch = $this->withHeaders($this->authHeaders()) - ->withBodyFormat('json') - ->call('patch', "{$this->endpoint}/{$id}", ['OrderName' => 'Updated Order']); - - $patch->assertStatus(200); - $patchData = json_decode($patch->getJSON(), true); - $this->assertEquals('success', $patchData['status']); - - $show = $this->withHeaders($this->authHeaders())->call('get', "{$this->endpoint}/{$id}"); - $show->assertStatus(200); - $showData = json_decode($show->getJSON(), true)['data']; - - $this->assertEquals('Updated Order', $showData['OrderName']); - $this->assertEquals($order['OrderCode'], $showData['OrderCode']); - } - - public function testPartialUpdateOrderTestNotFound() - { - $patch = $this->withHeaders($this->authHeaders()) - ->withBodyFormat('json') - ->call('patch', "{$this->endpoint}/999999", ['OrderName' => 'Updated']); - - $patch->assertStatus(404); - } - - public function testPartialUpdateOrderTestInvalidId() - { - $patch = $this->withHeaders($this->authHeaders()) - ->withBodyFormat('json') - ->call('patch', "{$this->endpoint}/invalid", ['OrderName' => 'Updated']); - - $patch->assertStatus(400); - } - - public function testPartialUpdateOrderTestEmptyPayload() - { - $order = $this->createOrderTest(); - $id = $order['OrderID']; - - $patch = $this->withHeaders($this->authHeaders()) - ->withBodyFormat('json') - ->call('patch', "{$this->endpoint}/{$id}", []); - - $patch->assertStatus(400); - } - - public function testPartialUpdateOrderTestSingleField() - { - $order = $this->createOrderTest(); - $id = $order['OrderID']; - - $patch = $this->withHeaders($this->authHeaders()) - ->withBodyFormat('json') - ->call('patch', "{$this->endpoint}/{$id}", ['OrderCode' => 'NEW_' . uniqid()]); - - $patch->assertStatus(200); - $showData = json_decode($this->withHeaders($this->authHeaders()) - ->call('get', "{$this->endpoint}/{$id}") - ->getJSON(), true)['data']; - - $this->assertNotEquals($order['OrderCode'], $showData['OrderCode']); - $this->assertEquals($order['OrderName'], $showData['OrderName']); - } -} + 'localhost', + 'aud' => 'localhost', + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 3600, + 'uid' => 1, + 'email' => 'admin@admin.com', + ]; + $this->token = JWT::encode($payload, $key, 'HS256'); + } + + private function authHeaders(): array + { + return ['Cookie' => 'token=' . $this->token]; + } + + private function findResultByOrderAndSite(int $internalOID, int $testSiteID): ?array + { + $resultModel = new PatResultModel(); + + return $resultModel->where('OrderID', $internalOID) + ->where('TestSiteID', $testSiteID) + ->where('DelDate', null) + ->orderBy('ResultID', 'DESC') + ->first(); + } + + private function findRowBySite(array $rows, int $testSiteID): ?array + { + foreach ($rows as $row) { + if ((int) ($row['TestSiteID'] ?? 0) === $testSiteID) { + return $row; + } + } + + return null; + } + + private function createOrderWithTest(): array + { + $patientModel = new PatientModel(); + $patient = $patientModel->where('DelDate', null)->first(); + $this->assertNotEmpty($patient, 'No active patient found for order patch test.'); + + $testModel = new TestDefSiteModel(); + $tests = $testModel->where('EndDate', null) + ->where('TestType', 'TEST') + ->findAll(2); + + if (count($tests) < 2) { + $tests = $testModel->where('EndDate', null)->findAll(2); + } + + $this->assertGreaterThanOrEqual(2, count($tests), 'Need at least 2 test definitions for lifecycle patch test.'); + + $response = $this->withHeaders($this->authHeaders()) + ->withBodyFormat('json') + ->call('post', $this->endpoint, [ + 'InternalPID' => (int) $patient['InternalPID'], + 'SiteID' => (int) ($tests[0]['SiteID'] ?? 1), + 'Tests' => [ + [ + 'TestSiteID' => (int) $tests[0]['TestSiteID'], + ], + ], + ]); + + $response->assertStatus(201); + $decoded = json_decode($response->getJSON(), true); + $this->assertSame('success', $decoded['status'] ?? null); + + return [ + 'order' => $decoded['data'], + 'baseTest' => $tests[0], + 'extraTest' => $tests[1], + ]; + } + + public function testPatchTestsLifecycleCreatedEditedDeleted(): void + { + $fixture = $this->createOrderWithTest(); + $order = $fixture['order']; + $baseTest = $fixture['baseTest']; + $extraTest = $fixture['extraTest']; + $orderID = $order['OrderID']; + $internalOID = (int) $order['InternalOID']; + $baseSiteID = (int) $baseTest['TestSiteID']; + $extraSiteID = (int) $extraTest['TestSiteID']; + + $createResponse = $this->withHeaders($this->authHeaders()) + ->withBodyFormat('json') + ->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); + } +}