feat: add calculator API support for test formulas and update docs

This commit is contained in:
mahdahar 2026-03-11 16:45:16 +07:00
parent ad8e1cc977
commit 911846592f
29 changed files with 870 additions and 2432 deletions

1
.serena/.gitignore vendored
View File

@ -1 +1,2 @@
/cache
/project.local.yml

View File

@ -1,419 +0,0 @@
# CLQMS Architecture & Codebase Structure
## High-Level Architecture
CLQMS follows a **clean architecture pattern** with clear separation of concerns:
```
┌─────────────────────────────────────────────────────────────┐
│ API Consumers │
│ (Web Apps, Mobile Apps, Desktop Clients, Instruments) │
└────────────────────┬────────────────────────────────────────┘
│ HTTP/HTTPS (JSON)
┌────────────────────┴────────────────────────────────────────┐
│ REST API Layer │
│ (Controllers: Patient, Order, Specimen, Result, etc.) │
│ - JWT Authentication Filter │
│ - Request Validation │
│ - Response Formatting │
└────────────────────┬────────────────────────────────────────┘
┌────────────────────┴────────────────────────────────────────┐
│ Business Logic Layer │
│ (Models + Libraries + Services) │
│ - ValueSet Library (JSON-based lookups) │
│ - Base Model (UTC normalization) │
│ - Edge Processing Service │
└────────────────────┬────────────────────────────────────────┘
┌────────────────────┴────────────────────────────────────────┐
│ Data Access Layer │
│ (CodeIgniter Query Builder) │
└────────────────────┬────────────────────────────────────────┘
┌────────────────────┴────────────────────────────────────────┐
│ MySQL Database │
│ (Migration-managed schema) │
└─────────────────────────────────────────────────────────────┘
```
## Directory Structure Overview
### Root Directory Files
```
clqms01-be/
├── .env # Environment configuration
├── .gitignore # Git ignore rules
├── AGENTS.md # AI agent instructions (THIS FILE)
├── README.md # Project documentation
├── PRD.md # Product Requirements Document
├── TODO.md # Implementation tasks
├── USER_STORIES.md # User stories
├── composer.json # PHP dependencies
├── composer.lock # Locked dependency versions
├── phpunit.xml.dist # PHPUnit configuration
├── spark # CodeIgniter CLI tool
└── preload.php # PHP preloader
```
### app/ - Application Core
```
app/
├── Controllers/ # API endpoint handlers
│ ├── BaseController.php # Base controller class
│ ├── AuthController.php # Authentication endpoints
│ ├── AuthV2Controller.php # V2 auth endpoints
│ ├── DashboardController.php # Dashboard data
│ ├── EdgeController.php # Instrument integration
│ ├── Patient/ # Patient management
│ │ └── PatientController.php
│ ├── Organization/ # Organization structure
│ │ ├── AccountController.php
│ │ ├── SiteController.php
│ │ ├── DisciplineController.php
│ │ ├── DepartmentController.php
│ │ └── WorkstationController.php
│ ├── Specimen/ # Specimen management
│ │ ├── SpecimenController.php
│ │ ├── SpecimenCollectionController.php
│ │ ├── SpecimenPrepController.php
│ │ ├── SpecimenStatusController.php
│ │ └── ContainerDefController.php
│ ├── OrderTest/ # Order management
│ │ └── OrderTestController.php
│ ├── Result/ # Result management
│ │ └── ResultController.php
│ ├── Test/ # Test definitions
│ │ └── TestsController.php
│ ├── Contact/ # Contact management
│ │ ├── ContactController.php
│ │ ├── OccupationController.php
│ │ └── MedicalSpecialtyController.php
│ ├── ValueSetController.php # ValueSet API endpoints
│ ├── ValueSetDefController.php # ValueSet definitions
│ ├── LocationController.php # Location management
│ ├── CounterController.php # Counter management
│ ├── PatVisitController.php # Patient visit management
│ └── SampleController.php # Sample management
├── Models/ # Data access layer
│ ├── BaseModel.php # Base model with UTC normalization
│ ├── Patient/ # Patient models
│ │ ├── PatientModel.php
│ │ ├── PatAttModel.php # Patient address
│ │ ├── PatComModel.php # Patient comments
│ │ └── PatIdtModel.php # Patient identifiers
│ ├── Organization/ # Organization models
│ │ ├── AccountModel.php
│ │ ├── SiteModel.php
│ │ ├── DisciplineModel.php
│ │ ├── DepartmentModel.php
│ │ └── WorkstationModel.php
│ ├── Specimen/ # Specimen models
│ │ ├── SpecimenModel.php
│ │ ├── SpecimenCollectionModel.php
│ │ ├── SpecimenPrepModel.php
│ │ ├── SpecimenStatusModel.php
│ │ └── ContainerDefModel.php
│ ├── OrderTest/ # Order models
│ │ ├── OrderTestModel.php
│ │ ├── OrderTestDetModel.php
│ │ └── OrderTestMapModel.php
│ ├── Result/ # Result models
│ │ ├── PatResultModel.php
│ │ └── ResultValueSetModel.php
│ ├── Test/ # Test models
│ │ ├── TestDefSiteModel.php
│ │ ├── TestDefTechModel.php
│ │ ├── TestDefCalModel.php
│ │ ├── TestDefGrpModel.php
│ │ └── RefNumModel.php
│ ├── Contact/ # Contact models
│ │ ├── ContactModel.php
│ │ ├── OccupationModel.php
│ │ └── MedicalSpecialtyModel.php
│ ├── ValueSet/ # ValueSet models (DB-based)
│ │ └── ValueSetModel.php
│ ├── EdgeResModel.php # Edge results
│ ├── CounterModel.php # Counter management
│ ├── PatVisitModel.php # Patient visits
│ └── ...
├── Libraries/ # Reusable libraries
│ ├── ValueSet.php # JSON-based lookup system
│ └── Data/ # ValueSet JSON files
│ ├── valuesets/
│ │ ├── sex.json
│ │ ├── marital_status.json
│ │ ├── race.json
│ │ ├── order_priority.json
│ │ ├── order_status.json
│ │ ├── specimen_type.json
│ │ ├── specimen_status.json
│ │ ├── result_status.json
│ │ ├── test_type.json
│ │ └── ... (many more)
├── Database/ # Database operations
│ ├── Migrations/ # Database schema migrations
│ │ ├── Format: YYYY-MM-DD-NNNNNN_Description.php
│ │ ├── Define up() and down() methods
│ │ └── Use $this->forge methods
│ └── Seeds/ # Database seeders
├── Config/ # Configuration files
│ ├── App.php # App configuration
│ ├── Database.php # Database configuration
│ ├── Routes.php # API route definitions
│ ├── Filters.php # Request filters (auth, etc.)
│ └── ...
├── Filters/ # Request/response filters
│ └── AuthFilter.php # JWT authentication filter
└── Helpers/ # Helper functions
└── utc_helper.php # UTC date conversion helpers
```
### public/ - Public Web Root
```
public/
├── index.php # Front controller (entry point)
├── api-docs.yaml # OpenAPI/Swagger documentation (CRITICAL!)
├── docs.html # API documentation HTML
├── .htaccess # Apache rewrite rules
└── robots.txt # SEO robots file
```
### tests/ - Test Suite
```
tests/
├── feature/ # Integration/API tests
│ ├── ContactControllerTest.php
│ ├── OrganizationControllerTest.php
│ ├── TestsControllerTest.php
│ ├── UniformShowTest.php # Tests show endpoint format
│ └── Patients/
│ └── PatientCreateTest.php
├── unit/ # Unit tests
├── _support/ # Test support utilities
└── README.md # Test documentation
```
### vendor/ - Composer Dependencies
```
vendor/
├── codeigniter4/ # CodeIgniter framework
├── firebase/ # JWT library
├── phpunit/ # PHPUnit testing framework
└── ... # Other dependencies
```
### writable/ - Writable Directory
```
writable/
├── cache/ # Application cache
├── logs/ # Application logs
├── session/ # Session files
└── uploads/ # File uploads
```
## API Route Structure
Routes are defined in `app/Config/Routes.php`:
### Public Routes (No Authentication)
```php
/api/v2/auth/login # User login
/api/v2/auth/register # User registration
/api/demo/order # Create demo order
```
### Authenticated Routes (JWT Required)
```php
/api/patient # Patient CRUD
/api/patvisit # Patient visit CRUD
/api/organization/* # Organization management
/api/specimen/* # Specimen management
/api/ordertest # Order management
/api/tests # Test definitions
/api/valueset/* # ValueSet management
/api/result/* # Result management
```
### Edge API (Instrument Integration)
```php
POST /api/edge/results # Receive results
GET /api/edge/orders # Fetch pending orders
POST /api/edge/orders/:id/ack # Acknowledge order
POST /api/edge/status # Log instrument status
```
## Core Design Patterns
### 1. BaseController Pattern
All controllers extend `BaseController`:
- Provides access to `$this->request`, `$this->response`
- Uses `ResponseTrait` for JSON responses
- Centralizes common functionality
### 2. BaseModel Pattern
All models extend `BaseModel`:
- **UTC Date Normalization**: Automatically converts dates to UTC before insert/update
- **ISO 8601 Output**: Automatically converts dates to ISO format on retrieval
- **Soft Deletes**: Automatic soft delete support via `DelDate` field
- **Hooks**: Uses `beforeInsert`, `beforeUpdate`, `afterFind`, etc.
### 3. ValueSet Pattern
JSON-based static lookup system:
- Fast, cached lookups for static values
- Easy maintenance via JSON files
- Automatic label transformation for API responses
- Clear cache after updates
### 4. Controller-Model-Database Flow
```
HTTP Request
Controller (Validation, Auth)
Model (Business Logic, Data Access)
Database (MySQL via Query Builder)
Model (Transform, Add Labels)
Controller (Format Response)
JSON Response
```
## Key Integration Points
### 1. JWT Authentication
- Filter: `AuthFilter` in `app/Filters/`
- Middleware checks JWT token from Cookie header
- Routes grouped with `'filter' => 'auth'`
### 2. Edge API Integration
- Controller: `EdgeController.php`
- Models: `EdgeResModel`, `EdgeStatusModel`, `EdgeAckModel`
- Staging: `edgeres` table for raw results
- Processing: Auto or manual to `patresult` table
### 3. ValueSet Integration
- Library: `ValueSet.php` in `app/Libraries/`
- Data: JSON files in `app/Libraries/Data/valuesets/`
- Usage: `ValueSet::get('name')`, `ValueSet::transformLabels()`
- Cache: Application-level caching
### 4. UTC Date Handling
- Model: `BaseModel.php` handles conversion
- Helper: `utc_helper.php` provides conversion functions
- Normalization: Local → UTC before DB operations
- Output: UTC → ISO 8601 for API responses
## Database Schema Organization
### Transactional Tables
- `patient` - Patient registry
- `porder` - Laboratory orders
- `specimen` - Specimens
- `patresult` - Patient results
- `patresultdetail` - Result details
- `patvisit` - Patient visits
- `edgeres` - Raw instrument results
### Master Data Tables
- `valueset` - Value set values
- `valuesetdef` - Value set definitions
- `testdefsite` - Test definitions
- `testdeftech` - Technical specs
- `testdefcal` - Calculated tests
- `testdefgrp` - Test groups
- `refnum` - Numeric reference ranges
- `reftxt` - Text reference ranges
### Organization Tables
- `account` - Accounts
- `site` - Sites
- `discipline` - Disciplines
- `department` - Departments
- `workstation` - Workstations
### Integration Tables
- `edgestatus` - Instrument status
- `edgeack` - Order acknowledgment
- `testmap` - Instrument test mapping
## Important Architectural Decisions
### 1. API-Only Design
- No view layer, no HTML rendering
- All responses are JSON
- Frontend-agnostic for maximum flexibility
### 2. JWT Authentication
- Stateless authentication
- Token stored in HTTP-only cookie
- 1-hour expiration (configurable)
### 3. Soft Deletes
- All transactional tables use `DelDate`
- Data preserved for audit trails
- Automatic filtering via BaseModel
### 4. UTC Timezone
- All database dates in UTC
- Automatic conversion via BaseModel
- ISO 8601 format for API responses
### 5. JSON-Based ValueSets
- Static lookups in JSON files
- Fast, cached access
- Easy to maintain and version control
### 6. Migration-Based Schema
- Database changes via migrations
- Version-controlled schema history
- Easy rollback capability
## Critical Files to Know
| File | Purpose | Importance |
|------|---------|------------|
| `AGENTS.md` | AI agent instructions | **Critical** - Always read first |
| `app/Config/Routes.php` | API route definitions | **Critical** - Defines all endpoints |
| `public/api-docs.yaml` | OpenAPI documentation | **Critical** - MUST update after changes |
| `app/Libraries/ValueSet.php` | Lookup system | High - Used throughout |
| `app/Models/BaseModel.php` | Base model with UTC | High - All models extend this |
| `app/Filters/AuthFilter.php` | JWT authentication | High - Secures endpoints |
| `phpunit.xml.dist` | Test configuration | Medium - Configure database for tests |
| `.env` | Environment config | High - Contains secrets (JWT_SECRET, DB creds) |
## Common Patterns for Code Navigation
### Finding Controller for an Endpoint
1. Check `app/Config/Routes.php` for route
2. Find controller class in `app/Controllers/`
3. View controller method implementation
### Finding Model for a Table
1. Table name: `patient` → Model: `PatientModel.php`
2. Look in `app/Models/` or subdirectories
3. Check `$table`, `$primaryKey`, `$allowedFields`
### Understanding a Feature
1. Start with controller method
2. Follow to model methods
3. Check related models via joins
4. Refer to migrations for table structure
5. Check API documentation in `public/api-docs.yaml`
### Adding a New Endpoint
1. Create controller method
2. Create/update model if needed
3. Add route in `app/Config/Routes.php`
4. Write tests in `tests/feature/`
5. Update `public/api-docs.yaml`
6. Run tests to verify

