From 399f4d615bc248deedc28ef32888a833e672aaaa Mon Sep 17 00:00:00 2001 From: mahdahar <89adham@gmail.com> Date: Wed, 1 Apr 2026 20:28:12 +0700 Subject: [PATCH] feat: support flat test mapping payloads and align patient identifier validation --- app/Controllers/Patient/PatientController.php | 49 ++++---- app/Controllers/Test/TestsController.php | 111 ++++++++++++------ public/api-docs.bundled.yaml | 100 +++++++++++++--- public/components/schemas/tests.yaml | 30 ++++- public/paths/tests.yaml | 106 ++++++++++++----- tests/feature/Patients/PatientUpdateTest.php | 73 ++++++++++-- tests/feature/Test/TestCreateVariantsTest.php | 82 +++++++++++++ 7 files changed, 428 insertions(+), 123 deletions(-) diff --git a/app/Controllers/Patient/PatientController.php b/app/Controllers/Patient/PatientController.php index adf2e31..4b503f7 100644 --- a/app/Controllers/Patient/PatientController.php +++ b/app/Controllers/Patient/PatientController.php @@ -84,18 +84,12 @@ class PatientController extends Controller { } } - public function create() { - $input = $this->request->getJSON(true); - - // Khusus untuk Override PATIDT - $type = $input['PatIdt']['IdentifierType'] ?? null; - $identifierRulesMap = [ - 'KTP' => 'required|regex_match[/^[0-9]{16}$/]', // 16 pas digit numeric - 'PASS' => 'required|regex_match[/^[A-Za-z0-9]{1,9}$/]', // alphanumeric max 9 - 'SSN' => 'required|regex_match[/^[0-9]{9}$/]', // numeric, pas 9 digit - 'SIM' => 'required|regex_match[/^[0-9]{19,20}$/]', // numeric 19–20 digit - 'KTAS' => 'required|regex_match[/^[0-9]{11}$/]', // numeric, pas 11 digit - ]; + public function create() { + $input = $this->request->getJSON(true); + + // Khusus untuk Override PATIDT + $type = $input['PatIdt']['IdentifierType'] ?? null; + $identifierRulesMap = $this->getPatIdtIdentifierRulesMap(); if ($type === null || $type === '' || !is_string($type)) { $identifierRule = 'permit_empty|max_length[255]'; $this->rules['PatIdt.IdentifierType'] = 'permit_empty'; @@ -127,15 +121,9 @@ class PatientController extends Controller { $input['InternalPID'] = (int) $InternalPID; - // Khusus untuk Override PATIDT - $type = $input['PatIdt']['IdentifierType'] ?? null; - $identifierRulesMap = [ - 'KTP' => 'required|regex_match[/^[0-9]{16}$/]', - 'PASS' => 'required|regex_match[/^[A-Za-z0-9]{6,9}$/]', - 'SSN' => 'required|regex_match[/^[0-9]{3}-[0-9]{2}-[0-9]{4}$/]', - 'SIM' => 'required|regex_match[/^[A-Za-z0-9]{12,14}$/]', - 'KTAS' => 'required|regex_match[/^[A-Za-z0-9]{12,15}$/]', - ]; + // Khusus untuk Override PATIDT + $type = $input['PatIdt']['IdentifierType'] ?? null; + $identifierRulesMap = $this->getPatIdtIdentifierRulesMap(); if ($type === null || $type === '' || !is_string($type)) { $identifierRule = 'permit_empty|max_length[255]'; $this->rules['PatIdt.IdentifierType'] = 'permit_empty'; @@ -150,10 +138,21 @@ class PatientController extends Controller { try { $InternalPID = $this->model->updatePatient($input); return $this->respondCreated([ 'status' => 'success', 'message' => "data $InternalPID update successfully" ]); - } catch (\Exception $e) { - return $this->failServerError('Something went wrong: ' . $e->getMessage()); - } - } + } catch (\Exception $e) { + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + private function getPatIdtIdentifierRulesMap(): array + { + return [ + 'KTP' => 'required|regex_match[/^[0-9]{16}$/]', + 'PASS' => 'required|regex_match[/^[A-Za-z0-9]{1,9}$/]', + 'SSN' => 'required|regex_match[/^[0-9]{9}$/]', + 'SIM' => 'required|regex_match[/^[0-9]{19,20}$/]', + 'KTAS' => 'required|regex_match[/^[0-9]{11}$/]', + ]; + } public function delete() { try { diff --git a/app/Controllers/Test/TestsController.php b/app/Controllers/Test/TestsController.php index da9e70c..129c531 100644 --- a/app/Controllers/Test/TestsController.php +++ b/app/Controllers/Test/TestsController.php @@ -592,9 +592,9 @@ class TestsController extends BaseController } } - private function saveTestMap($testSiteID, $testSiteCode, $mappings, $action) - { - if ($action === 'update' && $testSiteCode) { + private function saveTestMap($testSiteID, $testSiteCode, $mappings, $action) + { + if ($action === 'update' && $testSiteCode) { // Find existing mappings by test code through testmapdetail $existingMaps = $this->modelMap->getMappingsByTestCode($testSiteCode); @@ -605,33 +605,78 @@ class TestsController extends BaseController // Soft delete the testmap headers foreach ($existingMaps as $existingMap) { $this->modelMap->update($existingMap['TestMapID'], ['EndDate' => date('Y-m-d H:i:s')]); - } - } - - if (is_array($mappings)) { - foreach ($mappings as $map) { - $mapData = [ - 'HostType' => $map['HostType'] ?? null, - 'HostID' => $map['HostID'] ?? null, - 'ClientType' => $map['ClientType'] ?? null, - 'ClientID' => $map['ClientID'] ?? null, - ]; - $testMapID = $this->modelMap->insert($mapData); - - if ($testMapID && isset($map['details']) && is_array($map['details'])) { - foreach ($map['details'] as $detail) { - $detailData = [ - 'TestMapID' => $testMapID, - 'HostTestCode' => $detail['HostTestCode'] ?? null, - 'HostTestName' => $detail['HostTestName'] ?? null, - 'ConDefID' => $detail['ConDefID'] ?? null, - 'ClientTestCode' => $detail['ClientTestCode'] ?? null, - 'ClientTestName' => $detail['ClientTestName'] ?? null, - ]; - $this->modelMapDetail->insert($detailData); - } - } - } - } - } -} + } + } + + foreach ($this->normalizeTestMapPayload($mappings) as $map) { + $mapData = [ + 'HostType' => $map['HostType'] ?? null, + 'HostID' => $map['HostID'] ?? null, + 'ClientType' => $map['ClientType'] ?? null, + 'ClientID' => $map['ClientID'] ?? null, + ]; + + $testMapID = $this->modelMap->insert($mapData); + if (!$testMapID) { + continue; + } + + foreach ($this->extractTestMapDetails($map) as $detail) { + $detailData = [ + 'TestMapID' => $testMapID, + 'HostTestCode' => $detail['HostTestCode'] ?? null, + 'HostTestName' => $detail['HostTestName'] ?? null, + 'ConDefID' => $detail['ConDefID'] ?? null, + 'ClientTestCode' => $detail['ClientTestCode'] ?? null, + 'ClientTestName' => $detail['ClientTestName'] ?? null, + ]; + $this->modelMapDetail->insert($detailData); + } + } + } + + private function normalizeTestMapPayload($mappings): array + { + if (!is_array($mappings)) { + return []; + } + + if ($this->isAssoc($mappings)) { + return [$mappings]; + } + + return array_values(array_filter($mappings, static fn ($map) => is_array($map))); + } + + private function extractTestMapDetails(array $map): array + { + if (isset($map['details']) && is_array($map['details'])) { + return array_values(array_filter($map['details'], static fn ($detail) => is_array($detail))); + } + + $flatDetail = [ + 'HostTestCode' => $map['HostTestCode'] ?? null, + 'HostTestName' => $map['HostTestName'] ?? null, + 'ConDefID' => $map['ConDefID'] ?? null, + 'ClientTestCode' => $map['ClientTestCode'] ?? null, + 'ClientTestName' => $map['ClientTestName'] ?? null, + ]; + + foreach ($flatDetail as $value) { + if ($value !== null && $value !== '') { + return [$flatDetail]; + } + } + + return []; + } + + private function isAssoc(array $array): bool + { + if ($array === []) { + return false; + } + + return array_keys($array) !== range(0, count($array) - 1); + } +} diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index c41effa..7b35a7c 100644 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -4698,6 +4698,26 @@ paths: type: array items: type: object + properties: + HostType: + type: string + HostID: + type: string + HostTestCode: + type: string + HostTestName: + type: string + ClientType: + type: string + ClientID: + type: string + ClientTestCode: + type: string + ClientTestName: + type: string + ConDefID: + type: integer + nullable: true required: - SiteID - TestSiteCode @@ -4801,29 +4821,31 @@ paths: testmap: - HostType: SITE HostID: '1' + HostTestCode: GLU + HostTestName: Glucose ClientType: WST ClientID: '1' - details: - - HostTestCode: GLU - HostTestName: Glucose - ConDefID: 1 - ClientTestCode: GLU_C - ClientTestName: Glucose Client - - HostTestCode: CREA - HostTestName: Creatinine - ConDefID: 2 - ClientTestCode: CREA_C - ClientTestName: Creatinine Client + ClientTestCode: GLU_C + ClientTestName: Glucose Client + ConDefID: 1 + - HostType: SITE + HostID: '1' + HostTestCode: CREA + HostTestName: Creatinine + ClientType: WST + ClientID: '1' + ClientTestCode: CREA_C + ClientTestName: Creatinine Client + ConDefID: 2 - HostType: WST HostID: '3' + HostTestCode: HB + HostTestName: Hemoglobin ClientType: INST ClientID: '2' - details: - - HostTestCode: HB - HostTestName: Hemoglobin - ConDefID: 3 - ClientTestCode: HB_C - ClientTestName: Hemoglobin Client + ClientTestCode: HB_C + ClientTestName: Hemoglobin Client + ConDefID: 3 DisciplineID: 2 DepartmentID: 2 ResultType: NMRIC @@ -5339,6 +5361,26 @@ paths: type: array items: type: object + properties: + HostType: + type: string + HostID: + type: string + HostTestCode: + type: string + HostTestName: + type: string + ClientType: + type: string + ClientID: + type: string + ClientTestCode: + type: string + ClientTestName: + type: string + ConDefID: + type: integer + nullable: true required: - TestSiteID responses: @@ -7056,9 +7098,29 @@ components: format: date-time testmap: type: array - description: Test mappings + description: Flat test mapping payload for /api/test create/update items: - $ref: '#/components/schemas/TestMap' + type: object + properties: + HostType: + type: string + HostID: + type: string + HostTestCode: + type: string + HostTestName: + type: string + ClientType: + type: string + ClientID: + type: string + ClientTestCode: + type: string + ClientTestName: + type: string + ConDefID: + type: integer + nullable: true refnum: type: array description: Numeric reference ranges (optional). Mutually exclusive with reftxt - a test can only have ONE reference type. diff --git a/public/components/schemas/tests.yaml b/public/components/schemas/tests.yaml index 32aef2a..b65fa95 100644 --- a/public/components/schemas/tests.yaml +++ b/public/components/schemas/tests.yaml @@ -218,11 +218,31 @@ TestDefinition: EndDate: type: string format: date-time - testmap: - type: array - description: Test mappings - items: - $ref: '#/TestMap' + testmap: + type: array + description: Flat test mapping payload for /api/test create/update + items: + type: object + properties: + HostType: + type: string + HostID: + type: string + HostTestCode: + type: string + HostTestName: + type: string + ClientType: + type: string + ClientID: + type: string + ClientTestCode: + type: string + ClientTestName: + type: string + ConDefID: + type: integer + nullable: true refnum: type: array description: Numeric reference ranges (optional). Mutually exclusive with reftxt - a test can only have ONE reference type. diff --git a/public/paths/tests.yaml b/public/paths/tests.yaml index 4eb18a4..d2ba247 100644 --- a/public/paths/tests.yaml +++ b/public/paths/tests.yaml @@ -208,12 +208,32 @@ type: array items: type: object - testmap: - type: array - items: - type: object - required: - - SiteID + testmap: + type: array + items: + type: object + properties: + HostType: + type: string + HostID: + type: string + HostTestCode: + type: string + HostTestName: + type: string + ClientType: + type: string + ClientID: + type: string + ClientTestCode: + type: string + ClientTestName: + type: string + ConDefID: + type: integer + nullable: true + required: + - SiteID - TestSiteCode - TestSiteName - TestType @@ -312,32 +332,34 @@ AgeStart: 6570 AgeEnd: 36135 Flag: N - testmap: - - HostType: SITE - HostID: '1' - ClientType: WST - ClientID: '1' - details: - - HostTestCode: GLU - HostTestName: Glucose - ConDefID: 1 - ClientTestCode: GLU_C - ClientTestName: Glucose Client - - HostTestCode: CREA - HostTestName: Creatinine - ConDefID: 2 - ClientTestCode: CREA_C - ClientTestName: Creatinine Client - - HostType: WST - HostID: '3' - ClientType: INST - ClientID: '2' - details: - - HostTestCode: HB - HostTestName: Hemoglobin - ConDefID: 3 - ClientTestCode: HB_C - ClientTestName: Hemoglobin Client + testmap: + - HostType: SITE + HostID: '1' + HostTestCode: GLU + HostTestName: Glucose + ClientType: WST + ClientID: '1' + ClientTestCode: GLU_C + ClientTestName: Glucose Client + ConDefID: 1 + - HostType: SITE + HostID: '1' + HostTestCode: CREA + HostTestName: Creatinine + ClientType: WST + ClientID: '1' + ClientTestCode: CREA_C + ClientTestName: Creatinine Client + ConDefID: 2 + - HostType: WST + HostID: '3' + HostTestCode: HB + HostTestName: Hemoglobin + ClientType: INST + ClientID: '2' + ClientTestCode: HB_C + ClientTestName: Hemoglobin Client + ConDefID: 3 DisciplineID: 2 DepartmentID: 2 ResultType: NMRIC @@ -839,6 +861,26 @@ type: array items: type: object + properties: + HostType: + type: string + HostID: + type: string + HostTestCode: + type: string + HostTestName: + type: string + ClientType: + type: string + ClientID: + type: string + ClientTestCode: + type: string + ClientTestName: + type: string + ConDefID: + type: integer + nullable: true required: - TestSiteID responses: diff --git a/tests/feature/Patients/PatientUpdateTest.php b/tests/feature/Patients/PatientUpdateTest.php index f648da6..1d08572 100644 --- a/tests/feature/Patients/PatientUpdateTest.php +++ b/tests/feature/Patients/PatientUpdateTest.php @@ -184,8 +184,8 @@ class PatientUpdateTest extends CIUnitTestCase /** * 201 - Update tanpa PatAtt */ - public function testUpdateWithAttachments() - { + public function testUpdateWithAttachments() + { $faker = Factory::create('id_ID'); $payload = [ @@ -216,13 +216,68 @@ class PatientUpdateTest extends CIUnitTestCase $payload['DeathDateTime'] = null; } - $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); - $result->assertStatus(201); - } - - // /** - // * 500 - Invalid PatIdt - // */ + $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); + $result->assertStatus(201); + } + + public function testUpdatePatientWithSimIdentifierSuccess() + { + $faker = Factory::create('id_ID'); + + $payload = [ + 'PatientID' => 'SMAJ1', + 'NameFirst' => $faker->firstName, + 'NameMiddle' => $faker->firstName, + 'NameLast' => $faker->lastName, + 'Sex' => '1', + 'Birthdate' => $faker->date('Y-m-d'), + 'EmailAddress1' => 'update_' . $faker->numberBetween(1, 999) . '@gmail.com', + 'Phone' => $faker->numerify('08##########'), + 'MobilePhone' => $faker->numerify('08##########'), + 'PlaceOfBirth' => $faker->city, + 'PatIdt' => [ + 'IdentifierType' => 'SIM', + 'Identifier' => '12345321323213232132', + ], + ]; + + $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); + $result->assertStatus(201); + } + + public function testUpdatePatientWithSimIdentifierInvalidShouldFail() + { + $faker = Factory::create('id_ID'); + + $payload = [ + 'PatientID' => 'SMAJ1', + 'NameFirst' => $faker->firstName, + 'NameMiddle' => $faker->firstName, + 'NameLast' => $faker->lastName, + 'Sex' => '1', + 'Birthdate' => $faker->date('Y-m-d'), + 'EmailAddress1' => 'update_' . $faker->numberBetween(1, 999) . '@gmail.com', + 'Phone' => $faker->numerify('08##########'), + 'MobilePhone' => $faker->numerify('08##########'), + 'PlaceOfBirth' => $faker->city, + 'PatIdt' => [ + 'IdentifierType' => 'SIM', + 'Identifier' => '123456789012345678', + ], + ]; + + $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); + $result->assertStatus(400); + + $json = $result->getJSON(); + $data = json_decode($json, true); + $this->assertArrayHasKey('messages', $data); + $this->assertArrayHasKey('PatIdt.Identifier', $data['messages']); + } + + // /** + // * 500 - Invalid PatIdt + // */ public function testUpdatePatIdtInvalid() { $faker = Factory::create('id_ID'); diff --git a/tests/feature/Test/TestCreateVariantsTest.php b/tests/feature/Test/TestCreateVariantsTest.php index a7fee03..ea1c691 100644 --- a/tests/feature/Test/TestCreateVariantsTest.php +++ b/tests/feature/Test/TestCreateVariantsTest.php @@ -264,6 +264,71 @@ class TestCreateVariantsTest extends CIUnitTestCase } } + public function testCreateTechnicalWithFlatTestMapPayload(): void + { + $testCode = $this->generateTestCode('TEST'); + $payload = [ + 'SiteID' => self::SITE_ID, + 'TestSiteCode' => $testCode, + 'TestSiteName' => 'Auto Flat Map Create', + 'TestType' => 'TEST', + 'isVisibleScr' => 1, + 'isVisibleRpt' => 1, + 'isCountStat' => 1, + 'details' => [ + 'ResultType' => 'NMRIC', + 'RefType' => 'RANGE', + ], + 'testmap' => $this->buildFlatTestMap($testCode), + ]; + + $response = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload); + $response->assertStatus(201); + + $createdId = json_decode($response->getJSON(), true)['data']['TestSiteId']; + $show = $this->call('get', $this->endpoint . '/' . $createdId); + $show->assertStatus(200); + + $data = json_decode($show->getJSON(), true)['data']; + $this->assertNotEmpty($data['testmap']); + $this->assertSame('HIS', $data['testmap'][0]['HostType']); + $this->assertSame('SITE', $data['testmap'][0]['ClientType']); + } + + public function testPatchTechnicalWithFlatTestMapPayload(): void + { + $payload = $this->buildTechnicalPayload('TEST', [ + 'ResultType' => 'NMRIC', + 'RefType' => 'RANGE', + ]); + + $create = $this->withBodyFormat('json')->call('post', $this->endpoint, $payload); + $create->assertStatus(201); + + $createJson = json_decode($create->getJSON(), true); + $testSiteId = $createJson['data']['TestSiteId']; + $testCode = $payload['TestSiteCode']; + + $patchPayload = [ + 'details' => [ + 'ResultType' => 'NMRIC', + 'RefType' => 'RANGE', + ], + 'testmap' => $this->buildFlatTestMap($testCode), + ]; + + $patch = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/' . $testSiteId, $patchPayload); + $patch->assertStatus(200); + + $show = $this->call('get', $this->endpoint . '/' . $testSiteId); + $show->assertStatus(200); + + $data = json_decode($show->getJSON(), true)['data']; + $this->assertNotEmpty($data['testmap']); + $this->assertSame('HIS', $data['testmap'][0]['HostType']); + $this->assertSame('SITE', $data['testmap'][0]['ClientType']); + } + public function testCreateCalculatedTestWithoutReferenceOrMap(): void { $this->assertCalculatedCreated(false); @@ -557,6 +622,23 @@ class TestCreateVariantsTest extends CIUnitTestCase return $map; } + private function buildFlatTestMap(string $testCode): array + { + return [ + [ + 'HostType' => 'HIS', + 'HostID' => 'LOKAL', + 'HostTestCode' => $testCode, + 'HostTestName' => 'Host ' . $testCode, + 'ClientType' => 'SITE', + 'ClientID' => '1', + 'ClientTestCode' => $testCode, + 'ClientTestName' => 'Client ' . $testCode, + 'ConDefID' => null, + ], + ]; + } + private function generateTestCode(string $prefix): string { $clean = strtoupper(substr($prefix, 0, 3));