clqms-be/docs/manual-result-entry-plan.md
mahdahar 85c7e96405 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
2026-03-04 16:48:12 +07:00

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:

  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