View File

@ -1,97 +0,0 @@
# CLQMS Code Conventions
## PHP Standards
- **Version**: PHP 8.1+
- **Autoloading**: PSR-4
- **Coding Style**: PSR-12 (where applicable)
## Naming Conventions
| Element | Convention | Example |
|---------|-----------|---------|
| Classes | PascalCase | `PatientController` |
| Methods | camelCase | `createPatient()` |
| Properties | snake_case (legacy) / camelCase (new) | `$patient_id` / `$patientId` |
| Constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
| Database Tables | snake_case | `patient_visits` |
| Database Columns | PascalCase (legacy) | `PatientID`, `NameFirst` |
| JSON Fields | PascalCase | `"PatientID": "123"` |
## Imports & Namespaces
- Fully qualified namespaces at top of file
- Group imports: Framework first, then App, then external
- Alphabetical order within groups
```php
<?php
namespace App\Controllers;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\ResponseInterface;
use App\Traits\ResponseTrait;
use Firebase\JWT\JWT;
```
## Controller Pattern
Controllers handle HTTP requests, delegate business logic to Models (no DB queries in controllers).
```php
class ExampleController extends Controller
{
use ResponseTrait;
protected $model;
public function __construct()
{
$this->model = new \App\Models\ExampleModel();
}
}
```
## Response Format
All API responses use standardized format:
```php
// Success
return $this->respond([
'status' => 'success',
'message' => 'Operation completed',
'data' => $data
], 200);
// Error
return $this->respond([
'status' => 'failed',
'message' => 'Error description',
'data' => []
], 400);
```
## Database Operations
- Use CodeIgniter Query Builder or Model methods
- Use `helper('utc')` for UTC date conversion
- Wrap multi-table operations in transactions
```php
$this->db->transStart();
// ... operations
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->respond(['status' => 'error', ...], 500);
}
```
## Test Naming Convention
Format: `test<Action><Scenario><ExpectedResult>`
Examples: `testCreatePatientValidationFail`, `testUpdatePatientSuccess`
## HTTP Status Codes
- 200: GET/PATCH success
- 201: POST success
- 400: Validation error
- 401: Unauthorized
- 404: Not found
- 500: Server error
## Legacy Field Naming
Database uses PascalCase: `PatientID`, `NameFirst`, `Birthdate`, `CreatedAt`

View File

@ -1,223 +0,0 @@
# CLQMS Code Style and Conventions
## Naming Conventions
| Element | Convention | Example |
|---------|-----------|---------|
| Classes | PascalCase | `PatientController`, `PatientModel` |
| Methods | camelCase | `createPatient()`, `getPatients()` |
| Properties | snake_case (legacy) / camelCase (new) | `$patient_id` / `$patientId` |
| Constants | UPPER_SNAKE_CASE | `MAX_RETRY_COUNT` |
| Database Tables | snake_case | `patient`, `patient_visits`, `order_tests` |
| Database Columns | PascalCase (legacy) | `PatientID`, `NameFirst`, `Birthdate`, `CreatedAt` |
| JSON Fields | PascalCase | `"PatientID": "123"` |
## File and Directory Structure
### Controllers
- Grouped by domain in subdirectories: `app/Controllers/Patient/`, `app/Controllers/Specimen/`
- Each controller handles CRUD for its entity
- Use `ResponseTrait` for standardized responses
### Models
- Grouped by domain: `app/Models/Patient/`, `app/Models/Specimen/`
- Extend `BaseModel` for automatic UTC date handling
- Define `$table`, `$primaryKey`, `$allowedFields`
- Use `checkDbError()` for database error detection
## Code Patterns
### Controller Structure
```php
<?php
namespace App\Controllers\Patient;
use App\Traits\ResponseTrait;
use CodeIgniter\Controller;
use App\Models\Patient\PatientModel;
class PatientController extends Controller {
use ResponseTrait;
protected $db;
protected $model;
protected $rules;
public function __construct() {
$this->db = \Config\Database::connect();
$this->model = new PatientModel();
$this->rules = [ /* validation rules */ ];
}
public function index() { /* ... */ }
public function create() { /* ... */ }
public function show($id) { /* ... */ }
public function update() { /* ... */ }
public function delete() { /* ... */ }
}
```
### Model Structure
```php
<?php
namespace App\Models\Patient;
use App\Models\BaseModel;
use App\Services\AuditService;
class PatientModel extends BaseModel {
protected $table = 'patient';
protected $primaryKey = 'InternalPID';
protected $allowedFields = ['PatientID', 'NameFirst', ...];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $useSoftDeletes = true;
protected $deletedField = 'DelDate';
public function getPatients($filters = []) { /* ... */ }
public function createPatient($input) { /* ... */ }
private function checkDbError($db, string $context) {
$error = $db->error();
if (!empty($error['code'])) {
throw new \Exception("{$context} failed: {$error['code']} - {$error['message']}");
}
}
}
```
### Validation Rules
- Define in controller constructor as `$this->rules`
- Use CodeIgniter validation rules: `required`, `permit_empty`, `regex_match`, `max_length`, etc.
- For nested data, override rules dynamically based on input
### Response Format
```php
// Success
return $this->respond([
'status' => 'success',
'message' => 'Operation completed',
'data' => $data
], 200);
// Error
return $this->respond([
'status' => 'failed',
'message' => 'Error description',
'data' => []
], 400);
```
**Note:** Custom `ResponseTrait` automatically converts empty strings to `null`.
### Error Handling
- Use try-catch for JWT and external calls
- Log errors: `log_message('error', $message)`
- Return structured error responses with appropriate HTTP status codes
```php
try {
$decoded = JWT::decode($token, new Key($key, 'HS256'));
} catch (\Firebase\JWT\ExpiredException $e) {
return $this->respond(['status' => 'failed', 'message' => 'Token expired'], 401);
} catch (\Exception $e) {
return $this->respond(['status' => 'failed', 'message' => 'Invalid token'], 401);
}
```
### Database Operations
- Use CodeIgniter Query Builder or Model methods
- Use `helper('utc')` for UTC date conversion
- Wrap multi-table operations in transactions
```php
$this->db->transStart();
// ... database operations
$this->db->transComplete();
if ($this->db->transStatus() === false) {
return $this->respond(['status' => 'error', 'message' => 'Transaction failed'], 500);
}
```
### Audit Logging
Use `AuditService::logData()` for tracking data changes:
```php
AuditService::logData(
'CREATE|UPDATE|DELETE',
'table_name',
(string) $recordId,
'entity_name',
null,
$previousData,
$newData,
'Action description',
['metadata' => 'value']
);
```
## Route Patterns
```php
$routes->group('api/patient', function ($routes) {
$routes->get('/', 'Patient\PatientController::index');
$routes->post('/', 'Patient\PatientController::create');
$routes->get('(:num)', 'Patient\PatientController::show/$1');
$routes->patch('/', 'Patient\PatientController::update');
$routes->delete('/', 'Patient\PatientController::delete');
});
```
## Testing Guidelines
```php
<?php
namespace Tests\Feature\Patients;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
use Faker\Factory;
class PatientCreateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $endpoint = 'api/patient';
public function testCreatePatientSuccess()
{
$faker = Factory::create('id_ID');
$payload = [...];
$result = $this->withBodyFormat('json')->post($this->endpoint, $payload);
$result->assertStatus(201);
}
}
```
**Test Naming:** `test<Action><Scenario><ExpectedResult>` (e.g., `testCreatePatientValidationFail`)
## Security Best Practices
- Use `auth` filter for protected routes
- Sanitize user inputs with validation rules
- Use parameterized queries (CodeIgniter Query Builder handles this)
- Store secrets in `.env`, never commit to repository
## Legacy Field Naming
Database uses PascalCase columns: `PatientID`, `NameFirst`, `Birthdate`, `CreatedAt`, `UpdatedAt`
## ValueSet/Lookup Usage
```php
use App\Libraries\Lookups;
// Get all lookups
$allLookups = Lookups::getAll();
// Get single lookup formatted for dropdowns
$gender = Lookups::get('gender');
// Get label for a specific key
$label = Lookups::getLabel('gender', '1'); // Returns 'Female'
// Transform database records with lookup text labels
$labeled = Lookups::transformLabels($data, ['Sex' => 'gender']);
```

View File

