diff --git a/.cocoindex_code/cocoindex.db/mdb/data.mdb b/.cocoindex_code/cocoindex.db/mdb/data.mdb new file mode 100644 index 0000000..fe49e35 Binary files /dev/null and b/.cocoindex_code/cocoindex.db/mdb/data.mdb differ diff --git a/.cocoindex_code/cocoindex.db/mdb/lock.mdb b/.cocoindex_code/cocoindex.db/mdb/lock.mdb new file mode 100644 index 0000000..263357b Binary files /dev/null and b/.cocoindex_code/cocoindex.db/mdb/lock.mdb differ diff --git a/.cocoindex_code/settings.yml b/.cocoindex_code/settings.yml new file mode 100644 index 0000000..c910c16 --- /dev/null +++ b/.cocoindex_code/settings.yml @@ -0,0 +1,41 @@ +exclude_patterns: +- '**/.*' +- '**/__pycache__' +- '**/node_modules' +- '**/target' +- '**/build/assets' +- '**/dist' +- '**/vendor/*.*/*' +- '**/vendor/*' +- '**/.cocoindex_code' +include_patterns: +- '**/*.py' +- '**/*.pyi' +- '**/*.js' +- '**/*.jsx' +- '**/*.ts' +- '**/*.tsx' +- '**/*.mjs' +- '**/*.cjs' +- '**/*.rs' +- '**/*.go' +- '**/*.java' +- '**/*.c' +- '**/*.h' +- '**/*.cpp' +- '**/*.hpp' +- '**/*.cc' +- '**/*.cxx' +- '**/*.hxx' +- '**/*.hh' +- '**/*.cs' +- '**/*.sql' +- '**/*.sh' +- '**/*.bash' +- '**/*.zsh' +- '**/*.md' +- '**/*.mdx' +- '**/*.txt' +- '**/*.rst' +- '**/*.php' +- '**/*.lua' diff --git a/.cocoindex_code/target_sqlite.db b/.cocoindex_code/target_sqlite.db new file mode 100644 index 0000000..88fa5f4 Binary files /dev/null and b/.cocoindex_code/target_sqlite.db differ diff --git a/.gitignore b/.gitignore index 462bee8..6327b29 100644 --- a/.gitignore +++ b/.gitignore @@ -124,4 +124,5 @@ _modules/* /results/ /phpunit*.xml -/public/.htaccess \ No newline at end of file +/public/.htaccess + diff --git a/.serena/.gitignore b/.serena/.gitignore deleted file mode 100644 index 32a4ef9..0000000 --- a/.serena/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/cache -/project.local.yml diff --git a/.serena/memories/project_overview.md b/.serena/memories/project_overview.md deleted file mode 100644 index 938f590..0000000 --- a/.serena/memories/project_overview.md +++ /dev/null @@ -1,32 +0,0 @@ -# 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. - -## 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`. - -## 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` -- `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 deleted file mode 100644 index c6a8431..0000000 --- a/.serena/memories/style_and_conventions.md +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index b57344b..0000000 --- a/.serena/memories/suggested_commands.md +++ /dev/null @@ -1,32 +0,0 @@ -# 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` - -## 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` - -## OpenAPI Docs Commands -- Rebundle API docs after YAML changes: `node public/bundle-api-docs.js` - -## 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` - -## 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_checklist.md b/.serena/memories/task_completion_checklist.md deleted file mode 100644 index 3c69ed9..0000000 --- a/.serena/memories/task_completion_checklist.md +++ /dev/null @@ -1,9 +0,0 @@ -# Task Completion Checklist -When finishing a coding change in CLQMS: - -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 deleted file mode 100644 index 2db9369..0000000 --- a/.serena/project.yml +++ /dev/null @@ -1,135 +0,0 @@ -# the name by which the project can be referenced within Serena -project_name: "clqms01-be" - - -# list of languages for which language servers are started; choose from: -# al bash clojure cpp csharp -# csharp_omnisharp dart elixir elm erlang -# fortran fsharp go groovy haskell -# java julia kotlin lua markdown -# matlab nix pascal perl php -# php_phpactor powershell python python_jedi r -# rego ruby ruby_solargraph rust scala -# swift terraform toml typescript typescript_vts -# vue yaml zig -# (This list may be outdated. For the current list, see values of Language enum here: -# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py -# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.) -# Note: -# - For C, use cpp -# - For JavaScript, use typescript -# - For Free Pascal/Lazarus, use pascal -# Special requirements: -# Some languages require additional setup/installations. -# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers -# When using multiple languages, the first language server that supports a given file will be used for that file. -# The first language is the default language and the respective language server will be used as a fallback. -# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored. -languages: -- php - -# the encoding used by text files in the project -# 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 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 -# If set to true, all editing tools will be disabled and attempts to use them will result in an error -# Added on 2025-04-18 -read_only: false - -# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. -# Below is the complete list of tools for convenience. -# To make sure you have the latest list of tools, and to view their descriptions, -# execute `uv run scripts/print_tool_overview.py`. -# -# * `activate_project`: Activates a project by name. -# * `check_onboarding_performed`: Checks whether project onboarding was already performed. -# * `create_text_file`: Creates/overwrites a file in the project directory. -# * `delete_lines`: Deletes a range of lines within a file. -# * `delete_memory`: Deletes a memory from Serena's project-specific memory store. -# * `execute_shell_command`: Executes a shell command. -# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. -# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). -# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). -# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. -# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. -# * `initial_instructions`: Gets the initial instructions for the current project. -# Should only be used in settings where the system prompt cannot be set, -# e.g. in clients you have no control over, like Claude Desktop. -# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. -# * `insert_at_line`: Inserts content at a given line in a file. -# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. -# * `list_dir`: Lists files and directories in the given directory (optionally with recursion). -# * `list_memories`: Lists memories in Serena's project-specific memory store. -# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). -# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). -# * `read_file`: Reads a file within the project directory. -# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. -# * `remove_project`: Removes a project from the Serena configuration. -# * `replace_lines`: Replaces a range of lines within a file with new content. -# * `replace_symbol_body`: Replaces the full definition of a symbol. -# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. -# * `search_for_pattern`: Performs a search for a pattern in the project. -# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. -# * `switch_modes`: Activates modes by providing a list of their names -# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. -# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. -# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. -# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. -excluded_tools: [] - -# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default) -included_optional_tools: [] - -# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools. -# This cannot be combined with non-empty excluded_tools or included_optional_tools. -fixed_tools: [] - -# list of mode names to that are always to be included in the set of active modes -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this setting overrides the global configuration. -# Set this to [] to disable base modes for this project. -# Set this to a list of mode names to always include the respective modes for this project. -base_modes: - -# list of mode names that are to be activated by default. -# The full set of modes to be activated is base_modes + default_modes. -# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply. -# Otherwise, this overrides the setting from the global configuration (serena_config.yml). -# This setting can, in turn, be overridden by CLI parameters (--mode). -default_modes: - -# initial prompt for the project. It will always be given to the LLM upon activating the project -# (contrary to the memories, which are loaded on demand). -initial_prompt: "" - -# 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: - -# 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: [] diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 89974c7..690737b 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -17,16 +17,16 @@ $routes->group('api', ['filter' => 'auth'], function ($routes) { $routes->get('dashboard', 'DashboardController::index'); $routes->get('sample', 'SampleController::index'); - // Results CRUD - $routes->group('result', function ($routes) { - $routes->get('/', 'ResultController::index'); - $routes->get('(:num)', 'ResultController::show/$1'); - $routes->patch('(:num)', 'ResultController::update/$1'); - $routes->delete('(:num)', 'ResultController::delete/$1'); - }); + // Results CRUD + $routes->group('result', function ($routes) { + $routes->get('/', 'ResultController::index'); + $routes->get('(:num)', 'ResultController::show/$1'); + $routes->patch('(:num)', 'ResultController::update/$1'); + $routes->delete('(:num)', 'ResultController::delete/$1'); + }); - // Reports - $routes->get('report/(:num)', 'ReportController::view/$1'); + // Reports + $routes->get('report/(:num)', 'ReportController::view/$1'); }); @@ -58,7 +58,7 @@ $routes->group('api', function ($routes) { $routes->post('/', 'Patient\PatientController::create'); $routes->get('(:num)', 'Patient\PatientController::show/$1'); $routes->delete('/', 'Patient\PatientController::delete'); - $routes->patch('(:num)', 'Patient\PatientController::update/$1'); + $routes->patch('(:num)', 'Patient\PatientController::update/$1'); $routes->get('check', 'Patient\PatientController::patientCheck'); }); @@ -69,14 +69,14 @@ $routes->group('api', function ($routes) { $routes->get('patient/(:num)', 'PatVisitController::showByPatient/$1'); $routes->get('(:any)', 'PatVisitController::show/$1'); $routes->delete('/', 'PatVisitController::delete'); - $routes->patch('(:any)', 'PatVisitController::update/$1'); + $routes->patch('(:any)', 'PatVisitController::update/$1'); }); $routes->group('patvisitadt', function ($routes) { $routes->get('visit/(:num)', 'PatVisitController::getADTByVisit/$1'); $routes->get('(:num)', 'PatVisitController::showADT/$1'); $routes->post('/', 'PatVisitController::createADT'); - $routes->patch('(:num)', 'PatVisitController::updateADT/$1'); + $routes->patch('(:num)', 'PatVisitController::updateADT/$1'); $routes->delete('/', 'PatVisitController::deleteADT'); }); @@ -87,7 +87,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'LocationController::index'); $routes->get('(:num)', 'LocationController::show/$1'); $routes->post('/', 'LocationController::create'); - $routes->patch('(:num)', 'LocationController::update/$1'); + $routes->patch('(:num)', 'LocationController::update/$1'); $routes->delete('/', 'LocationController::delete'); }); @@ -96,7 +96,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Contact\ContactController::index'); $routes->get('(:num)', 'Contact\ContactController::show/$1'); $routes->post('/', 'Contact\ContactController::create'); - $routes->patch('(:num)', 'Contact\ContactController::update/$1'); + $routes->patch('(:num)', 'Contact\ContactController::update/$1'); $routes->delete('/', 'Contact\ContactController::delete'); }); @@ -104,7 +104,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Contact\OccupationController::index'); $routes->get('(:num)', 'Contact\OccupationController::show/$1'); $routes->post('/', 'Contact\OccupationController::create'); - $routes->patch('(:num)', 'Contact\OccupationController::update/$1'); + $routes->patch('(:num)', 'Contact\OccupationController::update/$1'); //$routes->delete('/', 'Contact\OccupationController::delete'); }); @@ -112,7 +112,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Contact\MedicalSpecialtyController::index'); $routes->get('(:num)', 'Contact\MedicalSpecialtyController::show/$1'); $routes->post('/', 'Contact\MedicalSpecialtyController::create'); - $routes->patch('(:num)', 'Contact\MedicalSpecialtyController::update/$1'); + $routes->patch('(:num)', 'Contact\MedicalSpecialtyController::update/$1'); }); // Lib ValueSet (file-based) @@ -152,14 +152,15 @@ $routes->group('api', function ($routes) { }); }); - $routes->post('calc/(:any)', 'CalculatorController::calculateByCodeOrName/$1'); + $routes->post('calc/testsite/(:num)', 'CalculatorController::calculateByTestSite/$1'); + $routes->post('calc/testcode/(:any)', 'CalculatorController::calculateByCodeOrName/$1'); // Counter $routes->group('counter', function ($routes) { $routes->get('/', 'CounterController::index'); $routes->get('(:num)', 'CounterController::show/$1'); $routes->post('/', 'CounterController::create'); - $routes->patch('(:num)', 'CounterController::update/$1'); + $routes->patch('(:num)', 'CounterController::update/$1'); $routes->delete('/', 'CounterController::delete'); }); @@ -177,7 +178,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Organization\AccountController::index'); $routes->get('(:num)', 'Organization\AccountController::show/$1'); $routes->post('/', 'Organization\AccountController::create'); - $routes->patch('(:num)', 'Organization\AccountController::update/$1'); + $routes->patch('(:num)', 'Organization\AccountController::update/$1'); $routes->delete('/', 'Organization\AccountController::delete'); }); @@ -186,7 +187,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Organization\SiteController::index'); $routes->get('(:num)', 'Organization\SiteController::show/$1'); $routes->post('/', 'Organization\SiteController::create'); - $routes->patch('(:num)', 'Organization\SiteController::update/$1'); + $routes->patch('(:num)', 'Organization\SiteController::update/$1'); $routes->delete('/', 'Organization\SiteController::delete'); }); @@ -195,7 +196,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Organization\DisciplineController::index'); $routes->get('(:num)', 'Organization\DisciplineController::show/$1'); $routes->post('/', 'Organization\DisciplineController::create'); - $routes->patch('(:num)', 'Organization\DisciplineController::update/$1'); + $routes->patch('(:num)', 'Organization\DisciplineController::update/$1'); $routes->delete('/', 'Organization\DisciplineController::delete'); }); @@ -204,7 +205,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Organization\DepartmentController::index'); $routes->get('(:num)', 'Organization\DepartmentController::show/$1'); $routes->post('/', 'Organization\DepartmentController::create'); - $routes->patch('(:num)', 'Organization\DepartmentController::update/$1'); + $routes->patch('(:num)', 'Organization\DepartmentController::update/$1'); $routes->delete('/', 'Organization\DepartmentController::delete'); }); @@ -213,7 +214,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Organization\WorkstationController::index'); $routes->get('(:num)', 'Organization\WorkstationController::show/$1'); $routes->post('/', 'Organization\WorkstationController::create'); - $routes->patch('(:num)', 'Organization\WorkstationController::update/$1'); + $routes->patch('(:num)', 'Organization\WorkstationController::update/$1'); $routes->delete('/', 'Organization\WorkstationController::delete'); }); @@ -222,7 +223,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Organization\HostAppController::index'); $routes->get('(:any)', 'Organization\HostAppController::show/$1'); $routes->post('/', 'Organization\HostAppController::create'); - $routes->patch('(:any)', 'Organization\HostAppController::update/$1'); + $routes->patch('(:any)', 'Organization\HostAppController::update/$1'); $routes->delete('/', 'Organization\HostAppController::delete'); }); @@ -231,7 +232,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Organization\HostComParaController::index'); $routes->get('(:any)', 'Organization\HostComParaController::show/$1'); $routes->post('/', 'Organization\HostComParaController::create'); - $routes->patch('(:any)', 'Organization\HostComParaController::update/$1'); + $routes->patch('(:any)', 'Organization\HostComParaController::update/$1'); $routes->delete('/', 'Organization\HostComParaController::delete'); }); @@ -240,7 +241,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Organization\CodingSysController::index'); $routes->get('(:num)', 'Organization\CodingSysController::show/$1'); $routes->post('/', 'Organization\CodingSysController::create'); - $routes->patch('(:num)', 'Organization\CodingSysController::update/$1'); + $routes->patch('(:num)', 'Organization\CodingSysController::update/$1'); $routes->delete('/', 'Organization\CodingSysController::delete'); }); }); @@ -250,18 +251,18 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Infrastructure\EquipmentListController::index'); $routes->get('(:num)', 'Infrastructure\EquipmentListController::show/$1'); $routes->post('/', 'Infrastructure\EquipmentListController::create'); - $routes->patch('(:num)', 'Infrastructure\EquipmentListController::update/$1'); + $routes->patch('(:num)', 'Infrastructure\EquipmentListController::update/$1'); $routes->delete('/', 'Infrastructure\EquipmentListController::delete'); }); - // Users - $routes->group('user', function ($routes) { - $routes->get('/', 'User\UserController::index'); - $routes->get('(:num)', 'User\UserController::show/$1'); - $routes->post('/', 'User\UserController::create'); - $routes->patch('(:num)', 'User\UserController::update/$1'); - $routes->delete('(:num)', 'User\UserController::delete/$1'); - }); + // Users + $routes->group('user', function ($routes) { + $routes->get('/', 'User\UserController::index'); + $routes->get('(:num)', 'User\UserController::show/$1'); + $routes->post('/', 'User\UserController::create'); + $routes->patch('(:num)', 'User\UserController::update/$1'); + $routes->delete('(:num)', 'User\UserController::delete/$1'); + }); // Specimen $routes->group('specimen', function ($routes) { @@ -270,54 +271,54 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Specimen\ContainerDefController::index'); $routes->get('(:num)', 'Specimen\ContainerDefController::show/$1'); $routes->post('/', 'Specimen\ContainerDefController::create'); - $routes->patch('(:num)', 'Specimen\ContainerDefController::update/$1'); + $routes->patch('(:num)', 'Specimen\ContainerDefController::update/$1'); }); $routes->group('containerdef', function ($routes) { $routes->get('/', 'Specimen\ContainerDefController::index'); $routes->get('(:num)', 'Specimen\ContainerDefController::show/$1'); $routes->post('/', 'Specimen\ContainerDefController::create'); - $routes->patch('(:num)', 'Specimen\ContainerDefController::update/$1'); + $routes->patch('(:num)', 'Specimen\ContainerDefController::update/$1'); }); $routes->group('prep', function ($routes) { $routes->get('/', 'Specimen\SpecimenPrepController::index'); $routes->get('(:num)', 'Specimen\SpecimenPrepController::show/$1'); $routes->post('/', 'Specimen\SpecimenPrepController::create'); - $routes->patch('(:num)', 'Specimen\SpecimenPrepController::update/$1'); + $routes->patch('(:num)', 'Specimen\SpecimenPrepController::update/$1'); }); $routes->group('status', function ($routes) { $routes->get('/', 'Specimen\SpecimenStatusController::index'); $routes->get('(:num)', 'Specimen\SpecimenStatusController::show/$1'); $routes->post('/', 'Specimen\SpecimenStatusController::create'); - $routes->patch('(:num)', 'Specimen\SpecimenStatusController::update/$1'); + $routes->patch('(:num)', 'Specimen\SpecimenStatusController::update/$1'); }); $routes->group('collection', function ($routes) { $routes->get('/', 'Specimen\SpecimenCollectionController::index'); $routes->get('(:num)', 'Specimen\SpecimenCollectionController::show/$1'); $routes->post('/', 'Specimen\SpecimenCollectionController::create'); - $routes->patch('(:num)', 'Specimen\SpecimenCollectionController::update/$1'); + $routes->patch('(:num)', 'Specimen\SpecimenCollectionController::update/$1'); }); $routes->get('/', 'Specimen\SpecimenController::index'); $routes->get('(:num)', 'Specimen\SpecimenController::show/$1'); $routes->post('/', 'Specimen\SpecimenController::create'); - $routes->patch('(:num)', 'Specimen\SpecimenController::update/$1'); + $routes->patch('(:num)', 'Specimen\SpecimenController::update/$1'); $routes->delete('(:num)', 'Specimen\SpecimenController::delete/$1'); }); - // Test Mapping + // Test $routes->group('test', function ($routes) { - $routes->get('/', 'Test\TestsController::index'); + $routes->get('/', 'Test\TestsController::index'); $routes->get('(:num)', 'Test\TestsController::show/$1'); $routes->post('/', 'Test\TestsController::create'); - $routes->patch('(:num)', 'Test\TestsController::update/$1'); + $routes->patch('(:num)', 'Test\TestsController::update/$1'); $routes->group('testmap', function ($routes) { $routes->get('/', 'Test\TestMapController::index'); $routes->get('(:num)', 'Test\TestMapController::show/$1'); $routes->post('/', 'Test\TestMapController::create'); - $routes->patch('(:num)', 'Test\TestMapController::update/$1'); + $routes->patch('(:num)', 'Test\TestMapController::update/$1'); $routes->delete('/', 'Test\TestMapController::delete'); // Filter routes @@ -328,7 +329,7 @@ $routes->group('api', function ($routes) { $routes->get('/', 'Test\TestMapDetailController::index'); $routes->get('(:num)', 'Test\TestMapDetailController::show/$1'); $routes->post('/', 'Test\TestMapDetailController::create'); - $routes->patch('(:num)', 'Test\TestMapDetailController::update/$1'); + $routes->patch('(:num)', 'Test\TestMapDetailController::update/$1'); $routes->delete('/', 'Test\TestMapDetailController::delete'); $routes->get('by-testmap/(:num)', 'Test\TestMapDetailController::showByTestMap/$1'); $routes->post('batch', 'Test\TestMapDetailController::batchCreate'); @@ -343,13 +344,13 @@ $routes->group('api', function ($routes) { $routes->get('/', 'OrderTestController::index'); $routes->get('(:any)', 'OrderTestController::show/$1'); $routes->post('/', 'OrderTestController::create'); - $routes->patch('(:any)', 'OrderTestController::update/$1'); + $routes->patch('(:any)', 'OrderTestController::update/$1'); $routes->delete('/', 'OrderTestController::delete'); $routes->post('status', 'OrderTestController::updateStatus'); }); - // Rules - $routes->group('rule', function ($routes) { + // Rules + $routes->group('rule', ['filter' => 'auth'], function ($routes) { $routes->get('/', 'Rule\RuleController::index'); $routes->get('(:num)', 'Rule\RuleController::show/$1'); $routes->post('/', 'Rule\RuleController::create'); @@ -358,21 +359,22 @@ $routes->group('api', function ($routes) { $routes->post('validate', 'Rule\RuleController::validateExpr'); $routes->post('compile', 'Rule\RuleController::compile'); }); + // Demo/Test Routes (No Auth) -$routes->group('api/demo', function ($routes) { - $routes->post('order', 'Test\DemoOrderController::createDemoOrder'); - $routes->get('order', 'Test\DemoOrderController::listDemoOrders'); -}); +$routes->group('api/demo', function ($routes) { + $routes->post('order', 'Test\DemoOrderController::createDemoOrder'); + $routes->get('order', 'Test\DemoOrderController::listDemoOrders'); +}); // Edge API - Integration with tiny-edge - $routes->group('edge', function ($routes) { - $routes->post('result', 'EdgeController::results'); - $routes->get('order', 'EdgeController::orders'); - $routes->post('order/(:num)/ack', 'EdgeController::ack/$1'); - $routes->post('status', 'EdgeController::status'); - }); -}); + $routes->group('edge', function ($routes) { + $routes->post('result', 'EdgeController::results'); + $routes->get('order', 'EdgeController::orders'); + $routes->post('order/(:num)/ack', 'EdgeController::ack/$1'); + $routes->post('status', 'EdgeController::status'); + }); +}); // Khusus /* diff --git a/app/Controllers/CalculatorController.php b/app/Controllers/CalculatorController.php index d9b627b..5b7fa5e 100644 --- a/app/Controllers/CalculatorController.php +++ b/app/Controllers/CalculatorController.php @@ -107,9 +107,9 @@ class CalculatorController extends Controller } } - /** - * POST api/calculate/test-site/{testSiteID} - * Calculate using TestDefCal definition + /** + * POST api/calc/testsite/{testSiteID} + * Calculate using TestDefCal definition * * Request: { * "result": 85, @@ -150,9 +150,9 @@ class CalculatorController extends Controller } } - /** - * POST api/calc/{codeOrName} - * Evaluate a configured calculation by its code or name and return only the result map. + /** + * POST api/calc/testcode/{codeOrName} + * Evaluate a configured calculation by its code or name and return only the result map. */ public function calculateByCodeOrName($codeOrName): ResponseInterface { diff --git a/app/Database/Migrations/2026-01-01-000004_CreateTestDefinitions.php b/app/Database/Migrations/2026-01-01-000004_CreateTestDefinitions.php index 38976e9..08146f6 100644 --- a/app/Database/Migrations/2026-01-01-000004_CreateTestDefinitions.php +++ b/app/Database/Migrations/2026-01-01-000004_CreateTestDefinitions.php @@ -9,7 +9,7 @@ class CreateTestDefinitions extends Migration { $this->forge->addField([ 'TestSiteID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'SiteID' => ['type' => 'INT', 'null' => false], - 'TestSiteCode' => ['type' => 'varchar', 'constraint'=> 6, 'null' => false], + 'TestSiteCode' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false], 'TestSiteName' => ['type' => 'varchar', 'constraint'=> 100, 'null' => false], 'TestType' => ['type' => 'VARCHAR', 'constraint' => 10, 'null' => false], 'Description' => ['type' => 'varchar', 'constraint'=> 255, 'null' => true], diff --git a/app/Database/Migrations/2026-01-01-000006_CreateSpecimens.php b/app/Database/Migrations/2026-01-01-000006_CreateSpecimens.php index 52af892..9384100 100644 --- a/app/Database/Migrations/2026-01-01-000006_CreateSpecimens.php +++ b/app/Database/Migrations/2026-01-01-000006_CreateSpecimens.php @@ -85,33 +85,10 @@ class CreateSpecimens extends Migration { $this->forge->addKey('SpcPrpID', true); $this->forge->createTable('specimenprep'); - $this->forge->addField([ - 'SpcLogID'=> ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], - 'TblName' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'RecID' => ['type' => 'INT', 'constraint' => 11, 'null' => true], - 'FldName' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'FldValuePrev'=> ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'UserID' => ['type' => 'INT', 'constraint' => 11, 'null' => true], - 'SiteID' => ['type' => 'INT', 'constraint' => 11, 'null' => true], - 'DIDType' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'DID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'MachineID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'SessionID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'AppID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'ProcessID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'WebPageID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'EventID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'ActivityID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'Reason' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'LogDate' => ['type' => 'Datetime', 'null' => true], - ]); - $this->forge->addKey('SpcLogID', true); - $this->forge->createTable('specimenlog'); - } + } public function down() { - $this->forge->dropTable('specimenlog'); - $this->forge->dropTable('specimenprep'); + $this->forge->dropTable('specimenprep'); $this->forge->dropTable('specimencollection'); $this->forge->dropTable('specimenstatus'); $this->forge->dropTable('specimen'); diff --git a/app/Database/Migrations/2026-01-01-000011_CreatePatientCore.php b/app/Database/Migrations/2026-01-01-000011_CreatePatientCore.php index a7c45c0..764f48d 100644 --- a/app/Database/Migrations/2026-01-01-000011_CreatePatientCore.php +++ b/app/Database/Migrations/2026-01-01-000011_CreatePatientCore.php @@ -95,33 +95,10 @@ class CreatePatientCore extends Migration { $this->forge->addKey('PatRelID', true); $this->forge->createTable('patrelation'); - $this->forge->addField([ - 'PatRegLogID'=> ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], - 'TblName' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'RecID' => ['type' => 'INT', 'constraint' => 11, 'null' => true], - 'FldName' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'FldValuePrev'=> ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'UserID' => ['type' => 'INT', 'constraint' => 11, 'null' => true], - 'SiteID' => ['type' => 'INT', 'constraint' => 11, 'null' => true], - 'DIDType' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'DID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'MachineID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'SessionID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'AppID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'ProcessID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'WebPageID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'EventID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'ActivityID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'Reason' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'LogDate' => ['type' => 'DATETIME', 'null' => true], - ]); - $this->forge->addKey('PatRegLogID', true); - $this->forge->createTable('patreglog'); - } + } public function down() { - $this->forge->dropTable('patreglog'); - $this->forge->dropTable('patrelation'); + $this->forge->dropTable('patrelation'); $this->forge->dropTable('patidt'); $this->forge->dropTable('patcom'); $this->forge->dropTable('patatt'); diff --git a/app/Database/Migrations/2026-01-01-000012_CreatePatientVisits.php b/app/Database/Migrations/2026-01-01-000012_CreatePatientVisits.php index 76c764b..4dab252 100644 --- a/app/Database/Migrations/2026-01-01-000012_CreatePatientVisits.php +++ b/app/Database/Migrations/2026-01-01-000012_CreatePatientVisits.php @@ -52,34 +52,10 @@ class CreatePatientVisits extends Migration { $this->forge->addKey('PVADTID', true); $this->forge->createTable('patvisitadt'); - $this->forge->addField([ - 'PatVisLogID'=> ['type' => 'INT', 'constraint' => 11, 'unsigned' => true, 'auto_increment' => true], - 'TblName' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'RecID' => ['type' => 'INT', 'constraint' => 11, 'null' => true], - 'FldName' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'FldValuePrev'=> ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'Origin' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'UserID' => ['type' => 'INT', 'constraint' => 11, 'null' => true], - 'SiteID' => ['type' => 'INT', 'constraint' => 11, 'null' => true], - 'DIDType' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'DID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'MachineID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'SessionID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'AppID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'ProcessID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'WebPageID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'EventID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'ActivityID' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'Reason' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], - 'LogDate' => ['type' => 'DATETIME', 'null' => true], - ]); - $this->forge->addKey('PatVisLogID', true); - $this->forge->createTable('patvisitlog'); - } + } public function down() { - $this->forge->dropTable('patvisitlog'); - $this->forge->dropTable('patvisitadt'); + $this->forge->dropTable('patvisitadt'); $this->forge->dropTable('patdiag'); $this->forge->dropTable('patvisit'); } diff --git a/app/Database/Seeds/TestSeeder.php b/app/Database/Seeds/TestSeeder.php index 2ddfb48..686df5c 100644 --- a/app/Database/Seeds/TestSeeder.php +++ b/app/Database/Seeds/TestSeeder.php @@ -2,8 +2,9 @@ namespace App\Database\Seeds; -use CodeIgniter\Database\Seeder; -use App\Libraries\ValueSet; +use CodeIgniter\Database\Seeder; +use App\Libraries\ValueSet; +use App\Services\RuleExpressionService; class TestSeeder extends Seeder { @@ -189,22 +190,22 @@ class TestSeeder extends Seeder $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', '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' => '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', 'FormulaCode' => '142 * (({CREA}/{KAPPA}) < 1 ? ({CREA}/{KAPPA}) : 1) ** {ALPHA} * (({CREA}/{KAPPA}) > 1 ? ({CREA}/{KAPPA}) : 1) ** -1.200 * (0.9938 ** {AGE}) * {SEXFAC}', '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', '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"]; + $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 @@ -284,13 +285,25 @@ class TestSeeder extends Seeder $this->db->table('testdefsite')->insert($data); $tIDs['WEIGHT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'AGE', 'TestSiteName' => 'Age', 'TestType' => 'PARAM', 'Description' => 'Usia', 'SeqScr' => '30', 'SeqRpt' => '30', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'years', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; - $this->db->table('testdefsite')->insert($data); - $tIDs['AGE'] = $this->db->insertID(); + $data = ['SiteID' => '1', 'TestSiteCode' => 'AGE', 'TestSiteName' => 'Age', 'TestType' => 'PARAM', 'Description' => 'Usia', 'SeqScr' => '30', 'SeqRpt' => '30', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'years', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; + $this->db->table('testdefsite')->insert($data); + $tIDs['AGE'] = $this->db->insertID(); + + $data = ['SiteID' => '1', 'TestSiteCode' => 'ALPHA', 'TestSiteName' => 'Alpha', 'TestType' => 'PARAM', 'Description' => 'eGFR Alpha', 'SeqScr' => '35', 'SeqRpt' => '35', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '3', 'Method' => '', 'CreateDate' => "$now"]; + $this->db->table('testdefsite')->insert($data); + $tIDs['ALPHA'] = $this->db->insertID(); + + $data = ['SiteID' => '1', 'TestSiteCode' => 'KAPPA', 'TestSiteName' => 'Kappa', 'TestType' => 'PARAM', 'Description' => 'eGFR Kappa', 'SeqScr' => '36', 'SeqRpt' => '36', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => '', 'CreateDate' => "$now"]; + $this->db->table('testdefsite')->insert($data); + $tIDs['KAPPA'] = $this->db->insertID(); + + $data = ['SiteID' => '1', 'TestSiteCode' => 'SEXFAC', 'TestSiteName' => 'Sex Factor', 'TestType' => 'PARAM', 'Description' => 'eGFR Sex Factor', 'SeqScr' => '37', 'SeqRpt' => '37', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '3', 'Method' => '', 'CreateDate' => "$now"]; + $this->db->table('testdefsite')->insert($data); + $tIDs['SEXFAC'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteCode' => 'SYSTL', 'TestSiteName' => 'Systolic BP', 'TestType' => 'PARAM', 'Description' => 'Tekanan Darah Sistolik', 'SeqScr' => '40', 'SeqRpt' => '40', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; - $this->db->table('testdefsite')->insert($data); - $tIDs['SYSTL'] = $this->db->insertID(); + $data = ['SiteID' => '1', 'TestSiteCode' => 'SYSTL', 'TestSiteName' => 'Systolic BP', 'TestType' => 'PARAM', 'Description' => 'Tekanan Darah Sistolik', 'SeqScr' => '40', 'SeqRpt' => '40', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; + $this->db->table('testdefsite')->insert($data); + $tIDs['SYSTL'] = $this->db->insertID(); $data = ['SiteID' => '1', 'TestSiteCode' => 'DIASTL', 'TestSiteName' => 'Diastolic BP', 'TestType' => 'PARAM', 'Description' => 'Tekanan Darah Diastolik', 'SeqScr' => '50', 'SeqRpt' => '50', 'IndentLeft' => '0', 'VisibleScr' => '1', 'VisibleRpt' => '0', 'CountStat' => '0', 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => 'NMRIC', 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); @@ -302,14 +315,71 @@ 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' => '', '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"], - ]); + $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"], + ['TestSiteID' => $tIDs['EGFR'], 'Member' => $tIDs['ALPHA'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['EGFR'], 'Member' => $tIDs['KAPPA'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['EGFR'], 'Member' => $tIDs['SEXFAC'], 'CreateDate' => "$now"], + ]); + + // ======================================== + // RULES: Auto-fill ALPHA/KAPPA by sex + // ======================================== + $ruleExpr = new RuleExpressionService(); + $ruleDefs = [ + [ + 'RuleCode' => 'ALPHA_SEX', + 'RuleName' => 'Set ALPHA from sex', + 'Description' => 'Set ALPHA based on patient sex (M=-0.302, F/other=-0.241)', + 'EventCode' => 'test_created', + 'TestSiteID' => $tIDs['ALPHA'], + 'Expr' => "if(sex('M'); set_result(-0.302); set_result(-0.241))", + ], + [ + 'RuleCode' => 'KAPPA_SEX', + 'RuleName' => 'Set KAPPA from sex', + 'Description' => 'Set KAPPA based on patient sex (M=0.9, F/other=0.7)', + 'EventCode' => 'test_created', + 'TestSiteID' => $tIDs['KAPPA'], + 'Expr' => "if(sex('M'); set_result(0.9); set_result(0.7))", + ], + [ + 'RuleCode' => 'SEXFAC_SEX', + 'RuleName' => 'Set SEXFAC from sex', + 'Description' => 'Set SEXFAC based on patient sex (F=1.012, M/other=1)', + 'EventCode' => 'test_created', + 'TestSiteID' => $tIDs['SEXFAC'], + 'Expr' => "if(sex('F'); set_result(1.012); set_result(1))", + ], + ]; + + foreach ($ruleDefs as $ruleDef) { + $compiled = $ruleExpr->compile($ruleDef['Expr']); + $data = [ + 'RuleCode' => $ruleDef['RuleCode'], + 'RuleName' => $ruleDef['RuleName'], + 'Description' => $ruleDef['Description'], + 'EventCode' => $ruleDef['EventCode'], + 'ConditionExpr' => $ruleDef['Expr'], + 'ConditionExprCompiled' => json_encode($compiled), + 'CreateDate' => $now, + 'StartDate' => null, + 'EndDate' => null, + ]; + $this->db->table('ruledef')->insert($data); + $ruleID = $this->db->insertID(); + + $this->db->table('testrule')->insert([ + 'RuleID' => $ruleID, + 'TestSiteID' => $ruleDef['TestSiteID'], + 'CreateDate' => $now, + ]); + } // ======================================== // TEST MAP - Specimen Mapping diff --git a/app/Services/CalculatorService.php b/app/Services/CalculatorService.php index 138c097..aee3368 100644 --- a/app/Services/CalculatorService.php +++ b/app/Services/CalculatorService.php @@ -1,14 +1,11 @@ 2, ]; - public function __construct() { - $this->parser = new StdMathParser(); - $this->evaluator = new Evaluator(); - } + public function __construct() { + $this->language = new ExpressionLanguage(); + } /** * Calculate formula with variables @@ -37,26 +33,19 @@ class CalculatorService { * @throws \Exception */ public function calculate(string $formula, array $variables = []): ?float { - try { - $normalizedFormula = $this->normalizeFormulaVariables($formula, $variables); - $expression = $this->prepareExpression($normalizedFormula, $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; - } - } - + try { + $normalizedFormula = $this->normalizeFormulaVariables($formula, $variables); + [$expression, $context] = $this->prepareExpression($normalizedFormula, $variables); + + $result = $this->language->evaluate($expression, $context); + + return (float) $result; + } catch (\Throwable $e) { + log_message('error', 'Calculator error: ' . $e->getMessage() . ' | Formula: ' . $formula); + throw new \Exception('Invalid formula: ' . $e->getMessage()); + } + } + /** * Validate formula syntax * @@ -64,15 +53,18 @@ class CalculatorService { * @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()]; - } - } + $formula = $this->normalizeFormulaVariables($formula, []); + $variables = array_fill_keys($this->extractVariables($formula), 1); + + try { + [$expression, $context] = $this->prepareExpression($formula, $variables); + $this->language->evaluate($expression, $context); + + return ['valid' => true, 'error' => null]; + } catch (\Throwable $e) { + return ['valid' => false, 'error' => $e->getMessage()]; + } + } /** * Extract variable names from formula @@ -88,32 +80,50 @@ class CalculatorService { /** * 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; - } + protected function prepareExpression(string $formula, array $variables): array { + $expression = $formula; + $context = []; + $usedNames = []; + + foreach ($variables as $key => $value) { + $placeholder = '{' . $key . '}'; + + if ($key === 'gender') { + $value = $this->normalizeGender($value); + } + + if (!is_numeric($value)) { + throw new \Exception("Variable '{$key}' must be numeric, got: " . var_export($value, true)); + } + + $variableName = $this->getSafeVariableName($key, $usedNames); + $usedNames[] = $variableName; + $context[$variableName] = (float) $value; + + $expression = str_replace($placeholder, $variableName, $expression); + } + + if (preg_match('/\{([^}]+)\}/', $expression, $unreplaced)) { + throw new \Exception("Missing variable value for: {$unreplaced[1]}"); + } + + return [$expression, $context]; + } + + protected function getSafeVariableName(string $key, array $usedNames): string { + $base = preg_replace('/[^A-Za-z0-9_]/', '_', (string) $key); + if ($base === '' || ctype_digit($base[0])) { + $base = 'v_' . $base; + } + + $name = $base; + $suffix = 1; + while (in_array($name, $usedNames, true)) { + $name = $base . '_' . $suffix++; + } + + return $name; + } /** * Normalize formulas that reference raw variable names instead of placeholders. diff --git a/composer.json b/composer.json index df8ac1a..db3e6c2 100644 --- a/composer.json +++ b/composer.json @@ -12,9 +12,8 @@ "require": { "php": "^8.1", "codeigniter4/framework": "^4.0", - "firebase/php-jwt": "^6.11", - "mossadal/math-parser": "^1.3", - "symfony/expression-language": "^7.0" + "firebase/php-jwt": "^6.11", + "symfony/expression-language": "^7.0" }, "require-dev": { "fakerphp/faker": "^1.24", diff --git a/composer.lock b/composer.lock index ffb31d3..5f3848f 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": "b111968eaeab80698adb5ca0eaeeb8c1", + "content-hash": "ad96435c829500be9b0bb553558894ea", "packages": [ { "name": "codeigniter4/framework", @@ -204,57 +204,6 @@ ], "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/cache", "version": "3.0.0", diff --git a/docs/audit-logging.md b/docs/audit-logging.md new file mode 100644 index 0000000..26cfdf8 --- /dev/null +++ b/docs/audit-logging.md @@ -0,0 +1,194 @@ +# Audit Logging Strategy + +## Overview + +This document defines how CLQMS should capture audit and operational logs across four tables: + +- `logpatient` — patient, visit, and ADT activity +- `logorder` — orders, tests, specimens, results, and QC +- `logmaster` — master data and configuration changes +- `logsystem` — sessions, security, import/export, and system operations + +The intent is to audit all domains, including master data changes, and to standardize event capture so reporting and compliance are consistent. + +## Table Ownership + +| Event | Table | +| --- | --- | +| Patient registered/updated/merged | `logpatient` | +| Insurance/consent changed | `logpatient` | +| Patient visit (admit/transfer/discharge) | `logpatient` | +| Order created/cancelled | `logorder` | +| Sample received/rejected | `logorder` | +| Result entered/verified/amended | `logorder` | +| Result released/retracted/corrected | `logorder` | +| QC result recorded | `logorder` | +| Test panel added/removed | `logmaster` | +| Reference range changed | `logmaster` | +| Analyzer config updated | `logmaster` | +| User role changed | `logmaster` | +| User login/logout | `logsystem` | +| Import/export job start/end | `logsystem` | + +## Standard Log Schema (Shared Columns) + +Use a shared schema for all four tables to keep instrumentation and reporting consistent. The legacy names below match existing patterns and can be reused. + +| Column | Description | +| --- | --- | +| `LogID` (PK) | Auto increment primary key per table (e.g., `LogPatientID`) | +| `TblName` | Source table name | +| `RecID` | Record ID of the entity | +| `FldName` | Field name that changed (nullable for bulk events) | +| `FldValuePrev` | Previous value (string or JSON) | +| `FldValueNew` | New value (string or JSON) | +| `UserID` | Acting user ID (nullable for system actions) | +| `SiteID` | Site context | +| `DIDType` | Device identifier type | +| `DID` | Device identifier | +| `MachineID` | Workstation or host identifier | +| `SessionID` | Session identifier | +| `AppID` | Client application ID | +| `ProcessID` | Process/workflow identifier | +| `WebPageID` | UI page/context (nullable) | +| `EventID` | Event code (see catalog) | +| `ActivityID` | Action code (create/update/delete/read/etc.) | +| `Reason` | User/system reason | +| `LogDate` | Timestamp of event | +| `Context` | JSON metadata (optional but recommended) | +| `IpAddress` | Remote IP (optional but recommended) | + +Recommended: keep a JSON string in `Context` for extra details (e.g., route, request id, batch id, error message). Use size limits to avoid oversized rows. + +## Event Catalog + +### logpatient + +**Patient core** + +- Register patient +- Update demographics +- Merge/unmerge/split +- Identity changes (MRN, external identifiers) +- Consent grant/revoke/update +- Insurance add/update/remove +- Patient record view (if required by compliance) + +**Visit/ADT** + +- Admit, transfer, discharge +- Bed/ward/unit changes +- Visit status updates + +**Other** + +- Patient notes/attachments added/removed +- Patient alerts/flags changes + +### logorder + +**Orders/tests** + +- Create/cancel/reopen order +- Add/remove tests +- Priority changes +- Order comments added/removed + +**Specimen lifecycle** + +- Collected, labeled, received, rejected +- Centrifuged, aliquoted, stored +- Disposed/expired + +**Results** + +- Result entered/updated +- Verified/amended +- Released/retracted/corrected +- Result comments/interpretation changes +- Auto-verification override + +**QC** + +- QC result recorded +- QC failure/override + +### logmaster + +**Value sets** + +- Create/update/retire value set items + +**Test definitions** + +- Test definition updates (units, methods, ranges) +- Reference range changes +- Formula/delta check changes +- Test panel membership add/remove + +**Infrastructure** + +- Analyzer/instrument config changes +- Host app integration config +- Coding system changes + +**Users/roles** + +- User create/disable/reset +- Role changes +- Permission changes + +**Sites/workstations** + +- Site/location/workstation CRUD + +### logsystem + +**Sessions & security** + +- Login/logout +- Failed login attempts +- Lockouts/password resets +- Token issue/refresh/revoke +- Authorization failures + +**Import/export** + +- Import/export job start/end +- Batch ID, source, record counts, status + +**System operations** + +- Background jobs start/end +- Integration sync runs +- System config changes +- Service errors that affect data integrity + +## Activity & Event Codes + +Use consistent `ActivityID` and `EventID` values. Recommended defaults: + +- `ActivityID`: `CREATE`, `UPDATE`, `DELETE`, `READ`, `MERGE`, `SPLIT`, `CANCEL`, `REOPEN`, `VERIFY`, `AMEND`, `RETRACT`, `RELEASE`, `IMPORT`, `EXPORT`, `LOGIN`, `LOGOUT` +- `EventID`: domain-specific codes (e.g., `PATIENT_REGISTERED`, `ORDER_CREATED`, `RESULT_VERIFIED`, `QC_RECORDED`) + +## Capture Guidelines + +- Always capture `UserID`, `SessionID`, `SiteID`, and `LogDate` when available. +- If the action is system-driven, set `UserID` to `SYSTEM` (or null) and add context in `Context`. +- Store payload diffs in `FldValuePrev` and `FldValueNew` for single-field changes; for multi-field changes, put a JSON diff in `Context` and leave `FldName` null. +- For bulk operations, store batch metadata in `Context` (`batch_id`, `record_count`, `source`). +- Do not log secrets, tokens, or full PHI when not required. Mask or omit sensitive fields. + +## Retention & Governance + +- Define retention policy per table (e.g., 7 years for patient/order, 2 years for system). +- Archive before purge; record purge activity in `logsystem`. +- Restrict write/delete permissions to service accounts only. + +## Implementation Checklist + +1. Create the four tables with shared schema (or migrate existing log tables to match). +2. Add a single audit service with helpers to build a normalized payload. +3. Instrument controllers/services for each event category above. +4. Add automated tests for representative audit writes. +5. Document `EventID` codes used by each endpoint/service. diff --git a/docs/calculator-operators.md b/docs/test-calc-engine.md similarity index 50% rename from docs/calculator-operators.md rename to docs/test-calc-engine.md index 1e289af..94687a6 100644 --- a/docs/calculator-operators.md +++ b/docs/test-calc-engine.md @@ -2,7 +2,68 @@ ## Overview -The `CalculatorService` (`app/Services/CalculatorService.php`) uses the [mossadal/math-parser](https://github.com/mossadal/math-parser) library to safely evaluate mathematical expressions. This document lists all available operators, functions, and constants. +The `CalculatorService` (`app/Services/CalculatorService.php`) evaluates formulas with Symfony's `ExpressionLanguage`. This document lists the operators, functions, and constants that are available in the current implementation. + +--- + +## API Endpoints + +All endpoints live under `/api` and accept JSON. Responses use the standard `{ status, message, data }` envelope unless stated otherwise. + +### Calculate By Test Site + +Uses the `testdefcal` definition for a test site. The incoming body supplies the variables required by the formula. + +```http +POST /api/calc/testsite/123 +Content-Type: application/json + +{ + "result": 85, + "gender": "female", + "age": 30 +} +``` + +Response: + +```json +{ + "status": "success", + "data": { + "result": 92.4, + "testSiteID": 123, + "formula": "{result} * {factor} + {age}", + "variables": { + "result": 85, + "gender": "female", + "age": 30 + } + } +} +``` + +### Calculate By Code Or Name + +Evaluates a configured calculation by `TestSiteCode` or `TestSiteName`. Returns a compact map with a single key/value or `{}` on failure. + +```http +POST /api/calc/testcode/GLU +Content-Type: application/json + +{ + "result": 110, + "factor": 1.1 +} +``` + +Response: + +```json +{ + "GLU": 121 +} +``` --- @@ -16,9 +77,34 @@ The `CalculatorService` (`app/Services/CalculatorService.php`) uses the [mossada | `-` | Subtraction | `10 - 4` | `6` | | `*` | Multiplication | `6 * 7` | `42` | | `/` | Division | `20 / 4` | `5` | -| `^` | Exponentiation (power) | `2 ^ 3` | `8` | -| `!` | Factorial | `5!` | `120` | -| `!!` | Semi-factorial (double factorial) | `5!!` | `15` | +| `%` | Modulo | `20 % 6` | `2` | +| `**` | Exponentiation (power) | `2 ** 3` | `8` | + +### Comparison Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `==` | Equal | `{result} == 10` | +| `!=` | Not equal | `{result} != 10` | +| `<` | Less than | `{result} < 10` | +| `<=` | Less than or equal | `{result} <= 10` | +| `>` | Greater than | `{result} > 10` | +| `>=` | Greater than or equal | `{result} >= 10` | + +### Logical Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `and` / `&&` | Logical AND | `{result} > 0 and {factor} > 0` | +| `or` / `||` | Logical OR | `{gender} == 1 or {gender} == 2` | +| `!` / `not` | Logical NOT | `not ({result} > 0)` | + +### Conditional Operators + +| Operator | Description | Example | +|----------|-------------|---------| +| `?:` | Ternary | `{result} > 10 ? {result} : 10` | +| `??` | Null coalescing | `{result} ?? 0` | ### Parentheses @@ -29,84 +115,35 @@ Use parentheses to control operation precedence: 2 + 3 * 4 // Result: 14 ``` +### Notes + +- `^` is bitwise XOR (not exponentiation). Use `**` for powers. +- Variables must be numeric after normalization (gender is mapped to 0/1/2). + --- -## Mathematical Functions +## Functions -### Rounding Functions +Only the default ExpressionLanguage functions are available: | Function | Description | Example | |----------|-------------|---------| -| `sqrt(x)` | Square root | `sqrt(16)` → `4` | -| `round(x)` | Round to nearest integer | `round(3.7)` → `4` | -| `ceil(x)` | Round up to integer | `ceil(3.2)` → `4` | -| `floor(x)` | Round down to integer | `floor(3.9)` → `3` | -| `abs(x)` | Absolute value | `abs(-5)` → `5` | -| `sgn(x)` | Sign function | `sgn(-10)` → `-1` | - -### Trigonometric Functions (Radians) - -| Function | Description | Example | -|----------|-------------|---------| -| `sin(x)` | Sine | `sin(pi/2)` → `1` | -| `cos(x)` | Cosine | `cos(0)` → `1` | -| `tan(x)` | Tangent | `tan(pi/4)` → `1` | -| `cot(x)` | Cotangent | `cot(pi/4)` → `1` | - -### Trigonometric Functions (Degrees) - -| Function | Description | Example | -|----------|-------------|---------| -| `sind(x)` | Sine (degrees) | `sind(90)` → `1` | -| `cosd(x)` | Cosine (degrees) | `cosd(0)` → `1` | -| `tand(x)` | Tangent (degrees) | `tand(45)` → `1` | -| `cotd(x)` | Cotangent (degrees) | `cotd(45)` → `1` | - -### Hyperbolic Functions - -| Function | Description | Example | -|----------|-------------|---------| -| `sinh(x)` | Hyperbolic sine | `sinh(1)` → `1.175...` | -| `cosh(x)` | Hyperbolic cosine | `cosh(1)` → `1.543...` | -| `tanh(x)` | Hyperbolic tangent | `tanh(1)` → `0.761...` | -| `coth(x)` | Hyperbolic cotangent | `coth(2)` → `1.037...` | - -### Inverse Trigonometric Functions - -| Function | Aliases | Description | Example | -|----------|---------|-------------|---------| -| `arcsin(x)` | `asin(x)` | Inverse sine | `arcsin(0.5)` → `0.523...` | -| `arccos(x)` | `acos(x)` | Inverse cosine | `arccos(0.5)` → `1.047...` | -| `arctan(x)` | `atan(x)` | Inverse tangent | `arctan(1)` → `0.785...` | -| `arccot(x)` | `acot(x)` | Inverse cotangent | `arccot(1)` → `0.785...` | - -### Inverse Hyperbolic Functions - -| Function | Aliases | Description | Example | -|----------|---------|-------------|---------| -| `arsinh(x)` | `asinh(x)`, `arcsinh(x)` | Inverse hyperbolic sine | `arsinh(1)` → `0.881...` | -| `arcosh(x)` | `acosh(x)`, `arccosh(x)` | Inverse hyperbolic cosine | `arcosh(2)` → `1.316...` | -| `artanh(x)` | `atanh(x)`, `arctanh(x)` | Inverse hyperbolic tangent | `artanh(0.5)` → `0.549...` | -| `arcoth(x)` | `acoth(x)`, `arccoth(x)` | Inverse hyperbolic cotangent | `arcoth(2)` → `0.549...` | - -### Logarithmic & Exponential Functions - -| Function | Aliases | Description | Example | -|----------|---------|-------------|---------| -| `exp(x)` | - | Exponential (e^x) | `exp(2)` → `7.389...` | -| `log(x)` | `ln(x)` | Natural logarithm (base e) | `log(e)` → `1` | -| `log10(x)` | `lg(x)` | Logarithm base 10 | `log10(100)` → `2` | +| `min(a, b, ...)` | Minimum value | `min({result}, 10)` | +| `max(a, b, ...)` | Maximum value | `max({result}, 10)` | +| `constant(name)` | PHP constant by name | `constant("PHP_INT_MAX")` | +| `enum(name)` | PHP enum case by name | `enum("App\\Enum\\Status::Active")` | --- ## Constants -| Constant | Value | Description | Example | -|----------|-------|-------------|---------| -| `pi` | 3.14159265... | Ratio of circle circumference to diameter | `pi * r ^ 2` | -| `e` | 2.71828182... | Euler's number | `e ^ x` | -| `NAN` | Not a Number | Invalid mathematical result | - | -| `INF` | Infinity | Positive infinity | - | +ExpressionLanguage recognizes boolean and null literals: + +| Constant | Value | Description | +|----------|-------|-------------| +| `true` | `true` | Boolean true | +| `false` | `false` | Boolean false | +| `null` | `null` | Null value | --- @@ -139,16 +176,12 @@ Or use string values: `'unknown'`, `'female'`, `'male'` ## Implicit Multiplication -The parser supports implicit multiplication (no explicit `*` operator needed): +Implicit multiplication is not supported. Always use `*` between values: -| Expression | Parsed As | Result (x=2, y=3) | -|------------|-----------|-------------------| -| `2x` | `2 * x` | `4` | -| `x sin(x)` | `x * sin(x)` | `1.818...` | -| `2xy` | `2 * x * y` | `12` | -| `x^2y` | `x^2 * y` | `12` | - -**Note:** Implicit multiplication has the same precedence as explicit multiplication. `xy^2z` is parsed as `x*y^2*z`, NOT as `x*y^(2*z)`. +| Expression | Use Instead | +|------------|-------------| +| `2x` | `2 * x` | +| `{result}{factor}` | `{result} * {factor}` | --- @@ -165,13 +198,9 @@ $calculator = new CalculatorService(); $result = $calculator->calculate("5 + 3 * 2"); // Result: 11 -// Using functions -$result = $calculator->calculate("sqrt(16) + abs(-5)"); -// Result: 9 - -// Using constants -$result = $calculator->calculate("2 * pi * r", ['r' => 5]); -// Result: 31.415... +// Using min/max +$result = $calculator->calculate("max({result}, 10)", ['result' => 7]); +// Result: 10 ``` ### With Variables @@ -190,7 +219,7 @@ $result = $calculator->calculate($formula, $variables); ### BMI Calculation ```php -$formula = "{weight} / ({height} ^ 2)"; +$formula = "{weight} / ({height} ** 2)"; $variables = [ 'weight' => 70, // kg 'height' => 1.75 // meters @@ -217,8 +246,8 @@ $result = $calculator->calculate($formula, $variables); ### Complex Formula ```php -// Pythagorean theorem with rounding -$formula = "round(sqrt({a} ^ 2 + {b} ^ 2))"; +// Pythagorean theorem +$formula = "(({a} ** 2 + {b} ** 2) ** 0.5)"; $variables = [ 'a' => 3, 'b' => 4 @@ -304,6 +333,5 @@ Common errors: ## References -- [math-parser GitHub](https://github.com/mossadal/math-parser) -- [math-parser Documentation](http://mossadal.github.io/math-parser/) +- [Symfony ExpressionLanguage](https://symfony.com/doc/current/components/expression_language.html) - `app/Services/CalculatorService.php` diff --git a/docs/test-rule-engine.md b/docs/test-rule-engine.md index 074d37a..bcf1968 100644 --- a/docs/test-rule-engine.md +++ b/docs/test-rule-engine.md @@ -142,7 +142,136 @@ if(sex('F') && (age >= 18 && age <= 50) && priority('S'); result_set('HIGH_PRIO' if(requested('GLU'); test_delete('INS'):comment_insert('Duplicate insulin request removed'); nothing) ``` -## API Usage +## API Endpoints + +All endpoints live under `/api/rule` (singular) and accept JSON. Responses use the standard `{ status, message, data }` envelope. These endpoints require auth (bearer token). + +### List Rules + +```http +GET /api/rule?EventCode=test_created&TestSiteID=12&search=glucose +``` + +Query Params: + +- `EventCode` (optional) filter by event code. +- `TestSiteID` (optional) filter rules linked to a test site. +- `search` (optional) partial match against `RuleName`. + +Response: + +```json +{ + "status": "success", + "message": "fetch success", + "data": [ + { + "RuleID": 1, + "RuleCode": "RULE_001", + "RuleName": "Sex-based result", + "EventCode": "test_created", + "ConditionExpr": "if(sex('M'); result_set(0.5); result_set(0.6))", + "ConditionExprCompiled": "{...}" + } + ] +} +``` + +### Get Rule + +```http +GET /api/rule/1 +``` + +Response includes `linkedTests`: + +```json +{ + "status": "success", + "message": "fetch success", + "data": { + "RuleID": 1, + "RuleCode": "RULE_001", + "RuleName": "Sex-based result", + "EventCode": "test_created", + "ConditionExpr": "if(sex('M'); result_set(0.5); result_set(0.6))", + "ConditionExprCompiled": "{...}", + "linkedTests": [1, 2] + } +} +``` + +### Create Rule + +```http +POST /api/rule +Content-Type: application/json + +{ + "RuleCode": "RULE_001", + "RuleName": "Sex-based result", + "EventCode": "test_created", + "ConditionExpr": "if(sex('M'); result_set(0.5); result_set(0.6))", + "ConditionExprCompiled": "", + "TestSiteIDs": [1, 2] +} +``` + +Response: + +```json +{ + "status": "success", + "message": "Rule created successfully", + "data": { + "RuleID": 1 + } +} +``` + +### Update Rule + +```http +PATCH /api/rule/1 +Content-Type: application/json + +{ + "RuleName": "Sex-based result v2", + "ConditionExpr": "if(sex('M'); result_set(0.7); result_set(0.6))", + "ConditionExprCompiled": "", + "TestSiteIDs": [1, 3] +} +``` + +Response: + +```json +{ + "status": "success", + "message": "Rule updated successfully", + "data": { + "RuleID": 1 + } +} +``` + +### Delete Rule + +```http +DELETE /api/rule/1 +``` + +Response: + +```json +{ + "status": "success", + "message": "Rule deleted successfully", + "data": { + "RuleID": 1 + } +} +``` ### Compile DSL @@ -157,11 +286,26 @@ Content-Type: application/json } ``` -The response contains `raw`, `compiled`, and `conditionExprCompiled` fields; store the JSON payload in `ConditionExprCompiled` before saving the rule. +Response: + +```json +{ + "status": "success", + "data": { + "raw": "if(sex('M'); result_set(0.5); result_set(0.6))", + "compiled": { + "conditionExpr": "sex('M')", + "then": ["result_set(0.5)"], + "else": ["result_set(0.6)"] + }, + "conditionExprCompiled": "{...}" + } +} +``` ### Evaluate Expression (Validation) -This endpoint simply evaluates an expression against a runtime context. It does not compile DSL or persist the result. +This endpoint evaluates an expression against a runtime context. It does not compile DSL or persist the result. ```http POST /api/rule/validate @@ -177,19 +321,15 @@ Content-Type: application/json } ``` -### Create Rule (example) - -```http -POST /api/rule -Content-Type: application/json +Response: +```json { - "RuleCode": "RULE_001", - "RuleName": "Sex-based result", - "EventCode": "test_created", - "ConditionExpr": "if(sex('M'); result_set(0.5); result_set(0.6))", - "ConditionExprCompiled": "", - "TestSiteIDs": [1, 2] + "status": "success", + "data": { + "valid": true, + "result": true + } } ``` diff --git a/public/api-docs.bundled.yaml b/public/api-docs.bundled.yaml index 7c4e778..3ce3e13 100644 --- a/public/api-docs.bundled.yaml +++ b/public/api-docs.bundled.yaml @@ -226,11 +226,11 @@ paths: responses: '201': description: User created - /api/calc/{codeOrName}: + /api/calc/testcode/{codeOrName}: post: tags: - Calculation - summary: Evaluate a configured calculation by test code or name and return the numeric result only. + summary: Evaluate a configured calculation by test code or name and return the raw result map. security: [] parameters: - name: codeOrName @@ -264,6 +264,81 @@ paths: IBIL: 2 incomplete: value: {} + /api/calc/testsite/{testSiteID}: + post: + tags: + - Calculation + summary: Evaluate a calculation defined for a test site and return a structured result. + security: [] + parameters: + - name: testSiteID + in: path + required: true + schema: + type: integer + description: Identifier for the test site whose definition should be evaluated. + requestBody: + required: true + content: + application/json: + schema: + type: object + description: Variable assignments required by the test site formula. + additionalProperties: + type: number + example: + result: 85 + gender: female + age: 30 + responses: + '200': + description: Returns the calculated result, testSiteID, formula code, and echoed variables. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + data: + type: object + properties: + result: + type: number + testSiteID: + type: integer + formula: + type: string + variables: + type: object + additionalProperties: + type: number + examples: + success: + value: + status: success + data: + result: 92.4 + testSiteID: 123 + formula: '{result} * {factor} + {age}' + variables: + result: 85 + gender: female + age: 30 + '404': + description: No calculation defined for the requested test site. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: failed + message: + type: string + example: No calculation defined for this test site /api/contact: get: tags: diff --git a/public/paths/calc.yaml b/public/paths/calc.yaml index 29681e2..c46de54 100644 --- a/public/paths/calc.yaml +++ b/public/paths/calc.yaml @@ -1,37 +1,112 @@ -/api/calc/{codeOrName}: +/api/calc/testcode/{codeOrName}: post: tags: [Calculation] - summary: Evaluate a configured calculation by test code or name and return the numeric result only. - security: [] - parameters: - - name: codeOrName - in: path - required: true - schema: - type: string - description: TestSiteCode or TestSiteName of the calculated test (case-insensitive). - requestBody: - required: true - content: - application/json: - schema: - type: object - description: Key-value pairs where keys match member tests used in the formula. - additionalProperties: - type: number - example: - TBIL: 5 - DBIL: 3 - responses: - '200': - description: Returns a single key/value pair with the canonical TestSiteCode or an empty object when the calculation is incomplete or missing. - content: - application/json: - schema: - type: object - examples: - success: - value: - IBIL: 2.0 - incomplete: - value: {} + summary: Evaluate a configured calculation by test code or name and return the raw result map. + security: [] + parameters: + - name: codeOrName + in: path + required: true + schema: + type: string + description: TestSiteCode or TestSiteName of the calculated test (case-insensitive). + requestBody: + required: true + content: + application/json: + schema: + type: object + description: Key-value pairs where keys match member tests used in the formula. + additionalProperties: + type: number + example: + TBIL: 5 + DBIL: 3 + responses: + '200': + description: Returns a single key/value pair with the canonical TestSiteCode or an empty object when the calculation is incomplete or missing. + content: + application/json: + schema: + type: object + examples: + success: + value: + IBIL: 2.0 + incomplete: + value: {} + +/api/calc/testsite/{testSiteID}: + post: + tags: [Calculation] + summary: Evaluate a calculation defined for a test site and return a structured result. + security: [] + parameters: + - name: testSiteID + in: path + required: true + schema: + type: integer + description: Identifier for the test site whose definition should be evaluated. + requestBody: + required: true + content: + application/json: + schema: + type: object + description: Variable assignments required by the test site formula. + additionalProperties: + type: number + example: + result: 85 + gender: "female" + age: 30 + responses: + '200': + description: Returns the calculated result, testSiteID, formula code, and echoed variables. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: success + data: + type: object + properties: + result: + type: number + testSiteID: + type: integer + formula: + type: string + variables: + type: object + additionalProperties: + type: number + examples: + success: + value: + status: success + data: + result: 92.4 + testSiteID: 123 + formula: "{result} * {factor} + {age}" + variables: + result: 85 + gender: female + age: 30 + '404': + description: No calculation defined for the requested test site. + content: + application/json: + schema: + type: object + properties: + status: + type: string + example: failed + message: + type: string + example: No calculation defined for this test site