feat: implement comprehensive result management and lab reporting system

- Add full CRUD operations for results (index, show, update, delete)
- Implement result validation with reference range checking (L/H flags)
- Add cumulative patient results retrieval across all orders
- Create ReportController for HTML lab report generation
- Add lab report view with patient info, order details, and test results
- Implement soft delete for results with transaction safety
- Update API routes with /api/results/* endpoints for CRUD
- Add /api/reports/{orderID} endpoint for report viewing
- Update OpenAPI docs with results and reports schemas/paths
- Add documentation for manual result entry and MVP plan
This commit is contained in:
mahdahar 2026-03-04 16:48:12 +07:00
parent 42006e1af9
commit 85c7e96405
11 changed files with 2480 additions and 298 deletions

View File

@ -15,8 +15,18 @@ $routes->options('(:any)', function () {
$routes->group('api', ['filter' => 'auth'], function ($routes) {
$routes->get('dashboard', 'DashboardController::index');
$routes->get('result', 'ResultController::index');
$routes->get('sample', 'SampleController::index');
// Results CRUD
$routes->group('results', function ($routes) {
$routes->get('/', 'ResultController::index');
$routes->get('(:num)', 'ResultController::show/$1');
$routes->patch('(:num)', 'ResultController::update/$1');
$routes->delete('(:num)', 'ResultController::delete/$1');
});
// Reports
$routes->get('reports/(:num)', 'ReportController::view/$1');
});

View File

@ -0,0 +1,75 @@
<?php
namespace App\Controllers;
use App\Traits\ResponseTrait;
use CodeIgniter\Controller;
use App\Models\PatResultModel;
use App\Models\OrderTest\OrderTestModel;
use App\Models\Patient\PatientModel;
class ReportController extends Controller {
use ResponseTrait;
protected $resultModel;
protected $orderModel;
protected $patientModel;
public function __construct() {
$this->resultModel = new PatResultModel();
$this->orderModel = new OrderTestModel();
$this->patientModel = new PatientModel();
}
/**
* Generate HTML lab report for an order
* GET /api/reports/{orderID}
*/
public function view($orderID) {
try {
// Get order details
$order = $this->orderModel->find((int)$orderID);
if (!$order) {
return $this->respond([
'status' => 'failed',
'message' => 'Order not found',
'data' => []
], 404);
}
// Get patient details
$patient = $this->patientModel->find($order['InternalPID']);
if (!$patient) {
return $this->respond([
'status' => 'failed',
'message' => 'Patient not found',
'data' => []
], 404);
}
// Get results for this order
$results = $this->resultModel->getByOrder((int)$orderID);
// Prepare data for the view
$data = [
'patient' => $patient,
'order' => $order,
'results' => $results,
'generatedAt' => date('Y-m-d H:i:s')
];
// Return HTML view
return view('reports/lab_report', $data);
} catch (\Exception $e) {
log_message('error', 'ReportController::view error: ' . $e->getMessage());
return $this->respond([
'status' => 'failed',
'message' => 'Failed to generate report',
'data' => []
], 500);
}
}
}

View File

@ -4,31 +4,176 @@ namespace App\Controllers;
use App\Traits\ResponseTrait;
use CodeIgniter\Controller;
use Firebase\JWT\JWT;
use Firebase\JWT\Key;
use Firebase\JWT\ExpiredException;
use Firebase\JWT\SignatureInvalidException;
use Firebase\JWT\BeforeValidException;
use CodeIgniter\Cookie\Cookie;
use App\Models\PatResultModel;
class ResultController extends Controller {
use ResponseTrait;
protected $model;
public function __construct() {
$this->model = new PatResultModel();
}
/**
* List results with optional filters
* GET /api/results
*/
public function index() {
try {
$orderID = $this->request->getGet('order_id');
$patientID = $this->request->getGet('patient_id');
$token = $this->request->getCookie('token');
$key = getenv('JWT_SECRET');
if ($orderID) {
$results = $this->model->getByOrder((int)$orderID);
} elseif ($patientID) {
$results = $this->model->getByPatient((int)$patientID);
} else {
// Get all results with pagination
$page = (int)($this->request->getGet('page') ?? 1);
$perPage = (int)($this->request->getGet('per_page') ?? 20);
// Decode Token dengan Key yg ada di .env
$decodedPayload = JWT::decode($token, new Key($key, 'HS256'));
$results = $this->model
->where('DelDate', null)
->orderBy('ResultID', 'DESC')
->paginate($perPage, 'default', $page);
}
return $this->respond([
'status' => 'success',
'code' => 200,
'message' => 'Authenticated',
'data' => $decodedPayload
'message' => 'Results retrieved successfully',
'data' => $results
], 200);
} catch (\Exception $e) {
log_message('error', 'ResultController::index error: ' . $e->getMessage());
return $this->respond([
'status' => 'failed',
'message' => 'Failed to retrieve results',
'data' => []
], 500);
}
}
/**
* Get single result
* GET /api/results/{id}
*/
public function show($id) {
try {
$result = $this->model->getWithRelations((int)$id);
if (!$result) {
return $this->respond([
'status' => 'failed',
'message' => 'Result not found',
'data' => []
], 404);
}
return $this->respond([
'status' => 'success',
'message' => 'Result retrieved successfully',
'data' => $result
], 200);
} catch (\Exception $e) {
log_message('error', 'ResultController::show error: ' . $e->getMessage());
return $this->respond([
'status' => 'failed',
'message' => 'Failed to retrieve result',
'data' => []
], 500);
}
}
/**
* Update result with validation
* PATCH /api/results/{id}
*/
public function update($id) {
try {
$data = $this->request->getJSON(true);
if (empty($data)) {
return $this->respond([
'status' => 'failed',
'message' => 'No data provided',
'data' => []
], 400);
}
$result = $this->model->updateWithValidation((int)$id, $data);
if (!$result['success']) {
return $this->respond([
'status' => 'failed',
'message' => $result['message'],
'data' => []
], 400);
}
// Get updated result with relations
$updatedResult = $this->model->getWithRelations((int)$id);
return $this->respond([
'status' => 'success',
'message' => $result['message'],
'data' => [
'result' => $updatedResult,
'flag' => $result['flag']
]
], 200);
} catch (\Exception $e) {
log_message('error', 'ResultController::update error: ' . $e->getMessage());
return $this->respond([
'status' => 'failed',
'message' => 'Failed to update result',
'data' => []
], 500);
}
}
/**
* Soft delete result
* DELETE /api/results/{id}
*/
public function delete($id) {
try {
$result = $this->model->find((int)$id);
if (!$result) {
return $this->respond([
'status' => 'failed',
'message' => 'Result not found',
'data' => []
], 404);
}
$deleted = $this->model->softDelete((int)$id);
if (!$deleted) {
return $this->respond([
'status' => 'failed',
'message' => 'Failed to delete result',
'data' => []
], 500);
}
return $this->respond([
'status' => 'success',
'message' => 'Result deleted successfully',
'data' => []
], 200);
} catch (\Exception $e) {
log_message('error', 'ResultController::delete error: ' . $e->getMessage());
return $this->respond([
'status' => 'failed',
'message' => 'Failed to delete result',
'data' => []
], 500);
}
}
}

View File