@ -1,481 +0,0 @@
# CLQMS Code Style & Conventions
## File Organization
### Directory Structure
```
app/
├── Controllers/ # API endpoint handlers
│ ├── Patient/ # Patient-related controllers
│ ├── Organization/ # Organization-related controllers
│ ├── Test/ # Test-related controllers
│ └── ...
├── Models/ # Data access layer
│ ├── BaseModel.php # Base model with UTC normalization
│ └── ...
├── Libraries/ # Reusable libraries
│ ├── ValueSet.php # JSON-based lookup system
│ └── Data/ # ValueSet JSON files
├── Database/
│ └── Migrations/ # Database schema migrations
└── Config/ # Configuration files
tests/
├── feature/ # Integration/API tests
├── unit/ # Unit tests
└── _support/ # Test utilities
public/
├── api-docs.yaml # OpenAPI/Swagger documentation (CRITICAL to update!)
└── index.php # Front controller
```
## Naming Conventions
| Type | Convention | Examples |
|------|------------|----------|
| **Classes** | PascalCase | `PatientController`, `PatientModel`, `ValueSet` |
| **Methods** | camelCase | `getPatient`, `createPatient`, `updatePatient` |
| **Variables** | camelCase | `$patientId`, `$rows`, `$input` |
| **Database Fields** | PascalCase with underscores | `PatientID`, `NameFirst`, `Street_1`, `InternalPID` |
| **Constants** | UPPER_SNAKE_CASE | `MAX_ATTEMPTS`, `DEFAULT_PRIORITY` |
| **Private Methods** | Prefix with underscore if needed | `_validatePatient` |
| **Files** | PascalCase | `PatientController.php`, `PatientModel.php` |
## Formatting & Style
### Indentation & Braces
- **2-space indentation** for all code
- **Same-line opening braces**: `public function index() {`
- No trailing whitespace
- Closing braces on new line
### Imports & Namespaces
```php
<?php
namespace App\Controllers\Patient;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use App\Libraries\ValueSet;
use App\Models\Patient\PatientModel;
```
- Namespace at top: `namespace App\Controllers\...`
- Organize use statements: Framework first, then App libraries/models
- Group imports: `use CodeIgniter\...` then `use App\...`
### Type Hints (PHP 8.1+)
```php
public function getPatient(?int $id): ?array
{
// Method body
}
```
- Type hints on method parameters where appropriate
- Return types required for all methods
- Use `?type` for nullable types
- Use `array<string, mixed>` for complex arrays when clear
## Controllers Pattern
### Standard Controller Structure
```php
<?php
namespace App\Controllers\Patient;
use CodeIgniter\API\ResponseTrait;
use CodeIgniter\Controller;
use App\Libraries\ValueSet;
use App\Models\Patient\PatientModel;
class PatientController extends Controller {
use ResponseTrait;
protected $db;
protected $model;
protected $rules;
public function __construct() {
$this->db = \Config\Database::connect();
$this->model = new PatientModel();
$this->rules = [
'NameFirst' => 'required|regex_match[/^[A-Za-z\'\. ]+$/]|min_length[1]|max_length[60]',
// More validation rules...
];
}
public function index() {
$filters = [
'InternalPID' => $this->request->getVar('InternalPID'),
'PatientID' => $this->request->getVar('PatientID'),
];
try {
$rows = $this->model->getPatients($filters);
$rows = ValueSet::transformLabels($rows, [
'Sex' => 'sex',
]);
return $this->respond([
'status' => 'success',
'message' => 'data fetched successfully',
'data' => $rows
], 200);
} catch (\Exception $e) {
return $this->failServerError('Exception : ' . $e->getMessage());
}
}
public function create() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$InternalPID = $this->model->createPatient($input);
return $this->respondCreated([
'status' => 'success',
'message' => "data $InternalPID created successfully"
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
}
```
### Controller Rules
- Extend `BaseController`
- Use `ResponseTrait`
- Define `$this->rules` array for validation
- Return JSON: `['status' => 'success', 'message' => '...', 'data' => ...]`
- Use try-catch with `$this->failServerError()`
- Input: `$this->request->getJSON(true)` or `$this->request->getVar()`
### Response Formats
```php
// Success
return $this->respond([
'status' => 'success',
'message' => 'Operation completed',
'data' => $data
], 200);
// Created
return $this->respondCreated([
'status' => 'success',
'message' => 'Resource created'
]);
// Validation Error
return $this->failValidationErrors($errors);
// Not Found
return $this->failNotFound('Resource not found');
// Server Error
return $this->failServerError('Error message');
```
## Models Pattern
### Standard Model Structure
```php
<?php
namespace App\Models\Patient;
use App\Models\BaseModel;
use App\Libraries\ValueSet;
class PatientModel extends BaseModel {
protected $table = 'patient';
protected $primaryKey = 'InternalPID';
protected $allowedFields = ['PatientID', 'NameFirst', 'NameLast', 'Sex', 'Birthdate', /*...*/];
protected $useTimestamps = true;
protected $createdField = 'CreateDate';
protected $updatedField = '';
protected $useSoftDeletes = true;
protected $deletedField = 'DelDate';
public function getPatients(array $filters = []) {
$this->select('InternalPID, PatientID, NameFirst, NameLast, Sex');
if (!empty($filters['PatientID'])) {
$this->like('PatientID', $filters['PatientID'], 'both');
}
$rows = $this->findAll();
$rows = ValueSet::transformLabels($rows, [
'Sex' => 'sex',
]);
return $rows;
}
public function createPatient(array $input) {
$db = \Config\Database::connect();
$db->transBegin();
try {
$this->insert($input);
$newInternalPID = $this->getInsertID();
$this->checkDbError($db, 'Insert patient');
// Additional operations...
$db->transCommit();
return $newInternalPID;
} catch (\Exception $e) {
$db->transRollback();
throw $e;
}
}
private function checkDbError($db, string $context) {
$error = $db->error();
if (!empty($error['code'])) {
throw new \Exception(
"{$context} failed: {$error['code']} - {$error['message']}"
);
}
}
}
```
### Model Rules
- Extend `BaseModel` (auto UTC date normalization)
- Define `$table`, `$primaryKey`, `$allowedFields`
- Use soft deletes: `$useSoftDeletes = true`, `$deletedField = 'DelDate'`
- Wrap multi-table operations in transactions
- Use `$this->checkDbError($db, 'context')` after DB operations
## Database Operations
### Query Builder (Preferred)
```php
// Select with joins
$this->select('patient.*, patcom.Comment')
->join('patcom', 'patcom.InternalPID = patient.InternalPID', 'left')
->where('patient.InternalPID', (int) $InternalPID)
->findAll();
// Insert
$this->insert($data);
// Update
$this->where('InternalPID', $id)->set($data)->update();
// Delete (soft)
$this->where('InternalPID', $id)->delete(); // Sets DelDate
```
### Escape Inputs
```php
// Escape for raw queries (rarely used)
$this->db->escape($value)
// Better: Use parameter binding
$this->where('PatientID', $patientId)->get();
```
## ValueSet Usage
### For Static Lookups
```php
use App\Libraries\ValueSet;
// Get all values for a lookup (formatted for dropdowns)
$gender = ValueSet::get('sex');
// Returns: [{"value"=>"1","label":"Female"},{"value"=>"2","label":"Male"},...]
// Get single label by key
$label = ValueSet::getLabel('sex', '1'); // Returns 'Female'
// Transform database results to add text labels
$patients = [
['ID' => 1, 'Sex' => '1'],
['ID' => 2, 'Sex' => '2'],
];
$labeled = ValueSet::transformLabels($patients, [
'Sex' => 'sex'
]);
// Result: [['ID'=>1, 'Sex'=>'1', 'SexLabel'=>'Female'], ...]
// Clear cache after modifying valueset JSON files
ValueSet::clearCache();
```
### ValueSet JSON File Format
```json
{
"name": "sex",
"description": "Patient gender",
"values": [
{"key": "1", "value": "Female"},
{"key": "2", "value": "Male"},
{"key": "3", "value": "Unknown"}
]
}
```
## Error Handling
### Validation
```php
if (!$this->validateData($input, $rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
```
### Try-Catch Pattern
```php
try {
$result = $this->model->createPatient($input);
return $this->respondCreated([
'status' => 'success',
'message' => 'Created successfully'
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
```
### HTTP Status Codes
- `200` - Success
- `201` - Created
- `400` - Bad Request (validation errors)
- `401` - Unauthorized
- `404` - Not Found
- `500` - Internal Server Error
## Testing Pattern
### Feature Test
```php
<?php
namespace Tests\Feature;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class PatientCreateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $token;
protected function setUp(): void
{
parent::setUp();
// Generate JWT token
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
$this->token = \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
}
public function testCanCreatePatient() {
$this->withHeaders(['Cookie' => 'token=' . $this->token])
->post('api/patient', [
'PatientID' => 'PT001',
'NameFirst' => 'John',
'NameLast' => 'Doe',
'Sex' => '2',
'Birthdate' => '1990-05-15'
])
->assertStatus(200)
->assertJSONFragment(['status' => 'success']);
}
}
```
### Test Commands
```bash
# Run all tests
vendor/bin/phpunit
# Run specific test file
vendor/bin/phpunit tests/feature/Patient/PatientCreateTest.php
# Run with coverage
vendor/bin/phpunit --coverage-text=tests/coverage.txt --coverage-html=tests/coverage/ -d memory_limit=1024m
```
## Special Considerations
### UTC Date Handling
- BaseModel automatically normalizes dates to UTC
- All dates in database are in UTC format
- API responses return dates in ISO 8601 format: `Y-m-d\TH:i:s\Z`
- Date conversions handled automatically via BaseModel hooks
### Soft Deletes
- All transactional tables use soft deletes via `DelDate` field
- Soft delete sets `DelDate` to current timestamp
- Query Builder automatically filters out deleted records
### Input Validation
- Use CodeIgniter's validation rules in controllers
- Custom regex patterns for specific formats (e.g., KTP, Passport)
- Validate before processing in controllers
### API Documentation (CRITICAL)
- After modifying ANY controller, you MUST update `public/api-docs.yaml`
- Update OpenAPI schema definitions for new/changed endpoints
- Update field names, types, and response formats
- Ensure schemas match actual controller responses
### Security
- Never log or commit secrets (JWT_SECRET, passwords)
- Escape user inputs before DB operations
- Use JWT authentication for API endpoints
- Validate all inputs before processing
## Common Patterns
### Nested Data Handling
```php
// Extract nested data before filtering
$patIdt = $input['PatIdt'] ?? null;
$patCom = $input['PatCom'] ?? null;
// Remove nested arrays that don't belong to parent table
unset($input['PatIdt'], $input['PatCom']);
// Process nested data separately
if (!empty($patIdt)) {
$modelPatIdt->createPatIdt($patIdt, $newInternalPID);
}
```
### Foreign Key Handling
```php
// Handle array-based foreign keys
if (!empty($input['LinkTo']) && is_array($input['LinkTo'])) {
$internalPids = array_column($input['LinkTo'], 'InternalPID');
$input['LinkTo'] = implode(',', $internalPids);
}
$input['LinkTo'] = empty($input['LinkTo']) ? null : $input['LinkTo'];
```
### Dynamic Validation Rules
```php
// Override validation rules based on input type
$type = $input['PatIdt']['IdentifierType'] ?? null;
$identifierRulesMap = [
'KTP' => 'required|regex_match[/^[0-9]{16}$/]',
'PASS' => 'required|regex_match[/^[A-Za-z0-9]{1,9}$/]',
];
if ($type) {
$this->rules['PatIdt.Identifier'] = $identifierRulesMap[$type] ?? 'permit_empty|max_length[255]';
}
```

View File

@ -1,22 +0,0 @@
# EquipmentList Seeder Merged
## Changes
- EquipmentListSeeder.php deleted (redundant)
- Equipment data now in OrganizationSeeder.php only
- Equipment seeding happens via: php spark db:seed OrganizationSeeder
## Equipment Data Location
File: app/Database/Seeds/OrganizationSeeder.php (lines 68-99)
## Seed Command
```bash
php spark db:seed OrganizationSeeder
```
This seeds:
- Account (3 records)
- Site (2 records)
- Discipline (13 records)
- Department (6 records)
- Workstation (9 records)
- EquipmentList (18 records)

View File

@ -1,32 +0,0 @@
# EquipmentList Seeder
## Summary
- Created: app/Database/Seeds/EquipmentListSeeder.php
- Total dummy records: 18 equipment
- Roles: A (Auto), B (Backup), M (Manual)
## Distribution
- Hematology (Dept 1): 5 equipment (including disabled)
- Chemistry (Dept 3): 5 equipment (including disabled)
- Immunology (Dept 4): 4 equipment
- Urinalysis (Dept 6): 4 equipment
## Equipment by Workstation
- WS 1 (Hem Auto): 2 equipment
- WS 2 (Hem Backup): 2 equipment
- WS 3 (Chem Auto): 2 equipment
- WS 4 (Chem Backup): 1 equipment
- WS 5 (Chem Manual): 1 equipment
- WS 6 (Imm Auto): 2 equipment
- WS 7 (Imm Manual): 2 equipment
- WS 8 (Uri Auto): 2 equipment
- WS 9 (Uri Manual): 2 equipment
## Equipment Roles
- A (Auto): 10 equipment
- B (Backup): 4 equipment
- M (Manual): 4 equipment
## Status
- Enabled: 16 equipment
- Disabled: 2 equipment (IDs 17, 18)

View File

