feat: add calculator API support for test formulas and update docs
This commit is contained in:
parent
ad8e1cc977
commit
911846592f
1
.serena/.gitignore
vendored
1
.serena/.gitignore
vendored
@ -1 +1,2 @@
|
||||
/cache
|
||||
/project.local.yml
|
||||
|
||||
@ -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
|
||||
@ -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`
|
||||
@ -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']);
|
||||
```
|
||||
@ -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]';
|
||||
}
|
||||
```
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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.
|
||||
```
|
||||
@ -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`
|
||||
43
.serena/memories/style_and_conventions.md
Normal file
43
.serena/memories/style_and_conventions.md
Normal 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.
|
||||
@ -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`.
|
||||
@ -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
|
||||
@ -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).
|
||||
@ -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 read‑only.
|
||||
# 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:
|
||||
|
||||
152
app/Controllers/CalculatorController.php
Normal file
152
app/Controllers/CalculatorController.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,7 +11,6 @@ class TestDefCalModel extends BaseModel {
|
||||
'TestSiteID',
|
||||
'DisciplineID',
|
||||
'DepartmentID',
|
||||
'FormulaInput',
|
||||
'FormulaCode',
|
||||
'RefType',
|
||||
'Unit1',
|
||||
|
||||
170
app/Services/CalculatorService.php
Normal file
170
app/Services/CalculatorService.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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
55
composer.lock
generated
@ -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
BIN
composer.phar
Normal file
Binary file not shown.
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
250
tests/feature/Calculator/CalculatorTest.php
Normal file
250
tests/feature/Calculator/CalculatorTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -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])
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user