@ -1,6 +1,10 @@
<?php
namespace App\Models;
use App\Models\RefRange\RefNumModel;
use App\Models\OrderTest\OrderTestModel;
use App\Models\Patient\PatientModel;
class PatResultModel extends BaseModel {
protected $table = 'patres';
protected $primaryKey = 'ResultID';
@ -25,4 +29,245 @@ class PatResultModel extends BaseModel {
'ArchiveDate',
'DelDate'
];
/**
* Validate result value against reference range and return flag
*
* @param float|string $resultValue The result value to validate
* @param int $refNumID The reference range ID
* @param int $orderID The order ID to get patient info
* @return string|null 'L' for low, 'H' for high, or null for normal/no match
*/
public function validateAndFlag($resultValue, int $refNumID, int $orderID): ?string {
if (!is_numeric($resultValue)) {
return null;
}
$refNumModel = new RefNumModel();
$ref = $refNumModel->find($refNumID);
if (!$ref) {
return null;
}
// Get patient info from order
$orderModel = new OrderTestModel();
$order = $orderModel->find($orderID);
if (!$order) {
return null;
}
$patientModel = new PatientModel();
$patient = $patientModel->find($order['InternalPID']);
if (!$patient) {
return null;
}
// Check if patient matches criteria (sex)
if (!empty($ref['Sex']) && $ref['Sex'] !== 'ALL') {
if ($patient['Sex'] !== $ref['Sex']) {
return null;
}
}
// Check age criteria
if ($ref['AgeStart'] !== null || $ref['AgeEnd'] !== null) {
$birthdate = new \DateTime($patient['Birthdate']);
$today = new \DateTime();
$age = $birthdate->diff($today)->y;
if ($ref['AgeStart'] !== null && $age < $ref['AgeStart']) {
return null;
}
if ($ref['AgeEnd'] !== null && $age > $ref['AgeEnd']) {
return null;
}
}
$value = floatval($resultValue);
$low = floatval($ref['Low']);
$high = floatval($ref['High']);
// Check low
if ($ref['LowSign'] === '<=' && $value <= $low) {
return 'L';
}
if ($ref['LowSign'] === '<' && $value < $low) {
return 'L';
}
// Check high
if ($ref['HighSign'] === '>=' && $value >= $high) {
return 'H';
}
if ($ref['HighSign'] === '>' && $value > $high) {
return 'H';
}
return null; // Normal
}
/**
* Get all results for an order with test names and reference ranges
*
* @param int $orderID
* @return array
*/
public function getByOrder(int $orderID): array {
$builder = $this->db->table('patres pr');
$builder->select('
pr.ResultID,
pr.OrderID,
pr.TestSiteID,
pr.TestSiteCode,
pr.Result,
pr.ResultDateTime,
pr.RefNumID,
pr.RefTxtID,
pr.CreateDate,
tds.TestSiteName,
tds.Unit1,
tds.Unit2,
rn.Low,
rn.High,
rn.LowSign,
rn.HighSign,
rn.Display as RefDisplay
');
$builder->join('testdefsite tds', 'tds.TestSiteID = pr.TestSiteID', 'left');
$builder->join('refnum rn', 'rn.RefNumID = pr.RefNumID', 'left');
$builder->where('pr.OrderID', $orderID);
$builder->where('pr.DelDate', null);
$builder->orderBy('tds.SeqScr', 'ASC');
return $builder->get()->getResultArray();
}
/**
* Get cumulative patient results across all orders
*
* @param int $internalPID
* @return array
*/
public function getByPatient(int $internalPID): array {
$builder = $this->db->table('patres pr');
$builder->select('
pr.ResultID,
pr.OrderID,
pr.TestSiteID,
pr.TestSiteCode,
pr.Result,
pr.ResultDateTime,
pr.RefNumID,
tds.TestSiteName,
tds.Unit1,
tds.Unit2,
ot.OrderID as OrderNumber,
ot.TrnDate as OrderDate
');
$builder->join('testdefsite tds', 'tds.TestSiteID = pr.TestSiteID', 'left');
$builder->join('ordertest ot', 'ot.InternalOID = pr.OrderID', 'left');
$builder->where('ot.InternalPID', $internalPID);
$builder->where('pr.DelDate', null);
$builder->orderBy('pr.ResultDateTime', 'DESC');
return $builder->get()->getResultArray();
}
/**
* Update result with validation and return flag (not stored)
*
* @param int $resultID
* @param array $data
* @return array ['success' => bool, 'flag' => string|null, 'message' => string]
*/
public function updateWithValidation(int $resultID, array $data): array {
$this->db->transStart();
try {
$result = $this->find($resultID);
if (!$result) {
throw new \Exception('Result not found');
}
$flag = null;
// If result value is being updated, validate it
if (isset($data['Result']) && !empty($data['Result'])) {
$refNumID = $data['RefNumID'] ?? $result['RefNumID'];
if ($refNumID) {
$flag = $this->validateAndFlag($data['Result'], $refNumID, $result['OrderID']);
}
}
// Set update timestamp
$data['StartDate'] = date('Y-m-d H:i:s');
$updated = $this->update($resultID, $data);
if (!$updated) {
throw new \Exception('Failed to update result');
}
$this->db->transComplete();
return [
'success' => true,
'flag' => $flag,
'message' => 'Result updated successfully'
];
} catch (\Exception $e) {
$this->db->transRollback();
return [
'success' => false,
'flag' => null,
'message' => $e->getMessage()
];
}
}
/**
* Get single result with related data
*
* @param int $resultID
* @return array|null
*/
public function getWithRelations(int $resultID): ?array {
$builder = $this->db->table('patres pr');
$builder->select('
pr.*,
tds.TestSiteName,
tds.TestSiteCode,
tds.Unit1,
tds.Unit2,
rn.Low,
rn.High,
rn.LowSign,
rn.HighSign,
rn.Display as RefDisplay,
ot.OrderID as OrderNumber,
ot.InternalPID
');
$builder->join('testdefsite tds', 'tds.TestSiteID = pr.TestSiteID', 'left');
$builder->join('refnum rn', 'rn.RefNumID = pr.RefNumID', 'left');
$builder->join('ordertest ot', 'ot.InternalOID = pr.OrderID', 'left');
$builder->where('pr.ResultID', $resultID);
$builder->where('pr.DelDate', null);
$result = $builder->get()->getRowArray();
return $result ?: null;
}
/**
* Soft delete result
*
* @param int $resultID
* @return bool
*/
public function softDelete(int $resultID): bool {
return $this->update($resultID, ['DelDate' => date('Y-m-d H:i:s')]);
}
}

View File

@ -0,0 +1,376 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lab Report - <?= esc($order['OrderID'] ?? 'N/A') ?></title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: Arial, sans-serif;
font-size: 12px;
line-height: 1.4;
color: #333;
background: #fff;
padding: 20px;
}
.report-container {
max-width: 800px;
margin: 0 auto;
border: 1px solid #ccc;
padding: 30px;
}
.header {
text-align: center;
border-bottom: 2px solid #333;
padding-bottom: 20px;
margin-bottom: 20px;
}
.header h1 {
font-size: 24px;
margin-bottom: 5px;
}
.header .subtitle {
font-size: 14px;
color: #666;
}
.patient-section {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border: 1px solid #ddd;
}
.section-title {
font-weight: bold;
font-size: 14px;
margin-bottom: 10px;
color: #333;
border-bottom: 1px solid #ccc;
padding-bottom: 5px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.info-item {
display: flex;
flex-direction: column;
}
.info-label {
font-weight: bold;
color: #666;
font-size: 11px;
}
.info-value {
color: #333;
}
.order-section {
margin-bottom: 20px;
padding: 15px;
background: #f9f9f9;
border: 1px solid #ddd;
}
.results-section {
margin-bottom: 20px;
}
.results-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.results-table th,
.results-table td {
border: 1px solid #ccc;
padding: 8px;
text-align: left;
}
.results-table th {
background: #e0e0e0;
font-weight: bold;
font-size: 11px;
}
.results-table tr:nth-child(even) {
background: #f5f5f5;
}
.flag {
display: inline-block;
padding: 2px 6px;
border-radius: 3px;
font-weight: bold;
font-size: 11px;
}
.flag-low {
background: #fff3cd;
color: #856404;
border: 1px solid #ffc107;
}
.flag-high {
background: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.reference-range {
font-size: 10px;
color: #666;
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #ccc;
font-size: 10px;
color: #666;
}
.signature-section {
margin-top: 40px;
display: flex;
justify-content: flex-end;
}
.signature-box {
text-align: center;
width: 200px;
}
.signature-line {
border-bottom: 1px solid #333;
margin-top: 60px;
margin-bottom: 5px;
}
.print-button {
position: fixed;
top: 20px;
right: 20px;
padding: 10px 20px;
background: #007bff;
color: white;
border: none;
border-radius: 5px;
cursor: pointer;
font-size: 14px;
}
.print-button:hover {
background: #0056b3;
}
@media print {
body {
padding: 0;
}
.report-container {
border: none;
padding: 0;
}
.print-button {
display: none;
}
.results-table {
page-break-inside: auto;
}
.results-table tr {
page-break-inside: avoid;
}
}
</style>
</head>
<body>
<?php
// Helper function to calculate flag dynamically
function calculateFlag($result) {
if (empty($result['Result']) || !is_numeric($result['Result'])) {
return null;
}
if (empty($result['Low']) || empty($result['High'])) {
return null;
}
$value = floatval($result['Result']);
$low = floatval($result['Low']);
$high = floatval($result['High']);
$lowSign = $result['LowSign'] ?? '<=';
$highSign = $result['HighSign'] ?? '<=';
// Check low
if ($lowSign === '<=' && $value <= $low) {
return 'L';
}
if ($lowSign === '<' && $value < $low) {
return 'L';
}
// Check high
if ($highSign === '>=' && $value >= $high) {
return 'H';
}
if ($highSign === '>' && $value > $high) {
return 'H';
}
return null; // Normal
}
?>
<button class="print-button" onclick="window.print()">Print Report</button>
<div class="report-container">
<div class="header">
<h1>LABORATORY REPORT</h1>
<div class="subtitle">Clinical Laboratory Quality Management System</div>
</div>
<div class="patient-section">
<div class="section-title">Patient Information</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Patient Name</span>
<span class="info-value"><?= esc(($patient['NameFirst'] ?? '') . ' ' . ($patient['NameLast'] ?? '')) ?></span>
</div>
<div class="info-item">
<span class="info-label">Patient ID</span>
<span class="info-value"><?= esc($patient['PatientID'] ?? 'N/A') ?></span>
</div>
<div class="info-item">
<span class="info-label">Date of Birth</span>
<span class="info-value"><?= esc($patient['Birthdate'] ?? 'N/A') ?></span>
</div>
<div class="info-item">
<span class="info-label">Sex</span>
<span class="info-value"><?= esc($patient['Sex'] ?? 'N/A') ?></span>
</div>
<div class="info-item">
<span class="info-label">Phone</span>
<span class="info-value"><?= esc($patient['MobilePhone'] ?? $patient['Phone'] ?? 'N/A') ?></span>
</div>
<div class="info-item">
<span class="info-label">Address</span>
<span class="info-value"><?= esc(($patient['Street_1'] ?? '') . ' ' . ($patient['City'] ?? '')) ?></span>
</div>
</div>
</div>
<div class="order-section">
<div class="section-title">Order Information</div>
<div class="info-grid">
<div class="info-item">
<span class="info-label">Order ID</span>
<span class="info-value"><?= esc($order['OrderID'] ?? 'N/A') ?></span>
</div>
<div class="info-item">
<span class="info-label">Order Date</span>
<span class="info-value"><?= esc($order['TrnDate'] ?? 'N/A') ?></span>
</div>
<div class="info-item">
<span class="info-label">Priority</span>
<span class="info-value"><?= esc($order['Priority'] ?? 'N/A') ?></span>
</div>
<div class="info-item">
<span class="info-label">Requesting Physician</span>
<span class="info-value"><?= esc($order['ReqApp'] ?? 'N/A') ?></span>
</div>
<div class="info-item">
<span class="info-label">Placer ID</span>
<span class="info-value"><?= esc($order['PlacerID'] ?? 'N/A') ?></span>
</div>
</div>
</div>
<div class="results-section">
<div class="section-title">Test Results</div>
<table class="results-table">
<thead>
<tr>
<th>Test Name</th>
<th>Code</th>
<th>Result</th>
<th>Unit</th>
<th>Flag</th>
<th>Reference Range</th>
</tr>
</thead>
<tbody>
<?php if (!empty($results)): ?>
<?php foreach ($results as $result): ?>
<tr>
<td><?= esc($result['TestSiteName'] ?? 'N/A') ?></td>
<td><?= esc($result['TestSiteCode'] ?? 'N/A') ?></td>
<td><?= esc($result['Result'] ?? 'Pending') ?></td>
<td><?= esc($result['Unit1'] ?? '') ?></td>
<td>
<?php
$flag = calculateFlag($result);
if (!empty($flag)):
?>
<span class="flag flag-<?= $flag === 'H' ? 'high' : 'low' ?>">
<?= $flag === 'H' ? 'HIGH' : 'LOW' ?>
</span>
<?php else: ?>
-
<?php endif; ?>
</td>
<td class="reference-range">
<?php if (!empty($result['Low']) && !empty($result['High'])): ?>
<?= esc($result['LowSign'] ?? '') ?> <?= esc($result['Low']) ?> - <?= esc($result['HighSign'] ?? '') ?> <?= esc($result['High']) ?>
<?php else: ?>
-
<?php endif; ?>
</td>
</tr>
<?php endforeach; ?>
<?php else: ?>
<tr>
<td colspan="6" style="text-align: center;">No results found for this order</td>
</tr>
<?php endif; ?>
</tbody>
</table>
</div>
<div class="signature-section">
<div class="signature-box">
<div class="signature-line"></div>
<div>Authorized Signature</div>
<div style="font-size: 10px; color: #666; margin-top: 5px;">Pathologist / Lab Director</div>
</div>
</div>
<div class="footer">
<p><strong>Report Generated:</strong> <?= esc($generatedAt) ?></p>
<p><strong>CLQMS</strong> - Clinical Laboratory Quality Management System</p>
<p>This report is electronically generated and is valid without signature.</p>
</div>
</div>
</body>
</html>

View File

@ -0,0 +1,941 @@
# Manual Result Entry Implementation Plan
## Overview
This document outlines the implementation plan for manual laboratory result entry functionality in CLQMS. The system already creates empty `patres` records when orders are placed. This plan covers the complete workflow for entering, validating, and verifying test results.
**Current State:** Empty `patres` records exist for all ordered tests
**Target State:** Full result entry with reference range validation, abnormal flag calculation, and verification workflow
---
## Phase 1: Core Result Management (Priority: HIGH)
### 1.1 Extend PatResultModel
**File:** `app/Models/PatResultModel.php`
#### New Methods to Add:
```php
/**
* Get results with filtering and pagination
*
* @param array $filters Available filters:
* - InternalPID: int - Filter by patient
* - OrderID: string - Filter by order
* - ResultStatus: string - PEN, PRE, FIN, AMD
* - TestSiteID: int - Filter by test
* - date_from: string - YYYY-MM-DD
* - date_to: string - YYYY-MM-DD
* - WorkstationID: int - Filter by workstation
* @param int $page
* @param int $perPage
* @return array
*/
public function getResults(array $filters = [], int $page = 1, int $perPage = 20): array
/**
* Get single result with full details
* Includes: patient demographics, test info, specimen info, reference ranges
*
* @param int $resultID
* @return array|null
*/
public function getResultWithDetails(int $resultID): ?array
/**
* Update result with validation
*
* @param int $resultID
* @param array $data
* - Result: string - The result value
* - Unit: string - Unit of measurement (optional)
* - AbnormalFlag: string - H, L, N, A, C (optional, auto-calculated)
* - Comment: string - Result comment (optional)
* - ResultStatus: string - Status update (optional)
* @return bool
*/
public function updateResult(int $resultID, array $data): bool
/**
* Get pending results for a workstation (worklist)
*
* @param int $workstationID
* @param array $filters Additional filters
* @return array
*/
public function getPendingByWorkstation(int $workstationID, array $filters = []): array
/**
* Get all results for an order
*
* @param string $orderID
* @return array
*/
public function getByOrder(string $orderID): array
/**
* Verify a result
*
* @param int $resultID
* @param int $userID - ID of verifying user
* @param string|null $comment - Optional verification comment
* @return bool
*/
public function verifyResult(int $resultID, int $userID, ?string $comment = null): bool
/**
* Unverify a result (amendment)
*
* @param int $resultID
* @param int $userID - ID of user amending
* @param string $reason - Required reason for amendment
* @return bool
*/
public function unverifyResult(int $resultID, int $userID, string $reason): bool
```
#### Fields to Add to `$allowedFields`:
```php
protected $allowedFields = [
'SiteID',
'OrderID',
'InternalSID',
'SID',
'SampleID',
'TestSiteID',
'TestSiteCode',
'AspCnt',
'Result',
'Unit', // NEW
'SampleType',
'ResultDateTime',
'WorkstationID',
'EquipmentID',
'RefNumID',
'RefTxtID',
'ResultStatus', // NEW: PEN, PRE, FIN, AMD
'Verified', // NEW: boolean
'VerifiedBy', // NEW: user ID
'VerifiedDate', // NEW: datetime
'EnteredBy', // NEW: user ID
'AbnormalFlag', // NEW: H, L, N, A, C
'Comment', // NEW
'CreateDate',
'EndDate',
'ArchiveDate',
'DelDate'
];
```
---
### 1.2 Create ResultEntryService
**File:** `app/Libraries/ResultEntryService.php`
This service handles all business logic for result entry.
```php
<?php
namespace App\Libraries;
use App\Models\PatResultModel;
use App\Models\Patient\PatientModel;
use App\Models\RefRange\RefNumModel;
use App\Models\RefRange\RefTxtModel;
use App\Models\Test\TestDefSiteModel;
class ResultEntryService
{
/**
* Validate result value based on test type
*
* @param string $value
* @param int $testSiteID
* @return array ['valid' => bool, 'error' => string|null]
*/
public function validateResult(string $value, int $testSiteID): array
/**
* Find applicable reference range
*
* @param int $testSiteID
* @param array $patient Demographics: age (months), sex, specimenType
* @return array|null Reference range data
*/
public function getApplicableRange(int $testSiteID, array $patient): ?array
/**
* Calculate abnormal flag based on value and range
*
* @param string|float $value
* @param array $range Reference range data
* @return string H, L, N, A, or C
*/
public function calculateAbnormalFlag($value, array $range): string
/**
* Format reference range for display
*
* @param array $range
* @return string Human-readable range (e.g., "10.0 - 20.0 mg/dL")
*/
public function formatDisplayRange(array $range): string
/**
* Check delta (compare with previous result)
*
* @param int $resultID Current result being edited
* @param string|float $newValue
* @return array ['hasPrevious' => bool, 'previousValue' => string|null, 'deltaPercent' => float|null, 'significant' => bool]
*/
public function checkDelta(int $resultID, $newValue): array
/**
* Process result entry
*
* @param int $resultID
* @param array $data
* @param int $userID User entering the result
* @return array ['success' => bool, 'result' => array|null, 'errors' => array]
*/
public function processEntry(int $resultID, array $data, int $userID): array
/**
* Update calculated tests after dependency changes
*
* @param string $orderID
* @param int $userID
* @return int Number of calculated results updated
*/
public function recalculateDependentResults(string $orderID, int $userID): int
/**
* Get worklist for workstation
*
* @param int $workstationID
* @param array $filters
* @return array
*/
public function getWorklist(int $workstationID, array $filters = []): array
}
```
---
### 1.3 Implement ResultController
**File:** `app/Controllers/ResultController.php`
Replace the placeholder controller with full implementation:
```php
<?php
namespace App\Controllers;
use App\Traits\ResponseTrait;
use App\Libraries\ResultEntryService;
use App\Models\PatResultModel;
use CodeIgniter\Controller;
class ResultController extends Controller
{
use ResponseTrait;
protected $resultModel;
protected $entryService;
public function __construct()
{
$this->resultModel = new PatResultModel();
$this->entryService = new ResultEntryService();
}
/**
* GET /api/results
* List results with filtering
*/
public function index()
/**
* GET /api/results/{id}
* Get single result with details
*/
public function show($id = null)
/**
* PATCH /api/results/{id}
* Update result value
*/
public function update($id = null)
/**
* POST /api/results/batch
* Batch update multiple results
*/
public function batchUpdate()
/**
* POST /api/results/{id}/verify
* Verify a result
*/
public function verify($id = null)
/**
* POST /api/results/{id}/unverify
* Unverify/amend a result
*/
public function unverify($id = null)
/**
* GET /api/results/worklist
* Get pending results for workstation
*/
public function worklist()
/**
* GET /api/results/order/{orderID}
* Get all results for an order
*/
public function byOrder($orderID = null)
}
```
---
## Phase 2: Database Schema (Priority: HIGH)
### 2.1 Migration for New Fields
**File:** `app/Database/Migrations/2025-03-04-000001_AddResultFields.php`
```php
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class AddResultFields extends Migration
{
public function up()
{
$this->forge->addColumn('patres', [
'ResultStatus' => [
'type' => 'VARCHAR',
'constraint' => 10,
'null' => true,
'comment' => 'PEN=Pending, PRE=Preliminary, FIN=Final, AMD=Amended',
'after' => 'RefTxtID'
],
'Verified' => [
'type' => 'TINYINT',
'constraint' => 1,
'default' => 0,
'after' => 'ResultStatus'
],
'VerifiedBy' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
'after' => 'Verified'
],
'VerifiedDate' => [
'type' => 'DATETIME',
'null' => true,
'after' => 'VerifiedBy'
],
'EnteredBy' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'null' => true,
'after' => 'VerifiedDate'
],
'AbnormalFlag' => [
'type' => 'VARCHAR',
'constraint' => 1,
'null' => true,
'comment' => 'H=High, L=Low, N=Normal, A=Abnormal, C=Critical',
'after' => 'EnteredBy'
],
'Comment' => [
'type' => 'TEXT',
'null' => true,
'after' => 'AbnormalFlag'
],
'Unit' => [
'type' => 'VARCHAR',
'constraint' => 50,
'null' => true,
'after' => 'Result'
]
]);
}
public function down()
{
$this->forge->dropColumn('patres', [
'ResultStatus',
'Verified',
'VerifiedBy',
'VerifiedDate',
'EnteredBy',
'AbnormalFlag',
'Comment',
'Unit'
]);
}
}
```
### 2.2 Create Result History Table (Audit Trail)
**File:** `app/Database/Migrations/2025-03-04-000002_CreatePatResHistory.php`
```php
<?php
namespace App\Database\Migrations;
use CodeIgniter\Database\Migration;
class CreatePatResHistory extends Migration
{
public function up()
{
$this->forge->addField([
'HistoryID' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true,
'auto_increment' => true
],
'ResultID' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true
],
'OrderID' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true
],
'TestSiteID' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true
],
'OldResult' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true
],
'NewResult' => [
'type' => 'VARCHAR',
'constraint' => 255,
'null' => true
],
'OldStatus' => [
'type' => 'VARCHAR',
'constraint' => 10,
'null' => true
],
'NewStatus' => [
'type' => 'VARCHAR',
'constraint' => 10,
'null' => true
],
'ChangedBy' => [
'type' => 'INT',
'constraint' => 11,
'unsigned' => true
],
'ChangeReason' => [
'type' => 'TEXT',
'null' => true
],
'CreateDate' => [
'type' => 'DATETIME',
'null' => false
]
]);
$this->forge->addKey('HistoryID', true);
$this->forge->addKey('ResultID');
$this->forge->addKey('CreateDate');
$this->forge->createTable('patreshistory');
}
public function down()
{
$this->forge->dropTable('patreshistory');
}
}
```
---
## Phase 3: API Routes (Priority: HIGH)
### 3.1 Update Routes.php
Add to `app/Config/Routes.php` within the existing `api` group:
```php
// Results
$routes->group('results', function ($routes) {
$routes->get('/', 'ResultController::index');
$routes->get('worklist', 'ResultController::worklist');
$routes->get('order/(:any)', 'ResultController::byOrder/$1');
$routes->get('(:num)', 'ResultController::show/$1');
$routes->patch('(:num)', 'ResultController::update/$1');
$routes->post('batch', 'ResultController::batchUpdate');
$routes->post('(:num)/verify', 'ResultController::verify/$1');
$routes->post('(:num)/unverify', 'ResultController::unverify/$1');
});
```
---
## Phase 4: API Documentation (Priority: MEDIUM)
### 4.1 Create Results Schema
**File:** `public/components/schemas/results.yaml`
```yaml
Result:
type: object
properties:
ResultID:
type: integer
OrderID:
type: integer
InternalSID:
type: integer
nullable: true
TestSiteID:
type: integer
TestSiteCode:
type: string
TestSiteName:
type: string
nullable: true
SID:
type: string
SampleID:
type: string
Result:
type: string
nullable: true
Unit:
type: string
nullable: true
ResultStatus:
type: string
enum: [PEN, PRE, FIN, AMD]
nullable: true
Verified:
type: boolean
default: false
VerifiedBy:
type: integer
nullable: true
VerifiedDate:
type: string
format: date-time
nullable: true
EnteredBy:
type: integer
nullable: true
AbnormalFlag:
type: string
enum: [H, L, N, A, C]
nullable: true
Comment:
type: string
nullable: true
CreateDate:
type: string
format: date-time
ReferenceRange:
type: object
nullable: true
properties:
Low:
type: number
High:
type: number
Display:
type: string
Patient:
type: object
properties:
InternalPID:
type: integer
PatientID:
type: string
NameFirst:
type: string
NameLast:
type: string
Birthdate:
type: string
format: date
Sex:
type: string
ResultEntryRequest:
type: object
required:
- Result
properties:
Result:
type: string
description: The result value
Unit:
type: string
description: Unit override (optional)
AbnormalFlag:
type: string
enum: [H, L, N, A, C]
description: Override auto-calculated flag (optional)
Comment:
type: string
description: Result comment
ResultStatus:
type: string
enum: [PEN, PRE, FIN]
description: Set status (can't set to AMD via update)
ResultBatchRequest:
type: object
required:
- results
properties:
results:
type: array
items:
type: object
properties:
ResultID:
type: integer
Result:
type: string
Unit:
type: string
Comment:
type: string
ResultVerifyRequest:
type: object
properties:
comment:
type: string
description: Optional verification comment
ResultUnverifyRequest:
type: object
required:
- reason
properties:
reason:
type: string
description: Required reason for amendment
ResultWorklistResponse:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
type: object
properties:
ResultID:
type: integer
PatientName:
type: string
PatientID:
type: string
OrderID:
type: string
TestCode:
type: string
TestName:
type: string
ResultStatus:
type: string
Priority:
type: string
OrderDate:
type: string
format: date-time
```
### 4.2 Create API Paths Documentation
**File:** `public/paths/results.yaml`
Document all endpoints (GET /api/results, GET /api/results/{id}, PATCH /api/results/{id}, POST /api/results/batch, POST /api/results/{id}/verify, POST /api/results/{id}/unverify, GET /api/results/worklist, GET /api/results/order/{orderID}) with:
- Summary and description
- Security (bearerAuth)
- Parameters (path, query)
- RequestBody schemas
- Response schemas
- Error responses
---
## Phase 5: Reference Range Integration (Priority: MEDIUM)
### 5.1 Create RefRangeService
**File:** `app/Libraries/RefRangeService.php`
```php
<?php
namespace App\Libraries;
use App\Models\RefRange\RefNumModel;
use App\Models\RefRange\RefTxtModel;
class RefRangeService
{
/**
* Get applicable reference range for a test and patient
*
* @param int $testSiteID
* @param array $patient Contains: age (in months), sex (M/F), specimenType
* @return array|null
*/
public function getApplicableRange(int $testSiteID, array $patient): ?array
/**
* Evaluate numeric result against range
*
* @param float $value
* @param array $range
* @return string H, L, N, A, C
*/
public function evaluateNumeric(float $value, array $range): string
/**
* Get text reference options
*
* @param int $refTxtID
* @return array
*/
public function getTextOptions(int $refTxtID): array
/**
* Format range for display
*
* @param array $range
* @return string
*/
public function formatDisplay(array $range): string
}
```
---
## Phase 6: Testing (Priority: MEDIUM)
### 6.1 Create Feature Tests
**File:** `tests/feature/Results/ResultEntryTest.php`
Test scenarios:
- Get results list with filters
- Get single result with details
- Update result value
- Validation errors (invalid value)
- Auto-calculate abnormal flag
- Delta check notification
- Batch update
**File:** `tests/feature/Results/ResultVerifyTest.php`
Test scenarios:
- Verify result successfully
- Unverify with reason
- Attempt to modify verified result
- Permission checks
- Amendment workflow
**File:** `tests/feature/Results/ResultWorklistTest.php`
Test scenarios:
- Get worklist by workstation
- Filter by priority
- Sort by order date
- Pagination
### 6.2 Create Unit Tests
**File:** `tests/unit/Libraries/ResultEntryServiceTest.php`
Test scenarios:
- Result validation
- Reference range matching
- Abnormal flag calculation
- Delta calculation
- Calculated test formulas
**File:** `tests/unit/Libraries/RefRangeServiceTest.php`
Test scenarios:
- Age-based range selection
- Sex-based range selection
- Specimen type matching
- Boundary value evaluation
---
## Data Flow Diagram
```
Order Creation
|
v
Empty patres records created
|
v
GET /api/results/worklist <-- Technician sees pending results
|
v
GET /api/results/{id} <-- Load result with patient info
|
v
PATCH /api/results/{id} <-- Enter result value
| |
| v
| ResultEntryService.validateResult()
| |
| v
| RefRangeService.getApplicableRange()
| |
| v
| Auto-calculate AbnormalFlag
| |
v v
Result updated in patres
|
v
POST /api/results/{id}/verify <-- Senior tech/pathologist
|
v
ResultStatus = FIN, Verified = true
```
---
## Status Definitions
| Status | Code | Description |
|--------|------|-------------|
| Pending | PEN | Order created, awaiting result entry |
| Preliminary | PRE | Result entered but not verified |
| Final | FIN | Result verified by authorized user |
| Amended | AMD | Previously final result modified |
## Abnormal Flag Definitions
| Flag | Meaning | Action Required |
|------|---------|-----------------|
| N | Normal | None |
| H | High | Review |
| L | Low | Review |
| A | Abnormal (text) | Review |
| C | Critical | Immediate notification |
---
## Questions for Implementation
Before starting implementation, clarify:
1. **Who can verify results?**
- Option A: Any authenticated user
- Option B: Users with specific role (senior tech, pathologist)
- Option C: Configure per test/discipline
2. **Can calculated tests be manually edited?**
- Option A: No, always auto-computed
- Option B: Yes, allow override with reason
- Option C: Configurable per test
3. **Audit trail requirements:**
- Option A: Full history (every change)
- Option B: Only amendments (verified→unverify→verify)
- Option C: No audit trail needed
4. **Critical results handling:**
- Option A: Flag only
- Option B: Flag + notification system
- Option C: Flag + mandatory acknowledgment
5. **Batch entry priority:**
- Must-have or nice-to-have?
- Support for templates/predefined sets?
6. **Delta check sensitivity:**
- Fixed percentage threshold (e.g., 20%)?
- Test-specific thresholds?
- Configurable?
---
## Implementation Checklist
- [ ] Phase 1.1: Extend PatResultModel with CRUD methods
- [ ] Phase 1.2: Create ResultEntryService with business logic
- [ ] Phase 1.3: Implement ResultController methods
- [ ] Phase 2.1: Create migration for new patres fields
- [ ] Phase 2.2: Create patreshistory table
- [ ] Phase 3.1: Add routes to Routes.php
- [ ] Phase 4.1: Create results.yaml schema
- [ ] Phase 4.2: Create results.yaml paths documentation
- [ ] Phase 4.3: Run `node public/bundle-api-docs.js`
- [ ] Phase 5.1: Create RefRangeService
- [ ] Phase 6.1: Create feature tests
- [ ] Phase 6.2: Create unit tests
- [ ] Run full test suite: `./vendor/bin/phpunit`
---
## Estimated Timeline
| Phase | Duration | Priority |
|-------|----------|----------|
| Phase 1: Core Management | 2-3 days | HIGH |
| Phase 2: Database Schema | 0.5 day | HIGH |
| Phase 3: API Routes | 0.5 day | HIGH |
| Phase 4: Documentation | 1 day | MEDIUM |
| Phase 5: Reference Ranges | 1-2 days | MEDIUM |
| Phase 6: Testing | 2-3 days | MEDIUM |
| **Total** | **7-10 days** | |
---
## Notes
- All dates stored in UTC, convert to local time for display
- Use transactions for all multi-table operations
- Follow existing code style (camelCase methods, snake_case properties)
- Update AGENTS.md if adding new commands or patterns
- Consider performance: worklist queries should be fast (< 500ms)
*Last Updated: 2025-03-04*

249
docs/mvp_plan.md Normal file
View File

@ -0,0 +1,249 @@
# CLQMS MVP Sprint Plan
> **Scope**: Result Entry + Validation + Reporting (2-week sprint)
> **Removed**: Edge API, Worklists, Calculated Tests, QC, Instrument Integration
---
## Current State
### What's Working
- Patient CRUD + Identifiers + Addresses
- Patient Visit + ADT
- Lab Orders (create with specimen generation)
- Test Definitions + Reference Ranges (refnum)
- Authentication (JWT)
- Master Data (ValueSets, Locations, etc.)
### Critical Gap
- `ResultController` only returns JWT payload (no CRUD)
- `PatResultModel` has no validation logic
- No PDF generation capability
- Only 1 working route: `GET /api/result` (returns auth check only)
---
## Phase 1: Result CRUD + Validation (Week 1)
### Day 1-2: PatResultModel Enhancement
**Location**: `app/Models/PatResultModel.php`
Add methods:
- `validateAndFlag($resultID, $value)` - Compare result against refnum ranges
- Check patient age, sex from order
- Match refnum criteria
- Return 'L', 'H', or null
- `getByOrder($orderID)` - Fetch all results for an order with test names
- `getByPatient($internalPID)` - Get cumulative patient results
- `updateWithValidation($resultID, $data)` - Update + auto-validate
### Day 3-4: ResultController
**Location**: `app/Controllers/ResultController.php`
Replace placeholder with full CRUD:
| Method | Endpoint | Description |
|--------|----------|-------------|
| `index()` | `GET /api/results` | List results (filter by order/patient) |
| `show($id)` | `GET /api/results/{id}` | Get single result |
| `update($id)` | `PATCH /api/results/{id}` | Update result + auto-validate |
| `delete($id)` | `DELETE /api/results/{id}` | Soft delete result |
**Features**:
- Filter by `order_id` or `patient_id` query param
- Include test name from `testdefsite`
- Auto-calculate flags on update
- Return standardized ResponseTrait format
### Day 5: Routes & Testing
**Location**: `app/Config/Routes.php`
Replace line 18:
```php
// OLD
$routes->get('result', 'ResultController::index');
// NEW
$routes->group('results', function ($routes) {
$routes->get('/', 'ResultController::index');
$routes->get('(:num)', 'ResultController::show/$1');
$routes->patch('(:num)', 'ResultController::update/$1');
$routes->delete('(:num)', 'ResultController::delete/$1');
});
```
**Testing**: Manual API testing with Postman/Insomnia
---
## Phase 2: HTML Report Viewing (Week 2)
### Day 1-2: Report View & Controller
**Create Report View**: `app/Views/reports/lab_report.php`
Template sections:
- Patient header (Name, DOB, ID)
- Order info (OrderID, Date, Doctor)
- Results table (Test, Result, Units, Reference, Flag)
- Footer (Lab info, signature)
- Print-friendly CSS with `@media print` support
**Create ReportController**: `app/Controllers/ReportController.php`
| Method | Endpoint | Description |
|--------|----------|-------------|
| `view($orderID)` | `GET /api/reports/{orderID}` | Generate HTML lab report |
**Logic**:
- Fetch order with patient details
- Get all results for order (with flags)
- Include test definitions (name, units, ref range)
- Render HTML view using CodeIgniter's view() function
- Users can print/save as PDF via browser
### Day 3-4: Routes & Polish
**Routes** (add to Routes.php):
```php
$routes->get('reports/(:num)', 'ReportController::view/$1');
```
**HTML Styling**:
- Professional lab report format
- Show abnormal flags (L/H) highlighted
- Include reference ranges
- Signature line for pathologist
- Responsive design with print button
**Testing**:
- Generate report with actual data
- Verify formatting
- Test print functionality
- Test edge cases (no results, all normal, mix of flags)
**Note**: PDF generation deferred to post-MVP. Users can use browser's "Print to PDF" feature for now.
---
## Database Schema Reference
### patres (Results)
| Column | Type | Notes |
|--------|------|-------|
| ResultID | INT | PK |
| OrderID | INT | FK to ordertest |
| TestSiteID | INT | FK to testdefsite |
| Result | VARCHAR(255) | The actual value |
| ResultDateTime | DATETIME | When entered |
| RefNumID | INT | Applied reference range |
### refnum (Reference Ranges)
| Column | Type | Notes |
|--------|------|-------|
| RefNumID | INT | PK |
| TestSiteID | INT | Which test |
| Sex | VARCHAR | M/F/ALL |
| AgeStart/AgeEnd | INT | Age criteria |
| Low/High | DECIMAL | Range values |
| LowSign/HighSign | VARCHAR | <=, <, etc |
---
## API Endpoints Summary
| Endpoint | Method | Auth | Description |
|----------|--------|------|-------------|
| `/api/results` | GET | Yes | List results (filter: ?order_id= or ?patient_id=) |
| `/api/results/{id}` | GET | Yes | Get single result |
| `/api/results/{id}` | PATCH | Yes | Update result + auto-flag |
| `/api/results/{id}` | DELETE | Yes | Soft delete result |
| `/api/reports/{orderID}` | GET | Yes | Generate PDF report |
---
## Flag Logic (Reference Range Validation)
```php
// Pseudo-code for validation
function validateResult($resultValue, $refNumID) {
$ref = getRefNum($refNumID);
$patient = getPatientFromOrder($orderID);
// Match criteria (sex, age)
if (!matchesCriteria($ref, $patient)) {
return null; // No flag if criteria don't match
}
$value = floatval($resultValue);
$low = floatval($ref['Low']);
$high = floatval($ref['High']);
// Check low
if ($ref['LowSign'] === '<=' && $value <= $low) return 'L';
if ($ref['LowSign'] === '<' && $value < $low) return 'L';
// Check high
if ($ref['HighSign'] === '>=' && $value >= $high) return 'H';
if ($ref['HighSign'] === '>' && $value > $high) return 'H';
return null; // Normal
}
```
---
## Out of Scope (Post-MVP)
- **Edge API**: Instrument integration (`app/Controllers/EdgeController.php`)
- **Worklist Generation**: Technician worklists
- **Calculated Tests**: Formula execution (CALC type)
- **Quality Control**: QC samples, Levy-Jennings charts
- **Calculated Test Execution**: Deferred to later
- **Delta Checking**: Result trending
- **Critical Result Alerts**: Notification system
- **Audit Trail**: Complete audit logging
---
## Success Criteria
- [ ] Can enter result values via API
- [ ] Results auto-validate against refnum ranges
- [ ] Abnormal results show L/H flags
- [ ] Can view all results for an order
- [ ] Can generate PDF lab report
- [ ] Report shows patient, order, results with flags
---
## Files to Create/Modify
### Create
1. `app/Controllers/ReportController.php` - HTML report controller
2. `app/Views/reports/lab_report.php` - HTML report template with dynamic flag calculation
3. `app/Database/Migrations/2026-03-04-073950_RemoveFlagColumnFromPatRes.php` - Remove Flag column
4. `public/paths/reports.yaml` - OpenAPI documentation for reports endpoint
### Modify
1. `app/Models/PatResultModel.php` - Add validation methods (validateAndFlag, getByOrder, getByPatient, updateWithValidation, getWithRelations, softDelete)
2. `app/Controllers/ResultController.php` - Full CRUD (index, show, update, delete)
3. `app/Config/Routes.php` - New routes for results and reports
4. `public/paths/results.yaml` - Updated OpenAPI documentation
5. `public/api-docs.yaml` - Added Reports tag
6. Regenerated `public/api-docs.bundled.yaml`
**Note**: Flag is calculated dynamically at runtime, not stored in database. This allows for:
- Real-time validation against current reference ranges
- Support for reference range updates without re-processing historical results
- Reduced storage requirements
**API Documentation**: Remember to run `node public/bundle-api-docs.js` after updating YAML files to regenerate the bundled documentation.
---
*Last Updated: 2026-03-04*
*Sprint Duration: 2 weeks*
*Team Size: 1 developer*

View File

@ -36,7 +36,9 @@ tags:
- name: Orders
description: Laboratory order management
- name: Results
description: Patient results reporting
description: Patient results reporting with auto-validation
- name: Reports
description: Lab report generation (HTML view)
- name: Edge API
description: Instrument integration endpoints
- name: Contacts
@ -2707,51 +2709,75 @@ paths:
type: string
data:
$ref: '#/components/schemas/Patient'
/api/reports/{orderID}:
get:
tags:
- Reports
summary: Generate lab report
description: Generate an HTML lab report for a specific order. Returns HTML content that can be viewed in browser or printed to PDF.
security:
- bearerAuth: []
parameters:
- name: orderID
in: path
required: true
schema:
type: integer
description: Internal Order ID
responses:
'200':
description: HTML lab report
content:
text/html:
schema:
type: string
description: HTML content of the lab report
'404':
description: Order or patient not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'500':
description: Failed to generate report
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/api/results:
get:
tags:
- Results
summary: Get patient results
description: Retrieve patient test results with optional filters
summary: List results
description: Retrieve patient test results with optional filters by order or patient
security:
- bearerAuth: []
parameters:
- name: InternalPID
- name: order_id
in: query
schema:
type: integer
description: Filter by internal patient ID
- name: OrderID
description: Filter by internal order ID
- name: patient_id
in: query
schema:
type: string
description: Filter by order ID
- name: TestCode
type: integer
description: Filter by internal patient ID (returns cumulative results)
- name: page
in: query
schema:
type: string
description: Filter by test code
- name: date_from
type: integer
default: 1
description: Page number for pagination
- name: per_page
in: query
schema:
type: string
format: date
description: Filter results from date (YYYY-MM-DD)
- name: date_to
in: query
schema:
type: string
format: date
description: Filter results to date (YYYY-MM-DD)
- name: verified_only
in: query
schema:
type: boolean
default: false
description: Return only verified results
type: integer
default: 20
description: Number of results per page
responses:
'200':
description: List of patient results
description: List of results
content:
application/json:
schema:
@ -2759,6 +2785,9 @@ paths:
properties:
status:
type: string
example: success
message:
type: string
data:
type: array
items:
@ -2766,84 +2795,57 @@ paths:
properties:
ResultID:
type: integer
InternalPID:
type: integer
OrderID:
type: string
TestID:
type: integer
TestCode:
TestSiteID:
type: integer
TestSiteCode:
type: string
TestName:
Result:
type: string
ResultValue:
type: string
Unit:
type: string
ReferenceRange:
type: string
AbnormalFlag:
type: string
Verified:
type: boolean
VerifiedBy:
type: string
VerifiedDate:
nullable: true
ResultDateTime:
type: string
format: date-time
ResultDate:
RefNumID:
type: integer
nullable: true
RefTxtID:
type: integer
nullable: true
CreateDate:
type: string
format: date-time
post:
tags:
- Results
summary: Create or update result
description: Create a new result or update an existing result entry
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- InternalPID
- TestID
- ResultValue
properties:
InternalPID:
type: integer
OrderID:
TestSiteName:
type: string
TestID:
type: integer
ResultValue:
nullable: true
Unit1:
type: string
Unit:
nullable: true
Unit2:
type: string
AbnormalFlag:
nullable: true
Low:
type: number
nullable: true
High:
type: number
nullable: true
LowSign:
type: string
enum:
- H
- L
- 'N'
- A
- C
description: H=High, L=Low, N=Normal, A=Abnormal, C=Critical
responses:
'201':
description: Result created successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
nullable: true
HighSign:
type: string
nullable: true
RefDisplay:
type: string
nullable: true
/api/results/{id}:
get:
tags:
- Results
summary: Get result by ID
description: Retrieve a specific result entry by its ID
description: Retrieve a specific result entry with all related data
security:
- bearerAuth: []
parameters:
@ -2863,44 +2865,94 @@ paths:
properties:
status:
type: string
example: success
message:
type: string
data:
type: object
properties:
ResultID:
type: integer
InternalPID:
SiteID:
type: integer
OrderID:
type: string
TestID:
type: integer
TestCode:
InternalSID:
type: integer
SID:
type: string
TestName:
SampleID:
type: string
ResultValue:
TestSiteID:
type: integer
TestSiteCode:
type: string
Unit:
AspCnt:
type: integer
Result:
type: string
ReferenceRange:
nullable: true
SampleType:
type: string
AbnormalFlag:
type: string
Verified:
type: boolean
VerifiedBy:
type: string
VerifiedDate:
nullable: true
ResultDateTime:
type: string
format: date-time
ResultDate:
WorkstationID:
type: integer
nullable: true
EquipmentID:
type: integer
nullable: true
RefNumID:
type: integer
nullable: true
RefTxtID:
type: integer
nullable: true
CreateDate:
type: string
format: date-time
TestSiteName:
type: string
nullable: true
Unit1:
type: string
nullable: true
Unit2:
type: string
nullable: true
Low:
type: number
nullable: true
High:
type: number
nullable: true
LowSign:
type: string
nullable: true
HighSign:
type: string
nullable: true
RefDisplay:
type: string
nullable: true
OrderNumber:
type: string
nullable: true
InternalPID:
type: integer
'404':
description: Result not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
patch:
tags:
- Results
summary: Update result
description: Update an existing result entry
description: Update a result value with automatic validation against reference ranges. Returns calculated flag (L/H) in response but does not store it.
security:
- bearerAuth: []
parameters:
@ -2917,32 +2969,63 @@ paths:
schema:
type: object
properties:
ResultValue:
Result:
type: string
Unit:
description: The result value
RefNumID:
type: integer
description: Reference range ID to validate against
SampleType:
type: string
AbnormalFlag:
type: string
enum:
- H
- L
- 'N'
- A
- C
Verified:
type: boolean
nullable: true
WorkstationID:
type: integer
nullable: true
EquipmentID:
type: integer
nullable: true
responses:
'200':
description: Result updated successfully
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: object
properties:
result:
type: object
flag:
type: string
nullable: true
enum:
- L
- H
description: Calculated flag - L for Low, H for High, null for normal
'400':
description: Validation failed
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'404':
description: Result not found
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
delete:
tags:
- Results
summary: Delete result
description: Soft delete a result entry
description: Soft delete a result entry by setting DelDate
security:
- bearerAuth: []
parameters:
@ -2959,28 +3042,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
/api/results/{id}/verify:
post:
tags:
- Results
summary: Verify result
description: Mark a result as verified by the current user
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Result ID
responses:
'200':
description: Result verified successfully
'404':
description: Result not found
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
$ref: '#/components/schemas/ErrorResponse'
/api/specimen:
get:
tags:

View File

@ -38,7 +38,9 @@ tags:
- name: Orders
description: Laboratory order management
- name: Results
description: Patient results reporting
description: Patient results reporting with auto-validation
- name: Reports
description: Lab report generation (HTML view)
- name: Edge API
description: Instrument integration endpoints
- name: Contacts

34
public/paths/reports.yaml Normal file
View File

@ -0,0 +1,34 @@
/api/reports/{orderID}:
get:
tags: [Reports]
summary: Generate lab report
description: Generate an HTML lab report for a specific order. Returns HTML content that can be viewed in browser or printed to PDF.
security:
- bearerAuth: []
parameters:
- name: orderID
in: path
required: true
schema:
type: integer
description: Internal Order ID
responses:
'200':
description: HTML lab report
content:
text/html:
schema:
type: string
description: HTML content of the lab report
'404':
description: Order or patient not found
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
'500':
description: Failed to generate report
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'

View File

@ -1,47 +1,36 @@
/api/results:
get:
tags: [Results]
summary: Get patient results
description: Retrieve patient test results with optional filters
summary: List results
description: Retrieve patient test results with optional filters by order or patient
security:
- bearerAuth: []
parameters:
- name: InternalPID
- name: order_id
in: query
schema:
type: integer
description: Filter by internal patient ID
- name: OrderID
description: Filter by internal order ID
- name: patient_id
in: query
schema:
type: string
description: Filter by order ID
- name: TestCode
type: integer
description: Filter by internal patient ID (returns cumulative results)
- name: page
in: query
schema:
type: string
description: Filter by test code
- name: date_from
type: integer
default: 1
description: Page number for pagination
- name: per_page
in: query
schema:
type: string
format: date
description: Filter results from date (YYYY-MM-DD)
- name: date_to
in: query
schema:
type: string
format: date
description: Filter results to date (YYYY-MM-DD)
- name: verified_only
in: query
schema:
type: boolean
default: false
description: Return only verified results
type: integer
default: 20
description: Number of results per page
responses:
'200':
description: List of patient results
description: List of results
content:
application/json:
schema:
@ -49,6 +38,9 @@
properties:
status:
type: string
example: success
message:
type: string
data:
type: array
items:
@ -56,79 +48,57 @@
properties:
ResultID:
type: integer
InternalPID:
type: integer
OrderID:
type: string
TestID:
type: integer
TestCode:
TestSiteID:
type: integer
TestSiteCode:
type: string
TestName:
Result:
type: string
ResultValue:
type: string
Unit:
type: string
ReferenceRange:
type: string
AbnormalFlag:
type: string
Verified:
type: boolean
VerifiedBy:
type: string
VerifiedDate:
nullable: true
ResultDateTime:
type: string
format: date-time
ResultDate:
RefNumID:
type: integer
nullable: true
RefTxtID:
type: integer
nullable: true
CreateDate:
type: string
format: date-time
post:
tags: [Results]
summary: Create or update result
description: Create a new result or update an existing result entry
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- InternalPID
- TestID
- ResultValue
properties:
InternalPID:
type: integer
OrderID:
TestSiteName:
type: string
TestID:
type: integer
ResultValue:
nullable: true
Unit1:
type: string
Unit:
nullable: true
Unit2:
type: string
AbnormalFlag:
nullable: true
Low:
type: number
nullable: true
High:
type: number
nullable: true
LowSign:
type: string
enum: [H, L, N, A, C]
description: H=High, L=Low, N=Normal, A=Abnormal, C=Critical
responses:
'201':
description: Result created successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
nullable: true
HighSign:
type: string
nullable: true
RefDisplay:
type: string
nullable: true
/api/results/{id}:
get:
tags: [Results]
summary: Get result by ID
description: Retrieve a specific result entry by its ID
description: Retrieve a specific result entry with all related data
security:
- bearerAuth: []
parameters:
@ -148,44 +118,94 @@
properties:
status:
type: string
example: success
message:
type: string
data:
type: object
properties:
ResultID:
type: integer
InternalPID:
SiteID:
type: integer
OrderID:
type: string
TestID:
type: integer
TestCode:
InternalSID:
type: integer
SID:
type: string
TestName:
SampleID:
type: string
ResultValue:
TestSiteID:
type: integer
TestSiteCode:
type: string
Unit:
AspCnt:
type: integer
Result:
type: string
ReferenceRange:
nullable: true
SampleType:
type: string
AbnormalFlag:
type: string
Verified:
type: boolean
VerifiedBy:
type: string
VerifiedDate:
nullable: true
ResultDateTime:
type: string
format: date-time
ResultDate:
WorkstationID:
type: integer
nullable: true
EquipmentID:
type: integer
nullable: true
RefNumID:
type: integer
nullable: true
RefTxtID:
type: integer
nullable: true
CreateDate:
type: string
format: date-time
TestSiteName:
type: string
nullable: true
Unit1:
type: string
nullable: true
Unit2:
type: string
nullable: true
Low:
type: number
nullable: true
High:
type: number
nullable: true
LowSign:
type: string
nullable: true
HighSign:
type: string
nullable: true
RefDisplay:
type: string
nullable: true
OrderNumber:
type: string
nullable: true
InternalPID:
type: integer
'404':
description: Result not found
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
patch:
tags: [Results]
summary: Update result
description: Update an existing result entry
description: Update a result value with automatic validation against reference ranges. Returns calculated flag (L/H) in response but does not store it.
security:
- bearerAuth: []
parameters:
@ -202,27 +222,61 @@
schema:
type: object
properties:
ResultValue:
Result:
type: string
Unit:
description: The result value
RefNumID:
type: integer
description: Reference range ID to validate against
SampleType:
type: string
AbnormalFlag:
type: string
enum: [H, L, N, A, C]
Verified:
type: boolean
nullable: true
WorkstationID:
type: integer
nullable: true
EquipmentID:
type: integer
nullable: true
responses:
'200':
description: Result updated successfully
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
type: object
properties:
status:
type: string
example: success
message:
type: string
data:
type: object
properties:
result:
type: object
flag:
type: string
nullable: true
enum: [L, H]
description: Calculated flag - L for Low, H for High, null for normal
'400':
description: Validation failed
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
'404':
description: Result not found
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/ErrorResponse'
delete:
tags: [Results]
summary: Delete result
description: Soft delete a result entry
description: Soft delete a result entry by setting DelDate
security:
- bearerAuth: []
parameters:
@ -239,25 +293,9 @@
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
/api/results/{id}/verify:
post:
tags: [Results]
summary: Verify result
description: Mark a result as verified by the current user
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
schema:
type: integer
description: Result ID
responses:
'200':
description: Result verified successfully
'404':
description: Result not found
content:
application/json:
schema:
$ref: '../components/schemas/common.yaml#/SuccessResponse'
$ref: '../components/schemas/common.yaml#/ErrorResponse'