@ -1,669 +0,0 @@
# CLQMS Important Patterns & Special Considerations
## JWT Authentication Pattern
### How Authentication Works
1. User logs in via `/api/v2/auth/login` or `/api/login`
2. Server generates JWT token with payload containing user info
3. Token stored in HTTP-only cookie named `token`
4. `AuthFilter` checks for token on protected routes
5. Token decoded using `JWT_SECRET` from environment
6. If invalid/missing, returns 401 Unauthorized
### AuthFilter Location
- File: `app/Filters/AuthFilter.php`
- Registered in: `app/Config/Filters.php` as `'auth'` alias
### Protected Routes
Routes are protected by adding `'filter' => 'auth'` to route group:
```php
$routes->group('api', ['filter' => 'auth'], function ($routes) {
$routes->get('patient', 'Patient\PatientController::index');
$routes->post('patient', 'Patient\PatientController::create');
});
```
### JWT Token Structure
```php
$payload = [
'iss' => 'localhost', // Issuer
'aud' => 'localhost', // Audience
'iat' => time(), // Issued at
'nbf' => time(), // Not before
'exp' => time() + 3600, // Expiration (1 hour)
'uid' => 1, // User ID
'email' => 'admin@admin.com' // User email
];
```
### Generating JWT Token (for Tests)
```php
$key = getenv('JWT_SECRET') ?: 'my-secret-key';
$payload = [
'iss' => 'localhost',
'aud' => 'localhost',
'iat' => time(),
'exp' => time() + 3600,
'uid' => 1,
'email' => 'admin@admin.com'
];
$token = \Firebase\JWT\JWT::encode($payload, $key, 'HS256');
```
### Injecting Token in Tests
```php
protected $token;
protected function setUp(): void {
parent::setUp();
// Generate token as shown above
$this->token = $encodedToken;
}
public function testEndpoint() {
$this->withHeaders(['Cookie' => 'token=' . $this->token])
->get('api/patient')
->assertStatus(200);
}
```
### Public Routes (No Authentication)
```php
$routes->group('api', function ($routes) {
$routes->post('login', 'AuthController::login'); // No auth filter
});
$routes->group('api/demo', function ($routes) {
$routes->post('order', 'Test\DemoOrderController::createDemoOrder'); // No auth
});
```
## UTC Date Handling Pattern
### BaseModel Date Normalization
`BaseModel` automatically handles UTC date conversion:
**Before Insert/Update:**
```php
protected $beforeInsert = ['normalizeDatesToUTC'];
protected $beforeUpdate = ['normalizeDatesToUTC'];
```
- Converts local dates to UTC before database operations
- Uses helper: `convert_array_to_utc($data)`
**After Find/Insert/Update:**
```php
protected $afterFind = ['convertDatesToUTCISO'];
protected $afterInsert = ['convertDatesToUTCISO'];
protected $afterUpdate = ['convertDatesToUTCISO'];
```
- Converts UTC dates to ISO 8601 format for API responses
- Uses helper: `convert_array_to_utc_iso($data)`
### Date Formats
**Database Format (UTC):**
- Format: `Y-m-d H:i:s`
- Timezone: UTC
- Example: `2026-02-11 23:55:08`
**API Response Format (ISO 8601):**
- Format: `Y-m-d\TH:i:s\Z`
- Example: `2026-02-11T23:55:08Z`
### Manual Date Conversion
```php
// Convert to UTC for storage
$birthdate = new \DateTime('1990-05-15', new \DateTimeZone('Asia/Jakarta'));
$utcDate = $birthdate->setTimezone(new \DateTimeZone('UTC'))->format('Y-m-d H:i:s');
// Format for display (ISO 8601)
$displayDate = $utcDate->format('Y-m-d\TH:i:s\Z');
```
### Date Fields in Database
- `CreateDate` - Record creation timestamp
- `DelDate` - Soft delete timestamp (null if not deleted)
- `TimeOfDeath` - Death timestamp (patient)
- Other date fields vary by table
## Soft Delete Pattern
### How Soft Deletes Work
- All transactional tables use `DelDate` field for soft deletes
- Setting `DelDate` to current timestamp marks record as deleted
- Queries automatically exclude records where `DelDate` is not null
- Data remains in database for audit trail
### BaseModel Soft Delete Configuration
```php
protected $useSoftDeletes = true;
protected $deletedField = 'DelDate';
```
### Manual Soft Delete
```php
// In controller
$this->db->table('patient')
->where('InternalPID', $InternalPID)
->update(['DelDate' => date('Y-m-d H:i:s')]);
// Or using model
$this->where('InternalPID', $id)->delete(); // BaseModel handles this
```
### Querying Deleted Records
If you need to include soft-deleted records:
```php
$this->withDeleted()->findAll();
```
### Including Deleted Records in Join
When joining with tables that might have soft-deleted records:
```php
->join('patatt', 'patatt.InternalPID = patient.InternalPID and patatt.DelDate is null', 'left')
```
## ValueSet Pattern
### ValueSet Library Location
- File: `app/Libraries/ValueSet.php`
- Data directory: `app/Libraries/Data/valuesets/`
### Getting ValueSet Data
```php
use App\Libraries\ValueSet;
// Get all values for a lookup (formatted for dropdowns)
$gender = ValueSet::get('sex');
// Returns: [{"value"=>"1","label":"Female"},{"value"=>"2","label":"Male"},...]
// Get raw data without formatting
$raw = ValueSet::getRaw('sex');
// Returns: [{"key":"1","value":"Female"},{"key":"2","value":"Male"},...]
// Get single label by key
$label = ValueSet::getLabel('sex', '1'); // Returns 'Female'
// Get key/value pairs for select inputs
$options = ValueSet::getOptions('sex');
// Returns: [["key"=>"1","value"=>"Female"],...]
```
### Transforming Database Results
```php
$patients = $this->model->getPatients();
$patients = ValueSet::transformLabels($patients, [
'Sex' => 'sex',
'Priority' => 'order_priority',
'MaritalStatus' => 'marital_status',
]);
// Adds fields: SexLabel, PriorityLabel, MaritalStatusLabel
```
### ValueSet JSON File Format
```json
{
"name": "sex",
"description": "Patient gender",
"values": [
{"key": "1", "value": "Female"},
{"key": "2", "value": "Male"},
{"key": "3", "value": "Unknown"}
]
}
```
### Clearing ValueSet Cache
After modifying ValueSet JSON files:
```php
ValueSet::clearCache();
```
### Common ValueSets
| Name | Description | Example Values |
|------|-------------|----------------|
| `sex` | Patient gender | Female, Male, Unknown |
| `marital_status` | Marital status | Single, Married, Divorced |
| `race` | Ethnicity | Jawa, Sunda, Batak, etc. |
| `order_priority` | Order priority | Stat, ASAP, Routine, Preop |
| `order_status` | Order lifecycle | STC, SCtd, SArrv, SRcvd |
| `specimen_type` | Specimen types | BLD, SER, PLAS, UR, CSF |
| `specimen_status` | Specimen status | Ordered, Collected, Received |
| `result_status` | Result validation | Preliminary, Final, Corrected |
| `test_type` | Test definition types | TEST, PARAM, CALC, GROUP |
## Error Handling Pattern
### Controller Error Handling
```php
public function create() {
$input = $this->request->getJSON(true);
if (!$this->validateData($input, $this->rules)) {
return $this->failValidationErrors($this->validator->getErrors());
}
try {
$result = $this->model->createPatient($input);
return $this->respondCreated([
'status' => 'success',
'message' => "data {$result} created successfully"
]);
} catch (\Exception $e) {
return $this->failServerError('Something went wrong: ' . $e->getMessage());
}
}
```
### Model Error Handling
```php
public function createPatient(array $input) {
$db = \Config\Database::connect();
$db->transBegin();
try {
$this->insert($input);
$newId = $this->getInsertID();
$this->checkDbError($db, 'Insert patient');
$db->transCommit();
return $newId;
} catch (\Exception $e) {
$db->transRollback();
throw $e;
}
}
private function checkDbError($db, string $context) {
$error = $db->error();
if (!empty($error['code'])) {
throw new \Exception(
"{$context} failed: {$error['code']} - {$error['message']}"
);
}
}
```
### ResponseTrait Methods
```php
// Success (200)
return $this->respond(['status' => 'success', 'data' => $data], 200);
// Created (201)
return $this->respondCreated(['status' => 'success', 'message' => 'Created']);
// Validation Error (400)
return $this->failValidationErrors($errors);
// Not Found (404)
return $this->failNotFound('Resource not found');
// Server Error (500)
return $this->failServerError('Error message');
// Unauthorized (401) - use in AuthFilter
return Services::response()
->setStatusCode(401)
->setJSON(['status' => 'failed', 'message' => 'Unauthorized']);
```
## Database Transaction Pattern
### Standard Transaction Pattern
```php
$db = \Config\Database::connect();
$db->transBegin();
try {
// Insert/Update main record
$this->insert($data);
$id = $this->getInsertID();
$this->checkDbError($db, 'Insert main');
// Insert related records
$relatedModel->insert($relatedData);
$this->checkDbError($db, 'Insert related');
$db->transCommit();
return $id;
} catch (\Exception $e) {
$db->transRollback();
throw $e;
}
```
### When to Use Transactions
- Multi-table operations (main record + related records)
- Operations that must be atomic
- When you need to rollback all changes if any operation fails
## Nested Data Handling Pattern
### Extracting Nested Data
```php
// Extract nested data before filtering
$patIdt = $input['PatIdt'] ?? null;
$patCom = $input['PatCom'] ?? null;
$patAtt = $input['PatAtt'] ?? null;
// Remove nested arrays that don't belong to parent table
unset($input['PatIdt'], $input['PatCom'], $input['PatAtt']);
// Now $input only contains fields for main table
```
### Processing Nested Data
```php
// Insert main record
$this->insert($input);
$mainId = $this->getInsertID();
// Process related records
if (!empty($patIdt)) {
$modelPatIdt->createPatIdt($patIdt, $mainId);
}
if (!empty($patCom)) {
$modelPatCom->createPatCom($patCom, $mainId);
}
if (!empty($patAtt) && is_array($patAtt)) {
foreach ($patAtt as $address) {
$modelPatAtt->createPatAtt($address, $mainId);
}
}
```
## Foreign Key Handling Pattern
### Array-Based Foreign Keys
```php
// Handle array of related records
if (!empty($input['LinkTo']) && is_array($input['LinkTo'])) {
$internalPids = array_column($input['LinkTo'], 'InternalPID');
$input['LinkTo'] = implode(',', $internalPids);
}
$input['LinkTo'] = empty($input['LinkTo']) ? null : $input['LinkTo'];
```
### Single Record Foreign Key
```php
// Handle single related record
if (!empty($input['Custodian']) && is_array($input['Custodian'])) {
$input['Custodian'] = $input['Custodian']['InternalPID'] ?? null;
if ($input['Custodian'] !== null) {
$input['Custodian'] = (int) $input['Custodian'];
}
}
```
## Validation Rules Pattern
### Dynamic Validation Rules
```php
// Override validation rules based on input type
$type = $input['PatIdt']['IdentifierType'] ?? null;
$identifierRulesMap = [
'KTP' => 'required|regex_match[/^[0-9]{16}$/]', // 16 digits
'PASS' => 'required|regex_match[/^[A-Za-z0-9]{1,9}$/]', // alphanumeric max 9
'SSN' => 'required|regex_match[/^[0-9]{9}$/]', // numeric 9 digits
'SIM' => 'required|regex_match[/^[0-9]{19,20}$/]', // numeric 19-20 digits
'KTAS' => 'required|regex_match[/^[0-9]{11}$/]', // numeric 11 digits
];
if ($type && is_string($type)) {
$identifierRule = $identifierRulesMap[$type] ?? 'permit_empty|max_length[255]';
$this->rules['PatIdt.IdentifierType'] = 'required';
$this->rules['PatIdt.Identifier'] = $identifierRule;
} else {
$this->rules['PatIdt.IdentifierType'] = 'permit_empty';
$this->rules['PatIdt.Identifier'] = 'permit_empty|max_length[255]';
}
```
### Common Validation Rules
```php
// Required field
'NameFirst' => 'required'
// String with letters, apostrophes, spaces
'NameFirst' => 'regex_match[/^[A-Za-z\'\. ]+$/]'
// Alphanumeric
'PatientID' => 'regex_match[/^[A-Za-z0-9]+$/]'
// Numeric
'InternalPID' => 'is_natural'
// Email
'EmailAddress1' => 'valid_email'
// Phone number with optional + and 8-15 digits
'Phone' => 'regex_match[/^\\+?[0-9]{8,15}$/]'
// Date
'Birthdate' => 'required'
// Permit empty
'AlternatePID' => 'permit_empty'
```
## Edge API Pattern
### Edge API Purpose
Integration with laboratory instruments via `tiny-edge` middleware for:
- Receiving instrument results
- Sending pending orders to instruments
- Acknowledging order delivery
- Logging instrument status
### Edge API Endpoints
```php
// Receive instrument results
POST /api/edge/results
// Fetch pending orders for instrument
GET /api/edge/orders?instrument=coulter_counter
// Acknowledge order delivery
POST /api/edge/orders/:id/ack
// Log instrument status
POST /api/edge/status
```
### Edge API Workflow
```
Instrument → tiny-edge → POST /api/edge/results → edgeres table
[Manual/Auto Processing]
patres table (patient results)
```
### Staging Table Pattern
- Raw results stored in `edgeres` table first
- Allows validation before processing to main tables
- Rerun handling via `AspCnt` field (attempt count)
## Security Considerations
### Environment Variables (NEVER COMMIT)
- `JWT_SECRET` - JWT signing key
- Database credentials (username, password)
- API keys for external services
### Input Validation
- Always validate user input with `$this->validateData($input, $rules)`
- Use CodeIgniter validation rules
- Custom regex patterns for specific formats
### SQL Injection Prevention
- Use Query Builder (parameter binding)
- Never concatenate user input into raw SQL
- If using raw SQL, escape inputs: `$this->db->escape($value)`
### Output Escaping
- ResponseTrait automatically handles JSON encoding
- For HTML output (if needed), use `esc()` helper
## API Documentation Pattern
### Critical Requirement
After modifying ANY controller, **MUST** update `public/api-docs.yaml`:
- Add new endpoints
- Update existing endpoint schemas
- Document request/response formats
- Include field names, types, and validation rules
- Add example requests/responses
### API Documentation Format
```yaml
paths:
/api/patient:
get:
summary: List patients
parameters:
- name: InternalPID
in: query
schema:
type: integer
responses:
'200':
description: Success
content:
application/json:
schema:
type: object
properties:
status:
type: string
message:
type: string
data:
type: array
items:
$ref: '#/components/schemas/Patient'
```
## Testing Pattern
### Feature Test Structure
```php
<?php
namespace Tests\Feature;
use CodeIgniter\Test\FeatureTestTrait;
use CodeIgniter\Test\CIUnitTestCase;
class PatientCreateTest extends CIUnitTestCase
{
use FeatureTestTrait;
protected $token;
protected function setUp(): void {
parent::setUp();
// Generate JWT token
$this->token = $this->generateToken();
}
public function testCanCreatePatient() {
$this->withHeaders(['Cookie' => 'token=' . $this->token])
->post('api/patient', [
'PatientID' => 'PT001',
'NameFirst' => 'John',
'NameLast' => 'Doe',
'Sex' => '2',
'Birthdate' => '1990-05-15'
])
->assertStatus(200)
->assertJSONFragment(['status' => 'success']);
}
}
```
### Test Commands
```bash
# Run all tests
vendor/bin/phpunit
# Run specific test file (IMPORTANT for debugging)
vendor/bin/phpunit tests/feature/Patient/PatientCreateTest.php
# Run with coverage
vendor/bin/phpunit --coverage-text=tests/coverage.txt --coverage-html=tests/coverage/ -d memory_limit=1024m
```
## Common Pitfalls
### Forgetting to Clear ValueSet Cache
**Problem:** Modified ValueSet JSON file but changes not reflected
**Solution:** Call `ValueSet::clearCache()` after modifications
### Not Updating api-docs.yaml
**Problem:** API documentation out of sync with implementation
**Solution:** Always update `public/api-docs.yaml` after controller changes
### Missing Soft Delete Filter in Joins
**Problem:** Query returns soft-deleted records from joined tables
**Solution:** Add `and table.DelDate is null` to join condition
### Incorrect Date Format
**Problem:** Dates not in UTC format causing issues
**Solution:** BaseModel handles this, but manual dates must be in `Y-m-d H:i:s` UTC
### Validation Rule Not Applied
**Problem:** Input not validated, invalid data inserted
**Solution:** Always call `$this->validateData($input, $rules)` before processing
### Transaction Not Rolled Back on Error
**Problem:** Partial data left in database on error
**Solution:** Always use try-catch with `$db->transRollback()`
### Not Using BaseModel for Date Handling
**Problem:** Dates not normalized to UTC
**Solution:** All models must extend `BaseModel`
## Debugging Tips
### Enable Detailed Error Messages
In development environment (`.env`):
```env
CI_ENVIRONMENT = development
```
### Log SQL Queries
Add to database config or temporarily enable:
```php
$db->setQueryLog(true);
$log = $db->getQueryLog();
```
### Check Application Logs
```bash
# Windows
type writable\logs\log-*.log
# Unix/Linux
tail -f writable/logs/log-*.log
```
### Add Temporary Debug Output
```php
var_dump($variable); die();
// or
log_message('debug', 'Debug info: ' . json_encode($data));
```
### Run Specific Test for Debugging
```bash
vendor/bin/phpunit tests/feature/SpecificTest.php --filter testMethodName
```
### Check Database State
```bash
php spark db:table patient
# or use MySQL Workbench, phpMyAdmin, etc.
```

