US-016: Save Monthly Results - Add batch save with validation and statistics

This commit is contained in:
mahdahar 2026-01-16 17:18:10 +07:00
parent 23aec3b11d
commit 1374c8769f
3 changed files with 151 additions and 37 deletions

View File

@ -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 ($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),
'resvalue' => $value,
'rescomment' => '',
];
$this->resultModel->saveResult($resultData);
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' => $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());

View File

@ -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();
}
}

View File

@ -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) {
const response = await fetch(`${window.BASEURL}/api/entry/monthly`, {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.status !== 'success') {
throw new Error(data.message || 'Failed to save data for test ' + this.getTestName(testId));
}
tests.push({ testId, resvalue });
}
}
if (this.comment) {
await this.saveComment();
}
const response = await fetch(`${window.BASEURL}/api/entry/monthly`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
controlId: this.control,
yearMonth: this.date,
operation: this.operation,
tests: tests
})
});
App.showToast('Data saved successfully!');
this.dirtyCells.clear();
this.saveDraft();
const data = await response.json();
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 (data.data.validations && data.data.validations.length > 0) {
message += `\n⚠ ${data.data.validations.length} value(s) out of control limits!`;
}
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.';