diff --git a/.serena/.gitignore b/.serena/.gitignore index 14d86ad..2e510af 100644 --- a/.serena/.gitignore +++ b/.serena/.gitignore @@ -1 +1,2 @@ /cache +/project.local.yml diff --git a/.serena/memories/architecture_structure.md b/.serena/memories/architecture_structure.md deleted file mode 100644 index 8b128f3..0000000 --- a/.serena/memories/architecture_structure.md +++ /dev/null @@ -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 diff --git a/.serena/memories/code_conventions.md b/.serena/memories/code_conventions.md deleted file mode 100644 index 01fe9a5..0000000 --- a/.serena/memories/code_conventions.md +++ /dev/null @@ -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 -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` -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` diff --git a/.serena/memories/code_style_and_conventions.md b/.serena/memories/code_style_and_conventions.md deleted file mode 100644 index 87659fa..0000000 --- a/.serena/memories/code_style_and_conventions.md +++ /dev/null @@ -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 -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 -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 -withBodyFormat('json')->post($this->endpoint, $payload); - $result->assertStatus(201); - } -} -``` - -**Test Naming:** `test` (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']); -``` diff --git a/.serena/memories/code_style_conventions.md b/.serena/memories/code_style_conventions.md deleted file mode 100644 index 03f96e3..0000000 --- a/.serena/memories/code_style_conventions.md +++ /dev/null @@ -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 -` for complex arrays when clear - -## Controllers Pattern - -### Standard Controller Structure -```php -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 -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 - '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]'; -} -``` diff --git a/.serena/memories/equipmentlist/seeder-merged.md b/.serena/memories/equipmentlist/seeder-merged.md deleted file mode 100644 index 49bb6e1..0000000 --- a/.serena/memories/equipmentlist/seeder-merged.md +++ /dev/null @@ -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) \ No newline at end of file diff --git a/.serena/memories/equipmentlist/seeder.md b/.serena/memories/equipmentlist/seeder.md deleted file mode 100644 index ac1c278..0000000 --- a/.serena/memories/equipmentlist/seeder.md +++ /dev/null @@ -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) \ No newline at end of file diff --git a/.serena/memories/important_patterns.md b/.serena/memories/important_patterns.md deleted file mode 100644 index cc83f91..0000000 --- a/.serena/memories/important_patterns.md +++ /dev/null @@ -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 -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. -``` diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md index 05b1b53..c1a0095 100644 --- a/.serena/memories/project_overview.md +++ b/.serena/memories/project_overview.md @@ -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` \ No newline at end of file diff --git a/.serena/memories/style_and_conventions.md b/.serena/memories/style_and_conventions.md new file mode 100644 index 0000000..5179848 --- /dev/null +++ b/.serena/memories/style_and_conventions.md @@ -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`. +- 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. \ No newline at end of file diff --git a/.serena/memories/suggested_commands.md b/.serena/memories/suggested_commands.md index a3b0392..e406b0e 100644 --- a/.serena/memories/suggested_commands.md +++ b/.serena/memories/suggested_commands.md @@ -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 ` +- Create model: `php spark make:model ` +- Create controller: `php spark make:controller ` +- 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 "" -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`. \ No newline at end of file diff --git a/.serena/memories/task_completion.md b/.serena/memories/task_completion.md deleted file mode 100644 index 1374db8..0000000 --- a/.serena/memories/task_completion.md +++ /dev/null @@ -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` - -## 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 diff --git a/.serena/memories/task_completion_checklist.md b/.serena/memories/task_completion_checklist.md index 70080d8..e89d338 100644 --- a/.serena/memories/task_completion_checklist.md +++ b/.serena/memories/task_completion_checklist.md @@ -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). \ No newline at end of file diff --git a/.serena/project.yml b/.serena/project.yml index 6f908a6..6d9bc7e 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -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: diff --git a/app/Controllers/CalculatorController.php b/app/Controllers/CalculatorController.php new file mode 100644 index 0000000..57c20f1 --- /dev/null +++ b/app/Controllers/CalculatorController.php @@ -0,0 +1,152 @@ +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); + } + } +} diff --git a/app/Controllers/Test/TestsController.php b/app/Controllers/Test/TestsController.php index a6e79ab..fff2ffc 100644 --- a/app/Controllers/Test/TestsController.php +++ b/app/Controllers/Test/TestsController.php @@ -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) diff --git a/app/Database/Migrations/2026-01-01-000004_CreateTestDefinitions.php b/app/Database/Migrations/2026-01-01-000004_CreateTestDefinitions.php index 219fcdb..40b79ed 100644 --- a/app/Database/Migrations/2026-01-01-000004_CreateTestDefinitions.php +++ b/app/Database/Migrations/2026-01-01-000004_CreateTestDefinitions.php @@ -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], diff --git a/app/Database/Seeds/TestSeeder.php b/app/Database/Seeds/TestSeeder.php index d71fd9d..0234af7 100644 --- a/app/Database/Seeds/TestSeeder.php +++ b/app/Database/Seeds/TestSeeder.php @@ -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], diff --git a/app/Models/OrderTest/OrderTestModel.php b/app/Models/OrderTest/OrderTestModel.php index 0b94f36..6d165b6 100644 --- a/app/Models/OrderTest/OrderTestModel.php +++ b/app/Models/OrderTest/OrderTestModel.php @@ -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); } } } diff --git a/app/Models/Test/TestDefCalModel.php b/app/Models/Test/TestDefCalModel.php index d32b51b..04b9f57 100644 --- a/app/Models/Test/TestDefCalModel.php +++ b/app/Models/Test/TestDefCalModel.php @@ -11,7 +11,6 @@ class TestDefCalModel extends BaseModel { 'TestSiteID', 'DisciplineID', 'DepartmentID', - 'FormulaInput', 'FormulaCode', 'RefType', 'Unit1', diff --git a/app/Services/CalculatorService.php b/app/Services/CalculatorService.php new file mode 100644 index 0000000..e6e88fc --- /dev/null +++ b/app/Services/CalculatorService.php @@ -0,0 +1,170 @@ + 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); + } +} diff --git a/composer.json b/composer.json index 902adb9..733a0da 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 2effc41..8d0b44d 100644 --- a/composer.lock +++ b/composer.lock @@ -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" } diff --git a/composer.phar b/composer.phar new file mode 100644 index 0000000..3d1b983 Binary files /dev/null and b/composer.phar differ diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index 9d35699..314c9c2 100644 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -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 diff --git a/public/components/schemas/tests.yaml b/public/components/schemas/tests.yaml index 3ab7fa8..285fbcf 100644 --- a/public/components/schemas/tests.yaml +++ b/public/components/schemas/tests.yaml @@ -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 diff --git a/tests/feature/Calculator/CalculatorTest.php b/tests/feature/Calculator/CalculatorTest.php new file mode 100644 index 0000000..03ef7e5 --- /dev/null +++ b/tests/feature/Calculator/CalculatorTest.php @@ -0,0 +1,250 @@ +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); + } +} diff --git a/tests/feature/TestsControllerTest.php b/tests/feature/TestsControllerTest.php index 35c999c..813673f 100644 --- a/tests/feature/TestsControllerTest.php +++ b/tests/feature/TestsControllerTest.php @@ -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]) diff --git a/tests/unit/TestDef/TestDefModelsTest.php b/tests/unit/TestDef/TestDefModelsTest.php index a04b848..fc3fb47 100644 --- a/tests/unit/TestDef/TestDefModelsTest.php +++ b/tests/unit/TestDef/TestDefModelsTest.php @@ -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);