View File

@ -1,54 +1,32 @@
# CLQMS Project Overview
- **Name:** CLQMS (Clinical Laboratory Quality Management System)
- **Type:** Headless REST API backend (no view layer for product UX)
- **Purpose:** Manage clinical laboratory workflows (patients, orders, specimens, results, value sets, edge/instrument integration) via JSON APIs.
- **Framework/Runtime:** CodeIgniter 4 on PHP 8.1+
- **Database:** MySQL (legacy PascalCase column naming in many tables)
- **Auth:** JWT (firebase/php-jwt), typically required for protected `/api/*` endpoints.
## Project Purpose
CLQMS (Clinical Laboratory Quality Management System) is a headless REST API backend for clinical laboratory workflows. It provides comprehensive JSON endpoints for:
- Patient management
- Order/test management
- Specimen tracking
- Result management and verification
- Reference ranges
- Laboratory instrument integration (Edge API)
## Architecture Notes
- API-first and frontend-agnostic; clients consume REST JSON endpoints.
- Controllers delegate business logic to models/services; avoid direct DB query logic in controllers.
- Standardized response format with `status`, `message`, `data`.
- ValueSet/Lookups system supports static lookup data and API-managed lookup definitions.
- OpenAPI docs live under `public/api-docs.yaml`, `public/paths/*.yaml`, `public/components/schemas/*.yaml` and are bundled into `public/api-docs.bundled.yaml`.
## Tech Stack
- **Language**: PHP 8.1+
- **Framework**: CodeIgniter 4 (API-only mode)
- **Database**: MySQL with MySQLi driver
- **Authentication**: JWT (JSON Web Tokens)
- **Testing**: PHPUnit 10.5+
- **Documentation**: OpenAPI/Swagger YAML
## Architecture
- **API-First**: No view layer, headless REST API only
- **Stateless**: JWT-based authentication per request
- **UTC Dates**: All dates stored in UTC, converted for display
- **PSR-4 Autoloading**: `App\``app/`, `Config\``app/Config/`
## Key Directories
```
app/
Controllers/ # API endpoint handlers
Models/ # Database models
Libraries/ # Helper classes (Lookups, ValueSet)
Database/
Migrations/ # Schema migrations
Seeds/ # Test data seeders
Helpers/ # json_helper.php, utc_helper.php
Traits/ # ResponseTrait
Config/ # Configuration files
Filters/ # AuthFilter, CORS
public/ # Web root
paths/ # OpenAPI path definitions
components/schemas/ # OpenAPI schemas
tests/
feature/ # Feature/integration tests
unit/ # Unit tests
_support/ # Test support files
```
## High-Level Structure
- `app/Config` - framework and app configuration (routes, filters, etc.)
- `app/Controllers` - REST controllers
- `app/Models` - data access and DB logic
- `app/Services` - service-layer logic
- `app/Filters` - auth/request filters
- `app/Helpers` - helper functions (including UTC handling per conventions)
- `app/Libraries` - shared libraries (lookups/valuesets, etc.)
- `app/Traits` - reusable traits (including response behavior)
- `tests/feature`, `tests/unit` - PHPUnit test suites
- `public/paths`, `public/components/schemas` - modular OpenAPI source files
## Key Dependencies
- `codeigniter4/framework` - Core framework
- `firebase/php-jwt` - JWT authentication
- `fakerphp/faker` - Test data generation (dev)
- `phpunit/phpunit` - Testing (dev)
- `codeigniter4/framework`
- `firebase/php-jwt`
- `mossadal/math-parser`
- Dev: `phpunit/phpunit`, `fakerphp/faker`

View File

@ -0,0 +1,43 @@
# CLQMS Style and Conventions
## PHP and Naming
- PHP 8.1+, PSR-4 autoloading (`App\\` => `app/`, `Config\\` => `app/Config/`).
- Follow PSR-12 where applicable.
- Class names: PascalCase.
- Method names: camelCase.
- Properties: legacy snake_case and newer camelCase coexist.
- Constants: UPPER_SNAKE_CASE.
- DB tables: snake_case; many DB columns are legacy PascalCase.
- JSON fields in API responses often use PascalCase for domain fields.
## Controller Pattern
- Controllers should handle HTTP concerns and delegate to model/service logic.
- Avoid embedding DB query logic directly inside controllers.
- Use `ResponseTrait` and consistent JSON envelope responses.
## Response Pattern
- Success: `status=success`, plus message/data.
- Error: structured error response with proper HTTP status codes.
- Empty strings may be normalized to `null` by custom response behavior.
## DB and Data Handling
- Prefer CodeIgniter Model/Query Builder usage.
- Use UTC helper conventions for datetime handling.
- Multi-table writes should be wrapped in transactions.
- For nested entities/arrays, extract and handle nested payloads carefully before filtering and persistence.
## Error Handling
- Use try/catch for JWT and external operations.
- Log errors with `log_message('error', ...)`.
- Return structured API errors with correct status codes.
## Testing Convention
- PHPUnit tests in `tests/`.
- Test naming: `test<Action><Scenario><ExpectedResult>`.
- Typical status expectation: 200 (GET/PATCH), 201 (POST), 400/401/404/500 as appropriate.
## API Docs Rule (Critical)
- Any controller/API contract change must update corresponding OpenAPI YAML files under:
- `public/paths/*.yaml`
- `public/components/schemas/*.yaml`
- optionally `public/api-docs.yaml` if references/tags change
- Rebuild docs bundle after YAML updates.

View File

@ -1,100 +1,32 @@
# CLQMS Suggested Commands
# Suggested Commands (Windows)
## Core Project Commands
- Install dependencies: `composer install`
- Run all tests: `./vendor/bin/phpunit`
- Run one test file: `./vendor/bin/phpunit tests/feature/Patients/PatientCreateTest.php`
- Run one test method: `./vendor/bin/phpunit --filter testCreatePatientSuccess tests/feature/Patients/PatientCreateTest.php`
- Run test suite: `./vendor/bin/phpunit --testsuite App`
- Run with coverage: `./vendor/bin/phpunit --coverage-html build/logs/html`
## Testing
```bash
# Run all tests
./vendor/bin/phpunit
## CodeIgniter/Spark Commands
- Create migration: `php spark make:migration <name>`
- Create model: `php spark make:model <name>`
- Create controller: `php spark make:controller <name>`
- Apply migrations: `php spark migrate`
- Rollback migrations: `php spark migrate:rollback`
- Run local app: `php spark serve`
# Run specific test file
./vendor/bin/phpunit tests/feature/Patients/PatientCreateTest.php
## OpenAPI Docs Commands
- Rebundle API docs after YAML changes: `node public/bundle-api-docs.js`
# Run specific test method
./vendor/bin/phpunit --filter testCreatePatientSuccess tests/feature/Patients/PatientCreateTest.php
## Git and Shell Utilities (Windows)
- Git status: `git status`
- Diff changes: `git diff`
- Show staged diff: `git diff --cached`
- Recent commits: `git log --oneline -n 10`
- List files (PowerShell): `Get-ChildItem`
- Recursive file search (PowerShell): `Get-ChildItem -Recurse -File`
- Text search (PowerShell): `Select-String -Path .\* -Pattern "<text>" -Recurse`
# Run tests with coverage
./vendor/bin/phpunit --coverage-html build/logs/html
# Run tests by suite
./vendor/bin/phpunit --testsuite App
# Run via composer
composer test
```
## Development Server
```bash
# Start PHP development server
php spark serve
# Or specify port
php spark serve --port 8080
```
## Database
```bash
# Run migrations
php spark migrate
# Rollback migrations
php spark migrate:rollback
# Create new migration
php spark make:migration CreateUsersTable
# Run database seeds
php spark db:seed DBSeeder
php spark db:seed PatientSeeder
```
## Code Generation (Scaffolding)
```bash
# Create controller
php spark make:controller Users
# Create model
php spark make:model UserModel
# Create migration
php spark make:migration CreateUsersTable
# Create seeder
php spark make:seeder UserSeeder
```
## API Documentation
```bash
# After updating YAML files, regenerate bundled docs
node public/bundle-api-docs.js
# Produces: public/api-docs.bundled.yaml
```
## Utilities (Windows)
```bash
# List files
dir
# Search in files (PowerShell)
Select-String -Path "app\*.php" -Pattern "PatientModel"
# Or using git bash (if available)
grep -r "PatientModel" app/
# Clear writable cache
del /q writable\cache\*
```
## Git Commands
```bash
# Check status
git status
# Add files
git add .
# Commit (only when explicitly asked)
git commit -m "message"
# View recent commits
git log --oneline -10
```
## Notes
- Testing DB values in `phpunit.xml.dist` are environment-specific; verify before running tests.
- API docs bundle output file: `public/api-docs.bundled.yaml`.

View File

@ -1,67 +0,0 @@
# CLQMS Task Completion Checklist
When completing a task, ensure:
## 1. Tests Pass
```bash
./vendor/bin/phpunit
```
- All existing tests must pass
- Add new tests for new features
- Test naming: `test<Action><Scenario><Result>`
## 2. API Documentation Updated (CRITICAL)
When updating ANY controller, update corresponding OpenAPI YAML:
| Controller | YAML Path File | YAML Schema File |
|-----------|----------------|------------------|
| `PatientController` | `paths/patients.yaml` | `components/schemas/patient.yaml` |
| `PatVisitController` | `paths/patient-visits.yaml` | `components/schemas/patient-visit.yaml` |
| `OrderTestController` | `paths/orders.yaml` | `components/schemas/orders.yaml` |
| `SpecimenController` | `paths/specimen.yaml` | `components/schemas/specimen.yaml` |
| `TestsController` | `paths/tests.yaml` | `components/schemas/tests.yaml` |
| `AuthController` | `paths/authentication.yaml` | `components/schemas/authentication.yaml` |
| `ResultController` | `paths/results.yaml` | `components/schemas/*.yaml` |
| `EdgeController` | `paths/edge-api.yaml` | `components/schemas/edge-api.yaml` |
| `LocationController` | `paths/locations.yaml` | `components/schemas/master-data.yaml` |
| `ValueSetController` | `paths/valuesets.yaml` | `components/schemas/valuesets.yaml` |
| `ContactController` | `paths/contact.yaml` | (inline schemas) |
After updating YAML files:
```bash
node public/bundle-api-docs.js
```
## 3. Code Quality Checks
- PSR-12 compliance where applicable
- No database queries in controllers
- Use transactions for multi-table operations
- Proper error handling with try-catch for JWT/external calls
- Log errors: `log_message('error', $message)`
## 4. Response Format Verification
Ensure all responses follow the standard format:
```php
return $this->respond([
'status' => 'success|failed',
'message' => 'Description',
'data' => $data
], $httpStatus);
```
## 5. Security Checklist
- Use `auth` filter for protected routes
- Sanitize user inputs
- Use parameterized queries
- No secrets committed to repo (use .env)
## 6. Naming Conventions
- Classes: PascalCase
- Methods: camelCase
- Properties: snake_case (legacy) / camelCase (new)
- Database columns: PascalCase (legacy convention)
## 7. Do NOT Commit Unless Explicitly Asked
- Check status: `git status`
- Never commit .env files
- Never commit secrets

View File

@ -1,227 +1,9 @@
# Task Completion Checklist
When finishing a coding change in CLQMS:
This checklist should be followed after completing any development task to ensure code quality and consistency.
## Immediate Post-Task Actions
### 1. Run Tests
```bash
# Run all tests to ensure nothing is broken
vendor/bin/phpunit
# If tests fail, run specific test file for debugging
vendor/bin/phpunit tests/feature/[SpecificTestFile].php
```
### 2. Check for PHP Syntax Errors
```bash
# Check syntax of modified files
php -l app/Controllers/YourController.php
php -l app/Models/YourModel.php
```
### 3. Verify Database Changes
```bash
# If you created a migration, verify it was applied
php spark migrate:status
# If you created a migration, run it
php spark migrate
```
### 4. Update API Documentation (CRITICAL)
- **MUST** update `public/api-docs.yaml` after modifying ANY controller
- Update OpenAPI schema definitions for new/changed endpoints
- Update field names, types, and response formats
- Ensure schemas match actual controller responses
## Code Quality Verification
### Controllers Checklist
- [ ] Extends `BaseController`
- [ ] Uses `ResponseTrait`
- [ ] Defines `$this->rules` array for validation
- [ ] Validates input: `$this->validateData($input, $rules)`
- [ ] Uses try-catch with `$this->failServerError()`
- [ ] Returns consistent JSON format: `['status' => 'success', 'message' => '...', 'data' => ...]`
- [ ] Uses `$this->request->getJSON(true)` for POST/PATCH
- [ ] Uses `$this->request->getVar()` for GET parameters
- [ ] Proper HTTP status codes: 200 (success), 201 (created), 400 (validation), 404 (not found), 500 (error)
### Models Checklist
- [ ] Extends `BaseModel` (for UTC normalization)
- [ ] Defines `$table`, `$primaryKey`, `$allowedFields`
- [ ] Uses soft deletes: `$useSoftDeletes = true`, `$deletedField = 'DelDate'`
- [ ] Wraps multi-table operations in transactions
- [ ] Uses `$this->checkDbError($db, 'context')` after DB operations
- [ ] Uses Query Builder, not raw SQL
- [ ] Escapes inputs properly via parameter binding
### Code Style Checklist
- [ ] 2-space indentation
- [ ] Same-line opening braces: `public function index() {`
- [ ] No trailing whitespace
- [ ] Namespace at top: `namespace App\Controllers\...`
- [ ] Organized imports: Framework first, then App libraries/models
- [ ] Classes: PascalCase (`PatientController`)
- [ ] Methods: camelCase (`getPatient`)
- [ ] Variables: camelCase (`$patientId`)
- [ ] Database fields: PascalCase with underscores (`PatientID`, `NameFirst`)
### Security Checklist
- [ ] No secrets logged or committed (JWT_SECRET, passwords)
- [ ] User inputs validated before processing
- [ ] JWT authentication required for protected endpoints
- [ ] SQL injection prevention via Query Builder
- [ ] XSS prevention via proper escaping
### Data Integrity Checklist
- [ ] UTC date normalization via BaseModel
- [ ] Soft delete using `DelDate` field
- [ ] Referential integrity maintained
- [ ] Transactional data consistency via `$db->transBegin()`, `$db->transCommit()`, `$db->transRollback()`
### Testing Checklist
- [ ] Tests written for new functionality
- [ ] Tests extend `CIUnitTestCase`
- [ ] Feature tests use `FeatureTestTrait`
- [ ] JWT token injected via `withHeaders(['Cookie' => 'token=' . $this->token])`
- [ ] Assert JSON structure: `$this->assertIsArray($body['data'])`
- [ ] All tests passing: `vendor/bin/phpunit`
### ValueSet Checklist
- [ ] If using static lookups, use `ValueSet::get('name')`
- [ ] Transform labels: `ValueSet::transformLabels($data, ['Field' => 'valueset'])`
- [ ] If modifying valueset JSON files, call `ValueSet::clearCache()`
- [ ] Valueset JSON files in `app/Libraries/Data/valuesets/`
### Database Checklist
- [ ] Migrations follow naming convention: `YYYY-MM-DD-NNNNNN_Description.php`
- [ ] Migrations define both `up()` and `down()` methods
- [ ] Foreign keys properly defined
- [ ] Indexes added for performance
- [ ] Tables dropped in reverse order in `down()` method
### API Documentation Checklist
- [ ] `public/api-docs.yaml` updated with new/changed endpoints
- [ ] Schemas match actual controller responses
- [ ] Field names and types documented
- [ ] Request/response examples provided
- [ ] Authentication requirements documented
## Verification Steps
### 1. Verify API Endpoints (If Applicable)
```bash
# If you have curl, test the endpoints
curl -X GET http://localhost:8080/api/patient \
-H "Cookie: token=your_jwt_token"
# Or use a REST client like Postman
```
### 2. Check Logs
```bash
# View recent application logs
type writable\logs\log-*.log # Windows
# or
tail -f writable/logs/log-*.log # Unix/Linux
```
### 3. Check Database
```bash
# Verify database state
php spark db:table [table_name]
# Or use your database client (MySQL Workbench, phpMyAdmin, etc.)
```
## Common Issues to Check
### Potential Issues
1. **Tests failing**: Check error messages, add debug output, verify database state
2. **Syntax errors**: Run `php -l` on modified files
3. **Validation errors**: Check `$this->rules` array, ensure input matches expected format
4. **Database errors**: Check migration status, verify table structure
5. **Authentication issues**: Verify JWT token, check `AuthFilter`, ensure route has auth filter
6. **Date issues**: BaseModel handles UTC normalization, verify date formats
7. **Soft deletes**: Check if `DelDate` field is being set, verify queries filter deleted records
### Performance Considerations
- [ ] Database queries optimized (use indexes, avoid SELECT *)
- [ ] Caching used appropriately (ValueSet, query caching)
- [ ] N+1 query problem avoided (use eager loading)
- [ ] Large datasets paginated
## Final Steps
### Before Marking Task Complete
1. **Run all tests**: `vendor/bin/phpunit`
2. **Check syntax**: `php -l` on modified files
3. **Update documentation**: `public/api-docs.yaml` (CRITICAL)
4. **Verify manually**: Test API endpoints if applicable
5. **Clean up**: Remove debug code, comments (unless requested)
### Git Commit (If Requested)
Only commit when explicitly requested by user. If committing:
1. Review changes: `git diff` and `git status`
2. Stage relevant files: `git add .`
3. Create meaningful commit message
4. Commit: `git commit -m "message"`
5. Do NOT push unless explicitly requested
## Example Task Completion Workflow
### Adding a New Controller Method
1. Implement controller method following pattern
2. Add model methods if needed
3. Add route in `app/Config/Routes.php`
4. Create test: `php spark make:test Feature/NewEndpointTest`
5. Implement tests
6. Run tests: `vendor/bin/phpunit`
7. Update `public/api-docs.yaml`
8. Verify with curl or REST client
9. Check syntax: `php -l app/Controllers/YourController.php`
10. Done!
### Modifying Existing Endpoint
1. Modify controller/model code
2. Run tests: `vendor/bin/phpunit`
3. Update `public/api-docs.yaml`
4. Test endpoint manually
5. Check syntax: `php -l` on modified files
6. Done!
### Creating a Database Migration
1. Create migration: `php spark make:migration Description`
2. Define `up()` and `down()` methods
3. Run migration: `php spark migrate`
4. Verify: `php spark migrate:status`
5. Create seeder if needed
6. Run tests to verify no regressions
7. Done!
## Quick Reference Commands
```bash
# Run all tests
vendor/bin/phpunit
# Run specific test
vendor/bin/phpunit tests/feature/SpecificTest.php
# Check PHP syntax
php -l path/to/file.php
# Run migrations
php spark migrate
# Check migration status
php spark migrate:status
# Start dev server
php spark serve
# Clear cache
php spark cache:clear
```
1. Run targeted tests first (file/method-level), then broader PHPUnit suite if scope warrants it.
2. Verify API response structure consistency (`status`, `message`, `data`) and proper HTTP status codes.
3. If controllers or API contracts changed, update OpenAPI YAML files in `public/paths` and/or `public/components/schemas`.
4. Rebundle OpenAPI docs with `node public/bundle-api-docs.js` after YAML updates.
5. Confirm no secrets/credentials were introduced in tracked files.
6. Review diff for legacy field naming compatibility (PascalCase DB columns/JSON domain fields where expected).

View File

@ -32,11 +32,24 @@ languages:
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
encoding: "utf-8"
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# whether to use project's .gitignore files to ignore files
ignore_all_files_in_gitignore: true
# list of additional paths to ignore in all projects
# same syntax as gitignore, so you can use * and **
# list of additional paths to ignore in this project.
# Same syntax as gitignore, so you can use * and **.
# Note: global ignored_paths from serena_config.yml are also applied additively.
ignored_paths: []
# whether the project is in read-only mode
@ -111,22 +124,12 @@ default_modes:
# (contrary to the memories, which are loaded on demand).
initial_prompt: ""
# override of the corresponding setting in serena_config.yml, see the documentation there.
# If null or missing, the value from the global config is used.
# time budget (seconds) per tool call for the retrieval of additional symbol information
# such as docstrings or parameter information.
# This overrides the corresponding setting in the global configuration; see the documentation there.
# If null or missing, use the setting from the global configuration.
symbol_info_budget:
# The language backend to use for this project.
# If not set, the global setting from serena_config.yml is used.
# Valid values: LSP, JetBrains
# Note: the backend is fixed at startup. If a project with a different backend
# is activated post-init, an error will be returned.
language_backend:
# list of regex patterns which, when matched, mark a memory entry as readonly.
# Extends the list from the global configuration, merging the two lists.
read_only_memory_patterns: []
# line ending convention to use when writing source files.
# Possible values: unset (use global setting), "lf", "crlf", or "native" (platform default)
# This does not affect Serena's own files (e.g. memories and configuration files), which always use native line endings.
line_ending:

View File

@ -0,0 +1,152 @@
<?php
namespace App\Controllers;
use App\Traits\ResponseTrait;
use App\Services\CalculatorService;
use App\Models\Test\TestDefCalModel;
use CodeIgniter\Controller;
use CodeIgniter\HTTP\ResponseInterface;
class CalculatorController extends Controller
{
use ResponseTrait;
protected CalculatorService $calculator;
protected TestDefCalModel $calcModel;
public function __construct()
{
$this->calculator = new CalculatorService();
$this->calcModel = new TestDefCalModel();
}
/**
* POST api/calculate
* Calculate a formula with provided variables
*
* Request: {
* "formula": "{result} * {factor} + {gender}",
* "variables": {
* "result": 100,
* "factor": 0.5,
* "gender": "female"
* }
* }
*/
public function calculate(): ResponseInterface
{
try {
$data = $this->request->getJSON(true);
if (empty($data['formula'])) {
return $this->respond([
'status' => 'failed',
'message' => 'Formula is required'
], 400);
}
$result = $this->calculator->calculate(
$data['formula'],
$data['variables'] ?? []
);
return $this->respond([
'status' => 'success',
'data' => [
'result' => $result,
'formula' => $data['formula'],
'variables' => $data['variables'] ?? []
]
], 200);
} catch (\Exception $e) {
return $this->respond([
'status' => 'failed',
'message' => $e->getMessage()
], 400);
}
}
/**
* POST api/calculate/validate
* Validate a formula syntax
*
* Request: {
* "formula": "{result} * 2 + 5"
* }
*/
public function validateFormula(): ResponseInterface
{
try {
$data = $this->request->getJSON(true);
if (empty($data['formula'])) {
return $this->respond([
'status' => 'failed',
'message' => 'Formula is required'
], 400);
}
$validation = $this->calculator->validate($data['formula']);
return $this->respond([
'status' => $validation['valid'] ? 'success' : 'failed',
'data' => [
'valid' => $validation['valid'],
'error' => $validation['error'],
'variables' => $this->calculator->extractVariables($data['formula'])
]
], 200);
} catch (\Exception $e) {
return $this->respond([
'status' => 'failed',
'message' => $e->getMessage()
], 400);
}
}
/**
* POST api/calculate/test-site/{testSiteID}
* Calculate using TestDefCal definition
*
* Request: {
* "result": 85,
* "gender": "female",
* "age": 30
* }
*/
public function calculateByTestSite($testSiteID): ResponseInterface
{
try {
$calcDef = $this->calcModel->existsByTestSiteID($testSiteID);
if (!$calcDef) {
return $this->respond([
'status' => 'failed',
'message' => 'No calculation defined for this test site'
], 404);
}
$testValues = $this->request->getJSON(true);
$result = $this->calculator->calculateFromDefinition($calcDef, $testValues);
return $this->respond([
'status' => 'success',
'data' => [
'result' => $result,
'testSiteID' => $testSiteID,
'formula' => $calcDef['FormulaCode'],
'variables' => $testValues
]
], 200);
} catch (\Exception $e) {
return $this->respond([
'status' => 'failed',
'message' => $e->getMessage()
], 400);
}
}
}

