- 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
22 KiB
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:
/**
* 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:
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
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
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
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
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:
// 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
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
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:
-
Who can verify results?
- Option A: Any authenticated user
- Option B: Users with specific role (senior tech, pathologist)
- Option C: Configure per test/discipline
-
Can calculated tests be manually edited?
- Option A: No, always auto-computed
- Option B: Yes, allow override with reason
- Option C: Configurable per test
-
Audit trail requirements:
- Option A: Full history (every change)
- Option B: Only amendments (verified→unverify→verify)
- Option C: No audit trail needed
-
Critical results handling:
- Option A: Flag only
- Option B: Flag + notification system
- Option C: Flag + mandatory acknowledgment
-
Batch entry priority:
- Must-have or nice-to-have?
- Support for templates/predefined sets?
-
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