feat: support flat test mapping payloads and align patient identifier validation

This commit is contained in:
mahdahar 2026-04-01 20:28:12 +07:00
parent 8aefeaca01
commit 399f4d615b
7 changed files with 428 additions and 123 deletions

View File

@ -84,18 +84,12 @@ class PatientController extends Controller {
} }
} }
public function create() { public function create() {
$input = $this->request->getJSON(true); $input = $this->request->getJSON(true);
// Khusus untuk Override PATIDT // Khusus untuk Override PATIDT
$type = $input['PatIdt']['IdentifierType'] ?? null; $type = $input['PatIdt']['IdentifierType'] ?? null;
$identifierRulesMap = [ $identifierRulesMap = $this->getPatIdtIdentifierRulesMap();
'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 1920 digit
'KTAS' => 'required|regex_match[/^[0-9]{11}$/]', // numeric, pas 11 digit
];
if ($type === null || $type === '' || !is_string($type)) { if ($type === null || $type === '' || !is_string($type)) {
$identifierRule = 'permit_empty|max_length[255]'; $identifierRule = 'permit_empty|max_length[255]';
$this->rules['PatIdt.IdentifierType'] = 'permit_empty'; $this->rules['PatIdt.IdentifierType'] = 'permit_empty';
@ -127,15 +121,9 @@ class PatientController extends Controller {
$input['InternalPID'] = (int) $InternalPID; $input['InternalPID'] = (int) $InternalPID;
// Khusus untuk Override PATIDT // Khusus untuk Override PATIDT
$type = $input['PatIdt']['IdentifierType'] ?? null; $type = $input['PatIdt']['IdentifierType'] ?? null;
$identifierRulesMap = [ $identifierRulesMap = $this->getPatIdtIdentifierRulesMap();
'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}$/]',
];
if ($type === null || $type === '' || !is_string($type)) { if ($type === null || $type === '' || !is_string($type)) {
$identifierRule = 'permit_empty|max_length[255]'; $identifierRule = 'permit_empty|max_length[255]';
$this->rules['PatIdt.IdentifierType'] = 'permit_empty'; $this->rules['PatIdt.IdentifierType'] = 'permit_empty';
@ -150,10 +138,21 @@ class PatientController extends Controller {
try { try {
$InternalPID = $this->model->updatePatient($input); $InternalPID = $this->model->updatePatient($input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $InternalPID update successfully" ]); return $this->respondCreated([ 'status' => 'success', 'message' => "data $InternalPID update successfully" ]);
} catch (\Exception $e) { } catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage()); 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() { public function delete() {
try { try {

View File

@ -592,9 +592,9 @@ class TestsController extends BaseController
} }
} }
private function saveTestMap($testSiteID, $testSiteCode, $mappings, $action) private function saveTestMap($testSiteID, $testSiteCode, $mappings, $action)
{ {
if ($action === 'update' && $testSiteCode) { if ($action === 'update' && $testSiteCode) {
// Find existing mappings by test code through testmapdetail // Find existing mappings by test code through testmapdetail
$existingMaps = $this->modelMap->getMappingsByTestCode($testSiteCode); $existingMaps = $this->modelMap->getMappingsByTestCode($testSiteCode);
@ -605,33 +605,78 @@ class TestsController extends BaseController
// Soft delete the testmap headers // Soft delete the testmap headers
foreach ($existingMaps as $existingMap) { foreach ($existingMaps as $existingMap) {
$this->modelMap->update($existingMap['TestMapID'], ['EndDate' => date('Y-m-d H:i:s')]); $this->modelMap->update($existingMap['TestMapID'], ['EndDate' => date('Y-m-d H:i:s')]);
} }
} }
if (is_array($mappings)) { foreach ($this->normalizeTestMapPayload($mappings) as $map) {
foreach ($mappings as $map) { $mapData = [
$mapData = [ 'HostType' => $map['HostType'] ?? null,
'HostType' => $map['HostType'] ?? null, 'HostID' => $map['HostID'] ?? null,
'HostID' => $map['HostID'] ?? null, 'ClientType' => $map['ClientType'] ?? null,
'ClientType' => $map['ClientType'] ?? null, 'ClientID' => $map['ClientID'] ?? null,
'ClientID' => $map['ClientID'] ?? null, ];
];
$testMapID = $this->modelMap->insert($mapData); $testMapID = $this->modelMap->insert($mapData);
if (!$testMapID) {
if ($testMapID && isset($map['details']) && is_array($map['details'])) { continue;
foreach ($map['details'] as $detail) { }
$detailData = [
'TestMapID' => $testMapID, foreach ($this->extractTestMapDetails($map) as $detail) {
'HostTestCode' => $detail['HostTestCode'] ?? null, $detailData = [
'HostTestName' => $detail['HostTestName'] ?? null, 'TestMapID' => $testMapID,
'ConDefID' => $detail['ConDefID'] ?? null, 'HostTestCode' => $detail['HostTestCode'] ?? null,
'ClientTestCode' => $detail['ClientTestCode'] ?? null, 'HostTestName' => $detail['HostTestName'] ?? null,
'ClientTestName' => $detail['ClientTestName'] ?? null, 'ConDefID' => $detail['ConDefID'] ?? null,
]; 'ClientTestCode' => $detail['ClientTestCode'] ?? null,
$this->modelMapDetail->insert($detailData); '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);
}
}

View File

@ -4698,6 +4698,26 @@ paths:
type: array type: array
items: items:
type: object 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: required:
- SiteID - SiteID
- TestSiteCode - TestSiteCode
@ -4801,29 +4821,31 @@ paths:
testmap: testmap:
- HostType: SITE - HostType: SITE
HostID: '1' HostID: '1'
HostTestCode: GLU
HostTestName: Glucose
ClientType: WST ClientType: WST
ClientID: '1' ClientID: '1'
details: ClientTestCode: GLU_C
- HostTestCode: GLU ClientTestName: Glucose Client
HostTestName: Glucose ConDefID: 1
ConDefID: 1 - HostType: SITE
ClientTestCode: GLU_C HostID: '1'
ClientTestName: Glucose Client HostTestCode: CREA
- HostTestCode: CREA HostTestName: Creatinine
HostTestName: Creatinine ClientType: WST
ConDefID: 2 ClientID: '1'
ClientTestCode: CREA_C ClientTestCode: CREA_C
ClientTestName: Creatinine Client ClientTestName: Creatinine Client
ConDefID: 2
- HostType: WST - HostType: WST
HostID: '3' HostID: '3'
HostTestCode: HB
HostTestName: Hemoglobin
ClientType: INST ClientType: INST
ClientID: '2' ClientID: '2'
details: ClientTestCode: HB_C
- HostTestCode: HB ClientTestName: Hemoglobin Client
HostTestName: Hemoglobin ConDefID: 3
ConDefID: 3
ClientTestCode: HB_C
ClientTestName: Hemoglobin Client
DisciplineID: 2 DisciplineID: 2
DepartmentID: 2 DepartmentID: 2
ResultType: NMRIC ResultType: NMRIC
@ -5339,6 +5361,26 @@ paths:
type: array type: array
items: items:
type: object 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: required:
- TestSiteID - TestSiteID
responses: responses:
@ -7056,9 +7098,29 @@ components:
format: date-time format: date-time
testmap: testmap:
type: array type: array
description: Test mappings description: Flat test mapping payload for /api/test create/update
items: 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: refnum:
type: array type: array
description: Numeric reference ranges (optional). Mutually exclusive with reftxt - a test can only have ONE reference type. description: Numeric reference ranges (optional). Mutually exclusive with reftxt - a test can only have ONE reference type.

View File

@ -218,11 +218,31 @@ TestDefinition:
EndDate: EndDate:
type: string type: string
format: date-time format: date-time
testmap: testmap:
type: array type: array
description: Test mappings description: Flat test mapping payload for /api/test create/update
items: items:
$ref: '#/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: refnum:
type: array type: array
description: Numeric reference ranges (optional). Mutually exclusive with reftxt - a test can only have ONE reference type. description: Numeric reference ranges (optional). Mutually exclusive with reftxt - a test can only have ONE reference type.

View File

@ -208,12 +208,32 @@
type: array type: array
items: items:
type: object type: object
testmap: testmap:
type: array type: array
items: items:
type: object type: object
required: properties:
- SiteID 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 - TestSiteCode
- TestSiteName - TestSiteName
- TestType - TestType
@ -312,32 +332,34 @@
AgeStart: 6570 AgeStart: 6570
AgeEnd: 36135 AgeEnd: 36135
Flag: N Flag: N
testmap: testmap:
- HostType: SITE - HostType: SITE
HostID: '1' HostID: '1'
ClientType: WST HostTestCode: GLU
ClientID: '1' HostTestName: Glucose
details: ClientType: WST
- HostTestCode: GLU ClientID: '1'
HostTestName: Glucose ClientTestCode: GLU_C
ConDefID: 1 ClientTestName: Glucose Client
ClientTestCode: GLU_C ConDefID: 1
ClientTestName: Glucose Client - HostType: SITE
- HostTestCode: CREA HostID: '1'
HostTestName: Creatinine HostTestCode: CREA
ConDefID: 2 HostTestName: Creatinine
ClientTestCode: CREA_C ClientType: WST
ClientTestName: Creatinine Client ClientID: '1'
- HostType: WST ClientTestCode: CREA_C
HostID: '3' ClientTestName: Creatinine Client
ClientType: INST ConDefID: 2
ClientID: '2' - HostType: WST
details: HostID: '3'
- HostTestCode: HB HostTestCode: HB
HostTestName: Hemoglobin HostTestName: Hemoglobin
ConDefID: 3 ClientType: INST
ClientTestCode: HB_C ClientID: '2'
ClientTestName: Hemoglobin Client ClientTestCode: HB_C
ClientTestName: Hemoglobin Client
ConDefID: 3
DisciplineID: 2 DisciplineID: 2
DepartmentID: 2 DepartmentID: 2
ResultType: NMRIC ResultType: NMRIC
@ -839,6 +861,26 @@
type: array type: array
items: items:
type: object 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: required:
- TestSiteID - TestSiteID
responses: responses:

View File

@ -184,8 +184,8 @@ class PatientUpdateTest extends CIUnitTestCase
/** /**
* 201 - Update tanpa PatAtt * 201 - Update tanpa PatAtt
*/ */
public function testUpdateWithAttachments() public function testUpdateWithAttachments()
{ {
$faker = Factory::create('id_ID'); $faker = Factory::create('id_ID');
$payload = [ $payload = [
@ -216,13 +216,68 @@ class PatientUpdateTest extends CIUnitTestCase
$payload['DeathDateTime'] = null; $payload['DeathDateTime'] = null;
} }
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload); $result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201); $result->assertStatus(201);
} }
// /** public function testUpdatePatientWithSimIdentifierSuccess()
// * 500 - Invalid PatIdt {
// */ $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() public function testUpdatePatIdtInvalid()
{ {
$faker = Factory::create('id_ID'); $faker = Factory::create('id_ID');

View File

@ -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 public function testCreateCalculatedTestWithoutReferenceOrMap(): void
{ {
$this->assertCalculatedCreated(false); $this->assertCalculatedCreated(false);
@ -557,6 +622,23 @@ class TestCreateVariantsTest extends CIUnitTestCase
return $map; 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 private function generateTestCode(string $prefix): string
{ {
$clean = strtoupper(substr($prefix, 0, 3)); $clean = strtoupper(substr($prefix, 0, 3));