View File

@ -93,6 +93,7 @@ class TestsController extends BaseController
if ($typeCode === 'CALC') {
$row['testdefcal'] = $this->modelCal->getByTestSiteID($id);
$row['testdefgrp'] = $this->modelGrp->getGroupMembers($id);
} elseif ($typeCode === 'GROUP') {
$row['testdefgrp'] = $this->modelGrp->getGroupMembers($id);
} elseif ($typeCode !== 'TITLE') {
@ -312,6 +313,7 @@ class TestsController extends BaseController
if (TestValidationService::isCalc($typeCode)) {
$this->modelCal->disableByTestSiteID($id);
$this->modelGrp->disableByTestSiteID($id);
} elseif (TestValidationService::isGroup($typeCode)) {
$this->modelGrp->disableByTestSiteID($id);
} elseif (TestValidationService::isTechnicalTest($typeCode)) {
@ -370,7 +372,7 @@ class TestsController extends BaseController
switch ($typeCode) {
case 'CALC':
$this->saveCalcDetails($testSiteID, $details, $action);
$this->saveCalcDetails($testSiteID, $details, $input, $action);
break;
@ -452,13 +454,12 @@ class TestsController extends BaseController
$this->modelRefTxt->batchInsert($testSiteID, $siteID, $ranges);
}
private function saveCalcDetails($testSiteID, $data, $action)
private function saveCalcDetails($testSiteID, $data, $input, $action)
{
$calcData = [
'TestSiteID' => $testSiteID,
'DisciplineID' => $data['DisciplineID'] ?? null,
'DepartmentID' => $data['DepartmentID'] ?? null,
'FormulaInput' => $data['FormulaInput'] ?? null,
'FormulaCode' => $data['FormulaCode'] ?? $data['Formula'] ?? null,
'ResultType' => 'NMRIC',
'RefType' => $data['RefType'] ?? 'RANGE',
@ -480,6 +481,42 @@ class TestsController extends BaseController
} else {
$this->modelCal->insert($calcData);
}
if ($action === 'update') {
$this->modelGrp->disableByTestSiteID($testSiteID);
}
$memberIDs = $this->resolveCalcMemberIDs($data, $input);
foreach ($memberIDs as $memberID) {
$this->modelGrp->insert([
'TestSiteID' => $testSiteID,
'Member' => $memberID,
]);
}
}
private function resolveCalcMemberIDs(array $data, array $input): array
{
$memberIDs = [];
$rawMembers = $data['members'] ?? ($input['members'] ?? []);
if (is_array($rawMembers)) {
foreach ($rawMembers as $member) {
if (is_array($member)) {
$rawID = $member['Member'] ?? ($member['TestSiteID'] ?? null);
} else {
$rawID = is_numeric($member) ? $member : null;
}
if ($rawID !== null && is_numeric($rawID)) {
$memberIDs[] = (int) $rawID;
}
}
}
$memberIDs = array_values(array_unique(array_filter($memberIDs)));
return $memberIDs;
}
private function saveGroupDetails($testSiteID, $data, $input, $action)

View File

@ -50,7 +50,6 @@ class CreateTestDefinitions extends Migration {
'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false],
'DisciplineID' => ['type' => 'INT', 'null' => true],
'DepartmentID' => ['type' => 'INT', 'null' => true],
'FormulaInput' => ['type' => 'text', 'null' => true],
'FormulaCode' => ['type' => 'varchar', 'constraint'=> 255, 'null' => true],
'RefType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true, 'default' => 'NMRC'],
'Unit1' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true],

View File

@ -180,19 +180,43 @@ class TestSeeder extends Seeder
$this->db->table('testdefsite')->insert($data);
$tIDs['LDL'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteCode' => 'TBIL', 'TestSiteName' => 'Total Bilirubin', 'TestType' => 'TEST', 'Description' => 'Bilirubin Total', 'SeqScr' => '185', 'SeqRpt' => '185', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'Diazo', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['TBIL'] = $this->db->insertID();
$data = ['SiteID' => '1', 'TestSiteCode' => 'DBIL', 'TestSiteName' => 'Direct Bilirubin', 'TestType' => 'TEST', 'Description' => 'Bilirubin Direk', 'SeqScr' => '186', 'SeqRpt' => '186', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '1', 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => 'NMRIC', 'RefType' => 'RANGE', 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'Diazo', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['DBIL'] = $this->db->insertID();
// CALC: Chemistry Calculated Tests
$data = ['SiteID' => '1', 'TestSiteCode' => 'EGFR', 'TestSiteName' => 'eGFR (CKD-EPI)', 'TestType' => 'CALC', 'Description' => 'Estimated Glomerular Filtration Rate', 'SeqScr' => '190', 'SeqRpt' => '190', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '0', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['EGFR'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['EGFR'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CREA,AGE,GENDER', 'FormulaCode' => 'CKD_EPI(CREA,AGE,GENDER)', 'RefType' => 'RANGE', 'Unit1' => 'mL/min/1.73m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"];
$data = ['TestSiteID' => $tIDs['EGFR'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaCode' => 'CKD_EPI(CREA,AGE,GENDER)', 'RefType' => 'RANGE', 'Unit1' => 'mL/min/1.73m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"];
$this->db->table('testdefcal')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'LDLCALC', 'TestSiteName' => 'LDL Cholesterol (Calculated)', 'TestType' => 'CALC', 'Description' => 'Friedewald formula: TC - HDL - (TG/5)', 'SeqScr' => '200', 'SeqRpt' => '200', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '0', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['LDLCALC'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['LDLCALC'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CHOL,HDL,TG', 'FormulaCode' => 'CHOL - HDL - (TG/5)', 'RefType' => 'RANGE', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"];
$data = ['TestSiteID' => $tIDs['LDLCALC'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaCode' => 'CHOL - HDL - (TG/5)', 'RefType' => 'RANGE', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"];
$this->db->table('testdefcal')->insert($data);
$data = ['SiteID' => '1', 'TestSiteCode' => 'IBIL', 'TestSiteName' => 'Indirect Bilirubin', 'TestType' => 'CALC', 'Description' => 'Bilirubin Indirek: TBIL - DBIL', 'SeqScr' => '210', 'SeqRpt' => '210', 'IndentLeft' => '1', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '0', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['IBIL'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['IBIL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaCode' => 'TBIL - DBIL', 'RefType' => 'RANGE', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'CreateDate' => "$now"];
$this->db->table('testdefcal')->insert($data);
// CALC dependencies are grouped via testdefgrp
$this->db->table('testdefgrp')->insertBatch([
['TestSiteID' => $tIDs['EGFR'], 'Member' => $tIDs['CREA'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LDLCALC'], 'Member' => $tIDs['CHOL'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LDLCALC'], 'Member' => $tIDs['HDL'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LDLCALC'], 'Member' => $tIDs['TG'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['IBIL'], 'Member' => $tIDs['TBIL'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['IBIL'], 'Member' => $tIDs['DBIL'], 'CreateDate' => "$now"],
]);
// Add Chemistry Group members now that tests are defined
$this->db->table('testdefgrp')->insertBatch([
['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['CHOL'], 'CreateDate' => "$now"],
@ -204,7 +228,10 @@ class TestSeeder extends Seeder
$this->db->table('testdefgrp')->insertBatch([
['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGOT'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGPT'], 'CreateDate' => "$now"]
['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGPT'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['TBIL'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['DBIL'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['IBIL'], 'CreateDate' => "$now"]
]);
$this->db->table('testdefgrp')->insertBatch([
@ -275,9 +302,15 @@ class TestSeeder extends Seeder
$data = ['SiteID' => '1', 'TestSiteCode' => 'BMI', 'TestSiteName' => 'Body Mass Index', 'TestType' => 'CALC', 'Description' => 'Indeks Massa Tubuh - weight/(height^2)', 'SeqScr' => '100', 'SeqRpt' => '100', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '1', 'CountStat' => '0', 'CreateDate' => "$now"];
$this->db->table('testdefsite')->insert($data);
$tIDs['BMI'] = $this->db->insertID();
$data = ['TestSiteID' => $tIDs['BMI'], 'DisciplineID' => '10', 'DepartmentID' => '', 'FormulaInput' => 'WEIGHT,HEIGHT', 'FormulaCode' => 'WEIGHT / ((HEIGHT/100) * (HEIGHT/100))', 'RefType' => 'RANGE', 'Unit1' => 'kg/m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'CreateDate' => "$now"];
$data = ['TestSiteID' => $tIDs['BMI'], 'DisciplineID' => '10', 'DepartmentID' => '', 'FormulaCode' => 'WEIGHT / ((HEIGHT/100) * (HEIGHT/100))', 'RefType' => 'RANGE', 'Unit1' => 'kg/m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'CreateDate' => "$now"];
$this->db->table('testdefcal')->insert($data);
$this->db->table('testdefgrp')->insertBatch([
['TestSiteID' => $tIDs['BMI'], 'Member' => $tIDs['WEIGHT'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['BMI'], 'Member' => $tIDs['HEIGHT'], 'CreateDate' => "$now"],
['TestSiteID' => $tIDs['EGFR'], 'Member' => $tIDs['AGE'], 'CreateDate' => "$now"],
]);
// ========================================
// TEST MAP - Specimen Mapping
// ========================================
@ -342,14 +375,14 @@ class TestSeeder extends Seeder
],
// Chemistry: Site → CAUTO → Cobas (SST)
[
'tests' => ['GLU', 'CREA', 'UREA', 'SGOT', 'SGPT', 'CHOL', 'TG', 'HDL', 'LDL'],
'tests' => ['GLU', 'CREA', 'UREA', 'SGOT', 'SGPT', 'CHOL', 'TG', 'HDL', 'LDL', 'TBIL', 'DBIL'],
'panels' => ['LIPID', 'LFT', 'RFT'],
'siteToWs' => ['ws' => $wsCAuto, 'con' => null],
'wsToInst' => ['ws' => $wsCAuto, 'inst' => $instChemistry, 'con' => $conSST],
],
// Calculated: Site → CAUTO → Cobas (SST)
[
'tests' => ['EGFR', 'LDLCALC'],
'tests' => ['EGFR', 'LDLCALC', 'IBIL'],
'panels' => [],
'siteToWs' => ['ws' => $wsCAuto, 'con' => null],
'wsToInst' => ['ws' => $wsCAuto, 'inst' => $instChemistry, 'con' => $conSST],

View File

@ -249,15 +249,11 @@ class OrderTestModel extends BaseModel {
// Handle Calculated Test Dependencies
if ($testInfo['TestType'] === 'CALC') {
$calDetail = $calModel->where('TestSiteID', $testSiteID)->first();
if ($calDetail && !empty($calDetail['FormulaInput'])) {
$inputs = explode(',', $calDetail['FormulaInput']);
foreach ($inputs as $inputCode) {
$inputCode = trim($inputCode);
$inputTest = $testModel->where('TestSiteCode', $inputCode)->first();
if ($inputTest) {
$this->expandTest($inputTest['TestSiteID'], $testToOrder, $testModel, $grpModel, $calModel);
}
$members = $grpModel->getGroupMembers($testSiteID);
foreach ($members as $member) {
$memberID = $member['TestSiteID'] ?? null;
if ($memberID) {
$this->expandTest($memberID, $testToOrder, $testModel, $grpModel, $calModel);
}
}
}

View File

@ -11,7 +11,6 @@ class TestDefCalModel extends BaseModel {
'TestSiteID',
'DisciplineID',
'DepartmentID',
'FormulaInput',
'FormulaCode',
'RefType',
'Unit1',

View File

@ -0,0 +1,170 @@
<?php
namespace App\Services;
use MathParser\StdMathParser;
use MathParser\Interpreting\Evaluator;
use MathParser\Exceptions\MathParserException;
class CalculatorService {
protected StdMathParser $parser;
protected Evaluator $evaluator;
/**
* Gender mapping for calculations
* 0 = Unknown, 1 = Female, 2 = Male
*/
protected const GENDER_MAP = [
'unknown' => 0,
'female' => 1,
'male' => 2,
'0' => 0,
'1' => 1,
'2' => 2,
];
public function __construct() {
$this->parser = new StdMathParser();
$this->evaluator = new Evaluator();
}
/**
* Calculate formula with variables
*
* @param string $formula Formula with placeholders like {result}, {factor}, {gender}
* @param array $variables Array of variable values
* @return float|null Calculated result or null on error
* @throws \Exception
*/
public function calculate(string $formula, array $variables = []): ?float {
try {
// Convert placeholders to math-parser compatible format
$expression = $this->prepareExpression($formula, $variables);
// Parse the expression
$ast = $this->parser->parse($expression);
// Evaluate
$result = $ast->accept($this->evaluator);
return (float) $result;
} catch (MathParserException $e) {
log_message('error', 'MathParser error: ' . $e->getMessage() . ' | Formula: ' . $formula);
throw new \Exception('Invalid formula: ' . $e->getMessage());
} catch (\Exception $e) {
log_message('error', 'Calculator error: ' . $e->getMessage() . ' | Formula: ' . $formula);
throw $e;
}
}
/**
* Validate formula syntax
*
* @param string $formula Formula to validate
* @return array ['valid' => bool, 'error' => string|null]
*/
public function validate(string $formula): array {
try {
// Replace placeholders with dummy values for validation
$testExpression = preg_replace('/\{([^}]+)\}/', '1', $formula);
$this->parser->parse($testExpression);
return ['valid' => true, 'error' => null];
} catch (MathParserException $e) {
return ['valid' => false, 'error' => $e->getMessage()];
}
}
/**
* Extract variable names from formula
*
* @param string $formula Formula with placeholders
* @return array List of variable names
*/
public function extractVariables(string $formula): array {
preg_match_all('/\{([^}]+)\}/', $formula, $matches);
return array_unique($matches[1]);
}
/**
* Prepare expression by replacing placeholders with values
*/
protected function prepareExpression(string $formula, array $variables): string {
$expression = $formula;
foreach ($variables as $key => $value) {
$placeholder = '{' . $key . '}';
// Handle gender specially
if ($key === 'gender') {
$value = $this->normalizeGender($value);
}
// Ensure numeric value
if (!is_numeric($value)) {
throw new \Exception("Variable '{$key}' must be numeric, got: " . var_export($value, true));
}
$expression = str_replace($placeholder, (float) $value, $expression);
}
// Check for unreplaced placeholders
if (preg_match('/\{([^}]+)\}/', $expression, $unreplaced)) {
throw new \Exception("Missing variable value for: {$unreplaced[1]}");
}
return $expression;
}
/**
* Normalize gender value to numeric (0, 1, or 2)
*/
protected function normalizeGender($gender): int {
if (is_numeric($gender)) {
$num = (int) $gender;
return in_array($num, [0, 1, 2], true) ? $num : 0;
}
$genderLower = strtolower((string) $gender);
return self::GENDER_MAP[$genderLower] ?? 0;
}
/**
* Calculate from TestDefCal record
*
* @param array $calcDef Test calculation definition
* @param array $testValues Test result values
* @return float|null
*/
public function calculateFromDefinition(array $calcDef, array $testValues): ?float {
$formula = $calcDef['FormulaCode'] ?? '';
if (empty($formula)) {
throw new \Exception('No formula defined');
}
// Build variables array
$variables = [
'result' => $testValues['result'] ?? 0,
'factor' => $calcDef['Factor'] ?? 1,
];
// Add optional variables
if (isset($testValues['gender'])) {
$variables['gender'] = $testValues['gender'];
}
if (isset($testValues['age'])) {
$variables['age'] = $testValues['age'];
}
if (isset($testValues['ref_low'])) {
$variables['ref_low'] = $testValues['ref_low'];
}
if (isset($testValues['ref_high'])) {
$variables['ref_high'] = $testValues['ref_high'];
}
// Merge any additional test values
$variables = array_merge($variables, $testValues);
return $this->calculate($formula, $variables);
}
}

View File

@ -12,7 +12,8 @@
"require": {
"php": "^8.1",
"codeigniter4/framework": "^4.0",
"firebase/php-jwt": "^6.11"
"firebase/php-jwt": "^6.11",
"mossadal/math-parser": "^1.3"
},
"require-dev": {
"fakerphp/faker": "^1.24",

55
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "378f858a54e9db754b16aab150c31714",
"content-hash": "8fffd5cbb5e940a076c93e72a52f7734",
"packages": [
{
"name": "codeigniter4/framework",
@ -204,6 +204,57 @@
],
"time": "2025-05-06T19:29:36+00:00"
},
{
"name": "mossadal/math-parser",
"version": "v1.3.16",
"source": {
"type": "git",
"url": "https://github.com/mossadal/math-parser.git",
"reference": "981b03ca603fd281049e092d75245ac029e13dec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/mossadal/math-parser/zipball/981b03ca603fd281049e092d75245ac029e13dec",
"reference": "981b03ca603fd281049e092d75245ac029e13dec",
"shasum": ""
},
"require": {
"php": ">=5.4.0"
},
"require-dev": {
"phpdocumentor/phpdocumentor": "2.*",
"phpunit/php-code-coverage": "6.0.*",
"phpunit/phpunit": "7.3.*"
},
"type": "library",
"autoload": {
"psr-4": {
"MathParser\\": "src/MathParser"
}
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"LGPL-3.0"
],
"authors": [
{
"name": "Frank Wikström",
"email": "frank@mossadal.se",
"role": "Developer"
}
],
"description": "PHP parser for mathematical expressions, including elementary functions, variables and implicit multiplication. Also supports symbolic differentiation.",
"homepage": "https://github.com/mossadal/math-parser",
"keywords": [
"mathematics",
"parser"
],
"support": {
"issues": "https://github.com/mossadal/math-parser/issues",
"source": "https://github.com/mossadal/math-parser/tree/master"
},
"time": "2018-09-15T22:20:34+00:00"
},
{
"name": "psr/log",
"version": "3.0.2",
@ -2133,5 +2184,5 @@
"php": "^8.1"
},
"platform-dev": {},
"plugin-api-version": "2.6.0"
"plugin-api-version": "2.9.0"
}

BIN
composer.phar Normal file

Binary file not shown.

View File

@ -1454,6 +1454,12 @@ paths:
type: string
DisciplineCode:
type: string
SeqScr:
type: integer
description: Display order on screen
SeqRpt:
type: integer
description: Display order in reports
responses:
'200':
description: Discipline updated
@ -5532,6 +5538,12 @@ components:
type: string
DisciplineCode:
type: string
SeqScr:
type: integer
description: Display order on screen
SeqRpt:
type: integer
description: Display order in reports
Department:
type: object
properties:
@ -5890,9 +5902,6 @@ components:
EndDate:
type: string
format: date-time
FormulaInput:
type: string
description: Input variables for calculated tests
FormulaCode:
type: string
description: Formula expression for calculated tests
@ -5903,7 +5912,7 @@ components:
type: object
testdefgrp:
type: array
description: Group members (only for GROUP type)
description: Group members (for GROUP and CALC types)
items:
type: object
properties:
@ -6154,10 +6163,18 @@ components:
- TestCalID: 1
DisciplineID: 2
DepartmentID: 2
FormulaInput: CREA,AGE,GENDER
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
Unit1: mL/min/1.73m2
Decimal: 0
testdefgrp:
- TestSiteID: 21
TestSiteCode: CREA
TestSiteName: Creatinine
TestType: TEST
- TestSiteID: 51
TestSiteCode: AGE
TestSiteName: Age
TestType: PARAM
refnum:
- RefNumID: 5
NumRefType: NMRC

View File

@ -131,9 +131,6 @@ TestDefinition:
EndDate:
type: string
format: date-time
FormulaInput:
type: string
description: Input variables for calculated tests
FormulaCode:
type: string
description: Formula expression for calculated tests
@ -144,7 +141,7 @@ TestDefinition:
type: object
testdefgrp:
type: array
description: Group members (only for GROUP type)
description: Group members (for GROUP and CALC types)
items:
type: object
properties:
@ -391,10 +388,18 @@ TestDefinition:
- TestCalID: 1
DisciplineID: 2
DepartmentID: 2
FormulaInput: CREA,AGE,GENDER
FormulaCode: CKD_EPI(CREA,AGE,GENDER)
Unit1: mL/min/1.73m2
Decimal: 0
testdefgrp:
- TestSiteID: 21
TestSiteCode: CREA
TestSiteName: Creatinine
TestType: TEST
- TestSiteID: 51
TestSiteCode: AGE
TestSiteName: Age
TestType: PARAM
refnum:
- RefNumID: 5
NumRefType: NMRC

View File

@ -0,0 +1,250 @@
<?php
namespace Tests\Feature\Calculator;
use CodeIgniter\Test\CIUnitTestCase;
use App\Services\CalculatorService;
class CalculatorTest extends CIUnitTestCase
{
protected CalculatorService $calculator;
public function setUp(): void {
parent::setUp();
$this->calculator = new CalculatorService();
}
/**
* Test basic arithmetic operations
*/
public function testBasicArithmetic() {
// Addition and multiplication precedence
$result = $this->calculator->calculate('1+2*3');
$this->assertEquals(7.0, $result);
// Parentheses
$result = $this->calculator->calculate('(1+2)*3');
$this->assertEquals(9.0, $result);
// Division
$result = $this->calculator->calculate('10/2');
$this->assertEquals(5.0, $result);
// Power
$result = $this->calculator->calculate('2^3');
$this->assertEquals(8.0, $result);
}
/**
* Test formula with simple variables
*/
public function testFormulaWithVariables() {
$formula = '{result} * {factor}';
$variables = ['result' => 50, 'factor' => 2];
$result = $this->calculator->calculate($formula, $variables);
$this->assertEquals(100.0, $result);
}
/**
* Test formula with gender variable (numeric values)
*/
public function testFormulaWithGenderNumeric() {
// Gender: 0=Unknown, 1=Female, 2=Male
$formula = '50 + {gender} * 10';
// Male (2)
$result = $this->calculator->calculate($formula, ['gender' => 2]);
$this->assertEquals(70.0, $result);
// Female (1)
$result = $this->calculator->calculate($formula, ['gender' => 1]);
$this->assertEquals(60.0, $result);
// Unknown (0)
$result = $this->calculator->calculate($formula, ['gender' => 0]);
$this->assertEquals(50.0, $result);
}
/**
* Test formula with gender variable (string values)
*/
public function testFormulaWithGenderString() {
$formula = '50 + {gender} * 10';
// String values
$result = $this->calculator->calculate($formula, ['gender' => 'male']);
$this->assertEquals(70.0, $result);
$result = $this->calculator->calculate($formula, ['gender' => 'female']);
$this->assertEquals(60.0, $result);
$result = $this->calculator->calculate($formula, ['gender' => 'unknown']);
$this->assertEquals(50.0, $result);
}
/**
* Test mathematical functions
*/
public function testMathFunctions() {
// Square root
$result = $this->calculator->calculate('sqrt(16)');
$this->assertEquals(4.0, $result);
// Sine
$result = $this->calculator->calculate('sin(pi/2)');
$this->assertEqualsWithDelta(1.0, $result, 0.0001);
// Cosine
$result = $this->calculator->calculate('cos(0)');
$this->assertEquals(1.0, $result);
// Logarithm
$result = $this->calculator->calculate('log(100)');
$this->assertEqualsWithDelta(4.60517, $result, 0.0001);
// Natural log (ln)
$result = $this->calculator->calculate('ln(2.71828)');
$this->assertEqualsWithDelta(1.0, $result, 0.0001);
// Exponential
$result = $this->calculator->calculate('exp(1)');
$this->assertEqualsWithDelta(2.71828, $result, 0.0001);
}
/**
* Test formula validation
*/
public function testFormulaValidation() {
// Valid formula
$validation = $this->calculator->validate('{result} * 2 + 5');
$this->assertTrue($validation['valid']);
$this->assertNull($validation['error']);
// Invalid formula
$validation = $this->calculator->validate('{result} * * 2');
$this->assertFalse($validation['valid']);
$this->assertNotNull($validation['error']);
}
/**
* Test variable extraction
*/
public function testExtractVariables() {
$formula = '{result} * {factor} + {gender} - {age}';
$variables = $this->calculator->extractVariables($formula);
$this->assertEquals(['result', 'factor', 'gender', 'age'], $variables);
}
/**
* Test missing variable error
*/
public function testMissingVariableError() {
$this->expectException(\Exception::class);
$this->expectExceptionMessage("Missing variable value for: missing_var");
$this->calculator->calculate('{result} + {missing_var}', ['result' => 10]);
}
/**
* Test invalid formula syntax error
*/
public function testInvalidFormulaError() {
$this->expectException(\Exception::class);
$this->expectExceptionMessage("Invalid formula");
$this->calculator->calculate('1 + * 2');
}
/**
* Test complex formula with multiple variables
*/
public function testComplexFormula() {
// Complex formula: (result * factor / 100) + (gender * 5) - (age * 0.1)
$formula = '({result} * {factor} / 100) + ({gender} * 5) - ({age} * 0.1)';
$variables = [
'result' => 200,
'factor' => 10,
'gender' => 2, // Male
'age' => 30
];
// Expected: (200 * 10 / 100) + (2 * 5) - (30 * 0.1) = 20 + 10 - 3 = 27
$result = $this->calculator->calculate($formula, $variables);
$this->assertEquals(27.0, $result);
}
/**
* Test calculation from TestDefCal definition
*/
public function testCalculateFromDefinition() {
$calcDef = [
'FormulaCode' => '{result} * {factor} + 10',
'Factor' => 2,
];
$testValues = [
'result' => 50,
];
// Expected: 50 * 2 + 10 = 110
$result = $this->calculator->calculateFromDefinition($calcDef, $testValues);
$this->assertEquals(110.0, $result);
}
/**
* Test calculation with all optional variables
*/
public function testCalculateWithAllVariables() {
$calcDef = [
'FormulaCode' => '{result} + {factor} + {gender} + {age} + {ref_low} + {ref_high}',
'Factor' => 5,
];
$testValues = [
'result' => 10,
'gender' => 1,
'age' => 25,
'ref_low' => 5,
'ref_high' => 15,
];
// Expected: 10 + 5 + 1 + 25 + 5 + 15 = 61
$result = $this->calculator->calculateFromDefinition($calcDef, $testValues);
$this->assertEquals(61.0, $result);
}
/**
* Test empty formula error
*/
public function testEmptyFormulaError() {
$this->expectException(\Exception::class);
$this->expectExceptionMessage("No formula defined");
$calcDef = [
'FormulaCode' => '',
'Factor' => 1,
];
$this->calculator->calculateFromDefinition($calcDef, ['result' => 10]);
}
/**
* Test implicit multiplication
*/
public function testImplicitMultiplication() {
// math-parser supports implicit multiplication (2x means 2*x)
$result = $this->calculator->calculate('2*3');
$this->assertEquals(6.0, $result);
}
/**
* Test decimal calculations
*/
public function testDecimalCalculations() {
$formula = '{result} / 3';
$result = $this->calculator->calculate($formula, ['result' => 10]);
$this->assertEqualsWithDelta(3.33333, $result, 0.0001);
}
}

View File

@ -299,11 +299,11 @@ class TestsControllerTest extends CIUnitTestCase
'details' => [
'DisciplineID' => 1,
'DepartmentID' => 1,
'FormulaInput' => 'WEIGHT,HEIGHT',
'FormulaCode' => 'WEIGHT/(HEIGHT/100)^2',
'Unit1' => 'kg/m2',
'Decimal' => 1
]
],
'members' => []
];
$result = $this->withHeaders(['Cookie' => 'token=' . $this->token])

View File

@ -104,7 +104,6 @@ class TestDefModelsTest extends CIUnitTestCase
$this->assertContains('TestSiteID', $allowedFields);
$this->assertContains('DisciplineID', $allowedFields);
$this->assertContains('DepartmentID', $allowedFields);
$this->assertContains('FormulaInput', $allowedFields);
$this->assertContains('FormulaCode', $allowedFields);
$this->assertContains('RefType', $allowedFields);
$this->assertContains('Unit1', $allowedFields);