US-016: Save Monthly Results - Add batch save with validation and statistics
This commit is contained in:
parent
23aec3b11d
commit
1374c8769f
@ -88,26 +88,102 @@ class EntryApiController extends BaseController
|
||||
|
||||
try {
|
||||
$controlId = $input['controlId'] ?? 0;
|
||||
$testId = $input['testId'] ?? 0;
|
||||
$dates = $input['dates'] ?? '';
|
||||
$resvalues = $input['resvalue'] ?? [];
|
||||
$yearMonth = $input['yearMonth'] ?? '';
|
||||
$operation = $input['operation'] ?? 'replace';
|
||||
$tests = $input['tests'] ?? [];
|
||||
$results = [];
|
||||
$statistics = [];
|
||||
$validations = [];
|
||||
|
||||
foreach ($tests as $testData) {
|
||||
$testId = $testData['testId'] ?? 0;
|
||||
$resvalues = $testData['resvalue'] ?? [];
|
||||
|
||||
$controlTest = $this->controlTestModel->getByControlAndTest($controlId, $testId);
|
||||
$mean = $controlTest['mean'] ?? 0;
|
||||
$sd = $controlTest['sd'] ?? 0;
|
||||
$sdLimit = $sd > 0 ? $sd * 2 : 0;
|
||||
|
||||
$testValues = [];
|
||||
$validCount = 0;
|
||||
$validSum = 0;
|
||||
$validSqSum = 0;
|
||||
|
||||
foreach ($resvalues as $day => $value) {
|
||||
if (!empty($value)) {
|
||||
$resultData = [
|
||||
'control_ref_id' => $controlId,
|
||||
'test_ref_id' => $testId,
|
||||
'resdate' => $dates . '-' . str_pad($day, 2, '0', STR_PAD_LEFT),
|
||||
'resdate' => $yearMonth . '-' . str_pad($day, 2, '0', STR_PAD_LEFT),
|
||||
'resvalue' => $value,
|
||||
'rescomment' => '',
|
||||
];
|
||||
|
||||
if ($operation === 'replace') {
|
||||
$this->resultModel->saveResult($resultData);
|
||||
} else {
|
||||
$existing = $this->resultModel->checkExisting($controlId, $testId, $resultData['resdate']);
|
||||
if (!$existing) {
|
||||
$this->resultModel->saveResult($resultData);
|
||||
}
|
||||
}
|
||||
|
||||
$numValue = (float) $value;
|
||||
$testValues[] = $numValue;
|
||||
$validCount++;
|
||||
$validSum += $numValue;
|
||||
$validSqSum += $numValue * $numValue;
|
||||
|
||||
$withinLimit = true;
|
||||
if ($sdLimit > 0) {
|
||||
$withinLimit = abs($numValue - $mean) <= $sdLimit;
|
||||
}
|
||||
|
||||
if (!$withinLimit) {
|
||||
$validations[] = [
|
||||
'testId' => $testId,
|
||||
'day' => $day,
|
||||
'value' => $value,
|
||||
'mean' => $mean,
|
||||
'sd' => $sd,
|
||||
'status' => 'out_of_range'
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($validCount > 0) {
|
||||
$calcMean = $validSum / $validCount;
|
||||
$calcSd = 0;
|
||||
if ($validCount > 1) {
|
||||
$variance = ($validSqSum - ($validSum * $validSum) / $validCount) / ($validCount - 1);
|
||||
$calcSd = $variance > 0 ? sqrt($variance) : 0;
|
||||
}
|
||||
$cv = $calcMean > 0 ? ($calcSd / $calcMean) * 100 : 0;
|
||||
|
||||
$statistics[] = [
|
||||
'testId' => $testId,
|
||||
'n' => $validCount,
|
||||
'mean' => round($calcMean, 3),
|
||||
'sd' => round($calcSd, 3),
|
||||
'cv' => round($cv, 2)
|
||||
];
|
||||
}
|
||||
|
||||
$results[] = [
|
||||
'testId' => $testId,
|
||||
'saved' => count($resvalues)
|
||||
];
|
||||
}
|
||||
|
||||
return $this->respond([
|
||||
'status' => 'success',
|
||||
'message' => 'save success'
|
||||
'message' => 'save success',
|
||||
'data' => [
|
||||
'results' => $results,
|
||||
'statistics' => $statistics,
|
||||
'validations' => $validations
|
||||
]
|
||||
]);
|
||||
} catch (\Exception $e) {
|
||||
return $this->failServerError('Something went wrong: ' . $e->getMessage());
|
||||
|
||||
@ -58,4 +58,15 @@ class ResultModel extends BaseModel
|
||||
return $builder->insert($data);
|
||||
}
|
||||
}
|
||||
|
||||
public function checkExisting($controlId, $testId, $resdate)
|
||||
{
|
||||
$builder = $this->db->table('results');
|
||||
return $builder->select('result_id')
|
||||
->where('control_ref_id', $controlId)
|
||||
->where('test_ref_id', $testId)
|
||||
->where('resdate', $resdate)
|
||||
->get()
|
||||
->getRowArray();
|
||||
}
|
||||
}
|
||||
|
||||
@ -133,8 +133,15 @@
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Monthly Comment</label>
|
||||
<textarea x-model="comment" rows="2" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500" placeholder="Optional monthly comment"></textarea>
|
||||
</div>
|
||||
<div class="flex items-end">
|
||||
<button @click="saveData()" data-save-btn :disabled="loading || selectedTests.length === 0" class="btn btn-primary w-full md:w-auto">
|
||||
<div class="flex items-end gap-3">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm font-medium text-slate-700 mb-1">Operation Mode</label>
|
||||
<select x-model="operation" class="w-full px-4 py-2.5 text-sm bg-slate-50 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500">
|
||||
<option value="replace">Replace All</option>
|
||||
<option value="add">Add Only (Skip Existing)</option>
|
||||
</select>
|
||||
</div>
|
||||
<button @click="saveData()" data-save-btn :disabled="loading || selectedTests.length === 0" class="btn btn-primary flex-shrink-0">
|
||||
<template x-if="!loading">
|
||||
<span class="flex items-center justify-center">
|
||||
<i class="fa-solid fa-check mr-2"></i>
|
||||
@ -182,6 +189,8 @@ document.addEventListener('alpine:init', () => {
|
||||
monthlyData: {},
|
||||
comment: '',
|
||||
dirtyCells: new Set(),
|
||||
operation: 'replace',
|
||||
saveResult: null,
|
||||
|
||||
init() {
|
||||
this.loadDraft();
|
||||
@ -339,43 +348,61 @@ document.addEventListener('alpine:init', () => {
|
||||
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
this.saveResult = null;
|
||||
|
||||
try {
|
||||
const tests = [];
|
||||
for (const testId of this.selectedTests) {
|
||||
const formData = new FormData();
|
||||
formData.append('controlid', this.control);
|
||||
formData.append('testid', testId);
|
||||
formData.append('dates', this.date);
|
||||
|
||||
const resvalue = {};
|
||||
let hasData = false;
|
||||
for (let day = 1; day <= this.daysInMonth; day++) {
|
||||
const key = testId + '_' + day;
|
||||
const value = this.monthlyData[key];
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
formData.append(`resvalue[${day}]`, value);
|
||||
resvalue[day] = value;
|
||||
hasData = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasData) {
|
||||
tests.push({ testId, resvalue });
|
||||
}
|
||||
}
|
||||
|
||||
const response = await fetch(`${window.BASEURL}/api/entry/monthly`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
controlId: this.control,
|
||||
yearMonth: this.date,
|
||||
operation: this.operation,
|
||||
tests: tests
|
||||
})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.status !== 'success') {
|
||||
throw new Error(data.message || 'Failed to save data for test ' + this.getTestName(testId));
|
||||
}
|
||||
|
||||
if (data.status === 'success') {
|
||||
this.saveResult = data.data;
|
||||
|
||||
let message = 'Data saved successfully!';
|
||||
if (data.data.statistics && data.data.statistics.length > 0) {
|
||||
message += `\n\nStatistics:\n`;
|
||||
for (const stat of data.data.statistics) {
|
||||
const testName = this.getTestName(stat.testId);
|
||||
message += `${testName}: n=${stat.n}, Mean=${stat.mean}, SD=${stat.sd}, CV=${stat.cv}%\n`;
|
||||
}
|
||||
}
|
||||
|
||||
if (this.comment) {
|
||||
await this.saveComment();
|
||||
if (data.data.validations && data.data.validations.length > 0) {
|
||||
message += `\n⚠️ ${data.data.validations.length} value(s) out of control limits!`;
|
||||
}
|
||||
|
||||
App.showToast('Data saved successfully!');
|
||||
App.showToast(message, 'success');
|
||||
this.dirtyCells.clear();
|
||||
this.saveDraft();
|
||||
} else {
|
||||
throw new Error(data.message || 'Failed to save data');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.error = error.message || 'Network error. Please try again.';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user