fix: support partial PATCH updates across controllers and PatVisit

Allow update endpoints to validate only provided fields, avoid overwriting unchanged data, and preserve existing PatDiag when omitted from PatVisit PATCH payloads.
This commit is contained in:
mahdahar 2026-04-06 15:38:30 +07:00
parent e99a60fe93
commit c5c958b58e
8 changed files with 328 additions and 160 deletions

View File

@ -12,11 +12,13 @@ class ContactController extends BaseController {
protected $db;
protected $model;
protected $rules;
protected $patchRules;
public function __construct() {
$this->db = \Config\Database::connect();
$this->model = new ContactModel();
$this->rules = [ 'NameFirst' => 'required' ];
$this->patchRules = [ 'NameFirst' => 'permit_empty' ];
}
public function index() {
@ -84,8 +86,16 @@ class ContactController extends BaseController {
'data' => []
], 400);
}
if (empty($input) || !is_array($input)) {
return $this->failValidationErrors('No data provided for update.');
}
$validationInput = array_intersect_key($input, $this->patchRules);
if (!empty($validationInput) && !$this->validateData($validationInput, $this->patchRules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$input['ContactID'] = (int) $ContactID;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$this->model->saveContact($input);
$id = $input['ContactID'];

View File

@ -10,6 +10,7 @@ class LocationController extends BaseController {
protected $model;
protected $rules;
protected $patchRules;
public function __construct() {
$this->model = new LocationModel();
@ -17,6 +18,10 @@ class LocationController extends BaseController {
'LocCode' => 'required|max_length[6]',
'LocFull' => 'required',
];
$this->patchRules = [
'LocCode' => 'permit_empty|max_length[6]',
'LocFull' => 'permit_empty',
];
}
public function index() {
@ -59,9 +64,17 @@ class LocationController extends BaseController {
'data' => []
], 400);
}
if (empty($input) || !is_array($input)) {
return $this->failValidationErrors('No data provided for update.');
}
$validationInput = array_intersect_key($input, $this->patchRules);
if (!empty($validationInput) && !$this->validateData($validationInput, $this->patchRules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$input['LocationID'] = (int) $LocationID;
try {
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors()); }
$result = $this->model->saveLocation($input, true);
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $result ], 201);
} catch (\Throwable $e) {

View File

@ -13,6 +13,7 @@ class ContainerDefController extends BaseController {
protected $db;
protected $model;
protected $rules;
protected $patchRules;
public function __construct() {
$this->db = \Config\Database::connect();
@ -21,6 +22,10 @@ class ContainerDefController extends BaseController {
'ConCode' => 'required|max_length[50]',
'ConName' => 'required|max_length[50]'
];
$this->patchRules = [
'ConCode' => 'permit_empty|max_length[50]',
'ConName' => 'permit_empty|max_length[50]'
];
}
public function index() {
@ -78,8 +83,17 @@ class ContainerDefController extends BaseController {
if (!$ConDefID || !ctype_digit((string) $ConDefID)) {
return $this->failValidationErrors('ConDefID is required.');
}
if (empty($input) || !is_array($input)) {
return $this->failValidationErrors('No data provided for update.');
}
$validationInput = array_intersect_key($input, $this->patchRules);
if (!empty($validationInput) && !$this->validateData($validationInput, $this->patchRules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$input['ConDefID'] = (int) $ConDefID;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); }
try {
$ConDefID = $this->model->update($input['ConDefID'], $input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $ConDefID updated successfully" ]);

View File

@ -12,6 +12,7 @@ class TestMapController extends BaseController {
protected $db;
protected $rules;
protected $patchRules;
protected $model;
protected $modelDetail;
@ -23,6 +24,10 @@ class TestMapController extends BaseController {
'HostID' => 'required|integer',
'ClientID' => 'required|integer',
];
$this->patchRules = [
'HostID' => 'permit_empty|integer',
'ClientID' => 'permit_empty|integer',
];
}
public function index() {
@ -70,12 +75,20 @@ class TestMapController extends BaseController {
public function update($TestMapID = null) {
$input = $this->request->getJSON(true);
if (!$TestMapID || !ctype_digit((string) $TestMapID)) { return $this->failValidationErrors('TestMapID is required.'); }
if (empty($input) || !is_array($input)) {
return $this->failValidationErrors('No data provided for update.');
}
$id = (int) $TestMapID;
if (isset($input['TestMapID']) && (string) $input['TestMapID'] !== (string) $id) {
return $this->failValidationErrors('TestMapID in URL does not match body.');
}
$validationInput = array_intersect_key($input, $this->patchRules);
if (!empty($validationInput) && !$this->validateData($validationInput, $this->patchRules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$input['TestMapID'] = $id;
if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors() ); }
try {
$this->model->update($id,$input);
return $this->respondCreated([ 'status' => 'success', 'message' => "data updated successfully", 'data' => $id ]);

View File

@ -11,6 +11,7 @@ class TestMapDetailController extends BaseController {
protected $db;
protected $rules;
protected $patchRules;
protected $model;
public function __construct() {
@ -24,6 +25,14 @@ class TestMapDetailController extends BaseController {
'ClientTestCode' => 'permit_empty|max_length[10]',
'ClientTestName' => 'permit_empty|max_length[100]',
];
$this->patchRules = [
'TestMapID' => 'permit_empty|integer',
'HostTestCode' => 'permit_empty|max_length[10]',
'HostTestName' => 'permit_empty|max_length[100]',
'ConDefID' => 'permit_empty|integer',
'ClientTestCode' => 'permit_empty|max_length[10]',
'ClientTestName' => 'permit_empty|max_length[100]',
];
}
public function index() {
@ -94,13 +103,17 @@ class TestMapDetailController extends BaseController {
if (!$TestMapDetailID || !ctype_digit((string) $TestMapDetailID)) {
return $this->failValidationErrors('TestMapDetailID is required.');
}
if (empty($input) || !is_array($input)) {
return $this->failValidationErrors('No data provided for update.');
}
$id = (int) $TestMapDetailID;
if (isset($input['TestMapDetailID']) && (string) $input['TestMapDetailID'] !== (string) $id) {
return $this->failValidationErrors('TestMapDetailID in URL does not match body.');
}
$input['TestMapDetailID'] = $id;
if (!$this->validateData($input, $this->rules)) {
$validationInput = array_intersect_key($input, $this->patchRules);
if (!empty($validationInput) && !$this->validateData($validationInput, $this->patchRules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
@ -197,13 +210,21 @@ class TestMapDetailController extends BaseController {
continue;
}
if (!$this->validateData($item, $this->rules)) {
$updateData = $item;
unset($updateData['TestMapDetailID']);
if ($updateData === []) {
$results['failed'][] = ['index' => $index, 'error' => 'No fields to update'];
continue;
}
if (!$this->validateData($updateData, $this->patchRules)) {
$results['failed'][] = ['index' => $index, 'errors' => $this->validator->getErrors()];
continue;
}
try {
$this->model->update($id, $item);
$this->model->update($id, $updateData);
$results['success'][] = ['index' => $index, 'TestMapDetailID' => $id];
} catch (\Exception $e) {
$results['failed'][] = ['index' => $index, 'error' => $e->getMessage()];

View File

@ -263,7 +263,7 @@ class TestsController extends BaseController
];
foreach ($allowedUpdateFields as $field) {
if (isset($input[$field])) {
if (array_key_exists($field, $input)) {
$testSiteData[$field] = $input[$field];
}
}
@ -421,24 +421,33 @@ class TestsController extends BaseController
private function saveTechDetails($testSiteID, $data, $action, $typeCode)
{
$techData = [
'DisciplineID' => $data['DisciplineID'] ?? null,
'DepartmentID' => $data['DepartmentID'] ?? null,
'ResultType' => $data['ResultType'] ?? null,
'RefType' => $data['RefType'] ?? null,
'VSet' => $data['VSet'] ?? null,
'ReqQty' => $data['ReqQty'] ?? null,
'ReqQtyUnit' => $data['ReqQtyUnit'] ?? null,
'Unit1' => $data['Unit1'] ?? null,
'Factor' => $data['Factor'] ?? null,
'Unit2' => $data['Unit2'] ?? null,
'Decimal' => array_key_exists('Decimal', $data) ? $data['Decimal'] : null,
'CollReq' => $data['CollReq'] ?? null,
'Method' => $data['Method'] ?? null,
'ExpectedTAT' => $data['ExpectedTAT'] ?? null,
$allowedFields = [
'DisciplineID',
'DepartmentID',
'ResultType',
'RefType',
'VSet',
'ReqQty',
'ReqQtyUnit',
'Unit1',
'Factor',
'Unit2',
'Decimal',
'CollReq',
'Method',
'ExpectedTAT',
];
$this->model->update($testSiteID, $techData);
$techData = [];
foreach ($allowedFields as $field) {
if (array_key_exists($field, $data)) {
$techData[$field] = $data[$field];
}
}
if ($techData !== []) {
$this->model->update($testSiteID, $techData);
}
}
private function saveRefNumRanges($testSiteID, $ranges, $action, $siteID)
@ -461,30 +470,70 @@ class TestsController extends BaseController
private function saveCalcDetails($testSiteID, $data, $input, $action)
{
$calcData = [
'TestSiteID' => $testSiteID,
'DisciplineID' => $data['DisciplineID'] ?? null,
'DepartmentID' => $data['DepartmentID'] ?? null,
'FormulaCode' => $data['FormulaCode'] ?? $data['Formula'] ?? null,
'ResultType' => 'NMRIC',
'RefType' => $data['RefType'] ?? 'RANGE',
'Unit1' => $data['Unit1'] ?? $data['ResultUnit'] ?? null,
'Factor' => $data['Factor'] ?? null,
'Unit2' => $data['Unit2'] ?? null,
'Decimal' => array_key_exists('Decimal', $data) ? $data['Decimal'] : null,
'Method' => $data['Method'] ?? null,
$calcData = [];
$fieldMap = [
'DisciplineID' => 'DisciplineID',
'DepartmentID' => 'DepartmentID',
'Factor' => 'Factor',
'Unit2' => 'Unit2',
'Decimal' => 'Decimal',
'Method' => 'Method',
];
if ($action === 'update') {
$exists = $this->modelCal->existsByTestSiteID($testSiteID);
foreach ($fieldMap as $source => $target) {
if (array_key_exists($source, $data)) {
$calcData[$target] = $data[$source];
}
}
if ($exists) {
$this->modelCal->update($exists['TestCalID'], $calcData);
if (array_key_exists('FormulaCode', $data) || array_key_exists('Formula', $data)) {
$calcData['FormulaCode'] = $data['FormulaCode'] ?? $data['Formula'] ?? null;
}
if (array_key_exists('RefType', $data)) {
$calcData['RefType'] = $data['RefType'];
}
if (array_key_exists('Unit1', $data) || array_key_exists('ResultUnit', $data)) {
$calcData['Unit1'] = $data['Unit1'] ?? $data['ResultUnit'] ?? null;
}
$hasMemberPayload = isset($input['testdefgrp'])
&& is_array($input['testdefgrp'])
&& array_key_exists('members', $input['testdefgrp']);
if ($action === 'insert' && !array_key_exists('ResultType', $calcData)) {
$calcData['ResultType'] = 'NMRIC';
}
if ($action === 'insert' && !array_key_exists('RefType', $calcData)) {
$calcData['RefType'] = 'RANGE';
}
if ($calcData !== []) {
$calcData['TestSiteID'] = $testSiteID;
if ($action === 'update') {
$exists = $this->modelCal->existsByTestSiteID($testSiteID);
if ($exists) {
unset($calcData['TestSiteID']);
$this->modelCal->update($exists['TestCalID'], $calcData);
} else {
if (!array_key_exists('ResultType', $calcData)) {
$calcData['ResultType'] = 'NMRIC';
}
if (!array_key_exists('RefType', $calcData)) {
$calcData['RefType'] = 'RANGE';
}
$this->modelCal->insert($calcData);
}
} else {
$this->modelCal->insert($calcData);
}
} else {
$this->modelCal->insert($calcData);
}
if ($action === 'update' && !$hasMemberPayload) {
return;
}
if ($action === 'update') {
@ -493,7 +542,6 @@ class TestsController extends BaseController
$memberIDs = $this->resolveMemberIDs($input);
// Validate member IDs before insertion
$validation = $this->validateMemberIDs($memberIDs);
if (!$validation['valid']) {
throw new \Exception('Invalid member TestSiteID(s): ' . implode(', ', $validation['invalid']) . '. Make sure to use TestSiteID, not SeqScr or other values.');
@ -558,6 +606,14 @@ class TestsController extends BaseController
private function saveGroupDetails($testSiteID, $data, $input, $action)
{
$hasMemberPayload = isset($input['testdefgrp'])
&& is_array($input['testdefgrp'])
&& array_key_exists('members', $input['testdefgrp']);
if ($action === 'update' && !$hasMemberPayload) {
return;
}
if ($action === 'update') {
$this->modelGrp->disableByTestSiteID($testSiteID);
}

View File

@ -107,18 +107,25 @@ class PatVisitModel extends BaseModel {
throw new \Exception("Visit not found or has been deleted.");
}
$this->where('InternalPVID',$InternalPVID)->set($input)->update();
$visitData = array_intersect_key($input, array_flip($this->allowedFields));
if (!empty($visitData)) {
$this->where('InternalPVID', $InternalPVID)->set($visitData)->update();
}
// patdiag
$exist = $modelPD->where('InternalPVID',$InternalPVID)->find();
if($exist) {
if( !empty($input['PatDiag']) && ( !empty($input['PatDiag']['DiagCode']) || !empty($input['PatDiag']['Diagnosis']) ) ) {
$tmp = $modelPD->where('InternalPVID',$InternalPVID)->set($input['PatDiag'])->update();
} else { $tmp = $modelPD->delete($InternalPVID); }
} else {
if( !empty($input['PatDiag']) && ( !empty($input['PatDiag']['DiagCode']) || !empty($input['PatDiag']['Diagnosis']) ) ) {
$input['PatDiag']['InternalPVID'] = $InternalPVID;
$tmp = $modelPD->insert($input['PatDiag']);
if (array_key_exists('PatDiag', $input)) {
$exist = $modelPD->where('InternalPVID', $InternalPVID)->find();
if ($exist) {
if (!empty($input['PatDiag']) && (!empty($input['PatDiag']['DiagCode']) || !empty($input['PatDiag']['Diagnosis']))) {
$tmp = $modelPD->where('InternalPVID', $InternalPVID)->set($input['PatDiag'])->update();
} else {
$tmp = $modelPD->delete($InternalPVID);
}
} else {
if (!empty($input['PatDiag']) && (!empty($input['PatDiag']['DiagCode']) || !empty($input['PatDiag']['Diagnosis']))) {
$input['PatDiag']['InternalPVID'] = $InternalPVID;
$tmp = $modelPD->insert($input['PatDiag']);
}
}
}
if (isset($tmp) && $tmp === false) {
@ -141,7 +148,7 @@ class PatVisitModel extends BaseModel {
return false;
} else {
$db->transCommit();
$data = [ "PVID" => $input['PVID'], "InternalPVID" => $InternalPVID ];
$data = [ "PVID" => $input['PVID'] ?? $visit['PVID'], "InternalPVID" => $InternalPVID ];
return $data;
}

View File

@ -153,4 +153,38 @@ class PatVisitUpdateTest extends CIUnitTestCase
]);
}
public function testPatchWithoutPatDiagKeepsExistingPatDiag(): void
{
$createPayload = [
'InternalPID' => $this->createTestPatient(),
'EpisodeID' => 'KEEP-DIAG',
'PatDiag' => [
'DiagCode' => 'A02',
'DiagName' => 'Original Diagnosis',
],
'PatVisitADT' => [
'ADTCode' => 'A01',
'LocationID' => '1',
],
];
$createResponse = $this->withBodyFormat('json')->call('post', 'api/patvisit', $createPayload);
$createResponse->assertStatus(201);
$createJson = json_decode($createResponse->getJSON(), true);
$internalPVID = $createJson['data']['InternalPVID'];
$pvid = $createJson['data']['PVID'];
$patchResponse = $this->withBodyFormat('json')->call('patch', $this->endpoint . '/' . $internalPVID, [
'EpisodeID' => 'KEEP-DIAG-UPDATED',
]);
$patchResponse->assertStatus(200);
$showResponse = $this->call('get', $this->endpoint . '/' . $pvid);
$showResponse->assertStatus(200);
$showJson = json_decode($showResponse->getJSON(), true);
$this->assertEquals('A02', $showJson['data']['DiagCode'] ?? null);
}
}