feat: support partial patient patch updates

Implement true PATCH behavior so omitted fields stay unchanged, while null can explicitly clear nullable nested data. Align patient update tests and OpenAPI schemas/responses with the new 200/400/404 contract.
This commit is contained in:
mahdahar 2026-04-06 14:21:46 +07:00
parent ae56e34885
commit e99a60fe93
6 changed files with 545 additions and 44 deletions

View File

@ -110,7 +110,7 @@ class PatientController extends Controller {
}
public function update($InternalPID = null) {
$input = $this->request->getJSON(true);
$input = $this->request->getJSON(true) ?? [];
if (!$InternalPID || !ctype_digit((string) $InternalPID)) {
return $this->respond([
@ -119,30 +119,97 @@ class PatientController extends Controller {
], 400);
}
$input['InternalPID'] = (int) $InternalPID;
// 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';
$this->rules['PatIdt.Identifier'] = $identifierRule;
} else {
$identifierRule = $identifierRulesMap[$type] ?? 'permit_empty|max_length[255]';
$this->rules['PatIdt.IdentifierType'] = 'required';
$this->rules['PatIdt.Identifier'] = $identifierRule;
if (!is_array($input) || $input === []) {
return $this->respond([
'status' => 'failed',
'message' => 'Patch payload is required.'
], 400);
}
if (array_key_exists('PatIdt', $input) && $input['PatIdt'] !== null && !is_array($input['PatIdt'])) {
return $this->failValidationErrors([
'PatIdt' => 'PatIdt must be an object or null.'
]);
}
$patchRules = $this->buildPatchRules($input);
if ($patchRules !== [] && !$this->validateData($input, $patchRules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$InternalPID = $this->model->updatePatient($input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $InternalPID update successfully" ]);
$updatedPid = $this->model->updatePatientPartial((int) $InternalPID, $input);
if ($updatedPid === null) {
return $this->respond([
'status' => 'failed',
'message' => "data $InternalPID not found"
], 404);
}
return $this->respond([ 'status' => 'success', 'message' => "data $updatedPid update successfully" ], 200);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
private function buildPatchRules(array $input): array
{
$rules = [];
$fieldRules = [
'PatientID' => 'permit_empty|regex_match[/^[A-Za-z0-9]+$/]|max_length[30]',
'AlternatePID' => 'permit_empty|regex_match[/^[A-Za-z0-9]+$/]|max_length[30]',
'Prefix' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|max_length[10]',
'Sex' => 'permit_empty',
'NameFirst' => 'required|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]',
'NameMiddle' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]',
'NameMaiden' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]',
'NameLast' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]',
'Suffix' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|max_length[10]',
'PlaceOfBirth' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|max_length[100]',
'Citizenship' => 'permit_empty|regex_match[/^[A-Za-z\'\. ]+$/]|max_length[100]',
'Street_1' => 'permit_empty|regex_match[/^[A-Za-z0-9\'.,\/\- ]+$/]|max_length[255]',
'Street_2' => 'permit_empty|regex_match[/^[A-Za-z0-9\'.,\/\- ]+$/]|max_length[255]',
'Street_3' => 'permit_empty|regex_match[/^[A-Za-z0-9\'.,\/\- ]+$/]|max_length[255]',
'EmailAddress1' => 'permit_empty|valid_email|max_length[100]',
'EmailAddress2' => 'permit_empty|valid_email|max_length[100]',
'Birthdate' => 'permit_empty',
'ZIP' => 'permit_empty|is_natural|max_length[10]',
'Phone' => 'permit_empty|regex_match[/^\\+?[0-9]{8,15}$/]',
'MobilePhone' => 'permit_empty|regex_match[/^\\+?[0-9]{8,15}$/]',
'Country' => 'permit_empty|max_length[10]',
'Race' => 'permit_empty|max_length[100]',
'MaritalStatus' => 'permit_empty',
'Religion' => 'permit_empty|max_length[100]',
'Ethnic' => 'permit_empty|max_length[100]',
'isDead' => 'permit_empty',
'TimeOfDeath' => 'permit_empty',
'PatCom' => 'permit_empty|string',
'PatAtt' => 'permit_empty',
'LinkTo' => 'permit_empty',
'Custodian' => 'permit_empty',
];
foreach ($fieldRules as $field => $rule) {
if (array_key_exists($field, $input) && $field !== 'PatIdt') {
$rules[$field] = $rule;
}
}
if (array_key_exists('PatIdt', $input) && $input['PatIdt'] !== null) {
$type = $input['PatIdt']['IdentifierType'] ?? null;
$identifierRulesMap = $this->getPatIdtIdentifierRulesMap();
$identifierRule = is_string($type)
? ($identifierRulesMap[$type] ?? 'required|max_length[255]')
: 'required|max_length[255]';
$rules['PatIdt.IdentifierType'] = 'required';
$rules['PatIdt.Identifier'] = $identifierRule;
}
return $rules;
}
private function getPatIdtIdentifierRulesMap(): array
{
return [

View File

@ -363,6 +363,133 @@ class PatientModel extends BaseModel {
return $date->format('j M Y');
}
public function updatePatientPartial(int $InternalPID, array $input): ?int {
$db = \Config\Database::connect();
$modelPatIdt = new PatIdtModel();
$modelPatCom = new PatComModel();
$modelPatAtt = new PatAttModel();
$patient = $this->find($InternalPID);
if (!$patient) {
return null;
}
$hasPatIdt = array_key_exists('PatIdt', $input);
$hasPatCom = array_key_exists('PatCom', $input);
$hasPatAtt = array_key_exists('PatAtt', $input);
$patIdt = $hasPatIdt ? $input['PatIdt'] : null;
$patCom = $hasPatCom ? $input['PatCom'] : null;
$patAtt = $hasPatAtt ? $input['PatAtt'] : null;
unset($input['PatIdt'], $input['PatCom'], $input['PatAtt']);
if (array_key_exists('Custodian', $input)) {
if (is_array($input['Custodian'])) {
$input['Custodian'] = $input['Custodian']['InternalPID'] ?? null;
}
if ($input['Custodian'] !== null && $input['Custodian'] !== '') {
$input['Custodian'] = (int) $input['Custodian'];
} else {
$input['Custodian'] = null;
}
}
if (array_key_exists('LinkTo', $input)) {
if (is_array($input['LinkTo'])) {
$internalPids = array_column($input['LinkTo'], 'InternalPID');
$internalPids = array_filter($internalPids, static fn($pid) => $pid !== null && $pid !== '');
$input['LinkTo'] = empty($internalPids) ? null : implode(',', $internalPids);
}
}
$allowedMap = array_flip($this->allowedFields);
$patientUpdate = array_intersect_key($input, $allowedMap);
unset($patientUpdate['CreateDate'], $patientUpdate['DelDate']);
$db->transBegin();
try {
$previousData = $this->find($InternalPID) ?? [];
if ($patientUpdate !== []) {
$this->where('InternalPID', $InternalPID)->set($patientUpdate)->update();
$this->checkDbError($db, 'Update patient');
}
if ($hasPatIdt) {
if ($patIdt === null || $patIdt === []) {
$modelPatIdt->deletePatIdt((string) $InternalPID);
$this->checkDbError($db, 'Delete patidt');
} else {
$modelPatIdt->updatePatIdt($patIdt, (string) $InternalPID);
$this->checkDbError($db, 'Update patidt');
}
}
if ($hasPatCom) {
if ($patCom === null) {
$modelPatCom->deletePatCom((string) $InternalPID);
$this->checkDbError($db, 'Delete patcom');
} else {
$modelPatCom->updatePatCom((string) $patCom, (string) $InternalPID);
$this->checkDbError($db, 'Update PatCom');
}
}
if ($hasPatAtt) {
if ($patAtt === null) {
$modelPatAtt->deletePatAtt((string) $InternalPID);
$this->checkDbError($db, 'Delete patatt');
} else {
$modelPatAtt->updatePatAtt((array) $patAtt, (string) $InternalPID);
$this->checkDbError($db, 'Update/Delete patatt');
}
}
$afterData = array_merge((array) $previousData, $patientUpdate);
$auditDiff = $this->buildAuditDiff((array) $previousData, $afterData);
$changedFields = array_column($auditDiff, 'field');
if ($hasPatIdt) {
$changedFields[] = 'PatIdt';
}
if ($hasPatCom) {
$changedFields[] = 'PatCom';
}
if ($hasPatAtt) {
$changedFields[] = 'PatAtt';
}
AuditService::logData(
'PATIENT_DEMOGRAPHICS_UPDATED',
'UPDATE',
'patient',
(string) $InternalPID,
'patient',
null,
null,
null,
'Patient data updated',
[
'diff' => $auditDiff,
'changed_fields' => array_values(array_unique($changedFields)),
'validation_profile' => 'patient.patch',
],
['entity_version' => 1]
);
$db->transCommit();
return $InternalPID;
} catch (\Exception $e) {
$db->transRollback();
throw $e;
}
}
private function buildAuditDiff(array $before, array $after): array {
$diff = [];
$fields = array_unique(array_merge(array_keys($before), array_keys($after)));

View File

@ -2907,7 +2907,7 @@ paths:
patch:
tags:
- Patient
summary: Update patient
summary: Partially update patient
security:
- bearerAuth: []
parameters:
@ -2922,10 +2922,14 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/Patient'
$ref: '#/components/schemas/PatientPatch'
responses:
'200':
description: Patient updated successfully
'400':
description: Validation error
'404':
description: Patient not found
/api/report/{orderID}:
get:
tags:
@ -8322,6 +8326,155 @@ components:
CreateDate:
type: string
format: date-time
PatientPatch:
type: object
description: |
Partial patient update payload.
Omitted fields are left unchanged. Send null explicitly to clear nullable fields.
properties:
PatientID:
type: string
maxLength: 30
pattern: ^[A-Za-z0-9]+$
description: Internal patient identifier
AlternatePID:
type: string
maxLength: 30
pattern: ^[A-Za-z0-9]+$
Prefix:
type: string
maxLength: 10
enum:
- Mr
- Mrs
- Ms
- Dr
- Prof
Sex:
type: string
enum:
- '1'
- '2'
description: '1: Female, 2: Male'
NameFirst:
type: string
minLength: 1
maxLength: 60
pattern: ^[A-Za-z'\. ]+$
NameMiddle:
type: string
minLength: 1
maxLength: 60
NameMaiden:
type: string
minLength: 1
maxLength: 60
NameLast:
type: string
minLength: 1
maxLength: 60
Suffix:
type: string
maxLength: 10
Birthdate:
type: string
format: date-time
description: ISO 8601 UTC datetime
PlaceOfBirth:
type: string
maxLength: 100
Citizenship:
type: string
maxLength: 100
Street_1:
type: string
maxLength: 255
Street_2:
type: string
maxLength: 255
Street_3:
type: string
maxLength: 255
ZIP:
type: string
maxLength: 10
pattern: ^[0-9]+$
Phone:
type: string
pattern: ^\+?[0-9]{8,15}$
MobilePhone:
type: string
pattern: ^\+?[0-9]{8,15}$
EmailAddress1:
type: string
format: email
maxLength: 100
EmailAddress2:
type: string
format: email
maxLength: 100
PatIdt:
allOf:
- $ref: '#/components/schemas/PatientIdentifier'
nullable: true
LinkTo:
type: array
description: Array of linked patient references
items:
$ref: '#/components/schemas/LinkedPatient'
Custodian:
allOf:
- $ref: '#/components/schemas/Custodian'
nullable: true
isDead:
type: string
enum:
- '0'
- '1'
description: '0: No (alive), 1: Yes (deceased)'
TimeOfDeath:
type: string
format: date-time
description: ISO 8601 UTC datetime of death
PatCom:
type: string
description: Patient comment/notes
nullable: true
PatAtt:
type: array
description: Patient address entries
nullable: true
items:
$ref: '#/components/schemas/PatAttEntry'
Province:
type: integer
description: Province AreaGeoID (foreign key to areageo table)
City:
type: integer
description: City AreaGeoID (foreign key to areageo table)
Country:
type: string
maxLength: 10
description: Country ISO 3-letter code (e.g., IDN, USA)
Race:
type: string
maxLength: 100
MaritalStatus:
type: string
enum:
- A
- B
- D
- M
- S
- W
description: 'A: Annulled, B: Separated, D: Divorced, M: Married, S: Single, W: Widowed'
Religion:
type: string
maxLength: 100
Ethnic:
type: string
maxLength: 100
TestMapDetail:
type: object
properties:

View File

@ -184,6 +184,141 @@ Patient:
type: string
maxLength: 100
PatientPatch:
type: object
description: |
Partial patient update payload.
Omitted fields are left unchanged. Send null explicitly to clear nullable fields.
properties:
PatientID:
type: string
maxLength: 30
pattern: '^[A-Za-z0-9]+$'
description: Internal patient identifier
AlternatePID:
type: string
maxLength: 30
pattern: '^[A-Za-z0-9]+$'
Prefix:
type: string
maxLength: 10
enum: [Mr, Mrs, Ms, Dr, Prof]
Sex:
type: string
enum: ['1', '2']
description: '1: Female, 2: Male'
NameFirst:
type: string
minLength: 1
maxLength: 60
pattern: "^[A-Za-z'\\. ]+$"
NameMiddle:
type: string
minLength: 1
maxLength: 60
NameMaiden:
type: string
minLength: 1
maxLength: 60
NameLast:
type: string
minLength: 1
maxLength: 60
Suffix:
type: string
maxLength: 10
Birthdate:
type: string
format: date-time
description: ISO 8601 UTC datetime
PlaceOfBirth:
type: string
maxLength: 100
Citizenship:
type: string
maxLength: 100
Street_1:
type: string
maxLength: 255
Street_2:
type: string
maxLength: 255
Street_3:
type: string
maxLength: 255
ZIP:
type: string
maxLength: 10
pattern: '^[0-9]+$'
Phone:
type: string
pattern: "^\\+?[0-9]{8,15}$"
MobilePhone:
type: string
pattern: "^\\+?[0-9]{8,15}$"
EmailAddress1:
type: string
format: email
maxLength: 100
EmailAddress2:
type: string
format: email
maxLength: 100
PatIdt:
allOf:
- $ref: 'patient.yaml#/PatientIdentifier'
nullable: true
LinkTo:
type: array
description: Array of linked patient references
items:
$ref: 'patient.yaml#/LinkedPatient'
Custodian:
allOf:
- $ref: 'patient.yaml#/Custodian'
nullable: true
isDead:
type: string
enum: ['0', '1']
description: '0: No (alive), 1: Yes (deceased)'
TimeOfDeath:
type: string
format: date-time
description: ISO 8601 UTC datetime of death
PatCom:
type: string
description: Patient comment/notes
nullable: true
PatAtt:
type: array
description: Patient address entries
nullable: true
items:
$ref: 'patient.yaml#/PatAttEntry'
Province:
type: integer
description: Province AreaGeoID (foreign key to areageo table)
City:
type: integer
description: City AreaGeoID (foreign key to areageo table)
Country:
type: string
maxLength: 10
description: Country ISO 3-letter code (e.g., IDN, USA)
Race:
type: string
maxLength: 100
MaritalStatus:
type: string
enum: [A, B, D, M, S, W]
description: 'A: Annulled, B: Separated, D: Divorced, M: Married, S: Single, W: Widowed'
Religion:
type: string
maxLength: 100
Ethnic:
type: string
maxLength: 100
PatientListResponse:
type: object
properties:

View File

@ -150,7 +150,7 @@
patch:
tags: [Patient]
summary: Update patient
summary: Partially update patient
security:
- bearerAuth: []
parameters:
@ -165,7 +165,11 @@
content:
application/json:
schema:
$ref: '../components/schemas/patient.yaml#/Patient'
$ref: '../components/schemas/patient.yaml#/PatientPatch'
responses:
'200':
description: Patient updated successfully
'400':
description: Validation error
'404':
description: Patient not found

View File

@ -56,7 +56,7 @@ class PatientUpdateTest extends CIUnitTestCase
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/999999', $payload);
$result->assertStatus(201); // Update returns success even if no rows found (depending on logic)
$result->assertStatus(404);
}
/**
@ -99,7 +99,7 @@ class PatientUpdateTest extends CIUnitTestCase
}
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201);
$result->assertStatus(200);
$json = $result->getJSON();
$data = json_decode($json, true);
$this->assertEquals('success', $data['status']);
@ -141,7 +141,7 @@ class PatientUpdateTest extends CIUnitTestCase
}
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201);
$result->assertStatus(200);
}
/**
@ -178,7 +178,7 @@ class PatientUpdateTest extends CIUnitTestCase
}
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201);
$result->assertStatus(200);
}
/**
@ -217,7 +217,7 @@ class PatientUpdateTest extends CIUnitTestCase
}
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201);
$result->assertStatus(200);
}
public function testUpdatePatientWithSimIdentifierSuccess()
@ -242,7 +242,25 @@ class PatientUpdateTest extends CIUnitTestCase
];
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(201);
$result->assertStatus(200);
}
public function testPatchSingleFieldSuccess()
{
$faker = Factory::create('id_ID');
$payload = [
'NameFirst' => $faker->firstName,
];
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(200);
}
public function testPatchEmptyPayloadShouldFail()
{
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', []);
$result->assertStatus(400);
}
public function testUpdatePatientWithSimIdentifierInvalidShouldFail()
@ -312,14 +330,11 @@ class PatientUpdateTest extends CIUnitTestCase
$result = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/1', $payload);
$result->assertStatus(500);
$result->assertStatus(400);
$json = $result->getJSON();
$data = json_decode($json, true);
if (isset($data['messages']) && is_array($data['messages'])) {
$this->assertArrayHasKey('error', $data['messages']);
} else {
$this->assertArrayHasKey('error', $data);
}
$this->assertArrayHasKey('messages', $data);
$this->assertArrayHasKey('PatIdt.IdentifierType', $data['messages']);
}
}