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

@ -6,18 +6,20 @@ use App\Controllers\BaseController;
use App\Libraries\ValueSet;
use App\Models\Contact\ContactModel;
class ContactController extends BaseController {
class ContactController extends BaseController {
use ResponseTrait;
protected $db;
protected $model;
protected $rules;
protected $db;
protected $model;
protected $rules;
protected $patchRules;
public function __construct() {
$this->db = \Config\Database::connect();
$this->model = new ContactModel();
$this->rules = [ 'NameFirst' => 'required' ];
}
public function __construct() {
$this->db = \Config\Database::connect();
$this->model = new ContactModel();
$this->rules = [ 'NameFirst' => 'required' ];
$this->patchRules = [ 'NameFirst' => 'permit_empty' ];
}
public function index() {
$ContactName = $this->request->getVar('ContactName');
@ -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

@ -5,19 +5,24 @@ use App\Traits\ResponseTrait;
use App\Controllers\BaseController;
use App\Models\Location\LocationModel;
class LocationController extends BaseController {
use ResponseTrait;
class LocationController extends BaseController {
use ResponseTrait;
protected $model;
protected $rules;
protected $patchRules;
protected $model;
protected $rules;
public function __construct() {
$this->model = new LocationModel();
$this->rules = [
'LocCode' => 'required|max_length[6]',
'LocFull' => 'required',
];
}
public function __construct() {
$this->model = new LocationModel();
$this->rules = [
'LocCode' => 'required|max_length[6]',
'LocFull' => 'required',
];
$this->patchRules = [
'LocCode' => 'permit_empty|max_length[6]',
'LocFull' => 'permit_empty',
];
}
public function index() {
$LocName = $this->request->getVar('LocName');
@ -59,12 +64,20 @@ 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) {
return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $result ], 201);
} catch (\Throwable $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}

View File

@ -7,21 +7,26 @@ use App\Controllers\BaseController;
use App\Libraries\ValueSet;
use App\Models\Specimen\ContainerDefModel;
class ContainerDefController extends BaseController {
class ContainerDefController extends BaseController {
use ResponseTrait;
protected $db;
protected $model;
protected $rules;
protected $db;
protected $model;
protected $rules;
protected $patchRules;
public function __construct() {
$this->db = \Config\Database::connect();
$this->model = new ContainerDefModel();
$this->rules = [
'ConCode' => 'required|max_length[50]',
'ConName' => 'required|max_length[50]'
];
}
$this->model = new ContainerDefModel();
$this->rules = [
'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() {
try {
@ -78,11 +83,20 @@ 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" ]);
return $this->respondCreated([ 'status' => 'success', 'message' => "data $ConDefID updated successfully" ]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}

View File

@ -10,10 +10,11 @@ use App\Models\Test\TestMapDetailModel;
class TestMapController extends BaseController {
use ResponseTrait;
protected $db;
protected $rules;
protected $model;
protected $modelDetail;
protected $db;
protected $rules;
protected $patchRules;
protected $model;
protected $modelDetail;
public function __construct() {
$this->db = \Config\Database::connect();
@ -23,7 +24,11 @@ class TestMapController extends BaseController {
'HostID' => 'required|integer',
'ClientID' => 'required|integer',
];
}
$this->patchRules = [
'HostID' => 'permit_empty|integer',
'ClientID' => 'permit_empty|integer',
];
}
public function index() {
$rows = $this->model->getUniqueGroupings();
@ -70,15 +75,23 @@ 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 ]);
return $this->respondCreated([ 'status' => 'success', 'message' => "data updated successfully", 'data' => $id ]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}

View File

@ -9,22 +9,31 @@ use App\Models\Test\TestMapDetailModel;
class TestMapDetailController extends BaseController {
use ResponseTrait;
protected $db;
protected $rules;
protected $model;
protected $db;
protected $rules;
protected $patchRules;
protected $model;
public function __construct() {
$this->db = \Config\Database::connect();
$this->model = new TestMapDetailModel;
$this->rules = [
'TestMapID' => 'required|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]',
];
}
$this->rules = [
'TestMapID' => 'required|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]',
];
$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() {
$testMapID = $this->request->getGet('TestMapID');
@ -94,15 +103,19 @@ 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)) {
return $this->failValidationErrors($this->validator->getErrors());
}
$validationInput = array_intersect_key($input, $this->patchRules);
if (!empty($validationInput) && !$this->validateData($validationInput, $this->patchRules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$this->model->update($id, $input);
@ -179,7 +192,7 @@ class TestMapDetailController extends BaseController {
], 200);
}
public function batchUpdate() {
public function batchUpdate() {
$items = $this->request->getJSON(true);
if (!is_array($items)) {
@ -189,24 +202,32 @@ class TestMapDetailController extends BaseController {
$results = ['success' => [], 'failed' => []];
$this->db->transStart();
foreach ($items as $index => $item) {
$id = $item['TestMapDetailID'] ?? null;
foreach ($items as $index => $item) {
$id = $item['TestMapDetailID'] ?? null;
if (!$id) {
$results['failed'][] = ['index' => $index, 'error' => 'TestMapDetailID required'];
continue;
}
if (!$this->validateData($item, $this->rules)) {
$results['failed'][] = ['index' => $index, 'errors' => $this->validator->getErrors()];
continue;
}
try {
$this->model->update($id, $item);
$results['success'][] = ['index' => $index, 'TestMapDetailID' => $id];
} catch (\Exception $e) {
$results['failed'][] = ['index' => $index, 'error' => $e->getMessage()];
if (!$id) {
$results['failed'][] = ['index' => $index, 'error' => 'TestMapDetailID required'];
continue;
}
$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, $updateData);
$results['success'][] = ['index' => $index, 'TestMapDetailID' => $id];
} catch (\Exception $e) {
$results['failed'][] = ['index' => $index, 'error' => $e->getMessage()];
}
}

View File

@ -262,11 +262,11 @@ class TestsController extends BaseController
'StartDate',
];
foreach ($allowedUpdateFields as $field) {
if (isset($input[$field])) {
$testSiteData[$field] = $input[$field];
}
}
foreach ($allowedUpdateFields as $field) {
if (array_key_exists($field, $input)) {
$testSiteData[$field] = $input[$field];
}
}
if (!empty($testSiteData)) {
$this->model->update($id, $testSiteData);
@ -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,50 +470,89 @@ 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);
if ($exists) {
$this->modelCal->update($exists['TestCalID'], $calcData);
} else {
$this->modelCal->insert($calcData);
}
} else {
$this->modelCal->insert($calcData);
}
if ($action === 'update') {
$this->modelGrp->disableByTestSiteID($testSiteID);
}
foreach ($fieldMap as $source => $target) {
if (array_key_exists($source, $data)) {
$calcData[$target] = $data[$source];
}
}
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);
}
}
if ($action === 'update' && !$hasMemberPayload) {
return;
}
if ($action === 'update') {
$this->modelGrp->disableByTestSiteID($testSiteID);
}
$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.');
}
foreach ($memberIDs as $memberID) {
$this->modelGrp->insert([
'TestSiteID' => $testSiteID,
'Member' => $memberID,
]);
}
$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.');
}
foreach ($memberIDs as $memberID) {
$this->modelGrp->insert([
'TestSiteID' => $testSiteID,
'Member' => $memberID,
]);
}
}
private function resolveMemberIDs(array $input): array
@ -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,20 +107,27 @@ class PatVisitModel extends BaseModel {
throw new \Exception("Visit not found or has been deleted.");
}
$this->where('InternalPVID',$InternalPVID)->set($input)->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']);
}
}
$visitData = array_intersect_key($input, array_flip($this->allowedFields));
if (!empty($visitData)) {
$this->where('InternalPVID', $InternalPVID)->set($visitData)->update();
}
// 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) {
$error = $db->error();
throw new \Exception("Failed to update PatDiag record. ". $error['message']);
@ -141,9 +148,9 @@ class PatVisitModel extends BaseModel {
return false;
} else {
$db->transCommit();
$data = [ "PVID" => $input['PVID'], "InternalPVID" => $InternalPVID ];
return $data;
}
$data = [ "PVID" => $input['PVID'] ?? $visit['PVID'], "InternalPVID" => $InternalPVID ];
return $data;
}
} catch (\Exception $e) {
$this->db->transRollback();

View File

@ -152,5 +152,39 @@ class PatVisitUpdateTest extends CIUnitTestCase
'message' => 'Invalid or missing ID'
]);
}
}
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);
}
}