diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..5af0c47 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,86 @@ +# Valueset Data Scanning - Agent Instructions + +## Quick Reference + +**Database Tables:** +- [`valuesetdef`](app/Models/ValueSet/ValueSetDefModel.php:8) - Category definitions (VSetID, VSName, VSDesc) +- [`valueset`](app/Models/ValueSet/ValueSetModel.php:8) - Actual values (VID, VSetID, VValue, VDesc, VOrder) + +## Agent Workflow for Valueset Queries + +### Step 1: Identify the Request Type + +**Category Search:** +``` +User: "Show me all values in [CATEGORY_NAME]" +Agent: Search valuesetdef by VSName → Get VSetID → Query values by VSetID +``` + +**Reference Search:** +``` +User: "Show me values for [TABLE.COLUMN]" +Agent: Search valuesetdef by VSDesc → Get VSetID → Query values by VSetID +``` + +**ID Search:** +``` +User: "Show me values for VSetDefID [ID]" +Agent: Directly query values by VSetID +``` + +**Filtered Search:** +``` +User: "Find values containing [TERM] in [CATEGORY]" +Agent: Search valuesetdef by VSName → Get VSetID → Query values with LIKE filter +``` + +### Step 2: Execute Query + +**Model Methods:** +- [`ValueSetDefModel::getValueSetDefs($param)`](app/Models/ValueSet/ValueSetDefModel.php:18) - Search categories +- [`ValueSetModel::getValueSetByValueSetDef($VSetID)`](app/Models/ValueSet/ValueSetModel.php:52) - Get values by category +- [`ValueSetModel::getValueSets($param, $page, $limit, $VSetID)`](app/Models/ValueSet/ValueSetModel.php:18) - Get values with filters + +### Step 3: Return Results + +**Response Format:** +```json +{ + "VSetID": 27, + "VSName": "Test Type", + "values": [ + { "VID": 1, "VValue": "TEST", "VDesc": "Test", "VOrder": 1 }, + { "VID": 2, "VValue": "PARAM", "VDesc": "Parameter", "VOrder": 2 } + ] +} +``` + +## Common Valuesets + +| VSetDefID | VSName | VSDesc | Search Keywords | +|-----------|--------|--------|-----------------| +| 3 | Gender | - | gender, sex | +| 27 | Test Type | `testdefsite.TestType` | test, type, testdefsite | +| 15 | Specimen Type | - | specimen, type, blood, urine | +| 31 | Range Types | `refnum.RangeType` | range, refnum | +| 46 | Num Ref Type | `refnum.NumRefType` | numeric, reference | + +## Example Agent Conversations + +**User:** "Show me Gender values" +**Agent:** +1. `getValueSetDefs("Gender")` → VSetID = 3 +2. `getValueSetByValueSetDef(3)` → Returns values +3. Output: Female, Male, Unknown + +**User:** "What values for testdefsite.TestType?" +**Agent:** +1. `getValueSetDefs("testdefsite.TestType")` → VSetID = 27 +2. `getValueSetByValueSetDef(27)` → Returns values +3. Output: TEST, PARAM, CALC, GROUP, TITLE + +**User:** "Find values with 'STAT' in Priority" +**Agent:** +1. `getValueSetDefs("Priority")` → VSetID = 1 +2. `getValueSets("STAT", null, 50, 1)` → Returns matching values +3. Output: STAT, STAT2 (if exists) diff --git a/README.md b/README.md index 85aa714..7a9b8c3 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ The system is currently undergoing a strategic **Architectural Redesign** to con | **Security** | JWT (JSON Web Tokens) Authorization | | **Database** | MySQL (Optimized Schema Migration in progress) | + --- ## 📂 Documentation & Specifications @@ -49,6 +50,210 @@ Key documents: --- +## 🔧 Valueset Reference (VSetDefID) + +When working on UI components or dropdowns, **always check for existing ValueSets** before hardcoding options. Use the API endpoint `/api/valueset/valuesetdef/{ID}` to fetch options dynamically. + +| VSetDefID | Purpose | Usage | +|-----------|---------|-------| +| 27 | Test Types | TEST, PARAM, GROUP, CALC, TITLE | +| 28 | Methods | Lab test methods | +| 29 | Specimen Types | Blood, Urine, etc. | +| 30 | Ref Types | NMRC (Numeric), TEXT, LIST | +| 31 | Range Types | STD (Standard), AGSX (Age/Sex), COND | + +> **Important:** Always use ValueSet lookups for configurable options. This ensures consistency and allows administrators to modify options without code changes. + +--- + +## 📋 Master Data Management + +CLQMS provides comprehensive master data management for laboratory operations. All master data is accessible via the V2 UI at `/v2/master/*` endpoints. + +### 🧪 Laboratory Tests (`/v2/master/tests`) + +The Test Definitions module manages all laboratory test configurations including parameters, calculated tests, and test panels. + +#### Test Types + +| Type Code | Description | Table | +|-----------|-------------|-------| +| `TEST` | Individual laboratory test with technical specs | `testdefsite` + `testdeftech` | +| `PARAM` | Parameter value (non-lab measurement) | `testdefsite` + `testdeftech` | +| `CALC` | Calculated test with formula | `testdefsite` + `testdefcal` | +| `GROUP` | Panel/profile containing multiple tests | `testdefsite` + `testdefgrp` | +| `TITLE` | Section title for report organization | `testdefsite` | + +#### API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/tests` | List all tests with optional filtering | +| `GET` | `/api/tests/{id}` | Get test details with type-specific data | +| `POST` | `/api/tests` | Create new test definition | +| `PATCH` | `/api/tests` | Update existing test | +| `DELETE` | `/api/tests` | Soft delete test (sets EndDate) | + +#### Filtering Parameters + +- `TestSiteName` - Search by test name (partial match) +- `TestType` - Filter by test type VID (1-5) +- `VisibleScr` - Filter by screen visibility (0/1) +- `VisibleRpt` - Filter by report visibility (0/1) + +#### Test Response Structure + +```json +{ + "status": "success", + "message": "Data fetched successfully", + "data": [ + { + "TestSiteID": 1, + "TestSiteCode": "CBC", + "TestSiteName": "Complete Blood Count", + "TestType": 4, + "TypeCode": "GROUP", + "TypeName": "Group Test", + "SeqScr": 50, + "VisibleScr": 1, + "VisibleRpt": 1 + } + ] +} +``` + +### 📏 Reference Ranges (`/v2/master/refrange`) + +Reference Ranges define normal and critical values for test results. The system supports multiple reference range types based on patient demographics. + +#### Reference Range Types + +| Type | Table | Description | +|------|-------|-------------| +| Numeric | `refnum` | Numeric ranges with age/sex criteria | +| Threshold | `refthold` | Critical threshold values | +| Text | `reftxt` | Text-based reference values | +| Value Set | `refvset` | Coded reference values | + +#### Numeric Reference Range Structure + +| Field | Description | +|-------|-------------| +| `NumRefType` | Type: REF (Reference), CRTC (Critical), VAL (Validation), RERUN | +| `RangeType` | RANGE or THOLD | +| `Sex` | Gender filter (0=All, 1=Female, 2=Male) | +| `AgeStart` | Minimum age (years) | +| `AgeEnd` | Maximum age (years) | +| `LowSign` | Low boundary sign (=, <, <=) | +| `Low` | Low boundary value | +| `HighSign` | High boundary sign (=, >, >=) | +| `High` | High boundary value | +| `Flag` | Result flag (H, L, A, etc.) | + +#### API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/refnum` | List numeric reference ranges | +| `GET` | `/api/refnum/{id}` | Get reference range details | +| `POST` | `/api/refnum` | Create reference range | +| `PATCH` | `/api/refnum` | Update reference range | +| `DELETE` | `/api/refnum` | Soft delete reference range | + +### 📑 Value Sets (`/v2/master/valuesets`) + +Value Sets are configurable dropdown options used throughout the system. Each Value Set Definition (VSetDef) contains multiple Value Set Values (ValueSet). + +#### Value Set Hierarchy + +``` +valuesetdef (VSetDefID, VSName, VSDesc) + └── valueset (VID, VSetID, VValue, VDesc, VOrder, VCategory) +``` + +#### Common Value Sets + +| VSetDefID | Name | Example Values | +|-----------|------|----------------| +| 1 | Priority | STAT (S), ASAP (A), Routine (R), Preop (P) | +| 2 | Enable/Disable | Disabled (0), Enabled (1) | +| 3 | Gender | Female (1), Male (2), Unknown (3) | +| 10 | Order Status | STC, SCtd, SArrv, SRcvd, SAna, etc. | +| 15 | Specimen Type | BLD, SER, PLAS, UR, CSF, etc. | +| 16 | Unit | L, mL, g/dL, mg/dL, etc. | +| 27 | Test Type | TEST, PARAM, CALC, GROUP, TITLE | +| 28 | Result Unit | g/dL, g/L, mg/dL, x10^6/mL, etc. | +| 35 | Test Activity | Order, Analyse, VER, REV, REP | + +#### API Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `GET` | `/api/valuesetdef` | List all value set definitions | +| `GET` | `/api/valuesetdef/{id}` | Get valueset with all values | +| `GET` | `/api/valuesetdef/{id}/values` | Get values for specific valueset | +| `POST` | `/api/valuesetdef` | Create new valueset definition | +| `PATCH` | `/api/valuesetdef` | Update valueset definition | +| `DELETE` | `/api/valuesetdef` | Delete valueset definition | + +#### Value Set Response Structure + +```json +{ + "status": "success", + "data": { + "VSetDefID": 27, + "VSName": "Test Type", + "VSDesc": "testdefsite.TestType", + "values": [ + { "VID": 1, "VValue": "TEST", "VDesc": "Test", "VOrder": 1 }, + { "VID": 2, "VValue": "PARAM", "VDesc": "Parameter", "VOrder": 2 }, + { "VID": 3, "VValue": "CALC", "VDesc": "Calculated Test", "VOrder": 3 }, + { "VID": 4, "VValue": "GROUP", "VDesc": "Group Test", "VOrder": 4 }, + { "VID": 5, "VValue": "TITLE", "VDesc": "Title", "VOrder": 5 } + ] + } +} +``` + +### 📊 Database Tables Summary + +| Category | Tables | Purpose | +|----------|--------|---------| +| Tests | `testdefsite`, `testdeftech`, `testdefcal`, `testdefgrp`, `testmap` | Test definitions | +| Reference Ranges | `refnum`, `refthold`, `reftxt`, `refvset` | Result validation | +| Value Sets | `valuesetdef`, `valueset` | Configurable options | + +--- + +## 🔌 Edge API - Instrument Integration + +The **Edge API** provides endpoints for integrating laboratory instruments via the `tiny-edge` middleware. Results from instruments are staged in the `edgeres` table before processing into the main patient results (`patres`). + +### Endpoints + +| Method | Endpoint | Description | +|--------|----------|-------------| +| `POST` | `/api/edge/results` | Receive instrument results (stored in `edgeres`) | +| `GET` | `/api/edge/orders` | Fetch pending orders for an instrument | +| `POST` | `/api/edge/orders/:id/ack` | Acknowledge order delivery to instrument | +| `POST` | `/api/edge/status` | Log instrument status updates | + +### Workflow + +``` +Instrument → tiny-edge → POST /api/edge/results → edgeres table → [Manual/Auto Processing] → patres table +``` + +**Key Features:** +- **Staging Table:** All results land in `edgeres` first for validation +- **Rerun Handling:** Duplicate `SampleID` + `TestSiteCode` increments `AspCnt` in `patres` +- **Configurable Processing:** Auto or manual processing based on settings +- **Status Tracking:** Full audit trail via `edgestatus` and `edgeack` tables + +--- + ### 📜 Usage Notice This repository contains proprietary information intended for the 5Panda Team and authorized collaborators. diff --git a/app/Config/App.php b/app/Config/App.php index b761da7..c7d9813 100644 --- a/app/Config/App.php +++ b/app/Config/App.php @@ -16,7 +16,7 @@ class App extends BaseConfig * * E.g., http://example.com/ */ - public string $baseURL = 'http://localhost:8080/'; + public string $baseURL = ''; /** * Allowed Hostnames in the Site URL other than the hostname in the baseURL. @@ -40,7 +40,8 @@ class App extends BaseConfig * something else. If you have configured your web server to remove this file * from your site URIs, set this variable to an empty string. */ - public string $indexPage = 'index.php'; + #public string $indexPage = 'index.php'; + public string $indexPage = ''; /** * -------------------------------------------------------------------------- diff --git a/app/Config/Exceptions.php b/app/Config/Exceptions.php index 4e33963..6b74d5f 100644 --- a/app/Config/Exceptions.php +++ b/app/Config/Exceptions.php @@ -44,7 +44,7 @@ class Exceptions extends BaseConfig * * Default: APPPATH.'Views/errors' */ - public string $errorViewPath = APPPATH . 'Views/errors'; + public string $errorViewPath = __DIR__ . '/../Views/errors'; /** * -------------------------------------------------------------------------- diff --git a/app/Config/Filters.php b/app/Config/Filters.php index fdc2e9b..26e6a80 100644 --- a/app/Config/Filters.php +++ b/app/Config/Filters.php @@ -53,7 +53,7 @@ class Filters extends BaseFilters */ public array $required = [ 'before' => [ - 'forcehttps', // Force Global Secure Requests + // 'forcehttps', // Force Global Secure Requests - disabled for localhost 'pagecache', // Web Page Caching ], 'after' => [ diff --git a/app/Config/Routes.php b/app/Config/Routes.php index 45ef782..5fe5c9b 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -5,171 +5,287 @@ use CodeIgniter\Router\RouteCollection; /** * @var RouteCollection $routes */ -$routes->options('(:any)', function() { return ''; }); -$routes->get('/', 'Home::index'); +$routes->get('/', function () { + return redirect()->to('/v2'); +}); -// Frontend Pages -$routes->get('/login', 'Pages\AuthPage::login'); -$routes->get('/logout', 'Pages\AuthPage::logout'); -$routes->get('/dashboard', 'Pages\DashboardPage::index'); +$routes->options('(:any)', function () { + return ''; +}); + +$routes->group('api', ['filter' => 'auth'], function ($routes) { + $routes->get('dashboard', 'DashboardController::index'); + $routes->get('result', 'ResultController::index'); + $routes->get('sample', 'SampleController::index'); +}); + +// Public Routes (no auth required) +$routes->get('/v2/login', 'PagesController::login'); + +// V2 Auth API Routes (public - no auth required) +$routes->group('v2/auth', function ($routes) { + $routes->post('login', 'AuthV2Controller::login'); + $routes->post('register', 'AuthV2Controller::register'); + $routes->get('check', 'AuthV2Controller::checkAuth'); + $routes->post('logout', 'AuthV2Controller::logout'); +}); + +// Protected Page Routes - V2 (requires auth) +$routes->group('v2', ['filter' => 'auth'], function ($routes) { + $routes->get('/', 'PagesController::dashboard'); + $routes->get('dashboard', 'PagesController::dashboard'); + $routes->get('patients', 'PagesController::patients'); + $routes->get('requests', 'PagesController::requests'); + $routes->get('settings', 'PagesController::settings'); + + // Master Data - Organization + $routes->get('master/organization/accounts', 'PagesController::masterOrgAccounts'); + $routes->get('master/organization/sites', 'PagesController::masterOrgSites'); + $routes->get('master/organization/disciplines', 'PagesController::masterOrgDisciplines'); + $routes->get('master/organization/departments', 'PagesController::masterOrgDepartments'); + $routes->get('master/organization/workstations', 'PagesController::masterOrgWorkstations'); + + // Master Data - Specimen + $routes->get('master/specimen/containers', 'PagesController::masterSpecimenContainers'); + $routes->get('master/specimen/preparations', 'PagesController::masterSpecimenPreparations'); + + // Master Data - Tests & ValueSets + $routes->get('master/tests', 'PagesController::masterTests'); + $routes->get('master/valuesets', 'PagesController::masterValueSets'); +}); // Faker $routes->get('faker/faker-patient/(:num)', 'faker\FakerPatient::sendMany/$1'); -$routes->group('api', ['filter' => 'auth'], function($routes) { - $routes->get('dashboard', 'Dashboard::index'); - $routes->get('result', 'Result::index'); - $routes->get('sample', 'Sample::index'); +$routes->group('api', function ($routes) { + // Auth + $routes->group('auth', function ($routes) { + $routes->post('login', 'AuthController::login'); + $routes->post('change_pass', 'AuthController::change_pass'); + $routes->post('register', 'AuthController::register'); + $routes->get('check', 'AuthController::checkAuth'); + $routes->post('logout', 'AuthController::logout'); + }); + + // Patient + $routes->group('patient', function ($routes) { + $routes->get('/', 'Patient\PatientController::index'); + $routes->post('/', 'Patient\PatientController::create'); + $routes->get('(:num)', 'Patient\PatientController::show/$1'); + $routes->delete('/', 'Patient\PatientController::delete'); + $routes->patch('/', 'Patient\PatientController::update'); + $routes->get('check', 'Patient\PatientController::patientCheck'); + }); + + // PatVisit + $routes->group('patvisit', function ($routes) { + $routes->get('/', 'PatVisitController::index'); + $routes->post('/', 'PatVisitController::create'); + $routes->get('patient/(:num)', 'PatVisitController::showByPatient/$1'); + $routes->get('(:any)', 'PatVisitController::show/$1'); + $routes->delete('/', 'PatVisitController::delete'); + $routes->patch('/', 'PatVisitController::update'); + }); + + $routes->group('patvisitadt', function ($routes) { + $routes->post('/', 'PatVisitController::createADT'); + $routes->patch('/', 'PatVisitController::updateADT'); + }); + + // Master Data + $routes->group('race', function ($routes) { + $routes->get('/', 'Race::index'); + $routes->get('(:num)', 'Race::show/$1'); + }); + + $routes->group('country', function ($routes) { + $routes->get('/', 'Country::index'); + $routes->get('(:num)', 'Country::show/$1'); + }); + + $routes->group('religion', function ($routes) { + $routes->get('/', 'Religion::index'); + $routes->get('(:num)', 'Religion::show/$1'); + }); + + $routes->group('ethnic', function ($routes) { + $routes->get('/', 'Ethnic::index'); + $routes->get('(:num)', 'Ethnic::show/$1'); + }); + + // Location + $routes->group('location', function ($routes) { + $routes->get('/', 'LocationController::index'); + $routes->get('(:num)', 'LocationController::show/$1'); + $routes->post('/', 'LocationController::create'); + $routes->patch('/', 'LocationController::update'); + $routes->delete('/', 'LocationController::delete'); + }); + + // Contact + $routes->group('contact', function ($routes) { + $routes->get('/', 'Contact\ContactController::index'); + $routes->get('(:num)', 'Contact\ContactController::show/$1'); + $routes->post('/', 'Contact\ContactController::create'); + $routes->patch('/', 'Contact\ContactController::update'); + $routes->delete('/', 'Contact\ContactController::delete'); + }); + + $routes->group('occupation', function ($routes) { + $routes->get('/', 'Contact\OccupationController::index'); + $routes->get('(:num)', 'Contact\OccupationController::show/$1'); + $routes->post('/', 'Contact\OccupationController::create'); + $routes->patch('/', 'Contact\OccupationController::update'); + //$routes->delete('/', 'Contact\OccupationController::delete'); + }); + + $routes->group('medicalspecialty', function ($routes) { + $routes->get('/', 'Contact\MedicalSpecialtyController::index'); + $routes->get('(:num)', 'Contact\MedicalSpecialtyController::show/$1'); + $routes->post('/', 'Contact\MedicalSpecialtyController::create'); + $routes->patch('/', 'Contact\MedicalSpecialtyController::update'); + }); + + // ValueSet + $routes->group('valueset', function ($routes) { + $routes->get('/', 'ValueSet\ValueSetController::index'); + $routes->get('(:num)', 'ValueSet\ValueSetController::show/$1'); + $routes->get('valuesetdef/(:num)', 'ValueSet\ValueSetController::showByValueSetDef/$1'); + $routes->post('/', 'ValueSet\ValueSetController::create'); + $routes->patch('/', 'ValueSet\ValueSetController::update'); + $routes->delete('/', 'ValueSet\ValueSetController::delete'); + }); + + $routes->group('valuesetdef', function ($routes) { + $routes->get('/', 'ValueSet\ValueSetDefController::index'); + $routes->get('(:segment)', 'ValueSet\ValueSetDefController::show/$1'); + $routes->post('/', 'ValueSet\ValueSetDefController::create'); + $routes->patch('/', 'ValueSet\ValueSetDefController::update'); + $routes->delete('/', 'ValueSet\ValueSetDefController::delete'); + }); + + // Counter + $routes->group('counter', function ($routes) { + $routes->get('/', 'CounterController::index'); + $routes->get('(:num)', 'CounterController::show/$1'); + $routes->post('/', 'CounterController::create'); + $routes->patch('/', 'CounterController::update'); + $routes->delete('/', 'CounterController::delete'); + }); + + // AreaGeo + $routes->group('areageo', function ($routes) { + $routes->get('/', 'AreaGeoController::index'); + $routes->get('provinces', 'AreaGeoController::getProvinces'); + $routes->get('cities', 'AreaGeoController::getCities'); + }); + + // Organization + $routes->group('organization', function ($routes) { + // Account + $routes->group('account', function ($routes) { + $routes->get('/', 'Organization\AccountController::index'); + $routes->get('(:num)', 'Organization\AccountController::show/$1'); + $routes->post('/', 'Organization\AccountController::create'); + $routes->patch('/', 'Organization\AccountController::update'); + $routes->delete('/', 'Organization\AccountController::delete'); + }); + + // Site + $routes->group('site', function ($routes) { + $routes->get('/', 'Organization\SiteController::index'); + $routes->get('(:num)', 'Organization\SiteController::show/$1'); + $routes->post('/', 'Organization\SiteController::create'); + $routes->patch('/', 'Organization\SiteController::update'); + $routes->delete('/', 'Organization\SiteController::delete'); + }); + + // Discipline + $routes->group('discipline', function ($routes) { + $routes->get('/', 'Organization\DisciplineController::index'); + $routes->get('(:num)', 'Organization\DisciplineController::show/$1'); + $routes->post('/', 'Organization\DisciplineController::create'); + $routes->patch('/', 'Organization\DisciplineController::update'); + $routes->delete('/', 'Organization\DisciplineController::delete'); + }); + + // Department + $routes->group('department', function ($routes) { + $routes->get('/', 'Organization\DepartmentController::index'); + $routes->get('(:num)', 'Organization\DepartmentController::show/$1'); + $routes->post('/', 'Organization\DepartmentController::create'); + $routes->patch('/', 'Organization\DepartmentController::update'); + $routes->delete('/', 'Organization\DepartmentController::delete'); + }); + + // Workstation + $routes->group('workstation', function ($routes) { + $routes->get('/', 'Organization\WorkstationController::index'); + $routes->get('(:num)', 'Organization\WorkstationController::show/$1'); + $routes->post('/', 'Organization\WorkstationController::create'); + $routes->patch('/', 'Organization\WorkstationController::update'); + $routes->delete('/', 'Organization\WorkstationController::delete'); + }); + }); + + // Specimen + $routes->group('specimen', function ($routes) { + $routes->group('containerdef', function ($routes) { + $routes->get('/', 'Specimen\ContainerDefController::index'); + $routes->get('(:num)', 'Specimen\ContainerDefController::show/$1'); + $routes->post('/', 'Specimen\ContainerDefController::create'); + $routes->patch('/', 'Specimen\ContainerDefController::update'); + }); + + $routes->group('prep', function ($routes) { + $routes->get('/', 'Specimen\SpecimenPrepController::index'); + $routes->get('(:num)', 'Specimen\SpecimenPrepController::show/$1'); + $routes->post('/', 'Specimen\SpecimenPrepController::create'); + $routes->patch('/', 'Specimen\SpecimenPrepController::update'); + }); + + $routes->group('status', function ($routes) { + $routes->get('/', 'Specimen\SpecimenStatusController::index'); + $routes->get('(:num)', 'Specimen\SpecimenStatusController::show/$1'); + $routes->post('/', 'Specimen\SpecimenStatusController::create'); + $routes->patch('/', 'Specimen\SpecimenStatusController::update'); + }); + + $routes->group('collection', function ($routes) { + $routes->get('/', 'Specimen\SpecimenCollectionController::index'); + $routes->get('(:num)', 'Specimen\SpecimenCollectionController::show/$1'); + $routes->post('/', 'Specimen\SpecimenCollectionController::create'); + $routes->patch('/', 'Specimen\SpecimenCollectionController::update'); + }); + + $routes->get('/', 'Specimen\SpecimenController::index'); + $routes->get('(:num)', 'Specimen\SpecimenController::show/$1'); + $routes->post('/', 'Specimen\SpecimenController::create'); + $routes->patch('/', 'Specimen\SpecimenController::update'); + }); + + // Tests + $routes->group('tests', function ($routes) { + $routes->get('/', 'TestsController::index'); + $routes->get('(:num)', 'TestsController::show/$1'); + $routes->post('/', 'TestsController::create'); + $routes->patch('/', 'TestsController::update'); + }); + + // Edge API - Integration with tiny-edge + $routes->group('edge', function ($routes) { + $routes->post('results', 'EdgeController::results'); + $routes->get('orders', 'EdgeController::orders'); + $routes->post('orders/(:num)/ack', 'EdgeController::ack/$1'); + $routes->post('status', 'EdgeController::status'); + }); }); -$routes->post('/api/auth/login', 'Auth::login'); -$routes->post('/api/auth/change_pass', 'Auth::change_pass'); -$routes->post('/api/auth/register', 'Auth::register'); -$routes->get('/api/auth/check', 'Auth::checkAuth'); -$routes->post('/api/auth/logout', 'Auth::logout'); - -$routes->get('/api/patient', 'Patient\Patient::index'); -$routes->post('/api/patient', 'Patient\Patient::create'); -$routes->get('/api/patient/(:num)', 'Patient\Patient::show/$1'); -$routes->delete('/api/patient', 'Patient\Patient::delete'); -$routes->patch('/api/patient', 'Patient\Patient::update'); -$routes->get('/api/patient/check', 'Patient\Patient::patientCheck'); - -$routes->get('/api/patvisit', 'PatVisit::index'); -$routes->post('/api/patvisit', 'PatVisit::create'); -$routes->get('/api/patvisit/patient/(:num)', 'PatVisit::showByPatient/$1'); -$routes->get('/api/patvisit/(:any)', 'PatVisit::show/$1'); -$routes->delete('/api/patvisit', 'PatVisit::delete'); -$routes->patch('/api/patvisit', 'PatVisit::update'); -$routes->post('/api/patvisitadt', 'PatVisit::createADT'); -$routes->patch('/api/patvisitadt', 'PatVisit::updateADT'); - -$routes->get('/api/race', 'Race::index'); -$routes->get('/api/race/(:num)', 'Race::show/$1'); - -$routes->get('/api/country', 'Country::index'); -$routes->get('/api/country/(:num)', 'Country::show/$1'); - -$routes->get('/api/religion', 'Religion::index'); -$routes->get('/api/religion/(:num)', 'Religion::show/$1'); - -$routes->get('/api/ethnic', 'Ethnic::index'); -$routes->get('/api/ethnic/(:num)', 'Ethnic::show/$1'); - -$routes->get('/api/location', 'Location::index'); -$routes->get('/api/location/(:num)', 'Location::show/$1'); -$routes->post('/api/location', 'Location::create'); -$routes->patch('/api/location', 'Location::update'); -$routes->delete('/api/location', 'Location::delete'); - -$routes->get('/api/contact', 'Contact\Contact::index'); -$routes->get('/api/contact/(:num)', 'Contact\Contact::show/$1'); -$routes->post('/api/contact', 'Contact\Contact::create'); -$routes->patch('/api/contact', 'Contact\Contact::update'); -$routes->delete('/api/contact', 'Contact\git Contact::delete'); - -$routes->get('/api/occupation', 'Contact\Occupation::index'); -$routes->get('/api/occupation/(:num)', 'Contact\Occupation::show/$1'); -$routes->post('/api/occupation', 'Contact\Occupation::create'); -$routes->patch('/api/occupation', 'Contact\Occupation::update'); -//$routes->delete('/api/occupation', 'Contact\Occupation::delete'); - -$routes->get('/api/medicalspecialty', 'Contact\MedicalSpecialty::index'); -$routes->get('/api/medicalspecialty/(:num)', 'Contact\MedicalSpecialty::show/$1'); -$routes->post('/api/medicalspecialty', 'Contact\MedicalSpecialty::create'); -$routes->patch('/api/medicalspecialty', 'Contact\MedicalSpecialty::update'); - -$routes->get('/api/valueset', 'ValueSet\ValueSet::index'); -$routes->get('/api/valueset/(:num)', 'ValueSet\ValueSet::show/$1'); -$routes->get('/api/valueset/valuesetdef/(:num)', 'ValueSet\ValueSet::showByValueSetDef/$1'); -$routes->post('/api/valueset', 'ValueSet\ValueSet::create'); -$routes->patch('/api/valueset', 'ValueSet\ValueSet::update'); -$routes->delete('/api/valueset', 'ValueSet\ValueSet::delete'); - -$routes->get('/api/valuesetdef/', 'ValueSet\ValueSetDef::index'); -$routes->get('/api/valuesetdef/(:num)', 'ValueSet\ValueSetDef::show/$1'); -$routes->post('/api/valuesetdef', 'ValueSet\ValueSetDef::create'); -$routes->patch('/api/valuesetdef', 'ValueSet\ValueSetDef::update'); -$routes->delete('/api/valuesetdef', 'ValueSet\ValueSetDef::delete'); - -$routes->get('/api/counter/', 'Counter::index'); -$routes->get('/api/counter/(:num)', 'Counter::show/$1'); -$routes->post('/api/counter', 'Counter::create'); -$routes->patch('/api/counter', 'Counter::update'); -$routes->delete('/api/counter', 'Counter::delete'); - -$routes->get('/api/areageo', 'AreaGeo::index'); -$routes->get('/api/areageo/provinces', 'AreaGeo::getProvinces'); -$routes->get('/api/areageo/cities', 'AreaGeo::getCities'); - -//organization -// account -$routes->get('/api/organization/account/', 'Organization\Account::index'); -$routes->get('/api/organization/account/(:num)', 'Organization\Account::show/$1'); -$routes->post('/api/organization/account', 'Organization\Account::create'); -$routes->patch('/api/organization/account', 'Organization\Account::update'); -$routes->delete('/api/organization/account', 'Organization\Account::delete'); -// site -$routes->get('/api/organization/site/', 'Organization\Site::index'); -$routes->get('/api/organization/site/(:num)', 'Organization\Site::show/$1'); -$routes->post('/api/organization/site', 'Organization\Site::create'); -$routes->patch('/api/organization/site', 'Organization\Site::update'); -$routes->delete('/api/organization/site', 'Organization\Site::delete'); -// discipline -$routes->get('/api/organization/discipline/', 'Organization\Discipline::index'); -$routes->get('/api/organization/discipline/(:num)', 'Organization\Discipline::show/$1'); -$routes->post('/api/organization/discipline', 'Organization\Discipline::create'); -$routes->patch('/api/organization/discipline', 'Organization\Discipline::update'); -$routes->delete('/api/organization/discipline', 'Organization\Discipline::delete'); -// department -$routes->get('/api/organization/department/', 'Organization\Department::index'); -$routes->get('/api/organization/department/(:num)', 'Organization\Department::show/$1'); -$routes->post('/api/organization/department', 'Organization\Department::create'); -$routes->patch('/api/organization/department', 'Organization\Department::update'); -$routes->delete('/api/organization/department', 'Organization\Department::delete'); -// workstation -$routes->get('/api/organization/workstation/', 'Organization\Workstation::index'); -$routes->get('/api/organization/workstation/(:num)', 'Organization\Workstation::show/$1'); -$routes->post('/api/organization/workstation', 'Organization\Workstation::create'); -$routes->patch('/api/organization/workstation', 'Organization\Workstation::update'); -$routes->delete('/api/organization/workstation', 'Organization\Workstation::delete'); - -$routes->group('api/specimen', function($routes) { - $routes->get('containerdef/(:num)', 'Specimen\ContainerDef::show/$1'); - $routes->post('containerdef', 'Specimen\ContainerDef::create'); - $routes->patch('containerdef', 'Specimen\ContainerDef::update'); - $routes->get('containerdef', 'Specimen\ContainerDef::index'); - - $routes->get('prep/(:num)', 'Specimen\Prep::show/$1'); - $routes->post('prep', 'Specimen\Prep::create'); - $routes->patch('prep', 'Specimen\Prep::update'); - $routes->get('prep', 'Specimen\Prep::index'); - - $routes->get('status/(:num)', 'Specimen\Status::show/$1'); - $routes->post('status', 'Specimen\Status::create'); - $routes->patch('status', 'Specimen\Status::update'); - $routes->get('status', 'Specimen\Status::index'); - - $routes->get('collection/(:num)', 'Specimen\Collection::show/$1'); - $routes->post('collection', 'Specimen\Collection::create'); - $routes->patch('collection', 'Specimen\Collection::update'); - $routes->get('collection', 'Specimen\Collection::index'); - - $routes->get('(:num)', 'Specimen\Specimen::show/$1'); - $routes->post('', 'Specimen\Specimen::create'); - $routes->patch('', 'Specimen\Specimen::update'); - $routes->get('', 'Specimen\Specimen::index'); -}); - -$routes->post('/api/tests', 'Tests::create'); -$routes->patch('/api/tests', 'Tests::update'); -$routes->get('/api/tests/(:any)', 'Tests::show/$1'); -$routes->get('/api/tests', 'Tests::index'); - // Khusus /* $routes->get('/api/zones', 'Zones::index'); $routes->get('/api/zones/synchronize', 'Zones::synchronize'); $routes->get('/api/zones/provinces', 'Zones::getProvinces'); $routes->get('/api/zones/cities', 'Zones::getCities'); -*/ \ No newline at end of file +*/ + diff --git a/app/Controllers/AreaGeo.php b/app/Controllers/AreaGeoController.php similarity index 97% rename from app/Controllers/AreaGeo.php rename to app/Controllers/AreaGeoController.php index 498e040..2775469 100644 --- a/app/Controllers/AreaGeo.php +++ b/app/Controllers/AreaGeoController.php @@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\AreaGeoModel; -class AreaGeo extends BaseController { +class AreaGeoController extends BaseController { use ResponseTrait; protected $model; @@ -44,4 +44,4 @@ class AreaGeo extends BaseController { return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); } -} \ No newline at end of file +} diff --git a/app/Controllers/Auth.php b/app/Controllers/AuthController.php similarity index 52% rename from app/Controllers/Auth.php rename to app/Controllers/AuthController.php index 230cbab..25e0dba 100644 --- a/app/Controllers/Auth.php +++ b/app/Controllers/AuthController.php @@ -12,23 +12,26 @@ use Firebase\JWT\SignatureInvalidException; use Firebase\JWT\BeforeValidException; use CodeIgniter\Cookie\Cookie; -class Auth extends Controller { +class AuthController extends Controller +{ use ResponseTrait; // ok - public function __construct() { + public function __construct() + { $this->db = \Config\Database::connect(); } // ok - public function checkAuth() { + public function checkAuth() + { $token = $this->request->getCookie('token'); - $key = getenv('JWT_SECRET'); + $key = getenv('JWT_SECRET'); // Jika token FE tidak ada langsung kabarkan failed if (!$token) { return $this->respond([ - 'status' => 'failed', + 'status' => 'failed', 'message' => 'No token found' ], 401); } @@ -38,43 +41,101 @@ class Auth extends Controller { $decodedPayload = JWT::decode($token, new Key($key, 'HS256')); return $this->respond([ - 'status' => 'success', + 'status' => 'success', 'message' => 'Authenticated', - 'data' => $decodedPayload + 'data' => $decodedPayload ], 200); } catch (ExpiredException $e) { return $this->respond([ - 'status' => 'failed', + 'status' => 'failed', 'message' => 'Token expired', - 'data' => [] + 'data' => [] ], 401); } catch (SignatureInvalidException $e) { return $this->respond([ - 'status' => 'failed', + 'status' => 'failed', 'message' => 'Invalid token signature', - 'data' => [] + 'data' => [] ], 401); } catch (BeforeValidException $e) { return $this->respond([ - 'status' => 'failed', + 'status' => 'failed', 'message' => 'Token not valid yet', - 'data' => [] + 'data' => [] ], 401); } catch (\Exception $e) { return $this->respond([ - 'status' => 'failed', + 'status' => 'failed', 'message' => 'Invalid token: ' . $e->getMessage(), - 'data' => [] + 'data' => [] ], 401); } } // ok - public function login() { + // public function login() { + + // // Ambil dari JSON Form dan Key .env + // $username = $this->request->getVar('username'); + // $password = $this->request->getVar('password'); + // $key = getenv('JWT_SECRET'); + + // if (!$username) { + // return $this->fail('Username required.', 400); + // } + + // $sql = "SELECT * FROM users WHERE username=" . $this->db->escape($username); + // $query = $this->db->query($sql); + // $row = $query->getResultArray(); + + // if (!$row) { return $this->fail('User not found.', 401); } + // $row = $row[0]; + // if (!password_verify($password, $row['password'])) { + // return $this->fail('Invalid password.', 401); + // } + + // // Buat JWT payload + // $exp = time() + 864000; + // $payload = [ + // 'userid' => $row['id'], + // 'roleid' => $row['role_id'], + // 'username' => $row['username'], + // 'exp' => $exp + // ]; + + // try { + // // Melakukan Hash terhadap Payload dengan Kunci .env menggunakan Algortima HMAC + SHA-256 + // $jwt = JWT::encode($payload, $key, 'HS256'); + // } catch (Exception $e) { + // return $this->fail('Error generating JWT: ' . $e->getMessage(), 500); + // } + + // // Kirim Respon ke HttpOnly yg akan disimpan di browser dan tidak akan dapat diakses oleh siapapun + // // $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; + // $this->response->setCookie([ + // // 'name' => 'token', // nama token + // // 'value' => $jwt, // value dari jwt yg sudah di hash + // // 'expire' => 864000, // 10 hari + // // 'path' => '/', // valid untuk semua path + // // 'secure' => $isSecure, // true for HTTPS, false for HTTP (localhost) + // // 'httponly' => true, // dipakai agar cookie berikut tidak dapat diakses oleh javascript + // // 'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX + // ]); + + + // // Response tanpa token di body + // return $this->respond([ + // 'status' => 'success', + // 'code' => 200, + // 'message' => 'Login successful' + // ], 200); + // } + public function login() + { // Ambil dari JSON Form dan Key .env $username = $this->request->getVar('username'); @@ -89,7 +150,9 @@ class Auth extends Controller { $query = $this->db->query($sql); $row = $query->getResultArray(); - if (!$row) { return $this->fail('User not found.', 401); } + if (!$row) { + return $this->fail('User not found.', 401); + } $row = $row[0]; if (!password_verify($password, $row['password'])) { return $this->fail('Invalid password.', 401); @@ -98,10 +161,10 @@ class Auth extends Controller { // Buat JWT payload $exp = time() + 864000; $payload = [ - 'userid' => $row['id'], + 'userid' => $row['id'], 'roleid' => $row['role_id'], 'username' => $row['username'], - 'exp' => $exp + 'exp' => $exp ]; try { @@ -113,44 +176,64 @@ class Auth extends Controller { // Kirim Respon ke HttpOnly yg akan disimpan di browser dan tidak akan dapat diakses oleh siapapun $this->response->setCookie([ - 'name' => 'token', // nama token - 'value' => $jwt, // value dari jwt yg sudah di hash - 'expire' => 864000, // 10 hari - 'path' => '/', // valid untuk semua path - 'secure' => true, // set true kalau sudah HTTPS + 'name' => 'token', // nama token + 'value' => $jwt, // value dari jwt yg sudah di hash + 'expire' => 864000, // 10 hari + 'path' => '/', // valid untuk semua path + 'secure' => true, // set true kalau sudah HTTPS 'httponly' => true, // dipakai agar cookie berikut tidak dapat diakses oleh javascript - 'samesite' => Cookie::SAMESITE_NONE + 'samesite' => Cookie::SAMESITE_NONE ]); // Response tanpa token di body return $this->respond([ - 'status' => 'success', + 'status' => 'success', 'code' => 200, 'message' => 'Login successful' ], 200); } // ok - public function logout() { + // public function logout() { + // // Definisikan ini pada cookies browser, harus sama dengan cookies login + // // $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; + // return $this->response->setCookie([ + // 'name' => 'token', + // 'value' => '', + // 'expire' => time() - 3600, + // 'path' => '/', + // 'secure' => $isSecure, + // 'httponly' => true, + // 'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX + + // ])->setJSON([ + // 'status' => 'success', + // 'code' => 200, + // 'message' => 'Logout successful' + // ], 200); + // } + public function logout() + { // Definisikan ini pada cookies browser, harus sama dengan cookies login return $this->response->setCookie([ - 'name' => 'token', - 'value' => '', - 'expire' => time() - 3600, - 'path' => '/', - 'secure' => true, + 'name' => 'token', + 'value' => '', + 'expire' => time() - 3600, + 'path' => '/', + 'secure' => true, 'httponly' => true, 'samesite' => Cookie::SAMESITE_NONE - + ])->setJSON([ - 'status' => 'success', - 'code' => 200, - 'message' => 'Logout successful' - ], 200); + 'status' => 'success', + 'code' => 200, + 'message' => 'Logout successful' + ], 200); } // ok - public function register() { + public function register() + { $username = strtolower($this->request->getJsonVar('username')); $password = $this->request->getJsonVar('password'); @@ -158,7 +241,7 @@ class Auth extends Controller { // Validasi Awal Dari BE if (empty($username) || empty($password)) { return $this->respond([ - 'status' => 'failed', + 'status' => 'failed', 'code' => 400, 'message' => 'Username and password are required' ], 400); // Gunakan 400 Bad Request @@ -167,11 +250,11 @@ class Auth extends Controller { // Cek Duplikasi Username $exists = $this->db->query("SELECT id FROM users WHERE username = ?", [$username])->getRow(); if ($exists) { - return $this->respond(['status' => 'failed', 'code'=>409,'message' => 'Username already exists'], 409); + return $this->respond(['status' => 'failed', 'code' => 409, 'message' => 'Username already exists'], 409); } $hashedPassword = password_hash($password, PASSWORD_DEFAULT); - + // Mulai transaksi Insert $this->db->transStart(); $this->db->query( @@ -183,8 +266,8 @@ class Auth extends Controller { // Cek status transaksi if ($this->db->transStatus() === false) { return $this->respond([ - 'status' => 'error', - 'code' => 500, + 'status' => 'error', + 'code' => 500, 'message' => 'Failed to create user. Please try again later.' ], 500); } @@ -194,7 +277,7 @@ class Auth extends Controller { 'status' => 'success', 'code' => 201, 'message' => 'User ' . $username . ' successfully created.' - ], 201); + ], 201); } @@ -219,19 +302,20 @@ class Auth extends Controller { // return $this->respond($response); // } - public function coba() { + public function coba() + { $token = $this->request->getCookie('token'); - $key = getenv('JWT_SECRET'); + $key = getenv('JWT_SECRET'); // Decode Token dengan Key yg ada di .env $decodedPayload = JWT::decode($token, new Key($key, 'HS256')); return $this->respond([ - 'status' => 'success', + 'status' => 'success', 'message' => 'Authenticated', - 'data' => $decodedPayload + 'data' => $decodedPayload ], 200); } - + } diff --git a/app/Controllers/AuthV2Controller.php b/app/Controllers/AuthV2Controller.php new file mode 100644 index 0000000..c84303b --- /dev/null +++ b/app/Controllers/AuthV2Controller.php @@ -0,0 +1,238 @@ +db = \Config\Database::connect(); + } + + /** + * Check authentication status + * GET /v2/auth/check + */ + public function checkAuth() + { + $token = $this->request->getCookie('token'); + $key = getenv('JWT_SECRET'); + + if (!$token) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'No token found' + ], 401); + } + + try { + $decodedPayload = JWT::decode($token, new Key($key, 'HS256')); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Authenticated', + 'data' => $decodedPayload + ], 200); + + } catch (ExpiredException $e) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Token expired' + ], 401); + + } catch (SignatureInvalidException $e) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Invalid token signature' + ], 401); + + } catch (BeforeValidException $e) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Token not valid yet' + ], 401); + + } catch (\Exception $e) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Invalid token: ' . $e->getMessage() + ], 401); + } + } + + /** + * Login user + * POST /v2/auth/login + */ + public function login() + { + $username = $this->request->getVar('username'); + $password = $this->request->getVar('password'); + $key = getenv('JWT_SECRET'); + + // Validate username + if (!$username) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Username is required' + ], 400); + } + + // Find user + $sql = "SELECT * FROM users WHERE username = " . $this->db->escape($username); + $query = $this->db->query($sql); + $row = $query->getResultArray(); + + if (!$row) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'User not found' + ], 401); + } + + $row = $row[0]; + + // Verify password + if (!password_verify($password, $row['password'])) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Invalid password' + ], 401); + } + + // Create JWT payload + $exp = time() + 864000; // 10 days + $payload = [ + 'userid' => $row['id'], + 'roleid' => $row['role_id'], + 'username' => $row['username'], + 'exp' => $exp + ]; + + try { + $jwt = JWT::encode($payload, $key, 'HS256'); + } catch (\Exception $e) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Error generating JWT: ' . $e->getMessage() + ], 500); + } + + // Detect if HTTPS is being used + $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; + + // Set HTTP-only cookie + $this->response->setCookie([ + 'name' => 'token', + 'value' => $jwt, + 'expire' => 864000, + 'path' => '/', + 'secure' => $isSecure, // false for localhost HTTP + 'httponly' => true, + 'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX + ]); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Login successful', + 'data' => [ + 'username' => $row['username'], + 'role_id' => $row['role_id'] + ] + ], 200); + } + + /** + * Logout user + * POST /v2/auth/logout + */ + public function logout() + { + // Detect if HTTPS is being used + $isSecure = isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on'; + + // Clear the token cookie + return $this->response->setCookie([ + 'name' => 'token', + 'value' => '', + 'expire' => time() - 3600, + 'path' => '/', + 'secure' => $isSecure, + 'httponly' => true, + 'samesite' => $isSecure ? Cookie::SAMESITE_NONE : Cookie::SAMESITE_LAX + ])->setJSON([ + 'status' => 'success', + 'message' => 'Logout successful' + ]); + } + + /** + * Register new user + * POST /v2/auth/register + */ + public function register() + { + $username = strtolower($this->request->getJsonVar('username')); + $password = $this->request->getJsonVar('password'); + + // Validate input + if (empty($username) || empty($password)) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Username and password are required' + ], 400); + } + + // Check for existing username + $exists = $this->db->query("SELECT id FROM users WHERE username = ?", [$username])->getRow(); + if ($exists) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Username already exists' + ], 409); + } + + // Hash password + $hashedPassword = password_hash($password, PASSWORD_DEFAULT); + + // Insert user + $this->db->transStart(); + $this->db->query( + "INSERT INTO users(username, password, role_id) VALUES(?, ?, ?)", + [$username, $hashedPassword, 1] + ); + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->respond([ + 'status' => 'failed', + 'message' => 'Failed to create user' + ], 500); + } + + return $this->respond([ + 'status' => 'success', + 'message' => 'User ' . $username . ' successfully created' + ], 201); + } +} diff --git a/app/Controllers/Contact/Contact.php b/app/Controllers/Contact/ContactController.php similarity index 92% rename from app/Controllers/Contact/Contact.php rename to app/Controllers/Contact/ContactController.php index 3b9ae67..6fb0706 100644 --- a/app/Controllers/Contact/Contact.php +++ b/app/Controllers/Contact/ContactController.php @@ -6,7 +6,7 @@ use App\Controllers\BaseController; use App\Models\Contact\ContactModel; -class Contact extends BaseController { +class ContactController extends BaseController { use ResponseTrait; protected $db; @@ -33,13 +33,13 @@ class Contact extends BaseController { public function show($ContactID = null) { $model = new ContactModel(); - $rows = $model->getContactWithDetail($ContactID); + $row = $model->getContactWithDetail($ContactID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200); } public function delete() { @@ -76,4 +76,4 @@ class Contact extends BaseController { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Controllers/Contact/MedicalSpecialty.php b/app/Controllers/Contact/MedicalSpecialtyController.php similarity index 90% rename from app/Controllers/Contact/MedicalSpecialty.php rename to app/Controllers/Contact/MedicalSpecialtyController.php index 659a465..8e3e9ae 100644 --- a/app/Controllers/Contact/MedicalSpecialty.php +++ b/app/Controllers/Contact/MedicalSpecialtyController.php @@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\Contact\MedicalSpecialtyModel; -class MedicalSpecialty extends BaseController { +class MedicalSpecialtyController extends BaseController { use ResponseTrait; protected $db; @@ -32,11 +32,11 @@ class MedicalSpecialty extends BaseController { public function show($SpecialtyID = null) { $model = new MedicalSpecialtyModel(); - $rows = $model->find($SpecialtyID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "no Data."], 200); + $row = $model->find($SpecialtyID); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200); } public function create() { @@ -61,4 +61,4 @@ class MedicalSpecialty extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/Contact/Occupation.php b/app/Controllers/Contact/OccupationController.php similarity index 90% rename from app/Controllers/Contact/Occupation.php rename to app/Controllers/Contact/OccupationController.php index 239e142..43d017a 100644 --- a/app/Controllers/Contact/Occupation.php +++ b/app/Controllers/Contact/OccupationController.php @@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\Contact\OccupationModel; -class Occupation extends BaseController { +class OccupationController extends BaseController { use ResponseTrait; protected $db; @@ -32,11 +32,11 @@ class Occupation extends BaseController { public function show($OccupationID = null) { $model = new OccupationModel(); - $rows = $model->find($OccupationID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "no Data."], 200); + $row = $model->find($OccupationID); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200); } public function create() { @@ -61,4 +61,4 @@ class Occupation extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/Counter.php b/app/Controllers/CounterController.php similarity index 90% rename from app/Controllers/Counter.php rename to app/Controllers/CounterController.php index dbc04a8..31e2039 100644 --- a/app/Controllers/Counter.php +++ b/app/Controllers/CounterController.php @@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\CounterModel; -class Counter extends BaseController { +class CounterController extends BaseController { use ResponseTrait; protected $model; @@ -24,13 +24,13 @@ class Counter extends BaseController { } public function show($CounterID = null) { - $rows = $this->model->find($CounterID); + $row = $this->model->find($CounterID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "No Data.", 'data' => [] ], 200); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "No Data.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row ], 200); } public function create() { @@ -62,4 +62,4 @@ class Counter extends BaseController { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Controllers/Sample.php b/app/Controllers/DashboardController.php similarity index 73% rename from app/Controllers/Sample.php rename to app/Controllers/DashboardController.php index 0ba1b58..c6b69b7 100644 --- a/app/Controllers/Sample.php +++ b/app/Controllers/DashboardController.php @@ -12,23 +12,25 @@ use Firebase\JWT\SignatureInvalidException; use Firebase\JWT\BeforeValidException; use CodeIgniter\Cookie\Cookie; -class Sample extends Controller { +class DashboardController extends Controller +{ use ResponseTrait; - public function index() { + public function index() + { $token = $this->request->getCookie('token'); - $key = getenv('JWT_SECRET'); + $key = getenv('JWT_SECRET'); // Decode Token dengan Key yg ada di .env $decodedPayload = JWT::decode($token, new Key($key, 'HS256')); return $this->respond([ - 'status' => 'success', - 'code' => 200, + 'status' => 'success', + 'code' => 200, 'message' => 'Authenticated', - 'data' => $decodedPayload + 'data' => $decodedPayload ], 200); } - + } diff --git a/app/Controllers/EdgeController.php b/app/Controllers/EdgeController.php new file mode 100644 index 0000000..b256669 --- /dev/null +++ b/app/Controllers/EdgeController.php @@ -0,0 +1,169 @@ +db = \Config\Database::connect(); + $this->edgeResModel = new \App\Models\EdgeResModel(); + } + + /** + * POST /api/edge/results + * Receive results from tiny-edge + */ + public function results() + { + try { + $input = $this->request->getJSON(true); + + if (empty($input)) { + return $this->failValidationErrors('Invalid JSON payload'); + } + + // Extract key fields from payload + $sampleId = $input['sample_id'] ?? null; + $instrumentId = $input['instrument_id'] ?? null; + $patientId = $input['patient_id'] ?? null; + + // Store in edgeres table + $data = [ + 'SiteID' => 1, // Default site, can be configured + 'InstrumentID' => $instrumentId, + 'SampleID' => $sampleId, + 'PatientID' => $patientId, + 'Payload' => json_encode($input), + 'Status' => 'pending', + 'AutoProcess' => 0, // Default to manual processing + 'CreateDate' => date('Y-m-d H:i:s') + ]; + + $id = $this->edgeResModel->insert($data); + + if (!$id) { + return $this->failServerError('Failed to save result'); + } + + return $this->respondCreated([ + 'status' => 'success', + 'message' => 'Result received and queued', + 'data' => [ + 'edge_res_id' => $id, + 'sample_id' => $sampleId, + 'instrument_id' => $instrumentId + ] + ]); + + } catch (\Throwable $e) { + return $this->failServerError('Error processing result: ' . $e->getMessage()); + } + } + + /** + * GET /api/edge/orders + * Return pending orders for an instrument + */ + public function orders() + { + try { + $instrumentId = $this->request->getGet('instrument'); + + if (!$instrumentId) { + return $this->failValidationErrors('instrument parameter is required'); + } + + // TODO: Implement order fetching logic + // For now, return empty array + return $this->respond([ + 'status' => 'success', + 'message' => 'Orders fetched', + 'data' => [] + ]); + + } catch (\Throwable $e) { + return $this->failServerError('Error fetching orders: ' . $e->getMessage()); + } + } + + /** + * POST /api/edge/orders/:id/ack + * Acknowledge order delivery + */ + public function ack($orderId = null) + { + try { + if (!$orderId) { + return $this->failValidationErrors('Order ID is required'); + } + + $input = $this->request->getJSON(true); + $instrumentId = $input['instrument_id'] ?? null; + + // Log acknowledgment + $this->db->table('edgeack')->insert([ + 'OrderID' => $orderId, + 'InstrumentID' => $instrumentId, + 'AckDate' => date('Y-m-d H:i:s'), + 'CreateDate' => date('Y-m-d H:i:s') + ]); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Order acknowledged', + 'data' => [ + 'order_id' => $orderId + ] + ]); + + } catch (\Throwable $e) { + return $this->failServerError('Error acknowledging order: ' . $e->getMessage()); + } + } + + /** + * POST /api/edge/status + * Log instrument status + */ + public function status() + { + try { + $input = $this->request->getJSON(true); + + $instrumentId = $input['instrument_id'] ?? null; + $status = $input['status'] ?? null; + $lastActivity = $input['last_activity'] ?? null; + $timestamp = $input['timestamp'] ?? date('Y-m-d H:i:s'); + + if (!$instrumentId || !$status) { + return $this->failValidationErrors('instrument_id and status are required'); + } + + // Store status log + $this->db->table('edgestatus')->insert([ + 'InstrumentID' => $instrumentId, + 'Status' => $status, + 'LastActivity' => $lastActivity, + 'Timestamp' => $timestamp, + 'CreateDate' => date('Y-m-d H:i:s') + ]); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Status logged' + ]); + + } catch (\Throwable $e) { + return $this->failServerError('Error logging status: ' . $e->getMessage()); + } + } +} diff --git a/app/Controllers/Home.php b/app/Controllers/HomeController.php similarity index 95% rename from app/Controllers/Home.php rename to app/Controllers/HomeController.php index 89f165a..e76ca6a 100644 --- a/app/Controllers/Home.php +++ b/app/Controllers/HomeController.php @@ -12,7 +12,7 @@ use Firebase\JWT\SignatureInvalidException; use Firebase\JWT\BeforeValidException; use CodeIgniter\Cookie\Cookie; -class Home extends Controller { +class HomeController extends Controller { use ResponseTrait; public function index() { diff --git a/app/Controllers/Location.php b/app/Controllers/LocationController.php similarity index 83% rename from app/Controllers/Location.php rename to app/Controllers/LocationController.php index 1bcfa13..fae89c0 100644 --- a/app/Controllers/Location.php +++ b/app/Controllers/LocationController.php @@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\Location\LocationModel; -class Location extends BaseController { +class LocationController extends BaseController { use ResponseTrait; protected $model; @@ -31,20 +31,20 @@ class Location extends BaseController { } public function show($LocationID = null) { - $rows = $this->model->getLocation($LocationID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); + $row = $this->model->getLocation($LocationID); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200); } public function create() { $input = $this->request->getJSON(true); if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors($this->validator->getErrors()); } try { - $id = $this->model->saveLocation($input); - return $this->respondCreated([ 'status' => 'success', 'message' => 'data created successfully', 'data' => $id ], 201); + $result = $this->model->saveLocation($input); + return $this->respondCreated([ 'status' => 'success', 'message' => 'data created successfully', 'data' => $result ], 201); } catch (\Throwable $e) { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } @@ -54,8 +54,8 @@ class Location extends BaseController { $input = $this->request->getJSON(true); try { if (!$this->validateData($input, $this->rules)) { return $this->failValidationErrors( $this->validator->getErrors()); } - $id = $this->model->saveLocation($input, true); - return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $id ], 201); + $result = $this->model->saveLocation($input, true); + return $this->respondCreated([ 'status' => 'success', 'message' => 'data updated successfully', 'data' => $result ], 201); } catch (\Throwable $e) { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } @@ -72,4 +72,4 @@ class Location extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/OrderTest.php b/app/Controllers/OrderTestController.php similarity index 98% rename from app/Controllers/OrderTest.php rename to app/Controllers/OrderTestController.php index 9a672dc..a7ed0da 100644 --- a/app/Controllers/OrderTest.php +++ b/app/Controllers/OrderTestController.php @@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait; use CodeIgniter\Controller; use CodeIgniter\Database\RawSql; -class OrderTest extends Controller { +class OrderTestController extends Controller { use ResponseTrait; public function __construct() { @@ -34,13 +34,13 @@ class OrderTest extends Controller { } public function show($OrderID = null) { - $row=$this->db->table('ordertest')->select("*")->where('OrderID=', $OrderID)->get()->getResultArray(); + $row=$this->db->table('ordertest')->select("*")->where('OrderID', $OrderID)->get()->getRowArray(); if (empty($row)) { return $this->respond([ 'status' => 'success', 'message' => "Data not found.", - 'data' => [], + 'data' => null, ], 200); } @@ -214,4 +214,4 @@ class OrderTest extends Controller { return $data; } -} \ No newline at end of file +} diff --git a/app/Controllers/Organization/Account.php b/app/Controllers/Organization/AccountController.php similarity index 92% rename from app/Controllers/Organization/Account.php rename to app/Controllers/Organization/AccountController.php index 1bdf83b..0cc7ec6 100644 --- a/app/Controllers/Organization/Account.php +++ b/app/Controllers/Organization/AccountController.php @@ -6,7 +6,7 @@ use App\Controllers\BaseController; use App\Models\Organization\AccountModel; -class Account extends BaseController { +class AccountController extends BaseController { use ResponseTrait; protected $db; @@ -34,13 +34,13 @@ class Account extends BaseController { public function show($AccountID = null) { //$rows = $this->model->where('AccountID', $AccountID)->findAll(); - $rows = $this->model->getAccount($AccountID); + $row = $this->model->getAccount($AccountID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200); } public function delete() { @@ -76,4 +76,4 @@ class Account extends BaseController { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Controllers/Organization/Department.php b/app/Controllers/Organization/DepartmentController.php similarity index 89% rename from app/Controllers/Organization/Department.php rename to app/Controllers/Organization/DepartmentController.php index ae3a43c..db76c50 100644 --- a/app/Controllers/Organization/Department.php +++ b/app/Controllers/Organization/DepartmentController.php @@ -6,7 +6,7 @@ use App\Controllers\BaseController; use App\Models\Organization\DepartmentModel; -class Department extends BaseController { +class DepartmentController extends BaseController { use ResponseTrait; protected $db; @@ -32,13 +32,12 @@ class Department extends BaseController { } public function show($DepartmentID = null) { - //$rows = $this->model->where('DepartmentID', $DepartmentID)->findAll(); - $rows = $this->model->getDepartment($DepartmentID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); + $row = $this->model->getDepartment($DepartmentID); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200); } public function delete() { @@ -73,4 +72,4 @@ class Department extends BaseController { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Controllers/Organization/Discipline.php b/app/Controllers/Organization/DisciplineController.php similarity index 91% rename from app/Controllers/Organization/Discipline.php rename to app/Controllers/Organization/DisciplineController.php index 6bcb314..c36d9a5 100644 --- a/app/Controllers/Organization/Discipline.php +++ b/app/Controllers/Organization/DisciplineController.php @@ -6,7 +6,7 @@ use App\Controllers\BaseController; use App\Models\Organization\DisciplineModel; -class Discipline extends BaseController { +class DisciplineController extends BaseController { use ResponseTrait; protected $db; @@ -32,13 +32,13 @@ class Discipline extends BaseController { } public function show($DisciplineID = null) { - $rows = $this->model->where('DisciplineID', $DisciplineID)->findAll(); + $row = $this->model->where('DisciplineID', $DisciplineID)->first(); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200); } public function delete() { @@ -79,4 +79,4 @@ class Discipline extends BaseController { } */ } -} \ No newline at end of file +} diff --git a/app/Controllers/Organization/Site.php b/app/Controllers/Organization/SiteController.php similarity index 92% rename from app/Controllers/Organization/Site.php rename to app/Controllers/Organization/SiteController.php index 03ccc19..9c0c2d6 100644 --- a/app/Controllers/Organization/Site.php +++ b/app/Controllers/Organization/SiteController.php @@ -6,7 +6,7 @@ use App\Controllers\BaseController; use App\Models\Organization\SiteModel; -class Site extends BaseController { +class SiteController extends BaseController { use ResponseTrait; protected $db; @@ -33,13 +33,13 @@ class Site extends BaseController { public function show($SiteID = null) { //$rows = $this->model->where('SiteID', $SiteID)->findAll(); - $rows = $this->model->getSite($SiteID); + $row = $this->model->getSite($SiteID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200); } public function delete() { @@ -75,4 +75,4 @@ class Site extends BaseController { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Controllers/Organization/Workstation.php b/app/Controllers/Organization/WorkstationController.php similarity index 91% rename from app/Controllers/Organization/Workstation.php rename to app/Controllers/Organization/WorkstationController.php index 153505e..2414783 100644 --- a/app/Controllers/Organization/Workstation.php +++ b/app/Controllers/Organization/WorkstationController.php @@ -6,7 +6,7 @@ use App\Controllers\BaseController; use App\Models\Organization\WorkstationModel; -class Workstation extends BaseController { +class WorkstationController extends BaseController { use ResponseTrait; protected $db; @@ -32,13 +32,13 @@ class Workstation extends BaseController { } public function show($WorkstationID = null) { - $rows = $this->model->getWorkstation($WorkstationID); + $row = $this->model->getWorkstation($WorkstationID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $rows ], 200); + return $this->respond([ 'status' => 'success', 'message'=> "fetch success", 'data' => $row ], 200); } public function delete() { @@ -73,4 +73,4 @@ class Workstation extends BaseController { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Controllers/Pages/AuthPage.php b/app/Controllers/Pages/AuthPage.php deleted file mode 100644 index 441a313..0000000 --- a/app/Controllers/Pages/AuthPage.php +++ /dev/null @@ -1,43 +0,0 @@ -request->getCookie('token'); - - if ($token) { - // If token exists, redirect to dashboard - return redirect()->to('/dashboard'); - } - - return view('pages/login', [ - 'title' => 'Login', - 'description' => 'Sign in to your CLQMS account' - ]); - } - - /** - * Handle logout - clear cookie and redirect - */ - public function logout() - { - // Delete the token cookie - $response = service('response'); - $response->deleteCookie('token'); - - return redirect()->to('/login'); - } -} diff --git a/app/Controllers/Pages/DashboardPage.php b/app/Controllers/Pages/DashboardPage.php deleted file mode 100644 index 129561f..0000000 --- a/app/Controllers/Pages/DashboardPage.php +++ /dev/null @@ -1,49 +0,0 @@ -request->getCookie('token'); - - if (!$token) { - return redirect()->to('/login'); - } - - try { - $key = getenv('JWT_SECRET'); - $decoded = JWT::decode($token, new Key($key, 'HS256')); - - return view('pages/dashboard', [ - 'title' => 'Dashboard', - 'description' => 'CLQMS Dashboard - Overview', - 'user' => $decoded - ]); - } catch (ExpiredException $e) { - // Token expired, redirect to login - $response = service('response'); - $response->deleteCookie('token'); - return redirect()->to('/login'); - } catch (\Exception $e) { - // Invalid token - $response = service('response'); - $response->deleteCookie('token'); - return redirect()->to('/login'); - } - } -} diff --git a/app/Controllers/PagesController.php b/app/Controllers/PagesController.php new file mode 100644 index 0000000..e930108 --- /dev/null +++ b/app/Controllers/PagesController.php @@ -0,0 +1,178 @@ + 'Dashboard', + 'activePage' => 'dashboard' + ]); + } + + /** + * Patients page + */ + public function patients() + { + return view('v2/patients/patients_index', [ + 'pageTitle' => 'Patients', + 'activePage' => 'patients' + ]); + } + + /** + * Lab Requests page + */ + public function requests() + { + return view('v2/requests/requests_index', [ + 'pageTitle' => 'Lab Requests', + 'activePage' => 'requests' + ]); + } + + /** + * Settings page + */ + public function settings() + { + return view('v2/settings/settings_index', [ + 'pageTitle' => 'Settings', + 'activePage' => 'settings' + ]); + } + + // ======================================== + // Master Data - Organization + // ======================================== + + /** + * Master Data - Organization Accounts + */ + public function masterOrgAccounts() + { + return view('v2/master/organization/accounts_index', [ + 'pageTitle' => 'Organization Accounts', + 'activePage' => 'master-org-accounts' + ]); + } + + /** + * Master Data - Organization Sites + */ + public function masterOrgSites() + { + return view('v2/master/organization/sites_index', [ + 'pageTitle' => 'Organization Sites', + 'activePage' => 'master-org-sites' + ]); + } + + /** + * Master Data - Organization Disciplines + */ + public function masterOrgDisciplines() + { + return view('v2/master/organization/disciplines_index', [ + 'pageTitle' => 'Disciplines', + 'activePage' => 'master-org-disciplines' + ]); + } + + /** + * Master Data - Organization Departments + */ + public function masterOrgDepartments() + { + return view('v2/master/organization/departments_index', [ + 'pageTitle' => 'Departments', + 'activePage' => 'master-org-departments' + ]); + } + + /** + * Master Data - Organization Workstations + */ + public function masterOrgWorkstations() + { + return view('v2/master/organization/workstations_index', [ + 'pageTitle' => 'Workstations', + 'activePage' => 'master-org-workstations' + ]); + } + + // ======================================== + // Master Data - Specimen + // ======================================== + + /** + * Master Data - Specimen Containers + */ + public function masterSpecimenContainers() + { + return view('v2/master/specimen/containers_index', [ + 'pageTitle' => 'Container Definitions', + 'activePage' => 'master-specimen-containers' + ]); + } + + /** + * Master Data - Specimen Preparations + */ + public function masterSpecimenPreparations() + { + return view('v2/master/specimen/preparations_index', [ + 'pageTitle' => 'Specimen Preparations', + 'activePage' => 'master-specimen-preparations' + ]); + } + + // ======================================== + // Master Data - Tests & ValueSets + // ======================================== + + /** + * Master Data - Lab Tests + */ + public function masterTests() + { + return view('v2/master/tests/tests_index', [ + 'pageTitle' => 'Lab Tests', + 'activePage' => 'master-tests' + ]); + } + + /** + * Master Data - Value Sets + */ + public function masterValueSets() + { + return view('v2/master/valuesets/valuesets_index', [ + 'pageTitle' => 'Value Sets', + 'activePage' => 'master-valuesets' + ]); + } + + /** + * Login page + */ + public function login() + { + return view('v2/auth/login', [ + 'pageTitle' => 'Login', + 'activePage' => '' + ]); + } +} diff --git a/app/Controllers/PatVisit.php b/app/Controllers/PatVisitController.php similarity index 91% rename from app/Controllers/PatVisit.php rename to app/Controllers/PatVisitController.php index f0916da..bea238f 100644 --- a/app/Controllers/PatVisit.php +++ b/app/Controllers/PatVisitController.php @@ -6,7 +6,7 @@ use App\Controllers\BaseController; use App\Models\PatVisit\PatVisitModel; use App\Models\PatVisit\PatVisitADTModel; -class PatVisit extends BaseController { +class PatVisitController extends BaseController { use ResponseTrait; protected $model; @@ -18,9 +18,10 @@ class PatVisit extends BaseController { public function show($PVID = null) { try { $row = $this->model->show($PVID); - if($row == []) { $message = "data not found"; } - else { $message = "data found"; } - return $this->respond([ 'status' => 'success', 'message'=> $message, 'data' => $row ], 200); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200); + } + return $this->respond([ 'status' => 'success', 'message'=> "data found", 'data' => $row ], 200); } catch (\Exception $e) { return $this->failServerError('Something went wrong '.$e->getMessage()); } @@ -81,4 +82,4 @@ class PatVisit extends BaseController { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Controllers/Patient/Patient.php b/app/Controllers/Patient/PatientController.php similarity index 96% rename from app/Controllers/Patient/Patient.php rename to app/Controllers/Patient/PatientController.php index ae3fb87..ae63990 100644 --- a/app/Controllers/Patient/Patient.php +++ b/app/Controllers/Patient/PatientController.php @@ -6,7 +6,7 @@ use CodeIgniter\Controller; use App\Models\Patient\PatientModel; -class Patient extends Controller { +class PatientController extends Controller { use ResponseTrait; protected $db; @@ -66,9 +66,9 @@ class Patient extends Controller { public function show($InternalPID = null) { try { - $rows = $this->model->getPatient($InternalPID); - if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "data not found." ], 200); } - return $this->respond([ 'status' => 'success', 'message' => "data fetched successfully", 'data' => $rows ], 200); + $row = $this->model->getPatient($InternalPID); + if (empty($row)) { return $this->respond([ 'status' => 'success', 'message' => "data not found.", 'data' => null ], 200); } + return $this->respond([ 'status' => 'success', 'message' => "data fetched successfully", 'data' => $row ], 200); } catch (\Exception $e) { return $this->failServerError('Something went wrong: ' . $e->getMessage()); } @@ -214,4 +214,4 @@ class Patient extends Controller { return $this->failServerError('Something went wrong.'.$e->getMessage()); } } -} \ No newline at end of file +} diff --git a/app/Controllers/Dashboard.php b/app/Controllers/ResultController.php similarity index 94% rename from app/Controllers/Dashboard.php rename to app/Controllers/ResultController.php index b9c1a4f..f6ee148 100644 --- a/app/Controllers/Dashboard.php +++ b/app/Controllers/ResultController.php @@ -12,7 +12,7 @@ use Firebase\JWT\SignatureInvalidException; use Firebase\JWT\BeforeValidException; use CodeIgniter\Cookie\Cookie; -class Dashboard extends Controller { +class ResultController extends Controller { use ResponseTrait; public function index() { diff --git a/app/Controllers/Result.php b/app/Controllers/SampleController.php similarity index 94% rename from app/Controllers/Result.php rename to app/Controllers/SampleController.php index a906d6a..c02e318 100644 --- a/app/Controllers/Result.php +++ b/app/Controllers/SampleController.php @@ -12,7 +12,7 @@ use Firebase\JWT\SignatureInvalidException; use Firebase\JWT\BeforeValidException; use CodeIgniter\Cookie\Cookie; -class Result extends Controller { +class SampleController extends Controller { use ResponseTrait; public function index() { diff --git a/app/Controllers/Specimen/ContainerDef.php b/app/Controllers/Specimen/ContainerDefController.php similarity index 87% rename from app/Controllers/Specimen/ContainerDef.php rename to app/Controllers/Specimen/ContainerDefController.php index 0c4271d..4f1cb48 100644 --- a/app/Controllers/Specimen/ContainerDef.php +++ b/app/Controllers/Specimen/ContainerDefController.php @@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\Specimen\ContainerDefModel; -class ContainerDef extends BaseController { +class ContainerDefController extends BaseController { use ResponseTrait; protected $db; @@ -37,8 +37,11 @@ class ContainerDef extends BaseController { public function show($ConDefID) { try { - $rows = $this->model->getContainer($ConDefID); - return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); + $row = $this->model->getContainer($ConDefID); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200); + } + return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200); } catch (\Exception $e) { return $this->failServerError('Exception : '.$e->getMessage()); } @@ -66,4 +69,4 @@ class ContainerDef extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/Specimen/SpecimenCollection.php b/app/Controllers/Specimen/SpecimenCollectionController.php similarity index 85% rename from app/Controllers/Specimen/SpecimenCollection.php rename to app/Controllers/Specimen/SpecimenCollectionController.php index edcf5af..69d38ac 100644 --- a/app/Controllers/Specimen/SpecimenCollection.php +++ b/app/Controllers/Specimen/SpecimenCollectionController.php @@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\Specimen\SpecimenCollectionModel; -class SpecimenCollection extends BaseController { +class SpecimenCollectionController extends BaseController { use ResponseTrait; protected $db; @@ -30,8 +30,11 @@ class SpecimenCollection extends BaseController { public function show($id) { try { - $rows = $this->model->where('SpcColID', $id)->findAll(); - return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); + $row = $this->model->where('SpcColID', $id)->first(); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200); + } + return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200); } catch (\Exception $e) { return $this->failServerError('Exception : '.$e->getMessage()); } @@ -59,4 +62,4 @@ class SpecimenCollection extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/Specimen/Specimen.php b/app/Controllers/Specimen/SpecimenController.php similarity index 85% rename from app/Controllers/Specimen/Specimen.php rename to app/Controllers/Specimen/SpecimenController.php index b7ff7ee..6eba3d0 100644 --- a/app/Controllers/Specimen/Specimen.php +++ b/app/Controllers/Specimen/SpecimenController.php @@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\Specimen\SpecimenModel; -class Specimen extends BaseController { +class SpecimenController extends BaseController { use ResponseTrait; protected $db; @@ -30,8 +30,11 @@ class Specimen extends BaseController { public function show($id) { try { - $rows = $this->model->where('SID',$id)->findAll(); - return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); + $row = $this->model->where('SID',$id)->first(); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200); + } + return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200); } catch (\Exception $e) { return $this->failServerError('Exception : '.$e->getMessage()); } @@ -59,4 +62,4 @@ class Specimen extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/Specimen/SpecimenPrep.php b/app/Controllers/Specimen/SpecimenPrepController.php similarity index 85% rename from app/Controllers/Specimen/SpecimenPrep.php rename to app/Controllers/Specimen/SpecimenPrepController.php index ac9608c..2238fe9 100644 --- a/app/Controllers/Specimen/SpecimenPrep.php +++ b/app/Controllers/Specimen/SpecimenPrepController.php @@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\Specimen\SpecimenPrepModel; -class SpecimenPrep extends BaseController { +class SpecimenPrepController extends BaseController { use ResponseTrait; protected $db; @@ -30,8 +30,11 @@ class SpecimenPrep extends BaseController { public function show($id) { try { - $rows = $this->model->where('SpcPrpID', $id)->findAll(); - return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); + $row = $this->model->where('SpcPrpID', $id)->first(); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200); + } + return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200); } catch (\Exception $e) { return $this->failServerError('Exception : '.$e->getMessage()); } @@ -59,4 +62,4 @@ class SpecimenPrep extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/Specimen/SpecimenStatus.php b/app/Controllers/Specimen/SpecimenStatusController.php similarity index 87% rename from app/Controllers/Specimen/SpecimenStatus.php rename to app/Controllers/Specimen/SpecimenStatusController.php index f94f263..2444135 100644 --- a/app/Controllers/Specimen/SpecimenStatus.php +++ b/app/Controllers/Specimen/SpecimenStatusController.php @@ -30,8 +30,11 @@ class ContainerDef extends BaseController { public function show($id) { try { - $rows = $this->model->where('SpcStaID', $id)->findAll(); - return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $rows ], 200); + $row = $this->model->where('SpcStaID', $id)->first(); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message'=> "data not found", 'data' => null ], 200); + } + return $this->respond([ 'status' => 'success', 'message'=> "data fetched successfully", 'data' => $row ], 200); } catch (\Exception $e) { return $this->failServerError('Exception : '.$e->getMessage()); } @@ -59,4 +62,4 @@ class ContainerDef extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/Test/TestMap.php b/app/Controllers/Test/TestMapController.php similarity index 87% rename from app/Controllers/Test/TestMap.php rename to app/Controllers/Test/TestMapController.php index 718600f..8d9677c 100644 --- a/app/Controllers/Test/TestMap.php +++ b/app/Controllers/Test/TestMapController.php @@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\Test\TestMapModel; -class TestMap extends BaseController { +class TestMapController extends BaseController { use ResponseTrait; protected $db; @@ -24,9 +24,9 @@ class TestMap extends BaseController { } public function show($id = null) { - $rows = $this->model->where('TestMapID',$id)->findAll(); - if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); + $row = $this->model->where('TestMapID',$id)->first(); + if (empty($row)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } + return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row ], 200); } public function create() { @@ -53,4 +53,4 @@ class TestMap extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/Tests.php b/app/Controllers/Tests.php deleted file mode 100644 index e9c6311..0000000 --- a/app/Controllers/Tests.php +++ /dev/null @@ -1,186 +0,0 @@ -db = \Config\Database::connect(); - $this->model = new \App\Models\Test\TestDefSiteModel; - $this->modelCal = new \App\Models\Test\TestDefCalModel; - $this->modelTech = new \App\Models\Test\TestDefTechModel; - $this->modelGrp = new \App\Models\Test\TestDefGrpModel; - $this->modelValueSet = new \App\Models\ValueSet\ValueSetModel; - - // Basic validation for the main part - $this->rules = [ - 'TestSiteCode' => 'required', - 'TestSiteName' => 'required', - 'TestType' => 'required' - ]; - } - - public function index() { - $rows = $this->model->getTests(); - if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); - } - - public function show($id = null) { - if (!$id) return $this->failValidationErrors('ID is required'); - $rows = $this->model->getTest($id); - if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); - } - - public function create() { - $input = $this->request->getJSON(true); - - if (!$this->validateData($input, $this->rules)) { - return $this->failValidationErrors($this->validator->getErrors()); - } - - $this->db->transStart(); - - try { - // 1. Insert into Main Table (testdefsite) - $id = $this->model->insert($input); - if (!$id) { - throw new \Exception("Failed to insert main test definition"); - } - - // 2. Handle Details based on TestType - $this->handleDetails($id, $input, 'insert'); - - $this->db->transComplete(); - - if ($this->db->transStatus() === false) { - return $this->failServerError('Transaction failed'); - } - - return $this->respondCreated([ 'status' => 'success', 'message' => "data created successfully", 'data'=> $id ]); - } catch (\Exception $e) { - $this->db->transRollback(); - return $this->failServerError('Something went wrong: ' . $e->getMessage()); - } - } - - public function update($id = null) { - $input = $this->request->getJSON(true); - - // Determine ID - if (!$id && isset($input["TestSiteID"])) { $id = $input["TestSiteID"]; } - if (!$id) { return $this->failValidationErrors('TestSiteID is required.'); } - - // Optional validation - // if (!$this->validateData($input, $this->rules)) { ... } - - $this->db->transStart(); - - try { - // 1. Update Main Table - $this->model->update($id, $input); - - // 2. Handle Details - $this->handleDetails($id, $input, 'update'); - - $this->db->transComplete(); - - if ($this->db->transStatus() === false) { - return $this->failServerError('Transaction failed'); - } - - return $this->respondCreated([ 'status' => 'success', 'message' => "data updated successfully", 'data'=> $id ]); - } catch (\Exception $e) { - $this->db->transRollback(); - return $this->failServerError('Something went wrong: ' . $e->getMessage()); - } - } - - /** - * Helper to handle inserting/updating sub-tables based on TestType - */ - private function handleDetails($testSiteID, $input, $action) { - $testTypeID = $input['TestType'] ?? null; - - // If update and TestType not in payload, fetch from DB - if (!$testTypeID && $action === 'update') { - $existing = $this->model->find($testSiteID); - $testTypeID = $existing['TestType'] ?? null; - } - - if (!$testTypeID) return; // Should not happen if required - - // Get Type Code (TEST, PARAM, CALC, GROUP, TITLE) - $vs = $this->modelValueSet->find($testTypeID); - $typeCode = $vs['VValue'] ?? ''; - - // Get details data if present (for 'details' key in unified JSON) - // We accept both flat (top-level) and nested 'details' for flexibility, prefer 'details' - $details = $input['details'] ?? $input; - $details['TestSiteID'] = $testSiteID; // Ensure foreign key is set - $details['SiteID'] = $input['SiteID'] ?? 1; - - switch ($typeCode) { - case 'CALC': - $this->saveSubTable($this->modelCal, $testSiteID, $details, $action, 'TestCalID'); - break; - - case 'GROUP': - // Groups are special: List of members - // Payload expected: details: { members: [{Member: 1}, {Member: 2}] } - if ($action === 'update') { - $this->modelGrp->where('TestSiteID', $testSiteID)->delete(); - } - - $members = $details['members'] ?? ($input['Members'] ?? []); - if (is_array($members)) { - foreach ($members as $m) { - $memberID = is_array($m) ? ($m['Member'] ?? null) : $m; - if ($memberID) { - $this->modelGrp->insert([ - 'SiteID' => $details['SiteID'], - 'TestSiteID' => $testSiteID, - 'Member' => $memberID - ]); - } - } - } - break; - - - - case 'TEST': - case 'PARAM': - default: - // Default to TestDefTech for 'TEST' and 'PARAM' - $this->saveSubTable($this->modelTech, $testSiteID, $details, $action, 'TestTechID'); - break; - } - } - - private function saveSubTable($model, $testSiteID, $data, $action, $pkName) { - if ($action === 'update') { - // Check existence - $exists = $model->where('TestSiteID', $testSiteID)->first(); - if ($exists) { - $model->update($exists[$pkName], $data); - } else { - $model->insert($data); - } - } else { - $model->insert($data); - } - } -} \ No newline at end of file diff --git a/app/Controllers/TestsController.php b/app/Controllers/TestsController.php new file mode 100644 index 0000000..b43ba6c --- /dev/null +++ b/app/Controllers/TestsController.php @@ -0,0 +1,741 @@ +db = \Config\Database::connect(); + $this->model = new \App\Models\Test\TestDefSiteModel; + $this->modelCal = new \App\Models\Test\TestDefCalModel; + $this->modelTech = new \App\Models\Test\TestDefTechModel; + $this->modelGrp = new \App\Models\Test\TestDefGrpModel; + $this->modelMap = new \App\Models\Test\TestMapModel; + $this->modelValueSet = new \App\Models\ValueSet\ValueSetModel; + $this->modelRefNum = new \App\Models\RefRange\RefNumModel; + $this->modelRefTxt = new \App\Models\RefRange\RefTxtModel; + + // Validation rules for main test definition + $this->rules = [ + 'TestSiteCode' => 'required|min_length[3]|max_length[6]', + 'TestSiteName' => 'required', + 'TestType' => 'required', + 'SiteID' => 'required' + ]; + } + + /** + * GET /v1/tests + * GET /v1/tests/site + * List all tests with optional filtering + */ + public function index() + { + $siteId = $this->request->getGet('SiteID'); + $testType = $this->request->getGet('TestType'); + $visibleScr = $this->request->getGet('VisibleScr'); + $visibleRpt = $this->request->getGet('VisibleRpt'); + $keyword = $this->request->getGet('TestSiteName'); + + $builder = $this->db->table('testdefsite') + ->select("testdefsite.TestSiteID, testdefsite.TestSiteCode, testdefsite.TestSiteName, testdefsite.TestType, + testdefsite.SeqScr, testdefsite.SeqRpt, testdefsite.VisibleScr, testdefsite.VisibleRpt, + testdefsite.CountStat, testdefsite.StartDate, testdefsite.EndDate, + valueset.VValue as TypeCode, valueset.VDesc as TypeName") + ->join("valueset", "valueset.VID=testdefsite.TestType", "left") + ->where('testdefsite.EndDate IS NULL'); + + if ($siteId) { + $builder->where('testdefsite.SiteID', $siteId); + } + + if ($testType) { + $builder->where('testdefsite.TestType', $testType); + } + + if ($visibleScr !== null) { + $builder->where('testdefsite.VisibleScr', $visibleScr); + } + + if ($visibleRpt !== null) { + $builder->where('testdefsite.VisibleRpt', $visibleRpt); + } + + if ($keyword) { + $builder->like('testdefsite.TestSiteName', $keyword); + } + + $rows = $builder->orderBy('testdefsite.SeqScr', 'ASC')->get()->getResultArray(); + + if (empty($rows)) { + return $this->respond(['status' => 'success', 'message' => "No data.", 'data' => []], 200); + } + return $this->respond(['status' => 'success', 'message' => "Data fetched successfully", 'data' => $rows], 200); + } + + /** + * GET /v1/tests/{id} + * GET /v1/tests/site/{id} + * Get single test by ID with all related details + */ + public function show($id = null) + { + if (!$id) + return $this->failValidationErrors('TestSiteID is required'); + + $row = $this->model->select("testdefsite.*, valueset.VValue as TypeCode, valueset.VDesc as TypeName") + ->join("valueset", "valueset.VID=testdefsite.TestType", "left") + ->where("testdefsite.TestSiteID", $id) + ->find($id); + + if (!$row) { + return $this->respond(['status' => 'success', 'message' => "No data.", 'data' => null], 200); + } + + // Load related details based on TestType + $typeCode = $row['TypeCode'] ?? ''; + + if ($typeCode === 'CALC') { + // Load calculation details + $row['testdefcal'] = $this->db->table('testdefcal') + ->select('testdefcal.*, d.DisciplineName, dept.DepartmentName') + ->join('discipline d', 'd.DisciplineID=testdefcal.DisciplineID', 'left') + ->join('department dept', 'dept.DepartmentID=testdefcal.DepartmentID', 'left') + ->where('testdefcal.TestSiteID', $id) + ->where('testdefcal.EndDate IS NULL') + ->get()->getResultArray(); + + // Load test mappings + $row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll(); + + } elseif ($typeCode === 'GROUP') { + // Load group members with test details + $row['testdefgrp'] = $this->db->table('testdefgrp') + ->select('testdefgrp.*, t.TestSiteCode, t.TestSiteName, t.TestType, vs.VValue as MemberTypeCode') + ->join('testdefsite t', 't.TestSiteID=testdefgrp.Member', 'left') + ->join('valueset vs', 'vs.VID=t.TestType', 'left') + ->where('testdefgrp.TestSiteID', $id) + ->where('testdefgrp.EndDate IS NULL') + ->orderBy('testdefgrp.TestGrpID', 'ASC') + ->get()->getResultArray(); + + // Load test mappings + $row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll(); + + } elseif ($typeCode === 'TITLE') { + // Load test mappings only for TITLE type + $row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll(); + + } else { + // TEST or PARAM - load technical details + $row['testdeftech'] = $this->db->table('testdeftech') + ->select('testdeftech.*, d.DisciplineName, dept.DepartmentName') + ->join('discipline d', 'd.DisciplineID=testdeftech.DisciplineID', 'left') + ->join('department dept', 'dept.DepartmentID=testdeftech.DepartmentID', 'left') + ->where('testdeftech.TestSiteID', $id) + ->where('testdeftech.EndDate IS NULL') + ->get()->getResultArray(); + + // Load test mappings + $row['testmap'] = $this->modelMap->where('TestSiteID', $id)->where('EndDate IS NULL')->findAll(); + + // Load refnum/reftxt based on RefType + if (!empty($row['testdeftech'])) { + $techData = $row['testdeftech'][0]; + $refType = (int) $techData['RefType']; + + // Load refnum for NMRC type (RefType = 1) + if ($refType === 1) { + $refnumData = $this->modelRefNum + ->where('TestSiteID', $id) + ->where('EndDate IS NULL') + ->orderBy('Display', 'ASC') + ->findAll(); + + // Add VValue for display + $row['refnum'] = array_map(function ($r) { + return [ + 'RefNumID' => $r['RefNumID'], + 'NumRefType' => (int) $r['NumRefType'], + 'NumRefTypeVValue' => $this->getVValue(46, $r['NumRefType']), + 'RangeType' => (int) $r['RangeType'], + 'RangeTypeVValue' => $this->getVValue(45, $r['RangeType']), + 'Sex' => (int) $r['Sex'], + 'SexVValue' => $this->getVValue(3, $r['Sex']), + 'AgeStart' => (int) $r['AgeStart'], + 'AgeEnd' => (int) $r['AgeEnd'], + 'LowSign' => $r['LowSign'] !== null ? (int) $r['LowSign'] : null, + 'LowSignVValue' => $this->getVValue(41, $r['LowSign']), + 'Low' => $r['Low'] !== null ? (int) $r['Low'] : null, + 'HighSign' => $r['HighSign'] !== null ? (int) $r['HighSign'] : null, + 'HighSignVValue' => $this->getVValue(41, $r['HighSign']), + 'High' => $r['High'] !== null ? (int) $r['High'] : null, + 'Flag' => $r['Flag'] + ]; + }, $refnumData ?? []); + + $row['numRefTypeOptions'] = $this->getValuesetOptions(46); + $row['rangeTypeOptions'] = $this->getValuesetOptions(45); + } + + // Load reftxt for TEXT type (RefType = 2) + if ($refType === 2) { + $reftxtData = $this->modelRefTxt + ->where('TestSiteID', $id) + ->where('EndDate IS NULL') + ->orderBy('RefTxtID', 'ASC') + ->findAll(); + + $row['reftxt'] = array_map(function ($r) { + return [ + 'RefTxtID' => $r['RefTxtID'], + 'TxtRefType' => (int) $r['TxtRefType'], + 'TxtRefTypeVValue' => $this->getVValue(47, $r['TxtRefType']), + 'Sex' => (int) $r['Sex'], + 'SexVValue' => $this->getVValue(3, $r['Sex']), + 'AgeStart' => (int) $r['AgeStart'], + 'AgeEnd' => (int) $r['AgeEnd'], + 'RefTxt' => $r['RefTxt'], + 'Flag' => $r['Flag'] + ]; + }, $reftxtData ?? []); + + $row['txtRefTypeOptions'] = $this->getValuesetOptions(47); + } + } + } + + // Include valueset options for dropdowns + $row['refTypeOptions'] = $this->getValuesetOptions(self::VALUESET_REF_TYPE); + $row['sexOptions'] = $this->getValuesetOptions(self::VALUESET_SEX); + $row['mathSignOptions'] = $this->getValuesetOptions(self::VALUESET_MATH_SIGN); + + return $this->respond(['status' => 'success', 'message' => "Data fetched successfully", 'data' => $row], 200); + } + + /** + * POST /v1/tests + * POST /v1/tests/site + * Create new test definition + */ + public function create() + { + $input = $this->request->getJSON(true); + + if (!$this->validateData($input, $this->rules)) { + return $this->failValidationErrors($this->validator->getErrors()); + } + + $this->db->transStart(); + + try { + // 1. Insert into Main Table (testdefsite) + $testSiteData = [ + 'SiteID' => $input['SiteID'], + 'TestSiteCode' => $input['TestSiteCode'], + 'TestSiteName' => $input['TestSiteName'], + 'TestType' => $input['TestType'], + 'Description' => $input['Description'] ?? null, + 'SeqScr' => $input['SeqScr'] ?? 0, + 'SeqRpt' => $input['SeqRpt'] ?? 0, + 'IndentLeft' => $input['IndentLeft'] ?? 0, + 'FontStyle' => $input['FontStyle'] ?? null, + 'VisibleScr' => $input['VisibleScr'] ?? 1, + 'VisibleRpt' => $input['VisibleRpt'] ?? 1, + 'CountStat' => $input['CountStat'] ?? 1, + 'StartDate' => $input['StartDate'] ?? date('Y-m-d H:i:s') + ]; + + $id = $this->model->insert($testSiteData); + if (!$id) { + throw new \Exception("Failed to insert main test definition"); + } + + // 2. Handle Details based on TestType + $this->handleDetails($id, $input, 'insert'); + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->failServerError('Transaction failed'); + } + + return $this->respondCreated([ + 'status' => 'created', + 'message' => "Test created successfully", + 'data' => ['TestSiteId' => $id] + ]); + } catch (\Exception $e) { + $this->db->transRollback(); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + /** + * PUT/PATCH /v1/tests/{id} + * PUT/PATCH /v1/tests/site/{id} + * Update existing test definition + */ + public function update($id = null) + { + $input = $this->request->getJSON(true); + + // Determine ID + if (!$id && isset($input["TestSiteID"])) { + $id = $input["TestSiteID"]; + } + if (!$id) { + return $this->failValidationErrors('TestSiteID is required.'); + } + + // Verify record exists + $existing = $this->model->find($id); + if (!$existing) { + return $this->failNotFound('Test not found'); + } + + $this->db->transStart(); + + try { + // 1. Update Main Table + $testSiteData = []; + $allowedUpdateFields = [ + 'TestSiteCode', + 'TestSiteName', + 'TestType', + 'Description', + 'SeqScr', + 'SeqRpt', + 'IndentLeft', + 'FontStyle', + 'VisibleScr', + 'VisibleRpt', + 'CountStat', + 'StartDate' + ]; + + foreach ($allowedUpdateFields as $field) { + if (isset($input[$field])) { + $testSiteData[$field] = $input[$field]; + } + } + + if (!empty($testSiteData)) { + $this->model->update($id, $testSiteData); + } + + // 2. Handle Details + $this->handleDetails($id, $input, 'update'); + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->failServerError('Transaction failed'); + } + + return $this->respond([ + 'status' => 'success', + 'message' => "Test updated successfully", + 'data' => ['TestSiteId' => $id] + ]); + } catch (\Exception $e) { + $this->db->transRollback(); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + /** + * DELETE /v1/tests/{id} + * DELETE /v1/tests/site/{id} + * Soft delete test by setting EndDate + */ + public function delete($id = null) + { + $input = $this->request->getJSON(true); + + // Determine ID + if (!$id && isset($input["TestSiteID"])) { + $id = $input["TestSiteID"]; + } + if (!$id) { + return $this->failValidationErrors('TestSiteID is required.'); + } + + // Verify record exists + $existing = $this->model->find($id); + if (!$existing) { + return $this->failNotFound('Test not found'); + } + + // Check if already disabled + if (!empty($existing['EndDate'])) { + return $this->failValidationErrors('Test is already disabled'); + } + + $this->db->transStart(); + + try { + $now = date('Y-m-d H:i:s'); + + // 1. Soft delete main record + $this->model->update($id, ['EndDate' => $now]); + + // 2. Get TestType to handle related records + $testType = $existing['TestType']; + $vs = $this->modelValueSet->find($testType); + $typeCode = $vs['VValue'] ?? ''; + + // 3. Soft delete related records based on TestType + if ($typeCode === 'CALC') { + $this->db->table('testdefcal') + ->where('TestSiteID', $id) + ->update(['EndDate' => $now]); + } elseif ($typeCode === 'GROUP') { + $this->db->table('testdefgrp') + ->where('TestSiteID', $id) + ->update(['EndDate' => $now]); + } elseif (in_array($typeCode, ['TEST', 'PARAM'])) { + $this->db->table('testdeftech') + ->where('TestSiteID', $id) + ->update(['EndDate' => $now]); + + // Soft delete refnum and reftxt records + $this->modelRefNum->where('TestSiteID', $id)->set('EndDate', $now)->update(); + $this->modelRefTxt->where('TestSiteID', $id)->set('EndDate', $now)->update(); + } + + // 4. Soft delete test mappings + $this->db->table('testmap') + ->where('TestSiteID', $id) + ->update(['EndDate' => $now]); + + $this->db->transComplete(); + + if ($this->db->transStatus() === false) { + return $this->failServerError('Transaction failed'); + } + + return $this->respond([ + 'status' => 'success', + 'message' => "Test disabled successfully", + 'data' => ['TestSiteId' => $id, 'EndDate' => $now] + ]); + } catch (\Exception $e) { + $this->db->transRollback(); + return $this->failServerError('Something went wrong: ' . $e->getMessage()); + } + } + + /** + * Helper to get valueset options + */ + private function getValuesetOptions($vsetID) + { + return $this->db->table('valueset') + ->select('VID as vid, VValue as vvalue, VDesc as vdesc') + ->where('VSetID', $vsetID) + ->orderBy('VOrder', 'ASC') + ->get()->getResultArray(); + } + + /** + * Helper to get VValue from VID for display + */ + private function getVValue($vsetID, $vid) + { + if ($vid === null || $vid === '') + return null; + $row = $this->db->table('valueset') + ->select('VValue as vvalue') + ->where('VSetID', $vsetID) + ->where('VID', (int) $vid) + ->get()->getRowArray(); + return $row ? $row['vvalue'] : null; + } + + /** + * Helper to handle inserting/updating sub-tables based on TestType + */ + private function handleDetails($testSiteID, $input, $action) + { + $testTypeID = $input['TestType'] ?? null; + + // If update and TestType not in payload, fetch from DB + if (!$testTypeID && $action === 'update') { + $existing = $this->model->find($testSiteID); + $testTypeID = $existing['TestType'] ?? null; + } + + if (!$testTypeID) + return; + + // Get Type Code (TEST, PARAM, CALC, GROUP, TITLE) + $vs = $this->modelValueSet->find($testTypeID); + $typeCode = $vs['VValue'] ?? ''; + + // Get details data from input + $details = $input['details'] ?? $input; + $details['TestSiteID'] = $testSiteID; + $details['SiteID'] = $input['SiteID'] ?? 1; + + switch ($typeCode) { + case 'CALC': + $this->saveCalcDetails($testSiteID, $details, $action); + break; + + case 'GROUP': + $this->saveGroupDetails($testSiteID, $details, $input, $action); + break; + + case 'TITLE': + // TITLE type only has testdefsite, no additional details needed + // But we should save test mappings if provided + if (isset($input['testmap']) && is_array($input['testmap'])) { + $this->saveTestMap($testSiteID, $input['testmap'], $action); + } + break; + + case 'TEST': + case 'PARAM': + default: + $this->saveTechDetails($testSiteID, $details, $action, $typeCode); + + // Save refnum/reftxt for TEST/PARAM types + if (in_array($typeCode, ['TEST', 'PARAM']) && isset($details['RefType'])) { + $refType = (int) $details['RefType']; + + // Save refnum for NMRC type (RefType = 1) + if ($refType === 1 && isset($input['refnum']) && is_array($input['refnum'])) { + $this->saveRefNumRanges($testSiteID, $input['refnum'], $action, $input['SiteID'] ?? 1); + } + + // Save reftxt for TEXT type (RefType = 2) + if ($refType === 2 && isset($input['reftxt']) && is_array($input['reftxt'])) { + $this->saveRefTxtRanges($testSiteID, $input['reftxt'], $action, $input['SiteID'] ?? 1); + } + } + break; + } + + // Save test mappings for TEST and CALC types as well + if (in_array($typeCode, ['TEST', 'CALC']) && isset($input['testmap']) && is_array($input['testmap'])) { + $this->saveTestMap($testSiteID, $input['testmap'], $action); + } + } + + /** + * Save technical details for TEST and PARAM types + */ + private function saveTechDetails($testSiteID, $data, $action, $typeCode) + { + $techData = [ + 'TestSiteID' => $testSiteID, + 'DisciplineID' => $data['DisciplineID'] ?? null, + 'DepartmentID' => $data['DepartmentID'] ?? null, + 'ResultType' => $data['ResultType'] ?? null, + 'RefType' => $data['RefType'] ?? null, + 'VSet' => $data['VSet'] ?? null, + 'ReqQty' => $data['ReqQty'] ?? null, + 'ReqQtyUnit' => $data['ReqQtyUnit'] ?? null, + 'Unit1' => $data['Unit1'] ?? null, + 'Factor' => $data['Factor'] ?? null, + 'Unit2' => $data['Unit2'] ?? null, + 'Decimal' => $data['Decimal'] ?? 2, + 'CollReq' => $data['CollReq'] ?? null, + 'Method' => $data['Method'] ?? null, + 'ExpectedTAT' => $data['ExpectedTAT'] ?? null + ]; + + if ($action === 'update') { + $exists = $this->db->table('testdeftech') + ->where('TestSiteID', $testSiteID) + ->where('EndDate IS NULL') + ->get()->getRowArray(); + + if ($exists) { + $this->modelTech->update($exists['TestTechID'], $techData); + } else { + $this->modelTech->insert($techData); + } + } else { + $this->modelTech->insert($techData); + } + } + + /** + * Save refnum ranges for NMRC type + */ + private function saveRefNumRanges($testSiteID, $ranges, $action, $siteID) + { + if ($action === 'update') { + $this->modelRefNum->where('TestSiteID', $testSiteID) + ->set('EndDate', date('Y-m-d H:i:s')) + ->update(); + } + + foreach ($ranges as $index => $range) { + $this->modelRefNum->insert([ + 'TestSiteID' => $testSiteID, + 'SiteID' => $siteID, + 'NumRefType' => (int) $range['NumRefType'], + 'RangeType' => (int) $range['RangeType'], + 'Sex' => (int) $range['Sex'], + 'AgeStart' => (int) ($range['AgeStart'] ?? 0), + 'AgeEnd' => (int) ($range['AgeEnd'] ?? 150), + 'LowSign' => !empty($range['LowSign']) ? (int) $range['LowSign'] : null, + 'Low' => !empty($range['Low']) ? (int) $range['Low'] : null, + 'HighSign' => !empty($range['HighSign']) ? (int) $range['HighSign'] : null, + 'High' => !empty($range['High']) ? (int) $range['High'] : null, + 'Flag' => $range['Flag'] ?? null, + 'Display' => $index, + 'CreateDate' => date('Y-m-d H:i:s') + ]); + } + } + + /** + * Save reftxt ranges for TEXT type + */ + private function saveRefTxtRanges($testSiteID, $ranges, $action, $siteID) + { + if ($action === 'update') { + $this->modelRefTxt->where('TestSiteID', $testSiteID) + ->set('EndDate', date('Y-m-d H:i:s')) + ->update(); + } + + foreach ($ranges as $range) { + $this->modelRefTxt->insert([ + 'TestSiteID' => $testSiteID, + 'SiteID' => $siteID, + 'TxtRefType' => (int) $range['TxtRefType'], + 'Sex' => (int) $range['Sex'], + 'AgeStart' => (int) ($range['AgeStart'] ?? 0), + 'AgeEnd' => (int) ($range['AgeEnd'] ?? 150), + 'RefTxt' => $range['RefTxt'] ?? '', + 'Flag' => $range['Flag'] ?? null, + 'CreateDate' => date('Y-m-d H:i:s') + ]); + } + } + + /** + * Save calculation details for CALC type + */ + private function saveCalcDetails($testSiteID, $data, $action) + { + $calcData = [ + 'TestSiteID' => $testSiteID, + 'DisciplineID' => $data['DisciplineID'] ?? null, + 'DepartmentID' => $data['DepartmentID'] ?? null, + 'FormulaInput' => $data['FormulaInput'] ?? null, + 'FormulaCode' => $data['FormulaCode'] ?? $data['Formula'] ?? null, + 'RefType' => $data['RefType'] ?? 'NMRC', + 'Unit1' => $data['Unit1'] ?? $data['ResultUnit'] ?? null, + 'Factor' => $data['Factor'] ?? null, + 'Unit2' => $data['Unit2'] ?? null, + 'Decimal' => $data['Decimal'] ?? 2, + 'Method' => $data['Method'] ?? null + ]; + + if ($action === 'update') { + $exists = $this->db->table('testdefcal') + ->where('TestSiteID', $testSiteID) + ->where('EndDate IS NULL') + ->get()->getRowArray(); + + if ($exists) { + $this->modelCal->update($exists['TestCalID'], $calcData); + } else { + $this->modelCal->insert($calcData); + } + } else { + $this->modelCal->insert($calcData); + } + } + + /** + * Save group details for GROUP type + */ + private function saveGroupDetails($testSiteID, $data, $input, $action) + { + if ($action === 'update') { + // Soft delete existing members + $this->db->table('testdefgrp') + ->where('TestSiteID', $testSiteID) + ->update(['EndDate' => date('Y-m-d H:i:s')]); + } + + // Get members from details or input + $members = $data['members'] ?? ($input['Members'] ?? []); + + if (is_array($members)) { + foreach ($members as $m) { + $memberID = is_array($m) ? ($m['Member'] ?? ($m['TestSiteID'] ?? null)) : $m; + if ($memberID) { + $this->modelGrp->insert([ + 'TestSiteID' => $testSiteID, + 'Member' => $memberID + ]); + } + } + } + } + + /** + * Save test mappings + */ + private function saveTestMap($testSiteID, $mappings, $action) + { + if ($action === 'update') { + // Soft delete existing mappings + $this->db->table('testmap') + ->where('TestSiteID', $testSiteID) + ->update(['EndDate' => date('Y-m-d H:i:s')]); + } + + if (is_array($mappings)) { + foreach ($mappings as $map) { + $mapData = [ + 'TestSiteID' => $testSiteID, + 'HostType' => $map['HostType'] ?? null, + 'HostID' => $map['HostID'] ?? null, + 'HostDataSource' => $map['HostDataSource'] ?? null, + 'HostTestCode' => $map['HostTestCode'] ?? null, + 'HostTestName' => $map['HostTestName'] ?? null, + 'ClientType' => $map['ClientType'] ?? null, + 'ClientID' => $map['ClientID'] ?? null, + 'ClientDataSource' => $map['ClientDataSource'] ?? null, + 'ConDefID' => $map['ConDefID'] ?? null, + 'ClientTestCode' => $map['ClientTestCode'] ?? null, + 'ClientTestName' => $map['ClientTestName'] ?? null + ]; + $this->modelMap->insert($mapData); + } + } + } +} diff --git a/app/Controllers/ValueSet/ValueSet.php b/app/Controllers/ValueSet/ValueSetController.php similarity index 75% rename from app/Controllers/ValueSet/ValueSet.php rename to app/Controllers/ValueSet/ValueSetController.php index f0f753b..6e80bcc 100644 --- a/app/Controllers/ValueSet/ValueSet.php +++ b/app/Controllers/ValueSet/ValueSetController.php @@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\ValueSet\ValueSetModel; -class ValueSet extends BaseController { +class ValueSetController extends BaseController { use ResponseTrait; protected $db; @@ -23,17 +23,31 @@ class ValueSet extends BaseController { public function index() { $param = $this->request->getVar('param'); - $rows = $this->model->getValueSets($param); - if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); + $VSetID = $this->request->getVar('VSetID'); + $page = $this->request->getVar('page') ?? 1; + $limit = $this->request->getVar('limit') ?? 20; + + $result = $this->model->getValueSets($param, $page, $limit, $VSetID); + + return $this->respond([ + 'status' => 'success', + 'message'=> "Data fetched successfully", + 'data' => $result['data'], + 'pagination' => [ + 'currentPage' => (int)$page, + 'totalPages' => $result['pager']->getPageCount(), + 'totalItems' => $result['pager']->getTotal(), + 'limit' => (int)$limit + ] + ], 200); } public function show($VID = null) { - $rows = $this->model->getValueSet($VID); - if (empty($rows)) { - return $this->respond([ 'status' => 'success', 'message' => "ValueSet with ID $VID not found.", 'data' => [] ], 200); + $row = $this->model->getValueSet($VID); + if (empty($row)) { + return $this->respond([ 'status' => 'success', 'message' => "ValueSet with ID $VID not found.", 'data' => null ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows], 200); + return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row], 200); } public function showByValueSetDef($VSetID = null) { @@ -80,4 +94,4 @@ class ValueSet extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/ValueSet/ValueSetDef.php b/app/Controllers/ValueSet/ValueSetDefController.php similarity index 90% rename from app/Controllers/ValueSet/ValueSetDef.php rename to app/Controllers/ValueSet/ValueSetDefController.php index a20a7f5..a3e5145 100644 --- a/app/Controllers/ValueSet/ValueSetDef.php +++ b/app/Controllers/ValueSet/ValueSetDefController.php @@ -5,7 +5,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\ValueSet\ValueSetDefModel; -class ValueSetDef extends BaseController { +class ValueSetDefController extends BaseController { use ResponseTrait; protected $db; @@ -29,9 +29,9 @@ class ValueSetDef extends BaseController { } public function show($VSetID = null) { - $rows = $this->model->find($VSetID); - if (empty($rows)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => [] ], 200); } - return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $rows ], 200); + $row = $this->model->find($VSetID); + if (empty($row)) { return $this->respond([ 'status' => 'success', 'message' => "no Data.", 'data' => null ], 200); } + return $this->respond([ 'status' => 'success', 'message'=> "Data fetched successfully", 'data' => $row ], 200); } public function create() { @@ -70,4 +70,4 @@ class ValueSetDef extends BaseController { } } -} \ No newline at end of file +} diff --git a/app/Controllers/Zones.php b/app/Controllers/ZonesController.php similarity index 98% rename from app/Controllers/Zones.php rename to app/Controllers/ZonesController.php index 84de855..86db321 100644 --- a/app/Controllers/Zones.php +++ b/app/Controllers/ZonesController.php @@ -6,7 +6,7 @@ use CodeIgniter\API\ResponseTrait; use App\Controllers\BaseController; use App\Models\SyncCRM\ZonesModel; -class Zones extends BaseController { +class ZonesController extends BaseController { use ResponseTrait; protected $model; @@ -92,4 +92,4 @@ class Zones extends BaseController { } } -*/ \ No newline at end of file +*/ diff --git a/app/Database/Migrations/2025-09-10-141522_Location.php b/app/Database/Migrations/2025-09-10-141522_Location.php index ba1239c..16d8fea 100644 --- a/app/Database/Migrations/2025-09-10-141522_Location.php +++ b/app/Database/Migrations/2025-09-10-141522_Location.php @@ -24,12 +24,14 @@ class CreateLocationTable extends Migration { $this->forge->addField([ 'LocationID' => ['type' => 'INT', 'unsigned' => true], 'Street1' => ['type' => 'Varchar', 'constraint' => 255, 'null' => true], - 'Street2' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => false], + 'Street2' => ['type' => 'VARCHAR', 'constraint' => 255, 'null' => true], 'City' => ['type' => 'int', 'null' => true], 'Province' => ['type' => 'int', 'null' => true], 'PostCode' => ['type' => 'varchar', 'constraint' => 255, 'null' => true], 'GeoLocationSystem' => ['type' => 'varchar', 'constraint' => 255, 'null' => true], 'GeoLocationData' => ['type' => 'varchar', 'constraint' => 255, 'null' => true], + 'Phone' => ['type' => 'varchar', 'constraint' => 100, 'null' => true], + 'Email' => ['type' => 'varchar', 'constraint' => 150, 'null' => true], 'CreateDate' => ['type' => 'DATETIME', 'null' => true], 'EndDate' => ['type' => 'DATETIME', 'null' => true] ]); diff --git a/app/Database/Migrations/2025-10-11-100001_Test.php b/app/Database/Migrations/2025-10-11-100001_Test.php index c5f4118..1c23faf 100644 --- a/app/Database/Migrations/2025-10-11-100001_Test.php +++ b/app/Database/Migrations/2025-10-11-100001_Test.php @@ -6,97 +6,107 @@ use CodeIgniter\Database\Migration; class CreateTestsTable extends Migration { public function up() { + // testdefsite - Main test definition table per site $this->forge->addField([ 'TestSiteID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'SiteID' => ['type' => 'INT', 'null' => false], 'TestSiteCode' => ['type' => 'varchar', 'constraint'=> 6, 'null' => false], - 'TestSiteName' => ['type' => 'varchar', 'constraint'=> 50, 'null' => false], + 'TestSiteName' => ['type' => 'varchar', 'constraint'=> 100, 'null' => false], 'TestType' => ['type' => 'int', 'null' => false], - 'Description' => ['type' => 'varchar', 'constraint'=> 150, 'null' => true], + 'Description' => ['type' => 'varchar', 'constraint'=> 255, 'null' => true], 'SeqScr' => ['type' => 'int', 'null' => true], 'SeqRpt' => ['type' => 'int', 'null' => true], - 'IndentLeft' => ['type' => 'int', 'null' => true], - 'VisibleScr' => ['type' => 'int', 'null' => true], - 'VisibleRpt' => ['type' => 'int', 'null' => true], - 'CountStat' => ['type' => 'int', 'null' => true], + 'IndentLeft' => ['type' => 'int', 'null' => true, 'default' => 0], + 'FontStyle' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true], + 'VisibleScr' => ['type' => 'int', 'null' => true, 'default' => 1], + 'VisibleRpt' => ['type' => 'int', 'null' => true, 'default' => 1], + 'CountStat' => ['type' => 'int', 'null' => true, 'default' => 1], 'CreateDate' => ['type' => 'Datetime', 'null' => true], + 'StartDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true], ]); $this->forge->addKey('TestSiteID', true); $this->forge->createTable('testdefsite'); + // testdeftech - Technical definition for TEST and PARAM types $this->forge->addField([ 'TestTechID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], - 'SiteID' => ['type' => 'INT', 'null' => true], - 'TestSiteID' => ['type' => 'INT', 'null' => false], + 'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false], 'DisciplineID' => ['type' => 'int', 'null' => true], 'DepartmentID' => ['type' => 'int', 'null' => true], - 'ResultType' => ['type' => 'int', 'null' => true], - 'RefType' => ['type' => 'int', 'null' => true], + 'ResultType' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true], + 'RefType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true], 'VSet' => ['type' => 'int', 'null' => true], - 'SpcType' => ['type' => 'int', 'null' => true], - 'ReqQty' => ['type' => 'int', 'null' => true], - 'ReqQtyUnit' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], - 'Unit1' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], - 'Factor' => ['type' => 'int', 'null' => true], - 'Unit2' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], - 'Decimal' => ['type' => 'int', 'null' => true], - 'CollReq' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], - 'Method' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], + 'ReqQty' => ['type' => 'DECIMAL', 'constraint'=> '10,2', 'null' => true], + 'ReqQtyUnit' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true], + 'Unit1' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true], + 'Factor' => ['type' => 'DECIMAL', 'constraint'=> '10,4', 'null' => true], + 'Unit2' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true], + 'Decimal' => ['type' => 'int', 'null' => true, 'default' => 2], + 'CollReq' => ['type' => 'varchar', 'constraint'=> 255, 'null' => true], + 'Method' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true], 'ExpectedTAT' => ['type' => 'INT', 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true] ]); $this->forge->addKey('TestTechID', true); + $this->forge->addForeignKey('TestSiteID', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE'); $this->forge->createTable('testdeftech'); + // testdefcal - Calculation definition for CALC type $this->forge->addField([ 'TestCalID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], - 'SiteID' => ['type' => 'INT', 'null' => true], - 'TestSiteID' => ['type' => 'INT', 'null' => false], + 'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false], 'DisciplineID' => ['type' => 'INT', 'null' => true], 'DepartmentID' => ['type' => 'INT', 'null' => true], - 'FormulaInput' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], - 'FormulaCode' => ['type' => 'varchar', 'constraint'=>150, 'null' => true], - 'Unit1' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], - 'Factor' => ['type' => 'int', 'null' => true], - 'Unit2' => ['type' => 'varchar', 'constraint'=>20, 'null' => true], - 'Decimal' => ['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], + 'Factor' => ['type' => 'DECIMAL', 'constraint'=> '10,4', 'null' => true], + 'Unit2' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true], + 'Decimal' => ['type' => 'int', 'null' => true, 'default' => 2], + 'Method' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true] ]); $this->forge->addKey('TestCalID', true); + $this->forge->addForeignKey('TestSiteID', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE'); $this->forge->createTable('testdefcal'); + // testdefgrp - Group definition for GROUP type $this->forge->addField([ 'TestGrpID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], - 'SiteID' => ['type' => 'INT', 'null' => true], - 'TestSiteID' => ['type' => 'INT', 'null' => false], - 'Member' => ['type' => 'INT', 'null' => true], + 'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false], + 'Member' => ['type' => 'INT', 'unsigned' => true, 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true] ]); $this->forge->addKey('TestGrpID', true); + $this->forge->addForeignKey('TestSiteID', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE'); + $this->forge->addForeignKey('Member', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE'); $this->forge->createTable('testdefgrp'); + // testmap - Test mapping for all types $this->forge->addField([ 'TestMapID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], - 'TestSiteID' => ['type' => 'INT', 'null' => false], - 'HostType' => ['type' => 'int', 'null' => true], - 'HostID' => ['type' => 'int', 'null' => true], - 'HostDataSource' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], - 'HostTestCode' => ['type' => 'varchar', 'constraint'=>10, 'null' => true], - 'HostTestName' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], - 'ClientType' => ['type' => 'int', 'null' => true], - 'ClientID' => ['type' => 'int', 'null' => true], - 'ClientDataSource' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], - 'ConDefID' => ['type' => 'int', 'null' => true], - 'ClientTestCode' => ['type' => 'varchar', 'constraint'=>10, 'null' => true], - 'ClientTestName' => ['type' => 'varchar', 'constraint'=>50, 'null' => true], + 'TestSiteID' => ['type' => 'INT', 'unsigned' => true, 'null' => false], + 'HostType' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true], + 'HostID' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true], + 'HostDataSource' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true], + 'HostTestCode' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true], + 'HostTestName' => ['type' => 'varchar', 'constraint'=> 100, 'null' => true], + 'ClientType' => ['type' => 'varchar', 'constraint'=> 20, 'null' => true], + 'ClientID' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true], + 'ClientDataSource' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true], + 'ConDefID' => ['type' => 'INT', 'null' => true], + 'ClientTestCode' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true], + 'ClientTestName' => ['type' => 'varchar', 'constraint'=> 100, 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true] ]); $this->forge->addKey('TestMapID', true); + $this->forge->addForeignKey('TestSiteID', 'testdefsite', 'TestSiteID', 'CASCADE', 'CASCADE'); $this->forge->createTable('testmap'); } @@ -107,4 +117,4 @@ class CreateTestsTable extends Migration { $this->forge->dropTable('testdefgrp'); $this->forge->dropTable('testmap'); } -} \ No newline at end of file +} diff --git a/app/Database/Migrations/2025-10-12-100001_RefRange.php b/app/Database/Migrations/2025-10-12-100001_RefRange.php index b9c3b25..9f27f0e 100644 --- a/app/Database/Migrations/2025-10-12-100001_RefRange.php +++ b/app/Database/Migrations/2025-10-12-100001_RefRange.php @@ -11,67 +11,43 @@ class CreateRefRangesTable extends Migration { 'RefNumID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'SiteID' => ['type' => 'INT', 'null' => true], 'TestSiteID' => ['type' => 'INT', 'null' => false], - 'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false], + 'SpcType' => ['type' => 'INT', 'null' => true], 'Sex' => ['type' => 'INT', 'null' => true], 'Criteria' => ['type' => 'varchar', 'constraint'=>100, 'null' => true], 'AgeStart' => ['type' => 'INT', 'null' => true], 'AgeEnd' => ['type' => 'int', 'null' => true], - 'CriticalLow' => ['type' => 'int', 'null' => true], - 'Low' => ['type' => 'int', 'null' => true], - 'High' => ['type' => 'int', 'null' => true], - 'CriticalHigh' => ['type' => 'int', 'null' => true], + 'NumRefType' => ['type' => 'INT', 'null' => true], + 'RangeType' => ['type' => 'INT', 'null' => true], + 'LowSign' => ['type' => 'INT', 'null' => true], + 'Low' => ['type' => 'INT', 'null' => true], + 'HighSign' => ['type' => 'INT', 'null' => true], + 'High' => ['type' => 'INT', 'null' => true], + 'Display' => ['type' => 'INT', 'null' => true], + 'Flag' => ['type' => 'varchar', 'constraint'=>10, 'null' => true], + 'Interpretation' => ['type' => 'varchar', 'constraint'=>255, 'null' => true], + 'Notes' => ['type' => 'varchar', 'constraint'=>255, 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true], + 'StartDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true], ]); $this->forge->addKey('RefNumID', true); $this->forge->createTable('refnum'); - $this->forge->addField([ - 'RefTHoldID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], - 'SiteID' => ['type' => 'INT', 'null' => true], - 'TestSiteID' => ['type' => 'INT', 'null' => false], - 'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false], - 'Sex' => ['type' => 'INT', 'null' => true], - 'AgeStart' => ['type' => 'INT', 'null' => true], - 'AgeEnd' => ['type' => 'int', 'null' => true], - 'TholdSign' => ['type' => 'int', 'null' => true], - 'TholdValue' => ['type' => 'int', 'null' => true], - 'BelowTxt' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true], - 'AboveTxt' => ['type' => 'varchar', 'constraint'=> 50, 'null' => true], - 'GrayZoneLow' => ['type' => 'int', 'null' => true], - 'GrayZoneHigh' => ['type' => 'int', 'null' => true], - 'GrayZoneTxt' => ['type' => 'varchar', 'constraint'=> 10, 'null' => true], - 'CreateDate' => ['type' => 'Datetime', 'null' => true], - 'EndDate' => ['type' => 'Datetime', 'null' => true], - ]); - $this->forge->addKey('RefTHoldID', true); - $this->forge->createTable('refthold'); - - $this->forge->addField([ - 'RefVSetID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], - 'SiteID' => ['type' => 'INT', 'null' => true], - 'TestSiteID' => ['type' => 'INT', 'null' => false], - 'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false], - 'Sex' => ['type' => 'INT', 'null' => true], - 'AgeStart' => ['type' => 'INT', 'null' => true], - 'AgeEnd' => ['type' => 'int', 'null' => true], - 'RefTxt' => ['type' => 'varchar', 'constraint'=>255, 'null' => true], - 'CreateDate' => ['type' => 'Datetime', 'null' => true], - 'EndDate' => ['type' => 'Datetime', 'null' => true], - ]); - $this->forge->addKey('RefVSetID', true); - $this->forge->createTable('refvset'); - $this->forge->addField([ 'RefTxtID' => ['type' => 'INT', 'auto_increment' => true, 'unsigned' => true], 'SiteID' => ['type' => 'INT', 'null' => true], 'TestSiteID' => ['type' => 'INT', 'null' => false], 'SpcType' => ['type' => 'varchar', 'constraint'=> 10, 'null' => false], 'Sex' => ['type' => 'INT', 'null' => true], + 'Criteria' => ['type' => 'varchar', 'constraint'=>100, 'null' => true], 'AgeStart' => ['type' => 'INT', 'null' => true], 'AgeEnd' => ['type' => 'int', 'null' => true], + 'TxtRefType' => ['type' => 'INT', 'null' => true], 'RefTxt' => ['type' => 'varchar', 'constraint'=>255, 'null' => true], + 'Flag' => ['type' => 'varchar', 'constraint'=>10, 'null' => true], + 'Notes' => ['type' => 'varchar', 'constraint'=>255, 'null' => true], 'CreateDate' => ['type' => 'Datetime', 'null' => true], + 'StartDate' => ['type' => 'Datetime', 'null' => true], 'EndDate' => ['type' => 'Datetime', 'null' => true], ]); $this->forge->addKey('RefTxtID', true); @@ -80,8 +56,6 @@ class CreateRefRangesTable extends Migration { public function down() { $this->forge->dropTable('refnum'); - $this->forge->dropTable('refthold'); - $this->forge->dropTable('refvset'); $this->forge->dropTable('reftxt'); } } \ No newline at end of file diff --git a/app/Database/Migrations/2025-12-29-150000_EdgeRes.php b/app/Database/Migrations/2025-12-29-150000_EdgeRes.php new file mode 100644 index 0000000..2e5a886 --- /dev/null +++ b/app/Database/Migrations/2025-12-29-150000_EdgeRes.php @@ -0,0 +1,58 @@ +forge->addField([ + 'EdgeResID' => ['type' => 'INT', 'auto_increment' => true], + 'SiteID' => ['type' => 'INT', 'null' => true], + 'InstrumentID' => ['type' => 'varchar', 'constraint' => 100, 'null' => true], + 'SampleID' => ['type' => 'varchar', 'constraint' => 30, 'null' => true], + 'PatientID' => ['type' => 'varchar', 'constraint' => 50, 'null' => true], + 'Payload' => ['type' => 'TEXT', 'null' => true], + 'Status' => ['type' => 'varchar', 'constraint' => 20, 'default' => 'pending'], + 'AutoProcess' => ['type' => 'TINYINT', 'default' => 0, 'null' => true], + 'ProcessedAt' => ['type' => 'DATETIME', 'null' => true], + 'ErrorMessage' => ['type' => 'TEXT', 'null' => true], + 'CreateDate' => ['type' => 'DATETIME', 'null' => true], + 'EndDate' => ['type' => 'DATETIME', 'null' => true], + 'ArchiveDate' => ['type' => 'DATETIME', 'null' => true], + 'DelDate' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addPrimaryKey('EdgeResID'); + $this->forge->createTable('edgeres'); + + // Edge status log - for instrument status tracking + $this->forge->addField([ + 'EdgeStatusID' => ['type' => 'INT', 'auto_increment' => true], + 'InstrumentID' => ['type' => 'varchar', 'constraint' => 100, 'null' => true], + 'Status' => ['type' => 'varchar', 'constraint' => 50, 'null' => true], + 'LastActivity' => ['type' => 'DATETIME', 'null' => true], + 'Timestamp' => ['type' => 'DATETIME', 'null' => true], + 'CreateDate' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addPrimaryKey('EdgeStatusID'); + $this->forge->createTable('edgestatus'); + + // Edge order acknowledgment log + $this->forge->addField([ + 'EdgeAckID' => ['type' => 'INT', 'auto_increment' => true], + 'OrderID' => ['type' => 'INT', 'null' => true], + 'InstrumentID' => ['type' => 'varchar', 'constraint' => 100, 'null' => true], + 'AckDate' => ['type' => 'DATETIME', 'null' => true], + 'CreateDate' => ['type' => 'DATETIME', 'null' => true], + ]); + $this->forge->addPrimaryKey('EdgeAckID'); + $this->forge->createTable('edgeack'); + } + + public function down() { + $this->forge->dropTable('edgeack', true); + $this->forge->dropTable('edgestatus', true); + $this->forge->dropTable('edgeres', true); + } +} diff --git a/app/Database/Seeds/DummySeeder.php b/app/Database/Seeds/DummySeeder.php index a8cda5d..9755ceb 100644 --- a/app/Database/Seeds/DummySeeder.php +++ b/app/Database/Seeds/DummySeeder.php @@ -9,10 +9,12 @@ class DummySeeder extends Seeder { $now = date('Y-m-d H:i:s'); // users + // Password: 'password' for all users (bcrypt hash) + $passwordHash = password_hash('password', PASSWORD_BCRYPT); $data = [ - ['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'], - ['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => '$2y$12$KwPedIPb7K/0IR/8/FcwdOMG4eBNNAXSjXnbkB26SwjH4Nf7PaYBe'], - ['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => '$2y$12$vSB7PpKOUKEyFKbeExiGkuujRfQbR.yl6YVudDpfy24FemZopBG0m'], + ['id' => 1, 'role_id' => 1, 'username' => 'zaka', 'password' => $passwordHash], + ['id' => 2, 'role_id' => 1, 'username' => 'tes' , 'password' => $passwordHash], + ['id' => 3, 'role_id' => 1, 'username' => 'tes2', 'password' => $passwordHash], ]; $this->db->table('users')->insertBatch($data); diff --git a/app/Database/Seeds/TestSeeder.php b/app/Database/Seeds/TestSeeder.php index c66fde0..be8b8cf 100644 --- a/app/Database/Seeds/TestSeeder.php +++ b/app/Database/Seeds/TestSeeder.php @@ -6,7 +6,7 @@ use CodeIgniter\Database\Seeder; use App\Models\ValueSet\ValueSetModel; class TestSeeder extends Seeder { - + public function run() { $now = date('Y-m-d H:i:s'); $vsModel = new ValueSetModel(); @@ -25,104 +25,104 @@ class TestSeeder extends Seeder { $data = ['SiteID' => '1', 'TestSiteCode' => 'HB', 'TestSiteName' => 'Hemoglobin', 'TestType' => $vs[27]['TEST'], 'Description' => '', 'SeqScr' => '2', 'SeqRpt' => '2', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['HB'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['HB'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['HB'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'HCT', 'TestSiteName' => 'Hematocrit', 'TestType' => $vs[27]['TEST'], 'Description' => '', 'SeqScr' => '3', 'SeqRpt' => '3', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['HCT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['HCT'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => '%', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['HCT'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => '%', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'RBC', 'TestSiteName' => 'Red Blood Cell', 'TestType' => $vs[27]['TEST'], 'Description' => 'Eritrosit', 'SeqScr' => '4', 'SeqRpt' => '4', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['RBC'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['RBC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^6/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['RBC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^6/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'WBC', 'TestSiteName' => 'White Blood Cell', 'TestType' => $vs[27]['TEST'], 'Description' => 'Leukosit', 'SeqScr' => '5', 'SeqRpt' => '5', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['WBC'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['WBC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['WBC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '2', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'PLT', 'TestSiteName' => 'Platelet', 'TestType' => $vs[27]['TEST'], 'Description' => 'Trombosit', 'SeqScr' => '6', 'SeqRpt' => '6', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['PLT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['PLT'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['PLT'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'x10^3/uL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'MCV', 'TestSiteName' => 'MCV', 'TestType' => $vs[27]['TEST'], 'Description' => 'Mean Corpuscular Volume', 'SeqScr' => '7', 'SeqRpt' => '7', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['MCV'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['MCV'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'fL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['MCV'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'fL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'MCH', 'TestSiteName' => 'MCH', 'TestType' => $vs[27]['TEST'], 'Description' => 'Mean Corpuscular Hemoglobin', 'SeqScr' => '8', 'SeqRpt' => '8', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['MCH'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['MCH'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'pg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['MCH'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'pg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'MCHC', 'TestSiteName' => 'MCHC', 'TestType' => $vs[27]['TEST'], 'Description' => 'Mean Corpuscular Hemoglobin Concentration', 'SeqScr' => '9', 'SeqRpt' => '9', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['MCHC'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['MCHC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['BLD'], 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['MCHC'], 'DisciplineID' => '1', 'DepartmentID' => '1', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '500', 'ReqQtyUnit' => 'uL', 'Unit1' => 'g/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'CBC Analyzer', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); // Chemistry Tests $data = ['SiteID' => '1', 'TestSiteCode' => 'GLU', 'TestSiteName' => 'Glucose', 'TestType' => $vs[27]['TEST'], 'Description' => 'Glukosa Sewaktu', 'SeqScr' => '11', 'SeqRpt' => '11', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['GLU'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['GLU'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '0.0555', 'Unit2' => 'mmol/L', 'Decimal' => '0', 'Method' => 'Hexokinase', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['GLU'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '0.0555', 'Unit2' => 'mmol/L', 'Decimal' => '0', 'Method' => 'Hexokinase', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'CREA', 'TestSiteName' => 'Creatinine', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kreatinin', 'SeqScr' => '12', 'SeqRpt' => '12', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['CREA'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['CREA'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '88.4', 'Unit2' => 'umol/L', 'Decimal' => '2', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['CREA'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '88.4', 'Unit2' => 'umol/L', 'Decimal' => '2', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'UREA', 'TestSiteName' => 'Blood Urea Nitrogen', 'TestType' => $vs[27]['TEST'], 'Description' => 'BUN', 'SeqScr' => '13', 'SeqRpt' => '13', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['UREA'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['UREA'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Urease-GLDH', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['UREA'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Urease-GLDH', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'SGOT', 'TestSiteName' => 'AST (SGOT)', 'TestType' => $vs[27]['TEST'], 'Description' => 'Aspartate Aminotransferase', 'SeqScr' => '14', 'SeqRpt' => '14', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['SGOT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['SGOT'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['SGOT'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'SGPT', 'TestSiteName' => 'ALT (SGPT)', 'TestType' => $vs[27]['TEST'], 'Description' => 'Alanine Aminotransferase', 'SeqScr' => '15', 'SeqRpt' => '15', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['SGPT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['SGPT'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['SGPT'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'U/L', 'Factor' => '0.017', 'Unit2' => 'ukat/L', 'Decimal' => '0', 'Method' => 'IFCC', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'CHOL', 'TestSiteName' => 'Total Cholesterol', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kolesterol Total', 'SeqScr' => '16', 'SeqRpt' => '16', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['CHOL'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['CHOL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['THOLD'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['CHOL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Enzymatic', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'TG', 'TestSiteName' => 'Triglycerides', 'TestType' => $vs[27]['TEST'], 'Description' => 'Trigliserida', 'SeqScr' => '17', 'SeqRpt' => '17', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['TG'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['TG'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['THOLD'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'GPO-PAP', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['TG'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'GPO-PAP', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'HDL', 'TestSiteName' => 'HDL Cholesterol', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kolesterol HDL', 'SeqScr' => '18', 'SeqRpt' => '18', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['HDL'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['HDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['THOLD'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['HDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'LDL', 'TestSiteName' => 'LDL Cholesterol', 'TestType' => $vs[27]['TEST'], 'Description' => 'Kolesterol LDL', 'SeqScr' => '19', 'SeqRpt' => '19', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['LDL'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['LDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['THOLD'], 'VSet' => '', 'SpcType' => $vs[15]['SER'], 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['LDL'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '300', 'ReqQtyUnit' => 'uL', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => 'Direct', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); // ======================================== @@ -131,31 +131,31 @@ class TestSeeder extends Seeder { $data = ['SiteID' => '1', 'TestSiteCode' => 'HEIGHT', 'TestSiteName' => 'Height', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Tinggi Badan', 'SeqScr' => '40', 'SeqRpt' => '40', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['HEIGHT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['HEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'cm', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['HEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'cm', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'WEIGHT', 'TestSiteName' => 'Weight', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Berat Badan', 'SeqScr' => '41', 'SeqRpt' => '41', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['WEIGHT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['WEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'kg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => '', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['WEIGHT'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'kg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => '', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'AGE', 'TestSiteName' => 'Age', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Usia', 'SeqScr' => '42', 'SeqRpt' => '42', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['AGE'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['AGE'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'years', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['AGE'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'years', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'SYSTL', 'TestSiteName' => 'Systolic BP', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Tekanan Darah Sistolik', 'SeqScr' => '43', 'SeqRpt' => '43', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['SYSTL'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['SYSTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['SYSTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'DIASTL', 'TestSiteName' => 'Diastolic BP', 'TestType' => $vs[27]['PARAM'], 'Description' => 'Tekanan Darah Diastolik', 'SeqScr' => '44', 'SeqRpt' => '44', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][0], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['DIASTL'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['DIASTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'SpcType' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['DIASTL'], 'DisciplineID' => '10', 'DepartmentID' => '', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => '', 'VSet' => '', 'ReqQty' => '', 'ReqQtyUnit' => '', 'Unit1' => 'mmHg', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'Method' => '', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); // ======================================== @@ -164,19 +164,19 @@ class TestSeeder extends Seeder { $data = ['SiteID' => '1', 'TestSiteCode' => 'BMI', 'TestSiteName' => 'Body Mass Index', 'TestType' => $vs[27]['CALC'], 'Description' => 'Indeks Massa Tubuh - weight/(height^2)', 'SeqScr' => '45', 'SeqRpt' => '45', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['BMI'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['BMI'], 'DisciplineID' => '10', 'DepartmentID' => '', 'FormulaInput' => 'WEIGHT,HEIGHT', 'FormulaCode' => 'WEIGHT / ((HEIGHT/100) * (HEIGHT/100))', 'Unit1' => 'kg/m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['BMI'], 'DisciplineID' => '10', 'DepartmentID' => '', 'FormulaInput' => 'WEIGHT,HEIGHT', 'FormulaCode' => 'WEIGHT / ((HEIGHT/100) * (HEIGHT/100))', 'Unit1' => 'kg/m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'CreateDate' => "$now"]; $this->db->table('testdefcal')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'EGFR', 'TestSiteName' => 'eGFR (CKD-EPI)', 'TestType' => $vs[27]['CALC'], 'Description' => 'Estimated Glomerular Filtration Rate', 'SeqScr' => '20', 'SeqRpt' => '20', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['EGFR'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['EGFR'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CREA,AGE,GENDER', 'FormulaCode' => 'CKD_EPI(CREA,AGE,GENDER)', 'Unit1' => 'mL/min/1.73m2', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['EGFR'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CREA,AGE,GENDER', 'FormulaCode' => 'CKD_EPI(CREA,AGE,GENDER)', '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' => $vs[27]['CALC'], 'Description' => 'Friedewald formula: TC - HDL - (TG/5)', 'SeqScr' => '21', 'SeqRpt' => '21', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][0], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['LDLCALC'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['LDLCALC'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CHOL,HDL,TG', 'FormulaCode' => 'CHOL - HDL - (TG/5)', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['LDLCALC'], 'DisciplineID' => '2', 'DepartmentID' => '2', 'FormulaInput' => 'CHOL,HDL,TG', 'FormulaCode' => 'CHOL - HDL - (TG/5)', 'Unit1' => 'mg/dL', 'Factor' => '', 'Unit2' => '', 'Decimal' => '0', 'CreateDate' => "$now"]; $this->db->table('testdefcal')->insert($data); // ======================================== @@ -186,68 +186,68 @@ class TestSeeder extends Seeder { $this->db->table('testdefsite')->insert($data); $tIDs['CBC'] = $this->db->insertID(); $this->db->table('testdefgrp')->insertBatch([ - ['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HB'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HCT'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['RBC'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['WBC'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['PLT'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCV'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCH'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCHC'], 'CreateDate' => "$now"] + ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HB'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['HCT'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['RBC'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['WBC'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['PLT'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCV'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCH'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['CBC'], 'Member' => $tIDs['MCHC'], 'CreateDate' => "$now"] ]); $data = ['SiteID' => '1', 'TestSiteCode' => 'LIPID', 'TestSiteName' => 'Lipid Profile', 'TestType' => $vs[27]['GROUP'], 'Description' => 'Profil Lipid', 'SeqScr' => '51', 'SeqRpt' => '51', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['LIPID'] = $this->db->insertID(); $this->db->table('testdefgrp')->insertBatch([ - ['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['CHOL'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['TG'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['HDL'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDL'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDLCALC'], 'CreateDate' => "$now"] + ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['CHOL'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['TG'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['HDL'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDL'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['LIPID'], 'Member' => $tIDs['LDLCALC'], 'CreateDate' => "$now"] ]); $data = ['SiteID' => '1', 'TestSiteCode' => 'LFT', 'TestSiteName' => 'Liver Function Test', 'TestType' => $vs[27]['GROUP'], 'Description' => 'Fungsi Hati', 'SeqScr' => '52', 'SeqRpt' => '52', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['LFT'] = $this->db->insertID(); $this->db->table('testdefgrp')->insertBatch([ - ['SiteID' => '1', 'TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGOT'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGPT'], 'CreateDate' => "$now"] + ['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGOT'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['LFT'], 'Member' => $tIDs['SGPT'], 'CreateDate' => "$now"] ]); $data = ['SiteID' => '1', 'TestSiteCode' => 'RFT', 'TestSiteName' => 'Renal Function Test', 'TestType' => $vs[27]['GROUP'], 'Description' => 'Fungsi Ginjal', 'SeqScr' => '53', 'SeqRpt' => '53', 'IndentLeft' => '0', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['RFT'] = $this->db->insertID(); $this->db->table('testdefgrp')->insertBatch([ - ['SiteID' => '1', 'TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['UREA'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['CREA'], 'CreateDate' => "$now"], - ['SiteID' => '1', 'TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['EGFR'], 'CreateDate' => "$now"] + ['TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['UREA'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['CREA'], 'CreateDate' => "$now"], + ['TestSiteID' => $tIDs['RFT'], 'Member' => $tIDs['EGFR'], 'CreateDate' => "$now"] ]); // Urinalysis Tests (with valueset result type) $data = ['SiteID' => '1', 'TestSiteCode' => 'UCOLOR', 'TestSiteName' => 'Urine Color', 'TestType' => $vs[27]['TEST'], 'Description' => 'Warna Urine', 'SeqScr' => '31', 'SeqRpt' => '31', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['UCOLOR'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['UCOLOR'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['VSET'], 'VSet' => '1001', 'SpcType' => $vs[15]['UR'], 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Visual', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['UCOLOR'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['TEXT'], 'VSet' => '1001', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Visual', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'UGLUC', 'TestSiteName' => 'Urine Glucose', 'TestType' => $vs[27]['TEST'], 'Description' => 'Glukosa Urine', 'SeqScr' => '32', 'SeqRpt' => '32', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['UGLUC'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['UGLUC'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['VSET'], 'VSet' => '1002', 'SpcType' => $vs[15]['UR'], 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['UGLUC'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['TEXT'], 'VSet' => '1002', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'UPROT', 'TestSiteName' => 'Urine Protein', 'TestType' => $vs[27]['TEST'], 'Description' => 'Protein Urine', 'SeqScr' => '33', 'SeqRpt' => '33', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['UPROT'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['UPROT'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['VSET'], 'VSet' => '1003', 'SpcType' => $vs[15]['UR'], 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['UPROT'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['VSET'], 'RefType' => $vs[44]['TEXT'], 'VSet' => '1003', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); $data = ['SiteID' => '1', 'TestSiteCode' => 'PH', 'TestSiteName' => 'Urine pH', 'TestType' => $vs[27]['TEST'], 'Description' => 'pH Urine', 'SeqScr' => '34', 'SeqRpt' => '34', 'IndentLeft' => '1', 'VisibleScr' => $vs[2][1], 'VisibleRpt' => $vs[2][1], 'CountStat' => $vs[2][1], 'CreateDate' => "$now"]; $this->db->table('testdefsite')->insert($data); $tIDs['PH'] = $this->db->insertID(); - $data = ['SiteID' => '1', 'TestSiteID' => $tIDs['PH'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['RANGE'], 'VSet' => '', 'SpcType' => $vs[15]['UR'], 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; + $data = ['TestSiteID' => $tIDs['PH'], 'DisciplineID' => '4', 'DepartmentID' => '4', 'ResultType' => $vs[43]['NMRIC'], 'RefType' => $vs[44]['NMRC'], 'VSet' => '', 'ReqQty' => '10', 'ReqQtyUnit' => 'mL', 'Unit1' => '', 'Factor' => '', 'Unit2' => '', 'Decimal' => '1', 'Method' => 'Dipstick', 'CreateDate' => "$now"]; $this->db->table('testdeftech')->insert($data); } -} \ No newline at end of file +} diff --git a/app/Database/Seeds/ValueSetSeeder.php b/app/Database/Seeds/ValueSetSeeder.php index e22f77b..548cb77 100644 --- a/app/Database/Seeds/ValueSetSeeder.php +++ b/app/Database/Seeds/ValueSetSeeder.php @@ -99,7 +99,7 @@ class ValueSetSeeder extends Seeder { ['VSetID' => 15,'VOrder' => 2, 'VValue' =>'BLDA', 'VDesc' => "Blood arterial", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 3, 'VValue' =>'BLDCO', 'VDesc' => "Cord blood", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 4, 'VValue' =>'FBLOOD', 'VDesc' => "Blood, Fetal", 'VCategory' => 1, 'CreateDate' => "$now"], - ['VSetID' => 15,'VOrder' => 5, 'VValue' =>'FBLOOD', 'VDesc' => "Blood, Fetal", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 15,'VOrder' => 5, 'VValue' =>'CSF', 'VDesc' => "Cerebral spinal fluid", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 6, 'VValue' =>'WB', 'VDesc' => "Blood, Whole", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 7, 'VValue' =>'BBL', 'VDesc' => "Blood bag", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 15,'VOrder' => 8, 'VValue' =>'SER', 'VDesc' => "Serum", 'VCategory' => 1, 'CreateDate' => "$now"], @@ -306,33 +306,46 @@ class ValueSetSeeder extends Seeder { ['VSetID' => 43,'VOrder' => 2, 'VValue' =>'RANGE', 'VDesc' => "Range", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 43,'VOrder' => 3, 'VValue' =>'TEXT', 'VDesc' => "Text", 'VCategory' => 1, 'CreateDate' => "$now"], ['VSetID' => 43,'VOrder' => 4, 'VValue' =>'VSET', 'VDesc' => "Value set", 'VCategory' => 1, 'CreateDate' => "$now"], - ['VSetID' => 44,'VOrder' => 1, 'VValue' =>'RANGE', 'VDesc' => "Range", 'VCategory' => 1, 'CreateDate' => "$now"], - ['VSetID' => 44,'VOrder' => 2, 'VValue' =>'THOLD', 'VDesc' => "Threshold", 'VCategory' => 1, 'CreateDate' => "$now"], - ['VSetID' => 44,'VOrder' => 3, 'VValue' =>'VSET', 'VDesc' => "Value Set", 'VCategory' => 1, 'CreateDate' => "$now"], - ['VSetID' => 44,'VOrder' => 4, 'VValue' =>'TEXT', 'VDesc' => "Text.", 'VCategory' => 1, 'CreateDate' => "$now"] + ['VSetID' => 44,'VOrder' => 1, 'VValue' =>'NMRC', 'VDesc' => "Numeric", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 44,'VOrder' => 2, 'VValue' =>'TEXT', 'VDesc' => "Text", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 45,'VOrder' => 1, 'VValue' =>'REF', 'VDesc' => "Reference Range", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 45,'VOrder' => 2, 'VValue' =>'CRTC', 'VDesc' => "Critical Range", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 45,'VOrder' => 3, 'VValue' =>'VAL', 'VDesc' => "Validation Range", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 45,'VOrder' => 4, 'VValue' =>'RERUN', 'VDesc' => "Rerun Range", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 46,'VOrder' => 1, 'VValue' =>'RANGE', 'VDesc' => "Range", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 46,'VOrder' => 2, 'VValue' =>'THOLD', 'VDesc' => "Threshold", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 47,'VOrder' => 1, 'VValue' =>'VSET', 'VDesc' => "Value Set", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 47,'VOrder' => 2, 'VValue' =>'TEXT', 'VDesc' => "Text.", 'VCategory' => 1, 'CreateDate' => "$now"], + ['VSetID' => 1001,'VOrder' => 1, 'VValue' =>'NEG', 'VDesc' => "Negative", 'VCategory' => 2, 'CreateDate' => "$now"], + ['VSetID' => 1001,'VOrder' => 2, 'VValue' =>'POS', 'VDesc' => "Positive", 'VCategory' => 2, 'CreateDate' => "$now"], + ['VSetID' => 1001,'VOrder' => 3, 'VValue' =>'GZ', 'VDesc' => "Grayzone", 'VCategory' => 2, 'CreateDate' => "$now"], + ['VSetID' => 1002,'VOrder' => 1, 'VValue' =>'KNG', 'VDesc' => "Kuning", 'VCategory' => 2, 'CreateDate' => "$now"], + ['VSetID' => 1002,'VOrder' => 2, 'VValue' =>'JNG', 'VDesc' => "Jingga", 'VCategory' => 2, 'CreateDate' => "$now"], + ['VSetID' => 1002,'VOrder' => 3, 'VValue' =>'MRH', 'VDesc' => "Merah", 'VCategory' => 2, 'CreateDate' => "$now"], + ['VSetID' => 1002,'VOrder' => 4, 'VValue' =>'CKLT', 'VDesc' => "Coklat tua", 'VCategory' => 2, 'CreateDate' => "$now"] ]; $this->db->table('valueset')->insertBatch($data); $data = [ ['VSName' => 'WSType','VSDesc' =>'workstation.Type', 'VSetID' => '1', 'CreateDate' => "$now"], - ['VSName' => 'Enable/Disable','VSDesc' =>'workstation.Enable equipmentlist.Enable testdef.CountStat testdefsite.CountStat testdefsite.VisibleScr testdefsite.VisibleRpt', 'VSetID' => '2', 'CreateDate' => "$now"], - ['VSName' => 'Gender','VSDesc' =>'patient.Gender', 'VSetID' => '3', 'CreateDate' => "$now"], + ['VSName' => 'Enable/Disable','VSDesc' =>'workstation.Enable, equipmentlist.Enable, testdef.CountStat, testdefsite.CountStat, testdefsite.VisibleScr, testdefsite.VisibleRpt', 'VSetID' => '2', 'CreateDate' => "$now"], + ['VSName' => 'Gender','VSDesc' =>'patient.Gender, refnum.Sex', 'VSetID' => '3', 'CreateDate' => "$now"], ['VSName' => 'Marital Status','VSDesc' =>'patient.MaritalStatus', 'VSetID' => '4', 'CreateDate' => "$now"], - ['VSName' => 'Deceased','VSDesc' =>'patient.Deceased', 'VSetID' => '5', 'CreateDate' => "$now"], + ['VSName' => 'Death Indicator','VSDesc' =>'patient.DeathIndicator', 'VSetID' => '5', 'CreateDate' => "$now"], ['VSName' => 'Identifier Type','VSDesc' =>'patidt.IdentifierType', 'VSetID' => '6', 'CreateDate' => "$now"], - ['VSName' => 'Operation','VSDesc' =>'patreglog.Operation patvisitlog.Operation orderlog.Operation', 'VSetID' => '7', 'CreateDate' => "$now"], - ['VSName' => 'DID Type','VSDesc' =>'patreglog.DIDType patvisitlog.DIDType', 'VSetID' => '8', 'CreateDate' => "$now"], + ['VSName' => 'Operation','VSDesc' =>'patreglog.Operation, patvisitlog.Operation, orderlog.Operation', 'VSetID' => '7', 'CreateDate' => "$now"], + ['VSName' => 'DID Type','VSDesc' =>'patreglog.DIDType, patvisitlog.DIDType', 'VSetID' => '8', 'CreateDate' => "$now"], ['VSName' => 'Requested Entity','VSDesc' =>'order.ReqEntity', 'VSetID' => '9', 'CreateDate' => "$now"], ['VSName' => 'Order Priority','VSDesc' =>'order.Priority', 'VSetID' => '10', 'CreateDate' => "$now"], ['VSName' => 'Order Status','VSDesc' =>'orderststatus.OrderStatus', 'VSetID' => '11', 'CreateDate' => "$now"], - ['VSName' => 'Location Type','VSDesc' =>'location.LocationType', 'VSetID' => '12', 'CreateDate' => "$now"], - ['VSName' => 'Additive','VSDesc' =>'containertype.Additive specimenprep.Additive', 'VSetID' => '13', 'CreateDate' => "$now"], + ['VSName' => 'Location TypeTable 34 location','VSDesc' =>'location.LocationType', 'VSetID' => '12', 'CreateDate' => "$now"], + ['VSName' => 'Additive','VSDesc' =>'containertype.Additive, specimenprep.Additive', 'VSetID' => '13', 'CreateDate' => "$now"], ['VSName' => 'Container Class','VSDesc' =>'containertype.ConClass', 'VSetID' => '14', 'CreateDate' => "$now"], - ['VSName' => 'Specimen Type','VSDesc' =>'testdeftech.SpcType refnum. SpcType refthold.SpcType', 'VSetID' => '15', 'CreateDate' => "$now"], - ['VSName' => 'Unit','VSDesc' =>'spcdef.Unit specimens.Unit specimenstatus.Unit specimenprep.AddUnit', 'VSetID' => '16', 'CreateDate' => "$now"], + ['VSName' => 'Specimen Type','VSDesc' =>'testdeftech.SpcType, refnum.SpcType, reftxt.SpcType', 'VSetID' => '15', 'CreateDate' => "$now"], + ['VSName' => 'Unit','VSDesc' =>'spcdef.Unit, specimens.Unit, specimenstatus.Unit, specimenprep.AddUnit', 'VSetID' => '16', 'CreateDate' => "$now"], ['VSName' => 'GenerateBy','VSDesc' =>'specimens. GenerateBy', 'VSetID' => '17', 'CreateDate' => "$now"], ['VSName' => 'Specimen Activity','VSDesc' =>'specimenstatus.SpcAct', 'VSetID' => '18', 'CreateDate' => "$now"], - ['VSName' => 'Activity Result','VSDesc' =>'specimenstatus.ActRes patrestatus.ActRes', 'VSetID' => '19', 'CreateDate' => "$now"], + ['VSName' => 'Activity Result','VSDesc' =>'specimenstatus.ActRes, patrestatus.ActRes', 'VSetID' => '19', 'CreateDate' => "$now"], ['VSName' => 'Specimen Status','VSDesc' =>'specimenstatus.SpcStatus', 'VSetID' => '20', 'CreateDate' => "$now"], ['VSName' => 'Specimen Condition','VSDesc' =>'specimenstatus.SpcCon', 'VSetID' => '21', 'CreateDate' => "$now"], ['VSName' => 'Specimen Role','VSDesc' =>'specimencollection.SpcRole', 'VSetID' => '22', 'CreateDate' => "$now"], @@ -340,8 +353,8 @@ class ValueSetSeeder extends Seeder { ['VSName' => 'Body Site','VSDesc' =>'specimencollection.BodySite', 'VSetID' => '24', 'CreateDate' => "$now"], ['VSName' => 'Container Size','VSDesc' =>'specimencollection.CntSize', 'VSetID' => '25', 'CreateDate' => "$now"], ['VSName' => 'Fasting Status','VSDesc' =>'specimencollection.Fasting', 'VSetID' => '26', 'CreateDate' => "$now"], - ['VSName' => 'Test Type','VSDesc' =>'testdefsite.Type', 'VSetID' => '27', 'CreateDate' => "$now"], - ['VSName' => 'Result Unit','VSDesc' =>'testdefsite.Unit1 testdefsite.Unit2', 'VSetID' => '28', 'CreateDate' => "$now"], + ['VSName' => 'Test Type','VSDesc' =>'testdefsite.TestType', 'VSetID' => '27', 'CreateDate' => "$now"], + ['VSName' => 'Result Unit','VSDesc' =>'testdefsite.Unit1, testdefsite.Unit2', 'VSetID' => '28', 'CreateDate' => "$now"], ['VSName' => 'Formula Languange','VSDesc' =>'testdefcal.FormulaLang', 'VSetID' => '29', 'CreateDate' => "$now"], ['VSName' => 'Race','VSDesc' =>'patient.Race', 'VSetID' => '30', 'CreateDate' => "$now"], ['VSName' => 'Religion','VSDesc' =>'patient.Religion', 'VSetID' => '31', 'CreateDate' => "$now"], @@ -352,12 +365,17 @@ class ValueSetSeeder extends Seeder { ['VSName' => 'ADT Event','VSDesc' =>'patvisitadt.Code', 'VSetID' => '36', 'CreateDate' => "$now"], ['VSName' => 'Site Type','VSDesc' =>'Site.SiteType', 'VSetID' => '37', 'CreateDate' => "$now"], ['VSName' => 'Site Class','VSDesc' =>'Site.SiteClass', 'VSetID' => '38', 'CreateDate' => "$now"], - ['VSName' => 'Entity Type','VSDesc' =>'testmap.HostType testmap.ClientType', 'VSetID' => '39', 'CreateDate' => "$now"], + ['VSName' => 'Entity Type','VSDesc' =>'testmap.HostType, testmap.ClientType', 'VSetID' => '39', 'CreateDate' => "$now"], ['VSName' => 'Area Class','VSDesc' =>'AreaGeo', 'VSetID' => '40', 'CreateDate' => "$now"], - ['VSName' => 'Math Sign','VSDesc' =>'refthold.TholdSign', 'VSetID' => '41', 'CreateDate' => "$now"], + ['VSName' => 'Math Sign','VSDesc' =>'refnum.LowSign, refnum.HighSign', 'VSetID' => '41', 'CreateDate' => "$now"], ['VSName' => 'VCategory','VSDesc' =>'valueset. VCategory', 'VSetID' => '42', 'CreateDate' => "$now"], ['VSName' => 'Result Type','VSDesc' =>'testdeftech.ResultType', 'VSetID' => '43', 'CreateDate' => "$now"], ['VSName' => 'Reference Type','VSDesc' =>'testdeftech.RefType', 'VSetID' => '44', 'CreateDate' => "$now"], + ['VSName' => 'Range Type','VSDesc' =>'refnum.RangeType', 'VSetID' => '45', 'CreateDate' => "$now"], + ['VSName' => 'Numeric Reference Type','VSDesc' =>'refnum.NumRefType', 'VSetID' => '46', 'CreateDate' => "$now"], + ['VSName' => 'Text Reference Type','VSDesc' =>'reftxt. TxtRefType', 'VSetID' => '47', 'CreateDate' => "$now"], + ['VSName' => 'HIV','VSDesc' =>'Value set untuk hasil HIV', 'VSetID' => '1001', 'CreateDate' => "$now"], + ]; $this->db->table('valuesetdef')->insertBatch($data); } diff --git a/app/Filters/AuthFilter.php b/app/Filters/AuthFilter.php index 59ff3cb..56ffc3e 100644 --- a/app/Filters/AuthFilter.php +++ b/app/Filters/AuthFilter.php @@ -16,14 +16,22 @@ class AuthFilter implements FilterInterface $key = getenv('JWT_SECRET'); $token = $request->getCookie('token'); // ambil dari cookie + // Check if this is an API request or a page request + $isApiRequest = strpos($request->getUri()->getPath(), '/api/') !== false + || $request->isAJAX(); + // Kalau tidak ada token if (!$token) { - return Services::response() - ->setStatusCode(401) - ->setJSON([ - 'status' => 'failed', - 'message' => 'Unauthorized: Token not found' - ]); + if ($isApiRequest) { + return Services::response() + ->setStatusCode(401) + ->setJSON([ + 'status' => 'failed', + 'message' => 'Unauthorized: Token not found' + ]); + } + // Redirect to login for page requests + return redirect()->to('/v2/login'); } try { @@ -36,12 +44,16 @@ class AuthFilter implements FilterInterface // $request->userData = $decoded; } catch (\Exception $e) { - return Services::response() - ->setStatusCode(401) - ->setJSON([ - 'status' => 'failed', - 'message' => 'Unauthorized: ' . $e->getMessage() - ]); + if ($isApiRequest) { + return Services::response() + ->setStatusCode(401) + ->setJSON([ + 'status' => 'failed', + 'message' => 'Unauthorized: ' . $e->getMessage() + ]); + } + // Redirect to login for page requests + return redirect()->to('/v2/login'); } } diff --git a/app/Filters/Cors.php b/app/Filters/Cors.php index c18b5aa..b7cdebe 100644 --- a/app/Filters/Cors.php +++ b/app/Filters/Cors.php @@ -10,6 +10,7 @@ class Cors implements FilterInterface { protected $allowedOrigins = [ 'http://localhost:5173', + 'http://localhost', 'https://clqms01.services-summit.my.id', ]; @@ -19,6 +20,11 @@ class Cors implements FilterInterface $origin = $_SERVER['HTTP_ORIGIN'] ?? ''; $response = service('response'); + // Allow same-origin requests (when no Origin header is present) + if (empty($origin)) { + return null; + } + if (in_array($origin, $this->allowedOrigins)) { $response->setHeader('Access-Control-Allow-Origin', $origin); $response->setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); diff --git a/app/Models/EdgeResModel.php b/app/Models/EdgeResModel.php new file mode 100644 index 0000000..8129787 --- /dev/null +++ b/app/Models/EdgeResModel.php @@ -0,0 +1,63 @@ +where('Status', 'pending') + ->whereNull('DelDate') + ->orderBy('CreateDate', 'ASC') + ->findAll($limit); + } + + /** + * Mark as processed + */ + public function markProcessed($id) { + return $this->update($id, [ + 'Status' => 'processed', + 'ProcessedAt' => date('Y-m-d H:i:s') + ]); + } + + /** + * Mark as error + */ + public function markError($id, $errorMessage) { + return $this->update($id, [ + 'Status' => 'error', + 'ErrorMessage' => $errorMessage, + 'ProcessedAt' => date('Y-m-d H:i:s') + ]); + } +} diff --git a/app/Models/Location/LocationAddressModel.php b/app/Models/Location/LocationAddressModel.php index 4684175..b6971d1 100644 --- a/app/Models/Location/LocationAddressModel.php +++ b/app/Models/Location/LocationAddressModel.php @@ -5,7 +5,8 @@ use App\Models\BaseModel; class LocationAddressModel extends BaseModel { protected $table = 'locationaddress'; protected $primaryKey = 'LocationID'; - protected $allowedFields = ['LocationID', 'Street1', 'Street2', 'City', 'Province', 'PostCode', 'GeoLocationSystem', 'GeoLocationData', 'CreateDate', 'EndDate']; + protected $allowedFields = ['LocationID', 'Street1', 'Street2', 'City', 'Province', 'PostCode', + 'GeoLocationSystem', 'GeoLocationData', 'Phone', 'Email', 'CreateDate', 'EndDate']; protected $useTimestamps = true; protected $createdField = 'CreateDate'; diff --git a/app/Models/Location/LocationModel.php b/app/Models/Location/LocationModel.php index 6f7f729..569a2fe 100644 --- a/app/Models/Location/LocationModel.php +++ b/app/Models/Location/LocationModel.php @@ -24,15 +24,15 @@ class LocationModel extends BaseModel { public function getLocation($LocationID) { //'Street1', 'Street2', 'City', 'Province', 'PostCode', 'GeoLocationSystem', 'GeoLocationData', - $rows = $this->select("location.*, la.Street1, la.Street2, la.PostCode, la.GeoLocationSystem, la.GeoLocationData, + $row = $this->select("location.*, la.Street1, la.Street2, la.PostCode, la.GeoLocationSystem, la.GeoLocationData, v.VDesc as LocTypeText, prop.AreaGeoID as ProvinceID, prop.AreaName as Province, city.AreaGeoID as CityID, city.AreaName as City, site.SiteID, site.SiteName") ->join("locationaddress la", "location.LocationID=la.LocationID", "left") ->join("valueset v", "v.VID=location.loctype", "left") ->join("areageo prop", "la.Province=prop.AreaGeoID", "left") ->join("areageo city", "la.City=city.AreaGeoID", "left") ->join("site", "site.SiteID=location.SiteID", "left") - ->where('location.LocationID', (int) $LocationID)->findAll(); - return $rows; + ->where('location.LocationID', (int) $LocationID)->first(); + return $row; } public function saveLocation(array $data): array { @@ -50,14 +50,15 @@ class LocationModel extends BaseModel { $modelAddress->insert($data); } if ($db->transStatus() === false) { + $error = $db->error(); $db->transRollback(); - throw new \Exception('Transaction failed'); + throw new \Exception($error['message'] ?? 'Transaction failed'); } $db->transCommit(); return [ 'status' => 'success', 'LocationID' => $LocationID ]; } catch (\Throwable $e) { $db->transRollback(); - return [ 'status' => 'error', 'message' => $e->getMessage() ]; + throw $e; } } @@ -70,14 +71,15 @@ class LocationModel extends BaseModel { $this->delete($LocationID); $modelAddress->delete($LocationID); if ($db->transStatus() === false) { + $error = $db->error(); $db->transRollback(); - throw new \Exception('Transaction failed'); + throw new \Exception($error['message'] ?? 'Transaction failed'); } $db->transCommit(); return [ 'status' => 'success', 'LocationID' => $LocationID ]; } catch (\Throwable $e) { $db->transRollback(); - return [ 'status' => 'error', 'message' => $e->getMessage() ]; + throw $e; } } } diff --git a/app/Models/Organization/AccountModel.php b/app/Models/Organization/AccountModel.php index 4127c8b..6d8023f 100644 --- a/app/Models/Organization/AccountModel.php +++ b/app/Models/Organization/AccountModel.php @@ -31,7 +31,7 @@ class AccountModel extends BaseModel { } public function getAccount($AccountID) { - $rows = $this->select('account.*, pa.AccountName as ParentName, areageo.AreaName, areageo.AreaGeoID, + $row = $this->select('account.*, pa.AccountName as ParentName, areageo.AreaName, areageo.AreaGeoID, city.AreaName as CityName, city.AreaGeoID as City, prov.AreaName as ProvName, prov.AreaGeoID as Prov, country.VValue as CountryName, country.VID as country') ->join('account pa', 'pa.AccountID=account.Parent', 'left') @@ -40,7 +40,7 @@ class AccountModel extends BaseModel { ->join('areageo prov', 'prov.AreaGeoID=account.Province', 'left') ->join('valueset country', 'country.VID=account.Country', 'left') ->where('account.AccountID', $AccountID) - ->findAll(); - return $rows; + ->first(); + return $row; } } diff --git a/app/Models/Organization/DepartmentModel.php b/app/Models/Organization/DepartmentModel.php index b51218b..1def9c8 100644 --- a/app/Models/Organization/DepartmentModel.php +++ b/app/Models/Organization/DepartmentModel.php @@ -30,11 +30,11 @@ class DepartmentModel extends BaseModel { } public function getDepartment($DepartmentID) { - $rows = $this->select('department.*, discipline.DisciplineCode, discipline.DisciplineName, site.SiteCode, site.SiteName') + $row = $this->select('department.*, discipline.DisciplineCode, discipline.DisciplineName, site.SiteCode, site.SiteName') ->join('discipline', 'discipline.DisciplineID=department.DisciplineID', 'left') ->join('site', 'site.SiteID=department.SiteID', 'left') ->where('department.DepartmentID', $DepartmentID) - ->findAll(); - return $rows; + ->first(); + return $row; } } diff --git a/app/Models/Organization/SiteModel.php b/app/Models/Organization/SiteModel.php index f2d4797..7f9ef3a 100644 --- a/app/Models/Organization/SiteModel.php +++ b/app/Models/Organization/SiteModel.php @@ -32,13 +32,13 @@ class SiteModel extends BaseModel { } public function getSite($SiteID) { - $rows = $this->select('site.*, account.AccountName, s1.SiteName as ParentName, sitetype.VValue as SiteType, siteclass.VValue as SiteClass') + $row = $this->select('site.*, account.AccountName, s1.SiteName as ParentName, sitetype.VValue as SiteType, siteclass.VValue as SiteClass') ->join('account', 'account.AccountID=site.AccountID', 'left') ->join('site s1', 's1.SiteID=site.Parent', 'left') ->join('valueset sitetype', 'site.SiteTypeID=sitetype.VID', 'left') ->join('valueset siteclass', 'site.SiteClassID=siteclass.VID', 'left') ->where('site.SiteID', $SiteID) - ->findAll(); - return $rows; + ->first(); + return $row; } } diff --git a/app/Models/Organization/WorkstationModel.php b/app/Models/Organization/WorkstationModel.php index ea4d701..626e088 100644 --- a/app/Models/Organization/WorkstationModel.php +++ b/app/Models/Organization/WorkstationModel.php @@ -30,13 +30,13 @@ class WorkstationModel extends BaseModel { } public function getWorkstation($WorkstationID) { - $rows = $this->select("workstation.*, department.DepartmentName, wst1.WorkstationName as LinkToName,enable.VDesc as EnableName, wstype.VDesc as TypeName") + $row = $this->select("workstation.*, department.DepartmentName, wst1.WorkstationName as LinkToName,enable.VDesc as EnableName, wstype.VDesc as TypeName") ->join('workstation wst1', 'workstation.LinkTo=wst1.WorkstationID', 'left') ->join('department', 'department.DepartmentID=workstation.DepartmentID', 'left') ->join('valueset wstype', 'wstype.VID=workstation.Type', 'left') ->join('valueset enable', 'enable.VID=workstation.Enable', 'left') ->where('workstation.WorkstationID', $WorkstationID) - ->findAll(); - return $rows; + ->first(); + return $row; } } diff --git a/app/Models/PatVisit/PatVisitModel.php b/app/Models/PatVisit/PatVisitModel.php index dae06b0..dfb462b 100644 --- a/app/Models/PatVisit/PatVisitModel.php +++ b/app/Models/PatVisit/PatVisitModel.php @@ -20,11 +20,11 @@ class PatVisitModel extends BaseModel { protected $visnum_prefix = "DV"; public function show($PVID) { - $rows = $this->select("*, patvisit.InternalPID, patvisit.CreateDate as PVCreateDate, patdiag.CreateDate as PDCreateDate, patvisitadt.CreateDate as PVACreateDate") + $row = $this->select("*, patvisit.InternalPID, patvisit.CreateDate as PVCreateDate, patdiag.CreateDate as PDCreateDate, patvisitadt.CreateDate as PVACreateDate") ->join('patdiag', 'patdiag.InternalPVID=patvisit.InternalPVID and patdiag.DelDate is null', 'left') ->join('patvisitadt', 'patvisitadt.InternalPVID=patvisit.InternalPVID', 'left') - ->where('patvisit.PVID',$PVID)->findAll(); - return $rows; + ->where('patvisit.PVID',$PVID)->first(); + return $row; } public function showByPatient($InternalPID) { diff --git a/app/Models/RefRange/RefNumModel.php b/app/Models/RefRange/RefNumModel.php index 6bc1e82..e33db9c 100644 --- a/app/Models/RefRange/RefNumModel.php +++ b/app/Models/RefRange/RefNumModel.php @@ -4,17 +4,36 @@ namespace App\Models\RefRange; use App\Models\BaseModel; -class RefNumModel extends BaseModel { +class RefNumModel extends BaseModel +{ protected $table = 'refnum'; protected $primaryKey = 'RefNumID'; - protected $allowedFields = ['SiteID', 'TestSiteID', 'SpcType', 'Sex', 'AgeStart', 'AgeEnd', - 'CriticalLow', 'Low', 'High', 'CriticalHigh', - 'CreateDate', 'EndDate']; - + protected $allowedFields = [ + 'SiteID', + 'TestSiteID', + 'SpcType', + 'Sex', + 'Criteria', + 'AgeStart', + 'AgeEnd', + 'NumRefType', + 'RangeType', + 'LowSign', + 'Low', + 'HighSign', + 'High', + 'Display', + 'Flag', + 'Interpretation', + 'Notes', + 'CreateDate', + 'StartDate', + 'EndDate' + ]; + protected $useTimestamps = true; protected $createdField = 'CreateDate'; protected $updatedField = ''; protected $useSoftDeletes = true; protected $deletedField = "EndDate"; - -} \ No newline at end of file +} diff --git a/app/Models/RefRange/RefTxtModel.php b/app/Models/RefRange/RefTxtModel.php new file mode 100644 index 0000000..054df49 --- /dev/null +++ b/app/Models/RefRange/RefTxtModel.php @@ -0,0 +1,33 @@ +select('containerdef.*, vscol.VValue as ColorTxt, vscla.VValue as ConClassTxt, vsadd.VValue as AdditiveTxt') + $row = $this->select('containerdef.*, vscol.VValue as ColorTxt, vscla.VValue as ConClassTxt, vsadd.VValue as AdditiveTxt') ->join('valueset vscol', 'vscol.VID=containerdef.Color', 'left') ->join('valueset vscla', 'vscla.VID=containerdef.ConClass', 'left') ->join('valueset vsadd', 'vsadd.VID=containerdef.Additive', 'left') - ->where('ConDefID', $ConDefID)->findAll(); - return $rows; + ->where('ConDefID', $ConDefID)->first(); + return $row; } } \ No newline at end of file diff --git a/app/Models/Test/TestDefCalModel.php b/app/Models/Test/TestDefCalModel.php index 192b2dc..492ad34 100644 --- a/app/Models/Test/TestDefCalModel.php +++ b/app/Models/Test/TestDefCalModel.php @@ -7,13 +7,72 @@ use App\Models\BaseModel; class TestDefCalModel extends BaseModel { protected $table = 'testdefcal'; protected $primaryKey = 'TestCalID'; - protected $allowedFields = ['SiteID', 'TestSiteID', 'DisciplineID', 'DepartmentID','FormulaCode', 'FormulaInput', - 'Unit1', 'Factor', 'Unit2', 'Decimal' ,'CreateDate', 'EndDate']; - + protected $allowedFields = [ + 'TestSiteID', + 'DisciplineID', + 'DepartmentID', + 'FormulaInput', + 'FormulaCode', + 'RefType', + 'Unit1', + 'Factor', + 'Unit2', + 'Decimal', + 'Method', + 'CreateDate', + 'EndDate' + ]; + protected $useTimestamps = true; protected $createdField = 'CreateDate'; protected $updatedField = ''; protected $useSoftDeletes = true; protected $deletedField = "EndDate"; -} \ No newline at end of file + /** + * Get calculation details for a test + */ + public function getCalcDetails($testSiteID) { + $db = \Config\Database::connect(); + + return $db->table('testdefcal') + ->select('testdefcal.*, d.DisciplineName, dept.DepartmentName') + ->join('discipline d', 'd.DisciplineID=testdefcal.DisciplineID', 'left') + ->join('department dept', 'dept.DepartmentID=testdefcal.DepartmentID', 'left') + ->where('testdefcal.TestSiteID', $testSiteID) + ->where('testdefcal.EndDate IS NULL') + ->get()->getResultArray(); + } + + /** + * Get calculated tests by discipline + */ + public function getCalcsByDiscipline($disciplineID, $siteID = null) { + $builder = $this->select('testdefcal.*, testdefsite.TestSiteCode, testdefsite.TestSiteName') + ->join('testdefsite', 'testdefsite.TestSiteID=testdefcal.TestSiteID', 'left') + ->where('testdefcal.DisciplineID', $disciplineID) + ->where('testdefcal.EndDate IS NULL'); + + if ($siteID) { + $builder->where('testdefsite.SiteID', $siteID); + } + + return $builder->findAll(); + } + + /** + * Get calculated tests by department + */ + public function getCalcsByDepartment($departmentID, $siteID = null) { + $builder = $this->select('testdefcal.*, testdefsite.TestSiteCode, testdefsite.TestSiteName') + ->join('testdefsite', 'testdefsite.TestSiteID=testdefcal.TestSiteID', 'left') + ->where('testdefcal.DepartmentID', $departmentID) + ->where('testdefcal.EndDate IS NULL'); + + if ($siteID) { + $builder->where('testdefsite.SiteID', $siteID); + } + + return $builder->findAll(); + } +} diff --git a/app/Models/Test/TestDefGrpModel.php b/app/Models/Test/TestDefGrpModel.php index 76b926d..dcd6d0a 100644 --- a/app/Models/Test/TestDefGrpModel.php +++ b/app/Models/Test/TestDefGrpModel.php @@ -7,12 +7,43 @@ use App\Models\BaseModel; class TestDefGrpModel extends BaseModel { protected $table = 'testdefgrp'; protected $primaryKey = 'TestGrpID'; - protected $allowedFields = ['SiteID', 'TestSiteID', 'Member', 'CreateDate', 'EndDate']; - + protected $allowedFields = [ + 'TestSiteID', + 'Member', + 'CreateDate', + 'EndDate' + ]; + protected $useTimestamps = true; protected $createdField = 'CreateDate'; protected $updatedField = ''; protected $useSoftDeletes = true; protected $deletedField = "EndDate"; -} \ No newline at end of file + /** + * Get group members for a test group + */ + public function getGroupMembers($testSiteID) { + $db = \Config\Database::connect(); + + return $db->table('testdefgrp') + ->select('testdefgrp.*, t.TestSiteCode, t.TestSiteName, t.TestType, vs.VValue as MemberTypeCode') + ->join('testdefsite t', 't.TestSiteID=testdefgrp.Member', 'left') + ->join('valueset vs', 'vs.VID=t.TestType', 'left') + ->where('testdefgrp.TestSiteID', $testSiteID) + ->where('testdefgrp.EndDate IS NULL') + ->orderBy('testdefgrp.TestGrpID', 'ASC') + ->get()->getResultArray(); + } + + /** + * Get all groups that contain a specific test + */ + public function getGroupsContainingTest($memberTestSiteID) { + return $this->select('testdefgrp.*, t.TestSiteCode, t.TestSiteName') + ->join('testdefsite t', 't.TestSiteID=testdefgrp.TestSiteID', 'left') + ->where('testdefgrp.Member', $memberTestSiteID) + ->where('testdefgrp.EndDate IS NULL') + ->findAll(); + } +} diff --git a/app/Models/Test/TestDefSiteModel.php b/app/Models/Test/TestDefSiteModel.php index 4608f52..52986d8 100644 --- a/app/Models/Test/TestDefSiteModel.php +++ b/app/Models/Test/TestDefSiteModel.php @@ -7,23 +7,70 @@ use App\Models\BaseModel; class TestDefSiteModel extends BaseModel { protected $table = 'testdefsite'; protected $primaryKey = 'TestSiteID'; - protected $allowedFields = ['SiteID', 'TestSiteCode', 'TestSiteName', 'TestType', 'Description', 'SeqScr', 'SeqRpt', 'IndentLeft', - 'VisibleScr', 'VisibleRpt', 'CountStat', 'CreateDate', 'EndDate']; - + protected $allowedFields = [ + 'SiteID', + 'TestSiteCode', + 'TestSiteName', + 'TestType', + 'Description', + 'SeqScr', + 'SeqRpt', + 'IndentLeft', + 'FontStyle', + 'VisibleScr', + 'VisibleRpt', + 'CountStat', + 'CreateDate', + 'StartDate', + 'EndDate' + ]; + protected $useTimestamps = true; protected $createdField = 'CreateDate'; - protected $updatedField = ''; + protected $updatedField = 'StartDate'; protected $useSoftDeletes = true; protected $deletedField = "EndDate"; - public function getTests() { - $rows = $this->select("TestSiteID, TestSiteCode, TestSiteName, TestType, valueset.VValue as TypeCode, valueset.VDesc as TypeName ") + /** + * Get all tests with type information + */ + public function getTests($siteId = null, $testType = null, $visibleScr = null, $visibleRpt = null, $keyword = null) { + $builder = $this->select("testdefsite.TestSiteID, testdefsite.TestSiteCode, testdefsite.TestSiteName, testdefsite.TestType, + testdefsite.SeqScr, testdefsite.SeqRpt, testdefsite.VisibleScr, testdefsite.VisibleRpt, + testdefsite.CountStat, testdefsite.StartDate, testdefsite.EndDate, + valueset.VValue as TypeCode, valueset.VDesc as TypeName") ->join("valueset", "valueset.VID=testdefsite.TestType", "left") - ->findAll(); - return $rows; + ->where('testdefsite.EndDate IS NULL'); + + if ($siteId) { + $builder->where('testdefsite.SiteID', $siteId); + } + + if ($testType) { + $builder->where('testdefsite.TestType', $testType); + } + + if ($visibleScr !== null) { + $builder->where('testdefsite.VisibleScr', $visibleScr); + } + + if ($visibleRpt !== null) { + $builder->where('testdefsite.VisibleRpt', $visibleRpt); + } + + if ($keyword) { + $builder->like('testdefsite.TestSiteName', $keyword); + } + + return $builder->orderBy('testdefsite.SeqScr', 'ASC')->findAll(); } + /** + * Get single test with all related details based on TestType + */ public function getTest($TestSiteID) { + $db = \Config\Database::connect(); + $row = $this->select("testdefsite.*, valueset.VValue as TypeCode, valueset.VDesc as TypeName") ->join("valueset", "valueset.VID=testdefsite.TestType", "left") ->where("testdefsite.TestSiteID", $TestSiteID) @@ -31,14 +78,58 @@ class TestDefSiteModel extends BaseModel { if (!$row) return null; - if ($row['TypeCode'] == 'Calculated') { - $row['testdefcal'] = $this->db->query("select * from testdefcal where TestSiteID='$TestSiteID'")->getResultArray(); - } elseif ($row['TypeCode'] == 'GROUP') { - $row['testdefgrp'] = $this->db->query("select testdefgrp.*, t.TestSiteCode, t.TestSiteName from testdefgrp left join testdefsite t on t.TestSiteID=testdefgrp.Member where testdefgrp.TestSiteID='$TestSiteID'")->getResultArray(); - } else { - $row['testdeftech'] = $this->db->query("select * from testdeftech where TestSiteID='$TestSiteID'")->getResultArray(); + $typeCode = $row['TypeCode'] ?? ''; + + // Load related details based on TestType + if ($typeCode === 'CALC') { + // Load calculation details with joined discipline and department + $row['testdefcal'] = $db->table('testdefcal') + ->select('testdefcal.*, d.DisciplineName, dept.DepartmentName') + ->join('discipline d', 'd.DisciplineID=testdefcal.DisciplineID', 'left') + ->join('department dept', 'dept.DepartmentID=testdefcal.DepartmentID', 'left') + ->where('testdefcal.TestSiteID', $TestSiteID) + ->where('testdefcal.EndDate IS NULL') + ->get()->getResultArray(); + + // Load test mappings + $testMapModel = new \App\Models\Test\TestMapModel(); + $row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll(); + + } elseif ($typeCode === 'GROUP') { + // Load group members with test details + $row['testdefgrp'] = $db->table('testdefgrp') + ->select('testdefgrp.*, t.TestSiteCode, t.TestSiteName, t.TestType, vs.VValue as MemberTypeCode') + ->join('testdefsite t', 't.TestSiteID=testdefgrp.Member', 'left') + ->join('valueset vs', 'vs.VID=t.TestType', 'left') + ->where('testdefgrp.TestSiteID', $TestSiteID) + ->where('testdefgrp.EndDate IS NULL') + ->orderBy('testdefgrp.TestGrpID', 'ASC') + ->get()->getResultArray(); + + // Load test mappings + $testMapModel = new \App\Models\Test\TestMapModel(); + $row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll(); + + } elseif ($typeCode === 'TITLE') { + // Load test mappings only for TITLE type + $testMapModel = new \App\Models\Test\TestMapModel(); + $row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll(); + + } elseif (in_array($typeCode, ['TEST', 'PARAM'])) { + // TEST or PARAM - load technical details with joined tables + $row['testdeftech'] = $db->table('testdeftech') + ->select('testdeftech.*, d.DisciplineName, dept.DepartmentName') + ->join('discipline d', 'd.DisciplineID=testdeftech.DisciplineID', 'left') + ->join('department dept', 'dept.DepartmentID=testdeftech.DepartmentID', 'left') + ->where('testdeftech.TestSiteID', $TestSiteID) + ->where('testdeftech.EndDate IS NULL') + ->get()->getResultArray(); + + // Load test mappings + $testMapModel = new \App\Models\Test\TestMapModel(); + $row['testmap'] = $testMapModel->where('TestSiteID', $TestSiteID)->where('EndDate IS NULL')->findAll(); } - + return $row; } -} \ No newline at end of file +} diff --git a/app/Models/Test/TestDefTechModel.php b/app/Models/Test/TestDefTechModel.php index 68da643..069fc3a 100644 --- a/app/Models/Test/TestDefTechModel.php +++ b/app/Models/Test/TestDefTechModel.php @@ -7,13 +7,76 @@ use App\Models\BaseModel; class TestDefTechModel extends BaseModel { protected $table = 'testdeftech'; protected $primaryKey = 'TestTechID'; - protected $allowedFields = ['SiteID', 'TestSiteID', 'DisciplineID', 'DepartmentID', 'WorkstationID', 'EquipmentID', 'VSet', 'SpcType', - 'ReqQty', 'ReqQtyUnit', 'Unit1', 'Factor', 'Unit2', 'Decimal', 'CollReq', 'Method', 'ExpectedTAT', 'CreateDate', 'EndDate']; - + protected $allowedFields = [ + 'TestSiteID', + 'DisciplineID', + 'DepartmentID', + 'ResultType', + 'RefType', + 'VSet', + 'ReqQty', + 'ReqQtyUnit', + 'Unit1', + 'Factor', + 'Unit2', + 'Decimal', + 'CollReq', + 'Method', + 'ExpectedTAT', + 'CreateDate', + 'EndDate' + ]; + protected $useTimestamps = true; protected $createdField = 'CreateDate'; protected $updatedField = ''; protected $useSoftDeletes = true; protected $deletedField = "EndDate"; -} \ No newline at end of file + /** + * Get technical details for a test + */ + public function getTechDetails($testSiteID) { + $db = \Config\Database::connect(); + + return $db->table('testdeftech') + ->select('testdeftech.*, d.DisciplineName, dept.DepartmentName') + ->join('discipline d', 'd.DisciplineID=testdeftech.DisciplineID', 'left') + ->join('department dept', 'dept.DepartmentID=testdeftech.DepartmentID', 'left') + ->where('testdeftech.TestSiteID', $testSiteID) + ->where('testdeftech.EndDate IS NULL') + ->get()->getResultArray(); + } + + /** + * Get tests by discipline + */ + public function getTestsByDiscipline($disciplineID, $siteID = null) { + $builder = $this->select('testdeftech.*, testdefsite.TestSiteCode, testdefsite.TestSiteName') + ->join('testdefsite', 'testdefsite.TestSiteID=testdeftech.TestSiteID', 'left') + ->where('testdeftech.DisciplineID', $disciplineID) + ->where('testdeftech.EndDate IS NULL'); + + if ($siteID) { + $builder->where('testdefsite.SiteID', $siteID); + } + + return $builder->findAll(); + } + + /** + * Get tests by department + */ + public function getTestsByDepartment($departmentID, $siteID = null) { + $builder = $this->select('testdeftech.*, testdefsite.TestSiteCode, testdefsite.TestSiteName') + ->join('testdefsite', 'testdefsite.TestSiteID=testdeftech.TestSiteID', 'left') + ->where('testdeftech.DepartmentID', $departmentID) + ->where('testdeftech.EndDate IS NULL'); + + if ($siteID) { + $builder->where('testdefsite.SiteID', $siteID); + } + + return $builder->findAll(); + } +} diff --git a/app/Models/Test/TestMapModel.php b/app/Models/Test/TestMapModel.php index 47afcec..b1c66a2 100644 --- a/app/Models/Test/TestMapModel.php +++ b/app/Models/Test/TestMapModel.php @@ -7,8 +7,22 @@ use App\Models\BaseModel; class TestMapModel extends BaseModel { protected $table = 'testmap'; protected $primaryKey = 'TestMapID'; - protected $allowedFields = ['HostType', 'HostID', 'HostDataSource', 'HostTestCode', 'HostTestName', - 'ClientType', 'ClientID', 'ClientTestCode', 'ClientTestName', 'CreateDate', 'EndDate' ]; + protected $allowedFields = [ + 'TestSiteID', + 'HostType', + 'HostID', + 'HostDataSource', + 'HostTestCode', + 'HostTestName', + 'ClientType', + 'ClientID', + 'ClientDataSource', + 'ConDefID', + 'ClientTestCode', + 'ClientTestName', + 'CreateDate', + 'EndDate' + ]; protected $useTimestamps = true; protected $createdField = 'CreateDate'; @@ -16,4 +30,67 @@ class TestMapModel extends BaseModel { protected $useSoftDeletes = true; protected $deletedField = "EndDate"; -} \ No newline at end of file + /** + * Get test mappings by test site + */ + public function getMappingsByTestSite($testSiteID) { + return $this->where('TestSiteID', $testSiteID) + ->where('EndDate IS NULL') + ->findAll(); + } + + /** + * Get test mappings by client (equipment/workstation) + */ + public function getMappingsByClient($clientType, $clientID) { + return $this->where('ClientType', $clientType) + ->where('ClientID', $clientID) + ->where('EndDate IS NULL') + ->findAll(); + } + + /** + * Get test mappings by host (site/HIS) + */ + public function getMappingsByHost($hostType, $hostID) { + return $this->where('HostType', $hostType) + ->where('HostID', $hostID) + ->where('EndDate IS NULL') + ->findAll(); + } + + /** + * Get test mapping by client test code and container + */ + public function getMappingByClientCode($clientTestCode, $conDefID = null) { + $builder = $this->where('ClientTestCode', $clientTestCode) + ->where('EndDate IS NULL'); + + if ($conDefID) { + $builder->where('ConDefID', $conDefID); + } + + return $builder->findAll(); + } + + /** + * Get test mapping by host test code + */ + public function getMappingByHostCode($hostTestCode) { + return $this->where('HostTestCode', $hostTestCode) + ->where('EndDate IS NULL') + ->findAll(); + } + + /** + * Check if mapping exists for client and host + */ + public function mappingExists($testSiteID, $clientType, $clientID, $clientTestCode) { + return $this->where('TestSiteID', $testSiteID) + ->where('ClientType', $clientType) + ->where('ClientID', $clientID) + ->where('ClientTestCode', $clientTestCode) + ->where('EndDate IS NULL') + ->countAllResults() > 0; + } +} diff --git a/app/Models/ValueSet/ValueSetDefModel.php b/app/Models/ValueSet/ValueSetDefModel.php index e740c6f..f6200b4 100644 --- a/app/Models/ValueSet/ValueSetDefModel.php +++ b/app/Models/ValueSet/ValueSetDefModel.php @@ -16,13 +16,25 @@ class ValueSetDefModel extends BaseModel { protected $deletedField = 'EndDate'; public function getValueSetDefs($param = null) { - if ($param !== null) { - $rows = $this->like('VSName', $param, 'both') - ->orlike('VSDesc', $param, 'both') - ->findAll(); - } else { - $rows = $this->findAll(); + // Get item counts subquery + $itemCounts = $this->db->table('valueset') + ->select('VSetID, COUNT(*) as ItemCount') + ->where('EndDate IS NULL') + ->groupBy('VSetID'); + + $builder = $this->db->table('valuesetdef vd'); + $builder->select('vd.*, COALESCE(ic.ItemCount, 0) as ItemCount'); + $builder->join("({$itemCounts->getCompiledSelect()}) ic", 'vd.VSetID = ic.VSetID', 'LEFT'); + $builder->where('vd.EndDate IS NULL'); + + if ($param !== null) { + $builder->groupStart() + ->like('vd.VSName', $param, 'both') + ->orLike('vd.VSDesc', $param, 'both') + ->groupEnd(); } + + $rows = $builder->get()->getResultArray(); return $rows; } diff --git a/app/Models/ValueSet/ValueSetModel.php b/app/Models/ValueSet/ValueSetModel.php index c1e14f1..88ed263 100644 --- a/app/Models/ValueSet/ValueSetModel.php +++ b/app/Models/ValueSet/ValueSetModel.php @@ -15,18 +15,30 @@ class ValueSetModel extends BaseModel { protected $useSoftDeletes = true; protected $deletedField = 'EndDate'; - public function getValueSets($param = null) { - $this->select("valueset.*, v1.VDesc as VCategoryName") - ->join('valueset v1', 'valueset.VCategory = v1.VID', 'LEFT'); + public function getValueSets($param = null, $page = null, $limit = 50, $VSetID = null) { + $this->select("valueset.*, valuesetdef.VSName as VCategoryName") + ->join('valuesetdef', 'valueset.VSetID = valuesetdef.VSetID', 'LEFT'); + + if ($VSetID !== null) { + $this->where('valueset.VSetID', $VSetID); + } + if ($param !== null) { - $this - ->groupStart() - ->like('VValue', $param, 'both') - ->orlike('VDesc', $param, 'both') - ->groupEnd(); + $this->groupStart() + ->like('valueset.VValue', $param, 'both') + ->orLike('valueset.VDesc', $param, 'both') + ->orLike('valuesetdef.VSName', $param, 'both') + ->groupEnd(); } - $rows = $this->findAll(); - return $rows; + + if ($page !== null) { + return [ + 'data' => $this->paginate($limit, 'default', $page), + 'pager' => $this->pager + ]; + } + + return $this->findAll(); } public function getValueSet($VID) { diff --git a/app/Views/layouts/main.php b/app/Views/layouts/main.php deleted file mode 100644 index 3dfc360..0000000 --- a/app/Views/layouts/main.php +++ /dev/null @@ -1,86 +0,0 @@ - - -
- - - - - -- You're successfully logged in. This is a placeholder page. -
- - -Sign in to your CLQMS account
-Clinical Laboratory Quality Management System
+© 2025 5Panda. All rights reserved.
+Clinical Laboratory Quality Management System
+Total Patients
+1,247
+Today's Visits
+89
+Pending Tests
+34
+Completed
+156
+New patient registered
+John Doe - 5 minutes ago
+Test completed
+Sample #12345 - 12 minutes ago
+Pending approval
+Request #789 - 25 minutes ago
+Manage organization accounts and entities
+Loading accounts...
+| ID | +Account Name | +Code | +Parent | +Actions | +
|---|---|---|---|---|
|
+
+
+
+ No accounts found + + |
+ ||||
| + + | ++ + | ++ | + + | +
+
+
+
+
+ |
+
Manage lab departments and functional units
+Loading departments...
+| ID | +Department Name | +Code | +Discipline | +Site | +Actions | +
|---|---|---|---|---|---|
|
+
+
+
+ No departments found + + |
+ |||||
| + + | ++ + | ++ | + | + |
+
+
+
+
+ |
+
Manage laboratory disciplines and specialties
+Loading disciplines...
+| ID | +Discipline Name | +Code | +Actions | +
|---|---|---|---|
|
+
+
+
+ No disciplines found + + |
+ |||
| + + | +
+
+
+
+
+ |
+ + |
+
+
+
+
+ |
+
Manage physical sites and locations
+Loading sites...
+| ID | +Site Name | +Code | +Account | +Parent Site | +Actions | +
|---|---|---|---|---|---|
|
+
+
+
+ No sites found + + |
+ |||||
| + + | ++ + | ++ | + | + |
+
+
+
+
+ |
+
Manage lab workstations and equipment units
+Loading workstations...
+| ID | +Workstation Name | +Code | +Department | +Status | +Actions | +
|---|---|---|---|---|---|
|
+
+
+
+ No workstations found + + |
+ |||||
| + + | ++ + | ++ | + | + + | +
+
+
+
+
+ |
+
Manage specimen collection containers and tubes
+Loading containers...
+| ID | +Container Name | +Code | +Color | +Additive | +Actions | +
|---|---|---|---|---|---|
|
+
+
+
+ No containers found + + |
+ |||||
| + + | ++ + | ++ |
+
+
+
+
+ |
+ + |
+
+
+
+
+ |
+
Manage specimen processing and preparation methods
+Loading preparations...
+| ID | +Description | +Method | +Additive | +Qty/Unit | +Actions | +
|---|---|---|---|---|---|
|
+
+
+
+ No preparations found + + |
+ |||||
| + + | ++ + | ++ | + | + + + | +
+
+
+
+
+ |
+
Manage test definitions, parameters, and groups +
+Loading tests...
+| ID | +Code | +Test Name | +Type | +Seq | +Visible | +Actions | +
|---|---|---|---|---|---|---|
|
+
+
+
+ No tests found + + |
+ ||||||
| + + | +
+
+ |
+
+
+
+
+ |
+ + + | ++ |
+
+
+
+
+
+
+
+
+ |
+
+
+
+
+
+
+ |
+
Manage value set categories and their items
+Value Set Definitions
+Loading categories...
+| ID | +Category Name | +Items | +Actions | +
|---|---|---|---|
|
+
+
+
+ No categories found + + |
+ |||
| + + | ++ + + | ++ + | +
+
+
+
+
+ |
+
+ + + + + Select a category to view items + +
+Select a category
+Click on a category from the left panel to view and manage its items
+Loading items...
+| ID | +Value | +Description | +Order | +Actions | +
|---|---|---|---|---|
|
+
+
+
+ No items found + + |
+ ||||
| + + | ++ + | ++ + | ++ + | +
+
+
+
+
+ |
+
Total Patients
+0
+New Today
+0
+Pending Visits
+0
+Loading patients...
+| Patient ID | +Name | +Gender | +Birth Date | +Phone | +Actions | +
|---|---|---|---|---|---|
|
+
+
+
+ No patients found + + |
+ |||||
| + + | +
+
+
+
+
+
+
+
+
+
+ |
+ + + | ++ | + |
+
+
+
+
+ |
+
Manage laboratory test requests and orders
+Pending
+34
+In Progress
+18
+Completed
+156
+Rejected
+3
+| Request ID | +Patient | +Test Type | +Priority | +Status | +Date | +Actions | +
|---|---|---|---|---|---|---|
|
+
+ No data available. Connect to API to load lab requests. + |
+ ||||||
Configure system settings and preferences
+This is a simple HTML page.
+ + + \ No newline at end of file diff --git a/data/clqms_v2.sql b/data/clqms_v2.sql deleted file mode 100644 index eabf5b8..0000000 --- a/data/clqms_v2.sql +++ /dev/null @@ -1,272 +0,0 @@ -CREATE TABLE `coding_sys` ( - `coding_sys_id` integer PRIMARY KEY AUTO_INCREMENT, - `abb` varchar(10) UNIQUE, - `name` varchar(255), - `description` text, - `create_date` datetime, - `end_date` datetime -); - -CREATE TABLE `races` ( - `race_id` integer PRIMARY KEY AUTO_INCREMENT, - `site_id` integer, - `coding_sys_id` integer, - `name` varchar(255), - `create_date` datetime, - `end_date` datetime -); - -CREATE TABLE `religions` ( - `religion_id` integer PRIMARY KEY AUTO_INCREMENT, - `site_id` integer, - `coding_sys_id` integer, - `name` varchar(255), - `create_date` datetime, - `end_date` datetime -); - -CREATE TABLE `ethnics` ( - `ethnic_id` integer PRIMARY KEY AUTO_INCREMENT, - `site_id` integer, - `coding_sys_id` integer, - `name` varchar(255), - `create_date` datetime, - `end_date` datetime -); - -CREATE TABLE `countries` ( - `country_id` integer PRIMARY KEY AUTO_INCREMENT, - `site_id` integer, - `coding_sys_id` integer, - `name` varchar(255), - `create_date` datetime, - `end_date` datetime -); - -CREATE TABLE `patients` ( - `pat_id` integer PRIMARY KEY AUTO_INCREMENT, - `pat_num` varchar(255) UNIQUE, - `pat_altnum` varchar(255) UNIQUE, - `prefix` varchar(255), - `name_first` varchar(255), - `name_middle` varchar(255), - `name_maiden` varchar(255), - `name_last` varchar(255), - `suffix` varchar(255), - `name_alias` varchar(255), - `gender` varchar(255), - `birth_place` varchar(255), - `birth_date` date, - `address_1` varchar(255), - `address_2` varchar(255), - `address_3` varchar(255), - `city` varchar(255), - `province` varchar(255), - `zip` varchar(255), - `email_1` varchar(255), - `email_2` varchar(255), - `phone` varchar(255), - `mobile_phone` varchar(255), - `mother` varchar(255), - `account_number` varchar(255), - `marital_status` varchar(255), - `country_id` integer, - `race_id` integer, - `religion_id` integer, - `ethnic_id` integer, - `citizenship` varchar(255), - `death` bit, - `death_date` datetime, - `link_to` integer, - `create_date` datetime, - `del_date` datetime -); - -CREATE TABLE `pat_comments` ( - `pat_com_id` integer PRIMARY KEY AUTO_INCREMENT, - `pat_id` integer, - `comment_text` text, - `user_id` integer, - `create_date` datetime, - `del_date` datetime -); - -CREATE TABLE `pat_identities` ( - `pat_idt_id` integer PRIMARY KEY AUTO_INCREMENT, - `pat_id` integer, - `identity_type` varchar(255), - `identity_num` varchar(255), - `effective_date` datetime, - `expiration_date` datetime, - `create_date` datetime, - `del_date` datetime -); - -CREATE TABLE `pat_diagnose` ( - `pat_dia_id` integer PRIMARY KEY AUTO_INCREMENT, - `pat_id` integer, - `diag_code` varchar(255), - `diag_comment` varchar(255), - `create_date` datetime, - `end_date` datetime, - `archive_date` datetime, - `del_date` datetime -); - -CREATE TABLE `pat_visits` ( - `pv_id` integer PRIMARY KEY AUTO_INCREMENT, - `pv_num` varchar(255) UNIQUE, - `pat_id` integer, - `episode_number` integer, - `pv_class_id` integer, - `bill_account` varchar(255), - `bill_status` integer, - `create_date` datetime, - `end_date` datetime, - `archive_date` datetime, - `del_date` datetime -); - -CREATE TABLE `pv_adts` ( - `pv_adt_id` integer PRIMARY KEY AUTO_INCREMENT, - `pv_id` integer, - `pv_adt_num` varchar(255), - `pv_adt_code` varchar(255), - `locid` integer, - `docid` integer, - `reff_docid` integer, - `adm_docid` integer, - `cns_docid` integer, - `create_date` datetime, - `end_date` datetime, - `archive_date` datetime, - `del_date` datetime -); - -CREATE TABLE `pv_log` ( - `pv_log_id` integer PRIMARY KEY AUTO_INCREMENT -); - -CREATE TABLE `requests` ( - `req_id` integer PRIMARY KEY AUTO_INCREMENT, - `req_num` varchar(255) UNIQUE, - `req_altnum` varchar(255) UNIQUE, - `pat_id` integer, - `pv_id` integer, - `req_app` varchar(255), - `req_entity` varchar(255), - `req_entity_id` integer, - `loc_id` integer, - `priority` varchar(255), - `att_doid` integer, - `reff_docid` integer, - `adm_docid` integer, - `cns_docid` integer, - `entered_by` varchar(255), - `req_date` datetime, - `eff_date` datetime, - `create_date` datetime, - `end_date` datetime, - `archive_date` datetime, - `del_date` datetime -); - -CREATE TABLE `req_comments` ( - `req_com_id` integer PRIMARY KEY AUTO_INCREMENT, - `req_id` integer, - `comment_text` text, - `user_id` integer, - `create_date` datetime, - `end_date` datetime, - `archive_date` datetime, - `del_date` datetime -); - -CREATE TABLE `req_atts` ( - `req_att_id` integer PRIMARY KEY AUTO_INCREMENT, - `req_id` integer, - `address` varchar(255), - `user_id` integer, - `create_date` datetime, - `end_date` datetime, - `archive_date` datetime, - `del_date` datetime -); - -CREATE TABLE `req_status` ( - `req_status_id` integer PRIMARY KEY AUTO_INCREMENT, - `req_id` integer, - `req_status` varchar(255), - `create_date` datetime, - `end_date` datetime, - `archive_date` datetime, - `del_date` datetime -); - -CREATE TABLE `req_logs` ( - `req_log_id` integer PRIMARY KEY AUTO_INCREMENT, - `tbl_name` varchar(255), - `record_id` integer, - `fld_name` varchar(255), - `fld_value_prev` varchar(255), - `user_id` integer, - `site_id` integer, - `machine_id` integer, - `session_id` integer, - `app_id` integer, - `process_id` integer, - `webpage_id` integer, - `event_id` integer, - `act_id` integer, - `reason` varchar(255), - `log_date` datetime -); - -CREATE TABLE `users` ( - `user_id` integer PRIMARY KEY AUTO_INCREMENT, - `username` varchar(255) UNIQUE, - `fullname` varchar(255), - `password` varchar(255), - `create_date` datetime, - `end_date` datetime, - `archive_date` datetime, - `del_date` datetime -); - -ALTER TABLE `races` ADD FOREIGN KEY (`coding_sys_id`) REFERENCES `coding_sys` (`coding_sys_id`); - -ALTER TABLE `religions` ADD FOREIGN KEY (`coding_sys_id`) REFERENCES `coding_sys` (`coding_sys_id`); - -ALTER TABLE `ethnics` ADD FOREIGN KEY (`coding_sys_id`) REFERENCES `coding_sys` (`coding_sys_id`); - -ALTER TABLE `countries` ADD FOREIGN KEY (`coding_sys_id`) REFERENCES `coding_sys` (`coding_sys_id`); - -ALTER TABLE `patients` ADD FOREIGN KEY (`country_id`) REFERENCES `countries` (`country_id`); - -ALTER TABLE `patients` ADD FOREIGN KEY (`race_id`) REFERENCES `races` (`race_id`); - -ALTER TABLE `patients` ADD FOREIGN KEY (`religion_id`) REFERENCES `religions` (`religion_id`); - -ALTER TABLE `patients` ADD FOREIGN KEY (`ethnic_id`) REFERENCES `ethnics` (`ethnic_id`); - -ALTER TABLE `pat_comments` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`); - -ALTER TABLE `pat_identities` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`); - -ALTER TABLE `pat_diagnose` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`); - -ALTER TABLE `pat_visits` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`); - -ALTER TABLE `pv_adts` ADD FOREIGN KEY (`pv_id`) REFERENCES `pat_visits` (`pv_id`); - -ALTER TABLE `requests` ADD FOREIGN KEY (`pat_id`) REFERENCES `patients` (`pat_id`); - -ALTER TABLE `requests` ADD FOREIGN KEY (`pv_id`) REFERENCES `pat_visits` (`pv_id`); - -ALTER TABLE `req_comments` ADD FOREIGN KEY (`req_id`) REFERENCES `requests` (`req_id`); - -ALTER TABLE `req_atts` ADD FOREIGN KEY (`req_id`) REFERENCES `requests` (`req_id`); - -ALTER TABLE `req_status` ADD FOREIGN KEY (`req_id`) REFERENCES `requests` (`req_id`); - -ALTER TABLE `req_logs` ADD FOREIGN KEY (`user_id`) REFERENCES `users` (`user_id`); diff --git a/data/lab.dbml b/data/lab.dbml deleted file mode 100644 index 9655622..0000000 --- a/data/lab.dbml +++ /dev/null @@ -1,234 +0,0 @@ -table coding_sys { - coding_sys_id integer [pk] - abb varchar(10) [unique] - name varchar - description text - create_date datetime - end_date datetime -} - -table races { - race_id integer [pk] - site_id integer - coding_sys_id integer [ref:>coding_sys.coding_sys_id] - name varchar - create_date datetime - end_date datetime -} - -table religions { - religion_id integer [pk] - site_id integer - coding_sys_id integer [ref:>coding_sys.coding_sys_id] - name varchar - create_date datetime - end_date datetime -} - -table ethnics { - ethnic_id integer [pk] - site_id integer - coding_sys_id integer [ref:>coding_sys.coding_sys_id] - name varchar - create_date datetime - end_date datetime -} - -table countries { - country_id integer [pk] - site_id integer - coding_sys_id integer [ref:>coding_sys.coding_sys_id] - name varchar - create_date datetime - end_date datetime -} - -table patients { - pat_id integer [pk] - pat_num varchar [unique] - pat_altnum varchar [unique] - prefix varchar - name_first varchar - name_middle varchar - name_maiden varchar - name_last varchar - suffix varchar - name_alias varchar - gender varchar - birth_place varchar - birth_date date - address_1 varchar - address_2 varchar - address_3 varchar - city varchar - province varchar - zip varchar - email_1 varchar - email_2 varchar - phone varchar - mobile_phone varchar - mother varchar - account_number varchar - marital_status varchar - country_id integer [ref:>countries.country_id] - race_id integer [ref:>races.race_id] - religion_id integer [ref:>religions.religion_id] - ethnic_id integer [ref:>ethnics.ethnic_id] - citizenship varchar - death bit - death_date datetime - link_to integer - create_date datetime - del_date datetime -} - -table pat_comments { - pat_com_id integer [pk] - pat_id integer [ref:>patients.pat_id] - comment_text text - user_id integer - create_date datetime - del_date datetime -} - -table pat_identities { - pat_idt_id integer [pk] - pat_id integer [ref:>patients.pat_id] - identity_type varchar - identity_num varchar - effective_date datetime - expiration_date datetime - create_date datetime - del_date datetime -} - -table pat_diagnose { - pat_dia_id integer [pk] - pat_id integer [ref:>patients.pat_id] - diag_code varchar - diag_comment varchar - create_date datetime - end_date datetime - archive_date datetime - del_date datetime -} - -table pat_visits { - pv_id integer [pk] - pv_num varchar [unique] - pat_id integer [ref:>patients.pat_id] - episode_number integer - pv_class_id integer - bill_account varchar - bill_status integer - create_date datetime - end_date datetime - archive_date datetime - del_date datetime -} - -table pv_adts { - pv_adt_id integer [pk] - pv_id integer [ref:>pat_visits.pv_id] - pv_adt_num varchar - pv_adt_code varchar - locid integer - docid integer - reff_docid integer - adm_docid integer - cns_docid integer - create_date datetime - end_date datetime - archive_date datetime - del_date datetime -} - -table pv_log { - pv_log_id integer [pk] -} - -table requests { - req_id integer [pk] - req_num varchar [unique] - req_altnum varchar [unique] - pat_id integer [ref:>patients.pat_id] - pv_id integer [ref:>pat_visits.pv_id] - req_app varchar - req_entity varchar - req_entity_id integer - loc_id integer - priority varchar - att_doid integer - reff_docid integer - adm_docid integer - cns_docid integer - entered_by varchar - req_date datetime - eff_date datetime - create_date datetime - end_date datetime - archive_date datetime - del_date datetime -} - -table req_comments { - req_com_id integer [pk] - req_id integer [ref:>requests.req_id] - comment_text text - user_id integer - create_date datetime - end_date datetime - archive_date datetime - del_date datetime -} - -table req_atts { - req_att_id integer [pk] - req_id integer [ref:>requests.req_id] - address varchar - user_id integer - create_date datetime - end_date datetime - archive_date datetime - del_date datetime -} - -table req_status { - req_status_id integer [pk] - req_id integer [ref:>requests.req_id] - req_status varchar - create_date datetime - end_date datetime - archive_date datetime - del_date datetime -} - -table req_logs { - req_log_id integer [pk] - tbl_name varchar - record_id integer - fld_name varchar - fld_value_prev varchar - user_id integer [ref:>users.user_id] - site_id integer - machine_id integer - session_id integer - app_id integer - process_id integer - webpage_id integer - event_id integer - act_id integer - reason varchar - log_date datetime -} - -table users { - user_id integer [pk] - username varchar [unique] - fullname varchar - password varchar - create_date datetime - end_date datetime - archive_date datetime - del_date datetime -} \ No newline at end of file diff --git a/docs/20251212001-database_design_review_sonnet.md b/docs/20251212001-database_design_review_sonnet.md deleted file mode 100644 index ae50a5b..0000000 --- a/docs/20251212001-database_design_review_sonnet.md +++ /dev/null @@ -1,1376 +0,0 @@ -# Database Schema Design Review -## Clinical Laboratory Quality Management System - -**Prepared by:** Claude Sonnet -**Date:** December 12, 2025 -**Purpose:** Schema Design Assessment - ---- - -## Table of Contents - -1. [Scope of Review](#scope-of-review) -2. [Executive Summary](#executive-summary) -3. [Critical Issues](#critical-issues) - - [Issue #1: Excessive Normalization](#issue-1-excessive-normalization) - - [Issue #2: Bizarre Unique Constraints](#issue-2-bizarre-unique-constraints) - - [Issue #3: Audit Trail Overkill](#issue-3-audit-trail-overkill) - - [Issue #4: Temporal Logic Confusion](#issue-4-temporal-logic-confusion) - - [Issue #5: Incomplete Business Logic](#issue-5-incomplete-business-logic) - - [Issue #6: Specimen Module Complexity](#issue-6-specimen-module-complexity) - - [Issue #7: Test Definition Over-Engineering](#issue-7-test-definition-over-engineering) -4. [Impact Assessment](#impact-assessment) -5. [Root Cause Analysis](#root-cause-analysis) -6. [Recommendations](#recommendations) -7. [Alternative Approaches](#alternative-approaches) -8. [Path Forward](#path-forward) -9. [Key Takeaways](#key-takeaways) -10. [Next Steps](#next-steps) -11. [Appendix](#appendix) - ---- - -## Scope of Review - -This comprehensive review analyzed the database schema design for the Clinical Laboratory Quality Management System (CLQMS). The analysis covered: - -- **17 migration files** reviewed in detail -- **40+ database tables** analyzed across all modules -- **Focus areas:** Design philosophy, architectural decisions, data modeling patterns, and operational concerns - -### Key Metrics - -| Metric | Count | -|--------|-------| -| Migration Files Reviewed | 17 | -| Database Tables Analyzed | 40+ | -| Critical Issues Identified | 7 | -| Blocking Defects Found | 3 | -| Potential Complexity Reduction | 45% | - ---- - -## Executive Summary - -### Overall Assessment: ⚠️ **Over-Engineered** - -The schema will **technically work** and can deliver the required functionality, but presents significant challenges in several critical areas that will impact long-term project success. - -#### Assessment Matrix - -| Aspect | Rating | Impact | Details | -|--------|--------|--------|---------| -| **Functionality** | ✅ Will work | Can deliver features | The schema structure is valid and will support application operations | -| **Maintainability** | ⚠️ Poor | High developer friction | Complex relationships require deep knowledge, steep learning curve | -| **Performance** | ❌ Problematic | Requires extensive optimization | Multiple JOINs for basic operations, no comprehensive indexing strategy | -| **Complexity** | ❌ Excessive | Steep learning curve | Over-normalized structure with unclear business logic | -| **Scalability** | ⚠️ Questionable | Architecture limitations | Design choices may become bottlenecks at scale | - -### Verdict - -> **The design applies enterprise-grade patterns without clear business justification, resulting in unnecessary complexity that will slow development velocity, increase maintenance burden, and create performance challenges.** - -The schema exhibits characteristics of premature optimization and over-engineering. While it demonstrates knowledge of advanced database design patterns, many of these patterns are applied without clear justification for the actual business requirements of a laboratory management system. - ---- - -## Critical Issues - -### Issue #1: Excessive Normalization - -**Severity:** 🟡 Medium -**Impact:** Developer productivity, query performance, code complexity - -#### Problem Description - -Single-field data has been separated into dedicated tables, creating unnecessary complexity and requiring additional JOINs for basic operations. This violates the principle of "normalize until it hurts, then denormalize until it works." - -#### Example: Patient Comments Table (`patcom`) - -```php -// Entire table for comments, but unique constraint allows only ONE comment per patient -$this->forge->addField([ - 'PatComID' => ['type' => 'INT', 'auto_increment' => true], - 'InternalPID' => ['type' => 'INT'], - 'Comment' => ['type' => 'TEXT'], - 'CreateDate' => ['type' => 'DATETIME'], - 'EndDate' => ['type' => 'DATETIME'] -]); -$this->forge->addUniqueKey('InternalPID'); // Only ONE comment per patient! -``` - -#### Issues Identified - -1. **Misleading Field Names**: `patatt` table uses field name `Address` for attachment URLs, creating confusion -2. **Unclear Purpose**: Without proper documentation, the relationship between these tables and the main `patient` table is ambiguous -3. **Performance Impact**: Requires JOIN for basic patient display/search operations -4. **Questionable Separation**: Some of these could be fields in the main table unless there's a clear versioning/history strategy - -#### Similar Patterns Found - -- **`patatt` (Patient Attachments)**: Stores attachment URLs - naming is misleading ("Address" field should be "AttachmentURL") -- **`patcom` (Patient Comments)**: Unique constraint allows only ONE comment per patient ever -- **`pattel` (Patient Telephone)**: Phone fields already exist in `patient` table -- **`patemail` (Patient Email)**: Email fields already exist in `patient` table - -#### Recommendation - -Either: -- **Remove these tables** and use fields in the main `patient` table, OR -- **Clearly document** the versioning/history strategy and implement proper temporal tracking with effective/expiration dates - ---- - -### Issue #2: Problematic Unique Constraints - -**Severity:** 🔴 Critical - Production Blocker -**Impact:** System will fail for real-world use cases - -#### The Problem - -Several unique constraints will prevent legitimate real-world scenarios: - -#### Critical Constraint Issues - -1. **`EmailAddress1` marked UNIQUE in `patient` table** - ```php - $this->forge->addUniqueKey('EmailAddress1'); // Line 90, PatientReg.php - ``` - - **Real-World Impact:** - - ❌ Families often share email addresses - - ❌ One email for household billing/communication - - ❌ Parents sharing email for children's accounts - - ❌ Couples using joint email addresses - - **This will break when the second family member attempts to register.** - -2. **`InternalPID` unique in `patcom` table** - ```php - $this->forge->addUniqueKey('InternalPID'); // Line 31, PatientReg.php - ``` - - **Real-World Impact:** - - ❌ Only allows **ONE comment per patient EVER** - - ❌ Cannot track multiple interactions, notes, or updates - - ❌ Defeats the entire purpose of a comments table - - ❌ No way to add follow-up notes or updates - -3. **Various "Code" fields marked unique** - - Without proper context of scope (site-level? system-level?) - - May prevent legitimate data entry - -#### Note on `patatt` Table - -The `Address` field in `patatt` has a unique constraint, but this is **actually correct** since the table stores patient attachment URLs (not physical addresses), and each attachment URL should be unique. However, the field name "Address" is **misleading** and should be renamed to `AttachmentURL` or `FileURL` for clarity. - -#### Why This Happened - -> **This strongly suggests the design was not validated against real-world use cases or tested with realistic sample data.** - -These constraints indicate insufficient analysis of how clinical systems handle family units and patient communications. - -#### Immediate Action Required - -Remove the problematic unique constraints before any production deployment. This is a **blocking issue** that must be addressed. - ---- - -### Issue #3: Audit Trail Overkill - -**Severity:** 🟡 Medium -**Impact:** Storage costs, developer burden, query performance - -#### The Problem - -Every log table tracks **15+ fields** per change, creating massive overhead with unclear benefit: - -```php -$this->forge->addField([ - 'TblName' => ['type' => 'VARCHAR', 'constraint' => 50], - 'RecID' => ['type' => 'INT'], - 'FldName' => ['type' => 'VARCHAR', 'constraint' => 50], - 'FldValuePrev' => ['type' => 'TEXT'], - 'UserID' => ['type' => 'INT'], - 'SiteID' => ['type' => 'INT'], - 'DIDType' => ['type' => 'INT'], - 'DID' => ['type' => 'INT'], - 'MachineID' => ['type' => 'VARCHAR', 'constraint' => 50], - 'SessionID' => ['type' => 'VARCHAR', 'constraint' => 50], - 'AppID' => ['type' => 'INT'], - 'ProcessID' => ['type' => 'INT'], - 'WebPageID' => ['type' => 'INT'], - 'EventID' => ['type' => 'INT'], - 'ActivityID' => ['type' => 'INT'], - 'Reason' => ['type' => 'TEXT'], - 'LogDate' => ['type' => 'DATETIME'] -]); -``` - -#### Critical Questions - -1. **Why `MachineID` + `SessionID` + `ProcessID`?** - - What business requirement needs all three? - - How are these consistently populated? - - What happens when any are missing? - -2. **Why `WebPageID` in database logs?** - - UI concerns should not be in data layer - - This creates tight coupling between frontend and database - - Makes API/mobile app integration confusing - -3. **Who populates all these fields?** - - Is there a centralized logging service? - - What's the fallback when values aren't available? - - How is consistency enforced? - -4. **What about performance?** - - No indexes on any of these fields - - Querying audit logs will require full table scans - - No partitioning strategy for large datasets - -#### Impact Analysis - -| Impact Area | Description | Severity | -|-------------|-------------|----------| -| **Storage Bloat** | 10x overhead per log entry compared to essential fields | 🔴 High | -| **Developer Burden** | Complex logging code required throughout application | 🔴 High | -| **Performance** | No indexes means slow audit queries | 🔴 High | -| **Maintenance** | Understanding and maintaining 15 fields per log | 🟡 Medium | -| **Data Quality** | High likelihood of incomplete/inconsistent data | 🟡 Medium | - -#### Industry Standard Comparison - -Most audit systems track 5-7 essential fields: -- What changed (table, record, field, old/new value) -- Who changed it (user ID) -- When it changed (timestamp) -- Why it changed (optional reason) - -The additional 8-10 fields in this design add complexity without clear business value. - ---- - -### Issue #4: Temporal Logic Confusion - -**Severity:** 🟡 Medium -**Impact:** Data quality, developer confusion, inconsistent queries - -#### The Problem - -Most tables have **3-4 overlapping date fields** with unclear business semantics: - -```php -'CreateDate' => ['type' => 'DATETIME'], // ✓ Makes sense - record creation -'EndDate' => ['type' => 'DATETIME'], // When does it "end"? -'ArchivedDate' => ['type' => 'DATETIME'], // How is this different from EndDate? -'DelDate' => ['type' => 'DATETIME'] // Soft delete timestamp -``` - -#### Critical Questions - -1. **What does `EndDate` mean for a patient record?** - - When the patient dies? - - When they're no longer active? - - When they moved to another facility? - - Something else entirely? - -2. **`ArchivedDate` vs `EndDate` - what's the difference?** - - Can a record be ended but not archived? - - Can it be archived but not ended? - - What queries should filter on which field? - -3. **Does `DelDate` prevent queries or just mark status?** - - Should application filter out records with `DelDate`? - - Or is it just an audit field? - - What about "undelete" operations? - -4. **What's the relationship between these fields?** - - Can `ArchivedDate` be before `EndDate`? - - Business rules for allowed transitions? - - Validation logic? - -#### Real-World Consequences - -**Without clear documentation, developers will:** -- Use these fields inconsistently across the codebase -- Create bugs where some queries respect certain dates and others don't -- Build features that contradict each other -- Generate incorrect reports -- Create data quality issues that compound over time - -#### Example Scenarios Without Clear Logic - -**Scenario 1: Deceased Patient** -``` -Question: Which fields get set when a patient dies? -- EndDate = date of death? -- DelDate = date of death? -- ArchivedDate = some time later? -- All three? -``` - -**Scenario 2: Patient Moves to Another Facility** -``` -Question: How do we mark them as inactive? -- EndDate = move date? -- ArchivedDate = move date? -- DelDate = NULL (not deleted, just moved)? -``` - -#### Recommendation - -Create a clear state machine diagram and document: -1. All possible record states -2. Valid transitions between states -3. Which date fields get set during each transition -4. How queries should filter records in different states - ---- - -### Issue #5: Incomplete Business Logic - -**Severity:** 🔴 Critical - Structural Defect -**Impact:** Table cannot fulfill its stated purpose - -#### The Problem: Patient Relations Table (`patrelation`) - -```php -$this->forge->addField([ - 'PatRelID' => ['type' => 'INT', 'auto_increment' => true], - 'InternalPID' => ['type' => 'INT'], - 'CreateDate' => ['type' => 'DATETIME'], - 'EndDate' => ['type' => 'DATETIME'] -]); -``` - -#### Missing Critical Fields - -This table is **structurally incomplete**. It's missing: - -1. ❌ **Related person ID** - - Who is the relation? - - Is it another patient in the system? - - An external contact? - -2. ❌ **Relationship type** - - Mother, father, spouse, child? - - Emergency contact? - - Legal guardian? - - Medical power of attorney? - -3. ❌ **Contact information** - - How do we reach this person? - - Phone, email, address? - -4. ❌ **Priority/Sequence** - - Primary vs secondary contact - - Order to call in emergency - - Preferred contact method - -5. ❌ **Status flags** - - Is this contact active? - - Can they receive medical information (HIPAA)? - - Are they authorized to make decisions? - -#### What Can This Table Actually Store? - -As currently defined, this table can only store: -- "Patient X has a relationship" -- That relationship started on date Y -- That relationship ended on date Z - -**It cannot answer:** -- Relationship to whom? -- What type of relationship? -- How to contact them? -- What are they authorized to do? - -> **This table cannot fulfill its stated purpose and will need to be redesigned before use.** - -#### Similar Issues in Other Tables - -This pattern of incomplete table definitions appears in several other areas, suggesting insufficient requirements analysis during design phase. - ---- - -### Issue #6: Specimen Module Complexity - -**Severity:** 🟡 Medium -**Impact:** Code complexity, unclear data ownership, potential duplication - -#### The Problem - -**Five separate tables** are used to manage specimens, creating complex relationships: - -``` -specimen - ├── specimenstatus - │ ├── specimencollection - │ ├── specimenprep - │ └── specimenlog -``` - -#### Data Duplication Concerns - -1. **`OrderID` appears in multiple tables** - - Present in both `specimen` AND `specimenstatus` - - Which is the source of truth? - - What if they conflict? - -2. **Quantity/Unit data in `specimenstatus`** - - Should belong in `specimen` base table - - Quantity is a property of the specimen itself - - Current location makes it appear quantity can change over time - -3. **Location tracking split across tables** - - Unclear separation of concerns - - Is location part of status or a separate concept? - - How to query current location efficiently? - -#### Unclear Relationships - -```php -// Is this a 1:1 or 1:many relationship? -specimen -> specimenstatus - -// Multiple statuses per specimen? Or status history? -// Multiple collections? Or collection history? -// The schema doesn't make this clear -``` - -#### Industry Standard Approach - -Most laboratory systems use a simpler model: - -``` -specimen (base entity) - └── specimen_events (history/audit trail) - ├── collection event - ├── processing event - ├── storage event - └── disposal event -``` - -This provides: -- Clear ownership of data -- Built-in history tracking -- Simpler queries -- Fewer JOINs - -#### Questions to Answer - -1. **Is this tracking status or status history?** - - Current design is ambiguous - - Needs clear documentation - -2. **Should this be 2-3 tables instead of 5?** - - `specimen` + `specimen_history` + `specimen_testing` - - Much clearer relationships - -3. **What's the performance impact?** - - 4-5 table JOIN to get full specimen info - - No apparent indexing strategy - ---- - -### Issue #7: Test Definition Over-Engineering - -**Severity:** 🟡 Medium -**Impact:** Unnecessary complexity, unclear purpose of some tables - -#### The Problem - -**Six tables** are used to define and configure tests: - -| Table | Stated Purpose | Necessary? | Justification Needed? | -|-------|---------------|------------|---------------------| -| `testdef` | Base test definition | ✅ Yes | Core entity | -| `testdefsite` | Site-specific configuration | ⚠️ Maybe | When are tests site-specific? | -| `testdeftech` | Technical details | ⚠️ Maybe | Why separate from testdef? | -| `testdefcal` | Calculated/derived tests | ⚠️ Maybe | Could be a type in testdef | -| `testgrp` | Test grouping/panels | ✅ Yes | Test panels are common | -| `testmap` | External system mapping | ⚠️ Maybe | Could be attributes in testdef | - -#### Industry Standard Comparison - -**Typical laboratory system test structure:** - -1. **Tests** - Individual test definitions - - Test code, name, description - - Sample type, collection requirements - - Result type (numeric, text, etc.) - -2. **Test Panels/Groups** - Collections of tests - - Panel code, name - - Which tests are included - - Panel-specific instructions - -3. **Reference Ranges** - Normal value ranges - - By age, gender, population - - Unit of measure - - Critical value thresholds - -**That's 3 tables for full functionality.** - -#### Questions About Current Design - -1. **`testdefsite` - Site-specific tests** - - Are different sites performing different tests? - - Or same tests with different configurations? - - Could this be handled with configuration flags in `testdef`? - -2. **`testdeftech` - Technical details** - - What details are so complex they need a separate table? - - Why not additional columns in `testdef`? - - Is this a 1:1 relationship? If so, why separate? - -3. **`testdefcal` - Calculated tests** - - Couldn't this be a `test_type` field: 'MANUAL', 'AUTOMATED', 'CALCULATED'? - - Does it really need a separate table? - - What additional fields justify the separation? - -4. **`testmap` - External mapping** - - Is this for LIS integration? - - Could external IDs be JSON field or separate mapping table? - - How many external systems justify this complexity? - -#### Recommendation - -**Start simple, grow as needed:** - -1. **Phase 1**: Implement with 3 core tables - - `tests` - - `test_panels` - - `reference_ranges` - -2. **Phase 2**: Add complexity only when requirements demand it - - If multi-site differences emerge, add `test_site_config` - - If external mappings become complex, add `test_mappings` - -This approach: -- ✅ Delivers functionality faster -- ✅ Reduces initial complexity -- ✅ Allows learning from actual usage patterns -- ✅ Grows based on real requirements, not imagined ones - ---- - -## Impact Assessment - -### Development Impact - -#### Query Complexity - -**Current Design Requires:** -- **5-7 table JOINs** for basic patient operations -- **4-5 table JOINs** to get complete specimen information -- **3-4 table JOINs** to retrieve test definitions with all attributes - -**Example: Get Patient with Full Details** -```sql -SELECT * -FROM patient p -LEFT JOIN patatt ON p.InternalPID = patatt.InternalPID -LEFT JOIN patemail ON p.InternalPID = patemail.InternalPID -LEFT JOIN pattel ON p.InternalPID = pattel.InternalPID -LEFT JOIN patcom ON p.InternalPID = patcom.InternalPID -LEFT JOIN patrelation ON p.InternalPID = patrelation.InternalPID -WHERE p.InternalPID = ? - AND (patatt.DelDate IS NULL OR patatt.DelDate > NOW()) - AND (patemail.DelDate IS NULL OR patemail.DelDate > NOW()) - -- ... repeat for each table -``` - -**Impact:** -- Complex queries are error-prone -- Difficult to optimize -- Hard to maintain -- Slow for developers to write - -#### Developer Onboarding - -**Estimated Learning Curve:** -- **2-3 weeks** to understand full schema -- **1-2 weeks** to understand temporal field logic -- **1 week** to understand audit trail requirements -- **Total: 4-6 weeks** before productive - -**Compared to industry standard: 1-2 weeks** - -#### Bug Risk Assessment - -| Risk Factor | Level | Description | -|-------------|-------|-------------| -| Incorrect JOINs | 🔴 High | Easy to miss required tables or use wrong join type | -| Temporal logic errors | 🔴 High | Unclear when to use which date fields | -| Data inconsistency | 🟡 Medium | Multiple sources of truth for same data | -| Performance issues | 🔴 High | Missing indexes, complex queries | -| Business logic errors | 🟡 Medium | Unclear rules, incomplete tables | - -#### Code Maintenance Burden - -Every feature touching patient data requires: -1. Understanding 6+ patient-related tables -2. Determining which temporal fields to check -3. Writing complex JOINs -4. Handling potential data conflicts -5. Populating 15+ audit fields -6. Testing all edge cases - -**Estimated overhead: 30-40% slower development** - -### Performance Impact - -#### By Data Scale - -| Data Scale | Expected Performance | Risk Level | Mitigation Required | -|------------|---------------------|------------|-------------------| -| **< 10K records** | Acceptable | 🟢 Low | None | -| **10K - 100K records** | Noticeable slowdown | 🟡 Low-Medium | Add indexes | -| **100K - 1M records** | 2-10x slowdown | 🟡 Medium | Comprehensive indexing, query optimization | -| **> 1M records** | Potential timeouts | 🔴 High | Caching, denormalization, partitioning | - -#### Specific Performance Concerns - -1. **No Comprehensive Indexing Strategy** - - Foreign keys lack indexes - - Temporal fields lack indexes - - Audit tables completely unindexed - - Search queries will be slow - -2. **JOIN Overhead** - - Basic operations require multiple JOINs - - Compounds with larger datasets - - No apparent query optimization strategy - -3. **Audit Log Growth** - - Will grow extremely large (15+ fields per change) - - No partitioning strategy - - No archival plan - - Will impact database backup/restore times - -4. **Temporal Field Queries** - - Every query must check 3-4 date fields - - No indexes on these fields - - Will slow down as data grows - -### Business Impact - -| Impact Area | Description | Severity | -|-------------|-------------|----------| -| **Time to Market** | Development takes longer due to complexity | 🟡 Medium | -| **Feature Velocity** | Each feature takes 30-40% longer to implement | 🔴 High | -| **Technical Debt** | Accumulating rapidly, will require refactoring | 🔴 High | -| **Team Morale** | Developer frustration with over-complicated system | 🟡 Medium | -| **Maintenance Costs** | Higher costs due to complexity | 🟡 Medium | -| **System Reliability** | More complexity = more potential failure points | 🟡 Medium | - -### User Impact - -While users don't see the schema directly, they will experience: - -1. **Slower Response Times** - Complex queries = slower pages -2. **More Bugs** - Complex code = more errors -3. **Delayed Features** - Longer development time -4. **Data Quality Issues** - Inconsistent data from unclear rules - ---- - -## Root Cause Analysis - -### Why Did This Happen? - -This schema suggests one of three scenarios (or a combination): - -### Scenario 1: Theoretical Knowledge > Practical Experience - -**Indicators:** -- Applying every design pattern learned in courses/books -- Not validated against real-world workflows -- Focus on "best practices" without understanding the "why" -- Assuming more normalization = better design - -**Common in:** -- Junior developers with strong theoretical background -- Developers new to database design -- Academic environments vs practical application - -**Analogy:** -A chef who knows every cooking technique but hasn't cooked for real customers, so they use molecular gastronomy techniques to make toast. - -### Scenario 2: Copying Enterprise Patterns - -**Indicators:** -- Mimicking HL7/FHIR standards without full understanding -- Hospital-grade complexity for clinic-scale needs -- Assuming big company patterns = good for all sizes -- "We might become a big system someday" - -**Common in:** -- Developers who worked at enterprise companies -- Copying open-source enterprise systems -- Consultants applying one-size-fits-all solutions - -**Analogy:** -Using Kubernetes, microservices, event sourcing, and a message queue for a personal blog because that's what Google does. - -### Scenario 3: Premature Optimization - -**Indicators:** -- Building for imagined future requirements -- "We might need this someday" syndrome -- Fear of refactoring later leads to over-engineering now -- Trying to solve every possible future problem - -**Common in:** -- Developers who've been burned by technical debt before -- Projects with unclear or changing requirements -- Fear-driven architecture decisions - -**Analogy:** -Building a house with an elevator, helipad, and nuclear bunker because "what if we need those later?" - -### The Real Issue: Missing Validation - -> **The core problem is that this design was never validated against:** -> - Real-world use cases -> - Sample data representing actual scenarios -> - Performance testing with realistic data volumes -> - Developer feedback during implementation -> - User workflow analysis - -### How to Prevent This in the Future - -1. **Start with requirements** - What does the system actually need to do? -2. **Create sample data** - Test with realistic scenarios -3. **Prototype first** - Build small, validate, then expand -4. **Get feedback early** - Show designs to developers who will use them -5. **Question complexity** - Every additional table needs clear justification -6. **Measure impact** - "Will this make queries faster or slower?" - ---- - -## Recommendations - -### 🔴 Critical Priority - Address Immediately - -These issues will cause **production failures** and must be fixed before deployment: - -#### 1. Remove Problematic Unique Constraints - -**Action Items:** -- [ ] Remove `UNIQUE` constraint on `EmailAddress1` in `patient` table -- [ ] Remove `UNIQUE` constraint on `InternalPID` in `patcom` table -- [ ] Audit all other unique constraints for real-world viability -- [ ] **Rename** `Address` field to `AttachmentURL` in `patatt` table for clarity (unique constraint is correct for URLs) - -**Rationale:** EmailAddress1 and patcom constraints violate real-world scenarios and will cause immediate failures. - -**Timeline:** Immediate (this week) - -#### 2. Fix Incomplete Tables - -**Action Items:** -- [ ] Add `RelatedPersonID` to `patrelation` table -- [ ] Add `RelationType` field (spouse, parent, emergency contact, etc.) -- [ ] Add contact information fields (phone, email) -- [ ] Add priority/sequence field -- [ ] Or remove the table if relationship tracking isn't actually needed - -**Rationale:** Table cannot fulfill its purpose in current form. - -**Timeline:** Before using relationship features (this week) - -#### 3. Document Temporal Field Logic - -**Action Items:** -- [ ] Create state machine diagram for record lifecycle -- [ ] Document when each date field gets set -- [ ] Define business rules for `EndDate`, `ArchivedDate`, `DelDate` -- [ ] Create developer guide for temporal field usage -- [ ] Add validation logic to enforce rules -- [ ] Update all queries to use consistent filtering - -**Rationale:** Without clear rules, developers will use these inconsistently, causing data quality issues. - -**Timeline:** This week - ---- - -### 🟡 High Priority - Plan for Refactoring - -These issues significantly impact development velocity and should be addressed soon: - -#### 4. Simplify Audit Trails - -**Action Items:** -- [ ] Reduce to 5-7 essential fields: - - `TableName`, `RecordID`, `FieldName` - - `OldValue`, `NewValue` - - `ChangedBy`, `ChangedAt`, `Reason` (optional) -- [ ] Remove UI-specific fields (`WebPageID`, `AppID`) -- [ ] Remove redundant system fields (`MachineID`, `SessionID`, `ProcessID`) -- [ ] Document who populates each field and when -- [ ] Add indexes for common audit queries -- [ ] Create centralized logging service - -**Rationale:** Current design creates 10x overhead with unclear business value. - -**Timeline:** Next sprint (2-4 weeks) - -#### 5. Consolidate Patient Data - -**Action Items:** -- [ ] Decide: Are separate tables for addresses/emails/phones needed? - - If YES: Implement proper versioning with effective/expiration dates - - If NO: Move data to main `patient` table -- [ ] Document decision and rationale -- [ ] Create migration plan -- [ ] Update all affected queries and code - -**Rationale:** Current design creates confusion without clear benefit. - -**Timeline:** Next sprint (2-4 weeks) - ---- - -### 🟢 Medium Priority - Future Improvements - -These should be considered for future iterations: - -#### 6. Reduce Specimen Tables - -**Action Items:** -- [ ] Analyze actual requirements for specimen tracking -- [ ] Consider consolidating to 2-3 tables: - - `specimens` (base entity) - - `specimen_events` (history/audit) - - `specimen_testing` (test-specific data) -- [ ] Prototype new design -- [ ] Migration plan for existing data - -**Timeline:** 1-2 months - -#### 7. Review Test Definition Complexity - -**Action Items:** -- [ ] Start with 3 core tables (tests, panels, ranges) -- [ ] Add additional tables only when requirements are clear -- [ ] Document justification for each additional table -- [ ] Ensure every table has a clear, single purpose - -**Timeline:** Next major feature iteration - -#### 8. Add Comprehensive Indexing - -**Action Items:** -- [ ] Add indexes on all foreign keys -- [ ] Add indexes on temporal fields used in WHERE clauses -- [ ] Add composite indexes for common query patterns -- [ ] Add indexes on audit log fields -- [ ] Monitor query performance and add indexes as needed - -**Timeline:** Ongoing, starting immediately - ---- - -## Alternative Approaches - -### Simplified Patient Module - -Rather than 6+ patient-related tables, consider a more streamlined approach: - -```sql -CREATE TABLE patient ( - -- Identity - InternalPID INT PRIMARY KEY AUTO_INCREMENT, - PatientID VARCHAR(50) NOT NULL UNIQUE, - - -- Personal Information - NameFirst VARCHAR(100), - NameLast VARCHAR(100), - NameMiddle VARCHAR(100), - Birthdate DATE, - Gender INT, - - -- Address (inline - most patients have one current address) - Street VARCHAR(255), - City VARCHAR(100), - Province VARCHAR(100), - ZIP VARCHAR(20), - Country VARCHAR(100), - - -- Contact Information (inline - most patients have one of each) - Email VARCHAR(255), - Phone VARCHAR(50), - MobilePhone VARCHAR(50), - - -- Emergency Contact (inline - most patients have one) - EmergencyContactName VARCHAR(200), - EmergencyContactPhone VARCHAR(50), - EmergencyContactRelation VARCHAR(100), - - -- Status and Temporal - Status ENUM('active', 'inactive', 'archived', 'deceased') NOT NULL DEFAULT 'active', - StatusChangedAt TIMESTAMP NULL, - StatusChangedBy INT NULL, - StatusChangedReason TEXT NULL, - - -- Audit fields - CreatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - CreatedBy INT NOT NULL, - UpdatedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UpdatedBy INT NULL, - - -- Indexes - INDEX idx_patient_id (PatientID), - INDEX idx_name (NameLast, NameFirst), - INDEX idx_birthdate (Birthdate), - INDEX idx_status (Status), - INDEX idx_created_by (CreatedBy), - INDEX idx_updated_by (UpdatedBy) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -``` - -#### Optional: Patient History Table (if history tracking is actually needed) - -```sql -CREATE TABLE patient_history ( - HistoryID BIGINT PRIMARY KEY AUTO_INCREMENT, - InternalPID INT NOT NULL, - FieldName VARCHAR(50) NOT NULL, - OldValue TEXT, - NewValue TEXT, - ChangedBy INT NOT NULL, - ChangedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - ChangeReason VARCHAR(255), - - INDEX idx_patient (InternalPID, ChangedAt), - INDEX idx_field (FieldName), - INDEX idx_changed_by (ChangedBy), - FOREIGN KEY (InternalPID) REFERENCES patient(InternalPID) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; -``` - -### Benefits of This Approach - -| Aspect | Improvement | -|--------|-------------| -| **Tables** | 6+ tables → 1-2 tables | -| **JOINs** | 5-6 JOINs → 0-1 JOINs for basic operations | -| **Clarity** | Clear single source of truth | -| **Performance** | Much faster queries, proper indexes | -| **Maintainability** | Easier to understand and modify | -| **Status Logic** | Clear ENUM values, single status field | - ---- - -### Simplified Audit Trail - -Rather than 15+ fields per log entry, use a focused approach: - -```sql -CREATE TABLE audit_log ( - LogID BIGINT PRIMARY KEY AUTO_INCREMENT, - - -- What changed - TableName VARCHAR(50) NOT NULL, - RecordID INT NOT NULL, - Action ENUM('CREATE', 'UPDATE', 'DELETE') NOT NULL, - - -- Who changed it - ChangedBy INT NOT NULL, - - -- When it changed - ChangedAt TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - - -- What changed (optional, for UPDATE actions) - FieldName VARCHAR(50), - OldValue TEXT, - NewValue TEXT, - - -- Why it changed (optional) - Reason VARCHAR(255), - - -- Indexes for common queries - INDEX idx_table_record (TableName, RecordID), - INDEX idx_changed_by (ChangedBy), - INDEX idx_changed_at (ChangedAt), - INDEX idx_table_field (TableName, FieldName), - - FOREIGN KEY (ChangedBy) REFERENCES users(UserID) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci -PARTITION BY RANGE (YEAR(ChangedAt)) ( - PARTITION p2024 VALUES LESS THAN (2025), - PARTITION p2025 VALUES LESS THAN (2026), - PARTITION p2026 VALUES LESS THAN (2027), - PARTITION pmax VALUES LESS THAN MAXVALUE -); -``` - -### Benefits of This Approach - -| Aspect | Current | Proposed | Improvement | -|--------|---------|----------|-------------| -| **Fields per log** | 15+ fields | 7 fields | -55% complexity | -| **Storage overhead** | 10x | 2-3x | -70% storage | -| **Query performance** | No indexes | 4 indexes | Fast queries | -| **Partitioning** | None | By year | Manageable growth | -| **Clarity** | Unclear purpose | Clear purpose | Easier to use | - ---- - -### Comparison: Current vs Proposed - -| Aspect | Current Design | Proposed Approach | Benefit | -|--------|---------------|-------------------|---------| -| **Patient tables** | 6+ tables (patient, patatt, patemail, pattel, patcom, patrelation) | 2-3 tables (patient, patient_history, patient_relations) | -50% to -65% reduction in JOINs | -| **Audit tables** | 3+ tables × 15 fields | 1 table × 7 fields | -70% storage overhead | -| **Specimen tables** | 5 tables (specimen, specimenstatus, specimencollection, specimenprep, specimenlog) | 2-3 tables (specimens, specimen_events) | Clearer data ownership | -| **Test tables** | 6 tables (testdef, testdefsite, testdeftech, testdefcal, testgrp, testmap) | 3-4 tables (tests, test_panels, reference_ranges, test_mappings) | Start simple, grow as needed | -| **Date fields** | 4 per table (CreateDate, EndDate, ArchivedDate, DelDate) | 2 per table (CreatedAt, UpdatedAt) + Status field | Clear temporal semantics | -| **Status tracking** | Multiple date fields with unclear meaning | ENUM status field with StatusChangedAt | Unambiguous state | - -### Expected Benefits - -#### Total Complexity Reduction: **40-50%** -- Fewer tables to understand -- Fewer JOINs in queries -- Clearer data ownership -- Simpler mental model - -#### Developer Productivity Gain: **30-40%** -- Faster to write queries -- Fewer bugs from complexity -- Easier onboarding -- Less maintenance burden - -#### Performance Improvement: **2-5x** -- Fewer JOINs = faster queries -- Proper indexing strategy -- Partitioning for large tables -- Clearer optimization path - ---- - -## Path Forward - -### Option A: Full Redesign - -**Description:** Redesign the schema from scratch using simplified approach - -**Pros:** -- ✅ Clean foundation for future development -- ✅ Faster development velocity long-term -- ✅ Better performance from the start -- ✅ Easier to maintain and understand - -**Cons:** -- ❌ Requires significant stakeholder buy-in -- ❌ 2-3 week delay to redesign and implement -- ❌ May face resistance from original designer -- ❌ Need to migrate any existing data - -**Best for:** Projects in early stages with minimal existing data - ---- - -### Option B: Tactical Fixes Only - -**Description:** Fix critical bugs but keep overall design - -**Immediate Actions:** -1. Remove blocking unique constraints -2. Add missing foreign key indexes -3. Fix incomplete tables (add missing fields) -4. Document temporal field usage rules - -**Pros:** -- ✅ No delay to project timeline -- ✅ Addresses blocking issues -- ✅ Less controversial -- ✅ Can start immediately - -**Cons:** -- ❌ Underlying complexity remains -- ❌ Development will still be slower than optimal -- ❌ Performance issues will emerge at scale -- ❌ Technical debt continues to accumulate - -**Best for:** Projects with political constraints or tight deadlines - ---- - -### ⭐ Option C: Hybrid Approach (RECOMMENDED) - -**Description:** Fix critical issues now, redesign incrementally - -**Phase 1: Critical Fixes (This Week)** -1. Remove blocking unique constraints -2. Fix incomplete table structures -3. Document temporal field rules -4. Add emergency indexes - -**Phase 2: Incremental Improvements (Next 2-4 Weeks)** -1. Simplify audit logging -2. Consolidate patient data tables -3. Add comprehensive indexing - -**Phase 3: New Modules Only (Ongoing)** -1. Use simplified design for new modules -2. Gradually refactor existing modules as needed -3. Measure and compare complexity/performance - -**Pros:** -- ✅ No project delay -- ✅ Immediate fixes for blocking issues -- ✅ Continuous improvement -- ✅ Learn from both approaches -- ✅ Can course-correct based on data - -**Cons:** -- ⚠️ Mixed design patterns temporarily -- ⚠️ Requires clear documentation of which modules use which approach -- ⚠️ Need discipline to not mix patterns within modules - -**Timeline:** -- Week 1: Critical fixes -- Weeks 2-4: High-priority improvements -- Months 2-3: Gradual refactoring and new module design - -**Best for:** Most real-world projects balancing speed and quality - ---- - -## Key Takeaways - -### 1. It Will Work, But... - -The schema is technically valid and will function. However, it creates unnecessary friction that will: -- Slow down development by 30-40% -- Increase bug count due to complexity -- Frustrate developers with unclear patterns -- Create performance issues at scale -- Accumulate technical debt rapidly - -### 2. Over-Engineering is Real - -This is a textbook example of over-engineering: -- Enterprise patterns applied without justification -- Complexity that doesn't solve actual problems -- "Future-proofing" that makes present harder -- More code to maintain = more points of failure - -**The antidote:** Start simple, grow based on real requirements. - -### 3. Real-World Validation Matters - -The unique constraint on addresses proves the design wasn't tested with realistic scenarios. Always: -- Create sample data representing real use cases -- Walk through actual workflows -- Test edge cases -- Get feedback from domain experts -- Prototype before full implementation - -### 4. Simplicity is Powerful - -The best design is often the simplest one that meets requirements: -- Easier to understand = fewer bugs -- Faster to implement = quicker time to market -- Better performance = happier users -- Less to maintain = lower costs - -**Remember:** You can always add complexity later if needed. Removing complexity is much harder. - -### 5. Question Everything - -Every design decision should answer: -- **What problem does this solve?** -- **Is there a simpler way?** -- **What's the maintenance cost?** -- **How will this scale?** -- **Can we prove we need this?** - -If you can't answer these clearly, reconsider the design. - -### 6. Patterns Are Tools, Not Rules - -Design patterns are tools in a toolbox: -- Use the right tool for the job -- Don't use a sledgehammer to hang a picture -- Enterprise patterns for enterprise problems -- Simple patterns for simple problems - -### 7. Design for Today, Plan for Tomorrow - -Build what you need now, with awareness of potential future needs: -- ✅ Design extensible systems -- ✅ Leave room for growth -- ❌ Don't build what you might need -- ❌ Don't optimize prematurely - ---- - -> **"Perfection is achieved not when there is nothing more to add, but when there is nothing left to take away."** -> — Antoine de Saint-Exupéry - ---- - -## Next Steps - -### 📅 Immediate Actions (This Week) - -**Critical Bug Fixes:** -- [ ] Schedule meeting with database architect/manager -- [ ] Present findings and get approval for changes -- [ ] Create migration to remove blocking unique constraints: - - [ ] `Address` unique constraint in `patatt` - - [ ] `EmailAddress1` unique constraint in `patient` - - [ ] `InternalPID` unique constraint in `patcom` -- [ ] Fix incomplete `patrelation` table or remove it -- [ ] Test migrations in development environment - -**Documentation:** -- [ ] Document temporal field business rules - - [ ] When to use `CreateDate` - - [ ] When to use `EndDate` - - [ ] When to use `ArchivedDate` - - [ ] When to use `DelDate` - - [ ] Valid state transitions -- [ ] Create state machine diagram -- [ ] Share with development team - ---- - -### 🔧 Short Term (2-4 Weeks) - -**Performance Improvements:** -- [ ] Audit all foreign key relationships -- [ ] Add indexes on foreign keys -- [ ] Add indexes on temporal fields used in queries -- [ ] Test query performance improvements - -**Design Documentation:** -- [ ] Document all table purposes -- [ ] Explain relationships between tables -- [ ] Create ERD (Entity Relationship Diagram) -- [ ] Write developer guide - -**Code Review:** -- [ ] Review existing queries for temporal logic -- [ ] Ensure consistent date field usage -- [ ] Update ORMs/models with proper relationships - -**Audit Trail Simplification:** -- [ ] Discuss audit requirements with stakeholders -- [ ] Identify which fields are actually used -- [ ] Plan migration to simplified audit structure -- [ ] Implement centralized logging service - ---- - -### 📈 Long Term (1-3 Months) - -**Strategic Planning:** -- [ ] Evaluate full redesign vs incremental refactoring -- [ ] Get stakeholder buy-in for chosen approach -- [ ] Create detailed implementation plan -- [ ] Set success metrics - -**If Redesigning:** -- [ ] Design simplified schema -- [ ] Create migration plan for existing data -- [ ] Prototype new design -- [ ] A/B test performance -- [ ] Plan phased rollout - -**If Incremental:** -- [ ] Identify highest-impact areas for improvement -- [ ] Refactor one module at a time -- [ ] Document patterns and anti-patterns -- [ ] Train team on preferred approaches - -**Process Improvements:** -- [ ] Establish schema design review process -- [ ] Create design guidelines document -- [ ] Set up automated performance testing -- [ ] Implement monitoring for slow queries -- [ ] Schedule regular schema reviews - ---- - -## Appendix - -### Review Statistics - -| Metric | Value | -|--------|-------| -| **Migration Files Reviewed** | 17 | -| **Database Tables Analyzed** | 40+ | -| **Critical Issues Identified** | 7 | -| **Blocking Defects Found** | 3 | -| **High Priority Issues** | 2 | -| **Medium Priority Issues** | 2 | -| **Potential Complexity Reduction** | ~45% | -| **Estimated Productivity Gain** | 30-40% | - -### Files Reviewed - -**Patient Module:** -- `2025-09-02-070826_PatientReg.php` - -**Visit Module:** -- `PatVisit.php` (referenced) - -**Specimen Module:** -- `Specimen.php` -- `SpecimenStatus.php` -- `SpecimenCollection.php` -- `SpecimenPrep.php` -- `SpecimenLog.php` - -**Test Module:** -- `Test.php` -- `TestDefSite.php` -- `TestDefTech.php` -- `TestDefCal.php` -- `TestGrp.php` -- `TestMap.php` - -**Additional Modules:** -- `OrderTest.php` -- `RefRange.php` -- 11+ additional migration files - -### Glossary - -| Term | Definition | -|------|------------| -| **CLQMS** | Clinical Laboratory Quality Management System | -| **Over-Engineering** | Adding complexity beyond what requirements demand | -| **Normalization** | Database design technique to reduce data redundancy | -| **JOIN** | SQL operation to combine rows from multiple tables | -| **Temporal Logic** | Rules for handling time-based data and state changes | -| **Audit Trail** | Record of all changes made to data over time | -| **Schema** | Structure and organization of database tables and relationships | -| **Foreign Key** | Field that creates relationship between two tables | -| **Index** | Database structure to speed up data retrieval | - -### References - -- **Database Design Best Practices**: Standard industry patterns for relational database design -- **Laboratory Information System (LIS)**: Common patterns in clinical laboratory systems -- **HL7/FHIR**: Healthcare interoperability standards -- **Temporal Patterns**: Effective dating, slow-changing dimensions, state machines - ---- - -## End of Report - -**For questions or discussion, contact:** -Claude Sonnet -December 12, 2025 - -**Document Version:** 1.0 -**Last Updated:** December 12, 2025 diff --git a/docs/20251212002-database_design_review_opus.md b/docs/20251212002-database_design_review_opus.md deleted file mode 100644 index 6ddc894..0000000 --- a/docs/20251212002-database_design_review_opus.md +++ /dev/null @@ -1,363 +0,0 @@ -# CLQMS Database Design Review Report - -**Prepared by:** Claude OPUS -**Date:** December 12, 2025 -**Subject:** Technical Assessment of Current Database Schema - ---- - -## Executive Summary - -This report presents a technical review of the CLQMS (Clinical Laboratory Quality Management System) database schema based on analysis of 16 migration files containing approximately 45+ tables. While the current design is functional, several critical issues have been identified that impact data integrity, development velocity, and long-term maintainability. - -**Overall Assessment:** The application will function, but the design causes significant developer friction and will create increasing difficulties as the system scales. - ---- - -## Critical Issues - -### 1. Missing Foreign Key Constraints - -**Severity:** 🔴 Critical - -The database schema defines **zero foreign key constraints**. All relationships are implemented as integer columns without referential integrity. - -| Impact | Description | -|--------|-------------| -| Data Integrity | Orphaned records when parent records are deleted | -| Data Corruption | Invalid references can be inserted without validation | -| Performance | Relationship logic must be enforced in application code | -| Debugging | Difficult to trace data lineage across tables | - -**Example:** A patient can be deleted while their visits, orders, and results still reference the deleted `InternalPID`. - ---- - -### 2. Test Definition Tables: Broken Relationships - -**Severity:** 🔴 Critical — Impacts API Development - -This issue directly blocks backend development. The test definition system spans **6 tables** with unclear and broken relationships: - -``` -testdef → Master test catalog (company-wide definitions) -testdefsite → Site-specific test configurations -testdeftech → Technical settings (units, decimals, methods) -testdefcal → Calculated test formulas -testgrp → Test panel/profile groupings -testmap → Host/Client analyzer code mappings -``` - -#### The Core Problem: Missing Link Between `testdef` and `testdefsite` - -**`testdef` table structure:** -``` -TestID (PK), Parent, TestCode, TestName, Description, DisciplineID, Method, ... -``` - -**`testdefsite` table structure:** -``` -TestSiteID (PK), SiteID, TestSiteCode, TestSiteName, TestType, Description, ... -``` - -> [!CAUTION] -> **There is NO `TestID` column in `testdefsite`!** -> The relationship between master tests and site-specific configurations is undefined. - -The assumed relationship appears to be matching `TestCode` = `TestSiteCode`, which is: -- **Fragile** — codes can change or differ -- **Non-performant** — string matching vs integer FK lookup -- **Undocumented** — developers must guess - -#### Developer Impact - -**Cannot create sample JSON payloads for API development.** - -To return a complete test with all configurations, we need to JOIN: -``` -testdef - → testdefsite (HOW? No FK exists!) - → testdeftech (via TestSiteID) - → testdefcal (via TestSiteID) - → testgrp (via TestSiteID) - → testmap (via TestSiteID) - → refnum/refthold/refvset/reftxt (via TestSiteID) -``` - -#### What a Complete Test JSON Should Look Like - -```json -{ - "test": { - "id": 1, - "code": "GLU", - "name": "Glucose", - "discipline": "Chemistry", - "method": "Hexokinase", - "sites": [ - { - "siteId": 1, - "siteName": "Main Lab", - "unit": "mg/dL", - "decimalPlaces": 0, - "referenceRange": { "low": 70, "high": 100 }, - "equipment": [ - { "name": "Cobas 6000", "hostCode": "GLU" } - ] - } - ], - "panelMemberships": ["BMP", "CMP"] - } -} -``` - -#### What We're Forced to Create Instead - -```json -{ - "testdef": { "TestID": 1, "TestCode": "GLU", "TestName": "Glucose" }, - "testdefsite": { "TestSiteID": 1, "SiteID": 1, "TestSiteCode": "GLU" }, - "testdeftech": { "TestTechID": 1, "TestSiteID": 1, "Unit1": "mg/dL" }, - "refnum": { "RefNumID": 1, "TestSiteID": 1, "Low": 70, "High": 100 } -} -``` - -**Problem:** How does the API consumer know `testdef.TestID=1` connects to `testdefsite.TestSiteID=1`? The relationship is implicit and undocumented. - -#### Recommended Fix - -Add `TestID` foreign key to `testdefsite`: - -```sql -ALTER TABLE testdefsite ADD COLUMN TestID INT NOT NULL; -ALTER TABLE testdefsite ADD CONSTRAINT fk_testdefsite_testdef - FOREIGN KEY (TestID) REFERENCES testdef(TestID); -``` - -#### Deeper Problem: Over-Engineered Architecture - -> [!WARNING] -> **Even with `TestID` added, the test table design remains excessively complex and confusing.** - -Adding the missing foreign key fixes the broken link, but does not address the fundamental over-engineering. To retrieve ONE complete test for ONE site, developers must JOIN across **10 tables**: - -``` -testdef ← "What is this test?" - └── testdefsite ← "Is it available at site X?" - └── testdeftech ← "What units/decimals at site X?" - └── testdefcal ← "Is it calculated at site X?" - └── testgrp ← "What panels is it in at site X?" - └── testmap ← "What analyzer codes at site X?" - └── refnum ← "Numeric reference ranges" - └── refthold ← "Threshold reference ranges" - └── refvset ← "Value set references" - └── reftxt ← "Text references" -``` - -**10 tables for one test at one site.** - -This design assumes maximum flexibility (every site configures everything differently), but creates: -- **Excessive query complexity** — Simple lookups require 5+ JOINs -- **Developer confusion** — Which table holds which data? -- **Maintenance burden** — Changes ripple across multiple tables -- **API design friction** — Difficult to create clean, intuitive endpoints - -#### What a Simpler Design Would Look Like - -| Current (10 tables) | Proposed (4 tables) | -|---------------------|---------------------| -| `testdef` | `tests` | -| `testdefsite` + `testdeftech` + `testdefcal` | `test_configurations` | -| `refnum` + `refthold` + `refvset` + `reftxt` | `test_reference_ranges` (with `type` column) | -| `testgrp` | `test_panel_members` | -| `testmap` | (merged into `test_configurations`) | - -#### Recommendation - -For long-term maintainability, consider a phased refactoring: - -1. **Phase 1:** Add `TestID` FK (immediate unblock) -2. **Phase 2:** Create database VIEWs that flatten the structure for API consumption -3. **Phase 3:** Evaluate consolidation of `testdefsite`/`testdeftech`/`testdefcal` into single table -4. **Phase 4:** Consolidate 4 reference range tables into one with discriminator column - ---- - -### 3. Data Type Mismatches Across Tables - -**Severity:** 🔴 Critical - -The same logical field uses different data types in different tables, making JOINs impossible. - -| Field | Table A | Type | Table B | Type | -|-------|---------|------|---------|------| -| `SiteID` | `ordertest` | `VARCHAR(15)` | `site` | `INT` | -| `OccupationID` | `contactdetail` | `VARCHAR(50)` | `occupation` | `INT` | -| `SpcType` | `testdeftech` | `INT` | `refnum` | `VARCHAR(10)` | -| `Country` | `patient` | `INT` | `account` | `VARCHAR(50)` | -| `City` | `locationaddress` | `INT` | `account` | `VARCHAR(150)` | - ---- - -## High-Priority Issues - -### 4. Inconsistent Naming Conventions - -| Issue | Examples | -|-------|----------| -| Mixed case styles | `InternalPID`, `CreateDate` vs `AreaCode`, `Parent` | -| Cryptic abbreviations | `patatt`, `patcom`, `patidt`, `patvisitadt` | -| Inconsistent ID naming | `InternalPID`, `PatientID`, `PatIdtID`, `PatComID` | -| Unclear field names | `VSet`, `VValue`, `AspCnt`, `ME`, `DIDType` | - ---- - -### 5. Inconsistent Soft-Delete Strategy - -Multiple date fields used inconsistently: - -| Table | Fields Used | -|-------|-------------| -| `patient` | `CreateDate`, `DelDate` | -| `patvisit` | `CreateDate`, `EndDate`, `ArchivedDate`, `DelDate` | -| `patcom` | `CreateDate`, `EndDate` | -| `testdef` | `CreateDate`, `EndDate` | - -**No documented standard** for determining record state (active/deleted/archived/ended). - ---- - -### 6. Duplicate Log Table Design - -Three nearly identical audit tables exist: -- `patreglog` -- `patvisitlog` -- `specimenlog` - -**Recommendation:** Consolidate into single `audit_log` table. - ---- - -## Medium Priority Issues - -### 7. Redundant Data Storage - -| Table | Redundancy | -|-------|------------| -| `patres` | Stores both `InternalSID` AND `SID` | -| `patres` | Stores both `TestSiteID` AND `TestSiteCode` | -| `patrestatus` | Duplicates `SID` from parent table | - -### 8. Incomplete Table Designs - -**`patrelation` table:** Missing `RelatedPatientID`, `RelationType` -**`users` table:** Missing `email`, `created_at`, `updated_at`, `status`, `last_login` - -### 9. Migration Script Bugs - -| File | Issue | -|------|-------| -| `Specimen.php` | Creates `specimen`, drops `specimens` | -| `CRMOrganizations.php` | Creates `account`/`site`, drops `accounts`/`sites` | -| `PatRes.php` | Drops non-existent `patrestech` table | - ---- - -## Recommendations - -### Immediate (Sprint 1-2) -1. **Add `TestID` to `testdefsite`** — Unblocks API development -2. **Fix migration script bugs** — Correct table names in `down()` methods -3. **Document existing relationships** — Create ERD with assumed relationships - -### Short-Term (Sprint 3-6) -4. **Add foreign key constraints** — Prioritize patient → visit → order → result chain -5. **Fix data type mismatches** — Create migration scripts for type alignment -6. **Standardize soft-delete** — Use `deleted_at` only, everywhere - -### Medium-Term (Sprint 7-12) -7. **Consolidate audit logs** — Single polymorphic audit table -8. **Normalize addresses** — Single `addresses` table -9. **Rename cryptic columns** — Document and rename for clarity - ---- - -## Appendix: Tables by Migration - -| Migration | Tables | -|-----------|--------| -| PatientReg | `patient`, `patatt`, `patcom`, `patidt`, `patreglog`, `patrelation` | -| PatVisit | `patvisit`, `patdiag`, `patvisitadt`, `patvisitlog` | -| Location | `location`, `locationaddress` | -| Users | `users` | -| Contact | `contact`, `contactdetail`, `occupation`, `medicalspecialty` | -| ValueSet | `valueset`, `valuesetdef` | -| Counter | `counter` | -| Specimen | `containerdef`, `specimen`, `specimenstatus`, `specimencollection`, `specimenprep`, `specimenlog` | -| OrderTest | `ordertest`, `ordercom`, `orderatt`, `orderstatus` | -| Test | `testdef`, `testdefsite`, `testdeftech`, `testdefcal`, `testgrp`, `testmap` | -| RefRange | `refnum`, `refthold`, `refvset`, `reftxt` | -| CRMOrganizations | `account`, `site` | -| Organization | `discipline`, `department`, `workstation` | -| Equipment | `equipmentlist`, `comparameters`, `devicelist` | -| AreaGeo | `areageo` | -| PatRes | `patres`, `patresflag`, `patrestatus`, `flagdef` | - ---- - -## Process Improvement: Database Design Ownership - -### Current Challenge - -The issues identified in this report share a common theme: **disconnect between database structure and API consumption patterns**. Many design decisions optimize for theoretical flexibility rather than practical developer workflow. - -This is not a critique of intent — the design shows careful thought about multi-site configurability. However, when database schemas are designed in isolation from the developers who build APIs on top of them, friction inevitably occurs. - -### Industry Best Practice - -Modern software development teams typically follow this ownership model: - -| Role | Responsibility | -|------|---------------| -| **Product/Business** | Define what data needs to exist (requirements) | -| **Backend Developers** | Design how data is structured (schema design) | -| **Backend Developers** | Implement APIs that consume the schema | -| **DBA (if applicable)** | Optimize performance, manage infrastructure | - -The rationale is simple: **those who consume the schema daily are best positioned to design it**. - -### Benefits of Developer-Owned Schema Design - -| Benefit | Description | -|---------|-------------| -| **API-First Thinking** | Tables designed with JSON output in mind | -| **Faster Iterations** | Schema changes driven by real implementation needs | -| **Reduced Friction** | No translation layer between "what was designed" and "what we need" | -| **Better Documentation** | Developers document what they build | -| **Ownership & Accountability** | Single team owns the full stack | - -### Recommendation - -Consider transitioning database schema design ownership to the backend development team for future modules. This would involve: - -1. **Requirements Gathering** — Business/product defines data needs -2. **Schema Proposal** — Backend team designs tables based on API requirements -3. **Review** — Technical review with stakeholders before implementation -4. **Implementation** — Backend team executes migrations and builds APIs - -This approach aligns with how most modern development teams operate and would prevent the types of issues found in this review. - -> [!NOTE] -> This recommendation is not about past decisions, but about optimizing future development velocity. The backend team's daily work with queries, JOINs, and API responses gives them unique insight into practical schema design. - ---- - -## Conclusion - -The test definition table structure is the most immediate blocker for development. Without a clear relationship between `testdef` and `testdefsite`, creating coherent API responses is not feasible. This should be prioritized in Sprint 1. - -The broader issues (missing FKs, type mismatches) represent significant technical debt that will compound over time. Investment in database refactoring now prevents costly incidents later. - ---- - -*Report generated from migration file analysis in `app/Database/Migrations/`* diff --git a/docs/20251216002-Test_OrderTest_RefRange_schema_redesign_proposal.md b/docs/20251216002-Test_OrderTest_RefRange_schema_redesign_proposal.md deleted file mode 100644 index 30b79d3..0000000 --- a/docs/20251216002-Test_OrderTest_RefRange_schema_redesign_proposal.md +++ /dev/null @@ -1,432 +0,0 @@ -# Database Schema Redesign Proposal: Test, OrderTest & RefRange Modules - -**Date:** 2025-12-16 -**Status:** Draft / Proposal -**Author:** Development Team -**Purpose:** Propose cleaner, more maintainable table structure - ---- - -## The Problem: Current Design Issues - -### 1. Test Module - Confusing Table Split - -**Current Structure:** -``` -testdefsite → Basic test info (code, name, description) -testdeftech → Technical info (result type, units, specimen) -testdefcal → Calculation formula -testgrp → Test grouping/panels -testmap → External system mapping -``` - -**Issues:** -| Problem | Description | -|:--------|:------------| -| ❌ Artificial separation | `testdefsite` and `testdeftech` are 1:1 relationship - why separate them? | -| ❌ Confusing naming | "def" prefix is redundant, "site" suffix is misleading | -| ❌ Redundant columns | `SiteID`, `DisciplineID`, `DepartmentID` duplicated across tables | -| ❌ Hard to query | Need multiple JOINs just to get basic test info | - ---- - -### 2. OrderTest Module - Unnecessary Normalization - -**Current Structure:** -``` -ordertest → Main order -ordercom → Comments (separate table) -orderatt → Attachments (separate table) -orderstatus → Status history (separate table) -``` - -**Issues:** -| Problem | Description | -|:--------|:------------| -| ❌ Over-normalized | Comments/attachments could be JSON or simpler structure | -| ❌ Status as separate table | If you only need current status, this adds complexity | -| ❌ Missing link | No link between order and actual tests ordered | - ---- - -### 3. RefRange Module - Too Many Similar Tables - -**Current Structure:** -``` -refnum → Numeric ranges (Low, High, Critical) -refthold → Threshold (single cutoff value) -reftxt → Text reference -refvset → Value set reference -``` - -**Issues:** -| Problem | Description | -|:--------|:------------| -| ❌ 4 tables for same concept | All are "reference ranges" with slight variations | -| ❌ Duplicated columns | Same columns repeated: TestSiteID, SpcType, Sex, AgeStart, AgeEnd | -| ❌ Hard to maintain | Adding a new field means updating 4 tables | - ---- - -## Proposed Redesign - -### Part A: Test Module - Consolidated Design - -**BEFORE (5 tables):** -``` -testdefsite + testdeftech + testdefcal + testgrp + testmap -``` - -**AFTER (3 tables):** -``` -tests → All test definition in ONE table -test_panels → Panel/group membership -test_mappings → External system mapping -``` - -#### A1. `tests` (Consolidated Test Definition) - -Merge `testdefsite`, `testdeftech`, and `testdefcal` into ONE table: - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ tests │ -├─────────────────────────────────────────────────────────────────┤ -│ id INT UNSIGNED PK AUTO_INCREMENT │ -│ site_id INT UNSIGNED -- Which lab site │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Basic Info (from testdefsite) -- │ -│ code VARCHAR(10) -- Test code │ -│ name VARCHAR(100) -- Test name │ -│ description VARCHAR(255) │ -│ test_type ENUM('single','panel','calculated') │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Technical Info (from testdeftech) -- │ -│ discipline_id INT UNSIGNED -- Chemistry, Hematology │ -│ department_id INT UNSIGNED │ -│ result_type ENUM('numeric','text','coded') │ -│ specimen_type VARCHAR(20) │ -│ specimen_qty DECIMAL(10,2) │ -│ specimen_unit VARCHAR(20) │ -│ unit VARCHAR(20) -- Result unit │ -│ decimal_places TINYINT │ -│ method VARCHAR(100) │ -│ expected_tat INT -- Turnaround time (mins) │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Calculated Test Info (from testdefcal) -- │ -│ formula TEXT -- NULL if not calculated │ -│ formula_inputs JSON -- List of input test IDs │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Display Order -- │ -│ sort_order_screen INT │ -│ sort_order_report INT │ -│ visible_screen BOOLEAN DEFAULT 1 │ -│ visible_report BOOLEAN DEFAULT 1 │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Audit -- │ -│ created_at DATETIME │ -│ updated_at DATETIME │ -│ deleted_at DATETIME -- Soft delete │ -└─────────────────────────────────────────────────────────────────┘ -``` - -**Benefits:** -- ✅ One query to get all test info -- ✅ No redundant columns -- ✅ Clear naming -- ✅ `test_type` tells you if it's a panel or calculated test - ---- - -#### A2. `test_panels` (Panel Membership) - -For tests that are panels (groups of other tests): - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ test_panels │ -├─────────────────────────────────────────────────────────────────┤ -│ id INT UNSIGNED PK AUTO_INCREMENT │ -│ panel_test_id INT UNSIGNED FK → tests.id -- The panel │ -│ member_test_id INT UNSIGNED FK → tests.id -- Member test │ -│ sort_order INT │ -│ created_at DATETIME │ -└─────────────────────────────────────────────────────────────────┘ -``` - -**Example:** CBC panel contains: WBC, RBC, HGB, HCT, PLT -``` -panel_test_id=1 (CBC), member_test_id=2 (WBC) -panel_test_id=1 (CBC), member_test_id=3 (RBC) -panel_test_id=1 (CBC), member_test_id=4 (HGB) -... -``` - ---- - -#### A3. `test_mappings` (External System Mapping) - -Keep this separate (good design): - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ test_mappings │ -├─────────────────────────────────────────────────────────────────┤ -│ id INT UNSIGNED PK AUTO_INCREMENT │ -│ test_id INT UNSIGNED FK → tests.id │ -│ external_system VARCHAR(50) -- 'LIS', 'HIS', 'Analyzer'│ -│ external_code VARCHAR(50) -- Code in that system │ -│ external_name VARCHAR(100) │ -│ connection_id INT UNSIGNED -- Which connection/device │ -│ direction ENUM('inbound','outbound','both') │ -│ created_at DATETIME │ -│ updated_at DATETIME │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -### Part B: Reference Range - Unified Design - -**BEFORE (4 tables):** -``` -refnum + refthold + refvset + reftxt -``` - -**AFTER (1 table):** -``` -reference_ranges → All reference types in ONE table -``` - -#### B1. `reference_ranges` (Unified) - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ reference_ranges │ -├─────────────────────────────────────────────────────────────────┤ -│ id INT UNSIGNED PK AUTO_INCREMENT │ -│ test_id INT UNSIGNED FK → tests.id │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Criteria (same across all old tables) -- │ -│ specimen_type VARCHAR(20) │ -│ sex ENUM('M','F','A') -- A = All/Any │ -│ age_min INT -- In days for precision │ -│ age_max INT -- In days │ -│ age_unit ENUM('days','months','years') │ -│ criteria VARCHAR(100) -- Additional criteria │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Reference Type -- │ -│ ref_type ENUM('numeric','threshold','text','coded') │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Numeric Range (when ref_type = 'numeric') -- │ -│ critical_low DECIMAL(15,4) │ -│ normal_low DECIMAL(15,4) │ -│ normal_high DECIMAL(15,4) │ -│ critical_high DECIMAL(15,4) │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Threshold (when ref_type = 'threshold') -- │ -│ threshold_value DECIMAL(15,4) │ -│ threshold_operator ENUM('<','<=','>','>=','=') │ -│ below_text VARCHAR(50) -- "Negative", "Normal" │ -│ above_text VARCHAR(50) -- "Positive", "Abnormal" │ -│ gray_zone_low DECIMAL(15,4) │ -│ gray_zone_high DECIMAL(15,4) │ -│ gray_zone_text VARCHAR(50) -- "Equivocal" │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Text/Coded (when ref_type = 'text' or 'coded') -- │ -│ reference_text TEXT -- Expected values or desc │ -│ value_set JSON -- For coded: list of valid│ -├─────────────────────────────────────────────────────────────────┤ -│ -- Audit -- │ -│ created_at DATETIME │ -│ updated_at DATETIME │ -└─────────────────────────────────────────────────────────────────┘ -``` - -**Benefits:** -- ✅ One table instead of 4 -- ✅ Easy to add new reference types -- ✅ Single query with `ref_type` filter -- ✅ No duplicated criteria columns - ---- - -### Part C: OrderTest - Cleaner Design - -**BEFORE (4 tables):** -``` -ordertest + ordercom + orderatt + orderstatus -``` - -**AFTER (3 tables):** -``` -orders → Main order with current status -order_tests → Individual tests in the order (MISSING before!) -order_history → Status changes + comments combined -``` - -#### C1. `orders` (Main Order) - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ orders │ -├─────────────────────────────────────────────────────────────────┤ -│ id INT UNSIGNED PK AUTO_INCREMENT │ -│ order_number VARCHAR(30) UNIQUE -- Display order ID │ -│ accession_number VARCHAR(30) -- Lab accession │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Patient & Visit -- │ -│ patient_id INT UNSIGNED FK → patients.id │ -│ visit_id INT UNSIGNED FK → visits.id │ -│ site_id INT UNSIGNED │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Order Details -- │ -│ priority ENUM('routine','urgent','stat') │ -│ status ENUM('pending','collected','received', │ -│ 'in_progress','completed','cancelled') │ -│ ordered_by INT UNSIGNED -- Doctor/User ID │ -│ ordered_at DATETIME │ -│ collected_at DATETIME │ -│ received_at DATETIME │ -│ completed_at DATETIME │ -├─────────────────────────────────────────────────────────────────┤ -│ -- Audit -- │ -│ created_at DATETIME │ -│ updated_at DATETIME │ -│ deleted_at DATETIME │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -#### C2. `order_tests` (Tests in Order) — **NEW TABLE!** - -**This was MISSING in original design!** How do you know what tests are in an order? - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ order_tests │ -├─────────────────────────────────────────────────────────────────┤ -│ id INT UNSIGNED PK AUTO_INCREMENT │ -│ order_id INT UNSIGNED FK → orders.id │ -│ test_id INT UNSIGNED FK → tests.id │ -├─────────────────────────────────────────────────────────────────┤ -│ status ENUM('ordered','in_progress','resulted', │ -│ 'verified','cancelled') │ -│ result_value VARCHAR(255) -- The actual result │ -│ result_flag ENUM('N','L','H','LL','HH','A') -- Normal/Abn│ -│ result_comment TEXT │ -│ resulted_by INT UNSIGNED -- Tech who entered result │ -│ resulted_at DATETIME │ -│ verified_by INT UNSIGNED -- Supervisor who verified │ -│ verified_at DATETIME │ -├─────────────────────────────────────────────────────────────────┤ -│ created_at DATETIME │ -│ updated_at DATETIME │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -#### C3. `order_history` (Combined Audit Trail) - -Combine `ordercom`, `orderatt`, `orderstatus` into one audit table: - -``` -┌─────────────────────────────────────────────────────────────────┐ -│ order_history │ -├─────────────────────────────────────────────────────────────────┤ -│ id INT UNSIGNED PK AUTO_INCREMENT │ -│ order_id INT UNSIGNED FK → orders.id │ -│ order_test_id INT UNSIGNED FK → order_tests.id (nullable) │ -├─────────────────────────────────────────────────────────────────┤ -│ event_type ENUM('status_change','comment','attachment', │ -│ 'result_edit','verification') │ -│ old_value TEXT │ -│ new_value TEXT │ -│ comment TEXT │ -│ attachment_path VARCHAR(255) -- For attachments │ -├─────────────────────────────────────────────────────────────────┤ -│ created_by INT UNSIGNED │ -│ created_at DATETIME │ -└─────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Summary: Before vs After - -| Module | Before | After | Change | -|:-------|:-------|:------|:-------| -| **Test** | 5 tables | 3 tables | -2 tables | -| **RefRange** | 4 tables | 1 table | -3 tables | -| **OrderTest** | 4 tables | 3 tables | -1 table, +1 essential table | -| **Total** | 13 tables | 7 tables | **-6 tables** | - -### New ERD - -``` -┌─────────────────────────────────────────────────────────────────────────────┐ -│ PROPOSED ERD │ -├─────────────────────────────────────────────────────────────────────────────┤ -│ │ -│ ┌─────────────┐ ┌──────────────┐ ┌─────────────────┐ │ -│ │ tests │◄────────│ test_panels │ │ test_mappings │ │ -│ │ (All tests) │ │ (Panel→Test) │ │ (Ext. systems) │ │ -│ └──────┬──────┘ └──────────────┘ └─────────────────┘ │ -│ │ │ -│ │ 1:N │ -│ ▼ │ -│ ┌──────────────────┐ │ -│ │ reference_ranges │ (All ref types in one table) │ -│ └──────────────────┘ │ -│ │ -│ │ -│ ┌──────────┐ 1:N ┌─────────────┐ 1:N ┌───────────────┐ │ -│ │ patients │◄──────────│ orders │◄──────────│ order_history │ │ -│ └──────────┘ └──────┬──────┘ └───────────────┘ │ -│ │ │ -│ │ 1:N │ -│ ▼ │ -│ ┌─────────────┐ │ -│ │ order_tests │ (What tests are in order) │ -│ └──────┬──────┘ │ -│ │ │ -│ │ N:1 │ -│ ▼ │ -│ ┌─────────────┐ │ -│ │ tests │ │ -│ └─────────────┘ │ -│ │ -└─────────────────────────────────────────────────────────────────────────────┘ -``` - ---- - -## Migration Strategy - -Since this is a major restructure: - -1. **Create new migration files** (don't modify old ones) -2. **Write data migration script** to move data from old to new tables -3. **Update Models, Controllers, Views** to use new table names -4. **Test thoroughly** before dropping old tables - ---- - -## Questions for Discussion - -1. Is storing `formula` as TEXT acceptable, or need a more structured approach? -2. Should `order_history` store ALL changes, or just important ones? -3. Any additional fields needed that I missed? - ---- - -## Next Steps - -1. ✅ Review and approve this proposal -2. 🔲 Create new migration files -3. 🔲 Write data migration scripts -4. 🔲 Update Models to use new tables -5. 🔲 Update Controllers and Services -6. 🔲 Deprecate old tables diff --git a/docs/README.md b/docs/README.md deleted file mode 100644 index 0d6368f..0000000 --- a/docs/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# CLQMS Technical Documentation Index - -This repository serves as the central knowledge base for the CLQMS Backend. It contains architectural proposals, API contracts, and design reviews that guide the development of the clinical laboratory management system. - ---- - -## 🏗️ Architectural Redesign (Manager's Proposal) - -The system is currently transitioning to a consolidated database schema to enhance performance and maintainability. This is a critical initiative aimed at reducing schema complexity. - -- **[Detailed Redesign Proposal](./20251216002-Test_OrderTest_RefRange_schema_redesign_proposal.md)** - - *Focus:* Consolidating 13 legacy tables into 7 optimized tables. - - *Modules Impacted:* Test Definition, Reference Ranges, and Order Management. - ---- - -## 🛠️ Functional Modules - -### 1. Test Management -Handles the definition of laboratory tests, including technical specifications, calculation formulas, and external system mappings. -- See: `tests` table and `test_panels` in the redesign proposal. - -### 2. Patient & Order Workflow -Manages the lifecycle of a laboratory order: -- **[Patient Registration API Contract](./api_contract_patient_registration.md)**: Specifications for patient intake and data validation. -- **Order Tracking**: From collection and receipt to technical verification. - -### 3. Reference Range Engine -A unified logic for determining normal and critical flags across various test types. -- *Types supported:* Numeric, Threshold (Cut-off), Textual, and Coded (Value Sets). - ---- - -## 📊 Design Reviews & Legacy Reference - -Documentation regarding the initial assessments and legacy structures: -- **[Database Design Review - Sonnet](./20251212001-database_design_review_sonnet.md)**: Comprehensive analysis of legacy table relationships and bottlenecks. -- **[Database Design Review - Opus](./20251212002-database_design_review_opus.md)**: Additional perspectives on the initial system architecture. - ---- - -## 🛡️ Development Standards - -All contributions to the CLQMS backend must adhere to the following: -1. **Data Integrity:** All database migrations must include data validation scripts. -2. **Auditability:** Status changes in the `orders` module must be logged in `order_history`. -3. **Security:** Every endpoint requires JWT authentication. - ---- - -### 📜 Administration -Documentation maintained by the **5Panda Team**. - ---- -*Built with ❤️ by the 5Panda Team.* diff --git a/docs/api_contract_patient_registration.md b/docs/api_contract_patient_registration.md deleted file mode 100644 index 721f63c..0000000 --- a/docs/api_contract_patient_registration.md +++ /dev/null @@ -1,222 +0,0 @@ -# Patient Registration API Contract - -**Version:** 1.0.0 -**Base URL:** `/api/v1` - ---- - -## 1. Patients (`/patients`) - -### GET /patients -Retrieve list of patients. - -**Query Parameters:** -- `page` (int, default: 1) -- `limit` (int, default: 20) -- `search` (string) - Search by name, MRN (PatientID), or phone. - -**Response (200 OK):** -```json -{ - "data": [ - { - "InternalPID": 1001, - "PatientID": "MRN-2025-0001", - "NameFirst": "John", - "NameLast": "Doe", - "Gender": 1, - "Birthdate": "1990-05-15", - "MobilePhone": "+1234567890", - "EmailAddress1": "john.doe@example.com" - } - ] -} -``` - ---- - -### POST /patients -Register a new patient. - -**Request Body:** -```json -{ - "PatientID": "MRN-2025-0002", - "AlternatePID": "ALT-123", // Optional - "Prefix": "Mr.", // Optional - "NameFirst": "Jane", - "NameMiddle": "Marie", // Optional - "NameLast": "Doe", - "NameMaiden": null, // Optional - "Suffix": null, // Optional - "Gender": 2, // 1=Male, 2=Female (example) - "Birthdate": "1992-08-20", - "PlaceOfBirth": "New York", // Optional - "Street_1": "123 Main St", - "Street_2": "Apt 4B", // Optional - "City": "Metropolis", - "Province": "NY", - "ZIP": "10001", - "Phone": "555-0100", // Optional - "MobilePhone": "555-0199", - "EmailAddress1": "jane.doe@example.com", - "MaritalStatus": 1, // 1=Single, 2=Married, etc. - "Religion": 1, // Optional ID - "Race": 1, // Optional ID - "Citizenship": "USA" // Optional -} -``` - -**Response (201 Created):** -```json -{ - "message": "Patient created successfully", - "data": { - "InternalPID": 1002, - "PatientID": "MRN-2025-0002", - "CreateDate": "2025-12-16T10:00:00Z" - } -} -``` - ---- - -### GET /patients/{id} -Get full details of a specific patient. - -**Response (200 OK):** -```json -{ - "data": { - "InternalPID": 1001, - "PatientID": "MRN-2025-0001", - "NameFirst": "John", - "NameLast": "Doe", - "identifiers": [ - { - "PatIdtID": 5, - "IdentifierType": "Passport", - "Identifier": "A12345678" - } - ], - "relations": [], - "comments": [] - } -} -``` - ---- - -### PUT /patients/{id} -Update patient demographics. - -**Request Body:** -```json -{ - "NameLast": "Smith", - "MobilePhone": "555-9999", - "EmailAddress1": "john.smith@example.com" -} -``` - -**Response (200 OK):** -```json -{ - "message": "Patient updated successfully", - "data": { "InternalPID": 1001 } -} -``` - ---- - -## 2. Patient Identifiers (`/patients/{id}/identifiers`) - -### POST /patients/{id}/identifiers -Add an identifier (SSN, Passport, Driver's License) to a patient. - -**Request Body:** -```json -{ - "IdentifierType": "SSN", - "Identifier": "000-11-2222", - "EffectiveDate": "2020-01-01", // Optional - "ExpirationDate": "2030-01-01" // Optional -} -``` - -**Response (201 Created):** -```json -{ - "message": "Identifier added", - "data": { "PatIdtID": 15 } -} -``` - ---- - -## 3. Patient Comments (`/patients/{id}/comments`) - -### POST /patients/{id}/comments -Add a comment to a patient record. - -**Request Body:** -```json -{ - "Comment": "Patient requests wheelchair assistance upon arrival." -} -``` - -**Response (201 Created):** -```json -{ - "message": "Comment added", - "data": { "PatComID": 42 } -} -``` - ---- - -## 4. Patient Relations (`/patients/{id}/relations`) -*Note: Pending Schema Update (Currently `patrelation` is missing columns)* - -### POST /patients/{id}/relations -Add a family member or emergency contact. - -**Request Body:** -```json -{ - "RelatedPID": 1050, // If relation is also a patient - "RelationType": "Spouse", // Requires schema update to store this - "IsEmergency": true // Requires schema update -} -``` - -**Response (201 Created):** -```json -{ - "message": "Relation added", - "data": { "PatRelID": 8 } -} -``` - ---- - -## 5. Patient Attachments (`/patients/{id}/attachments`) - -### POST /patients/{id}/attachments -Upload a file for a patient (insurance card, ID scan). - -**Request (Multipart/Form-Data):** -- `file`: (Binary File) -- `Address`: (string, optional description or file path reference) - -**Response (201 Created):** -```json -{ - "message": "File uploaded", - "data": { - "PatAttID": 99, - "Address": "/uploads/patients/1001/scan_id.pdf" - } -} -``` diff --git a/docs/clqms-wst-concept.md b/docs/clqms-wst-concept.md deleted file mode 100644 index bfddc8a..0000000 --- a/docs/clqms-wst-concept.md +++ /dev/null @@ -1,157 +0,0 @@ ---- -title: "Project Pandaria: Next-Gen LIS Architecture" -description: "An offline-first, event-driven architecture concept for the CLQMS." -date: 2025-12-19 -order: 6 -tags: - - posts - - clqms -layout: clqms-post.njk ---- - -## 1. 💀 Pain vs. 🛡️ Solution - -### 🚩 Problem 1: "The Server is Dead!" -> **The Pain:** When the internet cuts or the server crashes, the entire lab stops. Patients wait, doctors get angry. - -**🛡️ The Solution: "Offline-First Mode"** -The workstation keeps working 100% offline. It has a local brain (database). Patients never know the internet is down. - ---- - -### 🚩 Problem 2: "Data Vanished?" -> **The Pain:** We pushed data, the network blinked, and the sample disappeared. We have to re-scan manually. - -**🛡️ The Solution: "The Outbox Guarantee"** -Data is treated like Registered Mail. It stays in a safe SQL "Outbox" until the workstation signs a receipt (ACK) confirming it is saved. - ---- - -### 🚩 Problem 3: "Spaghetti Code" -> **The Pain:** Adding a new machine (like Mindray) means hacking the core LIS code with endless `if-else` statements. - -**🛡️ The Solution: "Universal Adapters"** -Every machine gets a simple plugin (Driver). The Core System stays clean, modular, and untouched. - ---- - -### 🚩 Problem 4: "Inconsistent Results" -> **The Pain:** One machine says `WBC`, another says `Leukocytes`. The Database is a mess of different codes. - -**🛡️ The Solution: "The Translator"** -A built-in dictionary auto-translates everything to Standard English (e.g., `WBC`) before it ever touches the database. - ---- - -## 2. 🏗️ System Architecture: The "Edge" Concept - -We are moving from a **Dependent** model (dumb terminal) to an **Empowered** model (Edge Computing). - -### The "Core" (Central Server) -* **Role:** The "Hippocampus" (Long-term Memory). -* **Stack:** CodeIgniter 4 + MySQL. -* **Responsibilities:** - * Billing & Financials (Single Source of Truth). - * Permanent Patient History. - * API Gateway for external apps (Mobile, Website). - * Administrator Dashboard. - -### The "Edge" (Smart Workstation) -* **Role:** The "Cortex" (Immediate Processing). -* **Stack:** Node.js (Electron) + SQLite. -* **Responsibilities:** - * **Hardware I/O:** Speaking directly to RS232/TCP ports. - * **Hot Caching:** Keeping the last 7 days of active orders locally. - * **Logic Engine:** Validating results against reference ranges *before* syncing. - -> **Key Difference:** The Workstation no longer asks "Can I work?" It assumes it can work. It treats the server as a "Sync Partner," not a "Master." If the internet dies, the Edge keeps processing samples, printing labels, and validating results without a hiccup. - ---- - -## 3. 🔌 The "Universal Adapter" (Hardware Layer) - -We use the **Adapter Design Pattern** to isolate hardware chaos from our clean business logic. - -### The Problem: "The Tower of Babel" -Every manufacturer speaks a proprietary dialect. -* **Sysmex:** Uses ASTM protocols with checksums. -* **Roche:** Uses custom HL7 variants. -* **Mindray:** Often uses raw hex streams. - -### The Fix: "Drivers as Plugins" -The Workstation loads a specific `.js` file (The Driver) for each connected machine. This driver has one job: **Normalization.** - -#### Example: ASTM to JSON -**Raw Input (Alien Language):** -`P|1||12345||Smith^John||19800101|M|||||` -`R|1|^^^WBC|10.5|10^3/uL|4.0-11.0|N||F||` - -**Normalized Output (clean JSON):** -```json -{ - "test_code": "WBC", - "value": 10.5, - "unit": "10^3/uL", - "flag": "Normal", - "timestamp": "2025-12-19T10:00:00Z" -} -``` - -### Benefit: "Hot-Swappable Labs" -Buying a new machine? You don't need to obscurely patch the `LISSender.exe`. You just drop in `driver-sysmex-xn1000.js` into the `plugins/` folder, and the Edge Workstation instantly learns how to speak Sysmex. - ---- - -## 4. 🗣️ The "Translator" (Data Mapping) - -Machines are stubborn. They send whatever test codes they want (`WBC`, `Leukocytes`, `W.B.C`, `White_Cells`). If we save these directly, our database becomes a swamp. - -### The Solution: "Local Dictionary & Rules Engine" -Before data is saved to SQLite, it passes through the **Translator**. - -1. **Alias Matching:** - * The dictionary knows that `W.B.C` coming from *Machine A* actually means `WBC_TOTAL`. - * It renames the key instantly. - -2. **Unit Conversion (Math Layer):** - * *Machine A* sends Hemoglobin in `g/dL` (e.g., 14.5). - * *Our Standard* is `g/L` (e.g., 145). - * **The Rule:** `Apply: Value * 10`. - * The translator automatically mathematical normalized the result. - -This ensures that our Analytics Dashboard sees **clean, comparable data** regardless of whether it came from a 10-year-old machine or a brand new one. - ---- - -## 5. 📨 The "Registered Mail" Sync (Redis + Outbox) - -We are banning the word "Polling" (checking every 5 seconds). It's inefficient and scary. We are switching to **Events** using **Redis**. - -### 🤔 What is Redis? -Think of **MySQL** as a filing cabinet (safe, permanent, but slow to open). -Think of **Redis** as a **loudspeaker system** (instant, in-memory, very fast). - -We use Redis specifically for its **Pub/Sub (Publish/Subscribe)** feature. It lets us "broadcast" a message to all connected workstations instantly without writing to a disk. - -### 🔄 How the Flow Works: - -1. **👨⚕️ Order Created:** The Doctor saves an order on the Server. -2. **📮 The Outbox:** The server puts a copy of the order in a special SQL table called `outbox_queue`. -3. **🔔 The Bell (Redis):** The server "shouts" into the Redis loudspeaker: *"New mail for Lab 1!"*. -4. **📥 Delivery:** The Workstation (listening to Redis) hears the shout instantly. It then goes to the SQL Outbox to download the actual heavy data. -5. **✍️ The Signature (ACK):** The Workstation sends a digital signature back: *"I have received and saved Order #123."* -6. **✅ Done:** Only *then* does the server delete the message from the Outbox. - -**Safety Net & Self-Healing:** -* **Redis is just the doorbell:** If the workstation is offline and misses the shout, it doesn't matter. -* **SQL is the mailbox:** The message sits safely in the `outbox_queue` table indefinitely. -* **Recovery:** When the Workstation turns back on, it automatically asks: *"Did I miss anything?"* and downloads all pending items from the SQL Outbox. **Zero data loss, even if the notification is lost.** - ---- - -## 6. 🏆 Summary: Why We Win - -* **Reliability:** 🛡️ 100% Uptime for the Lab. -* **Speed:** ⚡ Instant response times (Local Database is faster than Cloud). -* **Sanity:** 🧘 No more panic attacks when the internet provider fails. -* **Future Proof:** 🚀 Ready for any new machine connection in the future. diff --git a/docs/clqms-wst-database.md b/docs/clqms-wst-database.md deleted file mode 100644 index 39294aa..0000000 --- a/docs/clqms-wst-database.md +++ /dev/null @@ -1,432 +0,0 @@ ---- -title: "Edge Workstation: SQLite Database Schema" -description: "Database design for the offline-first smart workstation." -date: 2025-12-19 -order: 7 -tags: - - posts - - clqms - - database -layout: clqms-post.njk ---- - -## Overview - -This document describes the **SQLite database schema** for the Edge Workstation — the local "brain" that enables **100% offline operation** for lab technicians. - -> **Stack:** Node.js (Electron) + SQLite -> **Role:** The "Cortex" — Immediate Processing - ---- - -## 📊 Entity Relationship Diagram - -``` -┌─────────────┐ ┌──────────────┐ -│ orders │────────<│ order_tests │ -└─────────────┘ └──────────────┘ - │ - ▼ -┌─────────────┐ ┌──────────────┐ -│ machines │────────<│ results │ -└─────────────┘ └──────────────┘ - │ - ▼ -┌─────────────────┐ -│ test_dictionary │ (The Translator) -└─────────────────┘ - -┌───────────────┐ ┌───────────────┐ -│ outbox_queue │ │ inbox_queue │ -└───────────────┘ └───────────────┘ - (Push to Server) (Pull from Server) - -┌───────────────┐ ┌───────────────┐ -│ sync_log │ │ config │ -└───────────────┘ └───────────────┘ -``` - ---- - -## 🗂️ Table Definitions - -### 1. `orders` — Cached Patient Orders - -Orders downloaded from the Core Server. Keeps the **last 7 days** for offline processing. - -| Column | Type | Description | -|--------|------|-------------| -| `id` | INTEGER | Primary key (local) | -| `server_order_id` | TEXT | Original ID from Core Server | -| `patient_id` | TEXT | Patient identifier | -| `patient_name` | TEXT | Patient full name | -| `patient_dob` | DATE | Date of birth | -| `patient_gender` | TEXT | M, F, or O | -| `order_date` | DATETIME | When order was created | -| `priority` | TEXT | `stat`, `routine`, `urgent` | -| `status` | TEXT | `pending`, `in_progress`, `completed`, `cancelled` | -| `barcode` | TEXT | Sample barcode | -| `synced_at` | DATETIME | Last sync timestamp | - -```sql -CREATE TABLE orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_order_id TEXT UNIQUE NOT NULL, - patient_id TEXT NOT NULL, - patient_name TEXT NOT NULL, - patient_dob DATE, - patient_gender TEXT CHECK(patient_gender IN ('M', 'F', 'O')), - order_date DATETIME NOT NULL, - priority TEXT DEFAULT 'routine' CHECK(priority IN ('stat', 'routine', 'urgent')), - status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'cancelled')), - barcode TEXT, - notes TEXT, - synced_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); -``` - ---- - -### 2. `order_tests` — Requested Tests per Order - -Each order can have multiple tests (CBC, Urinalysis, etc.) - -| Column | Type | Description | -|--------|------|-------------| -| `id` | INTEGER | Primary key | -| `order_id` | INTEGER | FK to orders | -| `test_code` | TEXT | Standardized code (e.g., `WBC_TOTAL`) | -| `test_name` | TEXT | Display name | -| `status` | TEXT | `pending`, `processing`, `completed`, `failed` | - -```sql -CREATE TABLE order_tests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_id INTEGER NOT NULL, - test_code TEXT NOT NULL, - test_name TEXT NOT NULL, - status TEXT DEFAULT 'pending', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE -); -``` - ---- - -### 3. `results` — Machine Output (Normalized) - -Results from lab machines, **already translated** to standard format by The Translator. - -| Column | Type | Description | -|--------|------|-------------| -| `id` | INTEGER | Primary key | -| `order_test_id` | INTEGER | FK to order_tests | -| `machine_id` | INTEGER | FK to machines | -| `test_code` | TEXT | Standardized test code | -| `value` | REAL | Numeric result | -| `unit` | TEXT | Standardized unit | -| `flag` | TEXT | `L`, `N`, `H`, `LL`, `HH`, `A` | -| `raw_value` | TEXT | Original value from machine | -| `raw_unit` | TEXT | Original unit from machine | -| `raw_test_code` | TEXT | Original code before translation | -| `validated` | BOOLEAN | Has been reviewed by tech? | - -```sql -CREATE TABLE results ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_test_id INTEGER, - machine_id INTEGER, - test_code TEXT NOT NULL, - value REAL NOT NULL, - unit TEXT NOT NULL, - reference_low REAL, - reference_high REAL, - flag TEXT CHECK(flag IN ('L', 'N', 'H', 'LL', 'HH', 'A')), - raw_value TEXT, - raw_unit TEXT, - raw_test_code TEXT, - validated BOOLEAN DEFAULT 0, - validated_by TEXT, - validated_at DATETIME, - machine_timestamp DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (order_test_id) REFERENCES order_tests(id), - FOREIGN KEY (machine_id) REFERENCES machines(id) -); -``` - ---- - -### 4. `outbox_queue` — The Registered Mail 📮 - -Data waits here until the Core Server sends an **ACK (acknowledgment)**. This is the heart of our **zero data loss** guarantee. - -| Column | Type | Description | -|--------|------|-------------| -| `id` | INTEGER | Primary key | -| `event_type` | TEXT | `result_created`, `result_validated`, etc. | -| `payload` | TEXT | JSON data to sync | -| `target_entity` | TEXT | `results`, `orders`, etc. | -| `priority` | INTEGER | 1 = highest, 10 = lowest | -| `retry_count` | INTEGER | Number of failed attempts | -| `status` | TEXT | `pending`, `processing`, `sent`, `acked`, `failed` | -| `acked_at` | DATETIME | When server confirmed receipt | - -```sql -CREATE TABLE outbox_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event_type TEXT NOT NULL, - payload TEXT NOT NULL, - target_entity TEXT, - target_id INTEGER, - priority INTEGER DEFAULT 5, - retry_count INTEGER DEFAULT 0, - max_retries INTEGER DEFAULT 5, - last_error TEXT, - status TEXT DEFAULT 'pending', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - sent_at DATETIME, - acked_at DATETIME -); -``` - -> **Flow:** Data enters as `pending` → moves to `sent` when transmitted → becomes `acked` when server confirms → deleted after cleanup. - ---- - -### 5. `inbox_queue` — Messages from Server 📥 - -Incoming orders/updates from Core Server waiting to be processed locally. - -```sql -CREATE TABLE inbox_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_message_id TEXT UNIQUE NOT NULL, - event_type TEXT NOT NULL, - payload TEXT NOT NULL, - status TEXT DEFAULT 'pending', - error_message TEXT, - received_at DATETIME DEFAULT CURRENT_TIMESTAMP, - processed_at DATETIME -); -``` - ---- - -### 6. `machines` — Connected Lab Equipment 🔌 - -Registry of all connected analyzers. - -| Column | Type | Description | -|--------|------|-------------| -| `id` | INTEGER | Primary key | -| `name` | TEXT | "Sysmex XN-1000" | -| `driver_file` | TEXT | "driver-sysmex-xn1000.js" | -| `connection_type` | TEXT | `RS232`, `TCP`, `USB`, `FILE` | -| `connection_config` | TEXT | JSON config | - -```sql -CREATE TABLE machines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, - manufacturer TEXT, - model TEXT, - serial_number TEXT, - driver_file TEXT NOT NULL, - connection_type TEXT CHECK(connection_type IN ('RS232', 'TCP', 'USB', 'FILE')), - connection_config TEXT, - is_active BOOLEAN DEFAULT 1, - last_communication DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); -``` - -**Example config:** -```json -{ - "port": "COM3", - "baudRate": 9600, - "dataBits": 8, - "parity": "none" -} -``` - ---- - -### 7. `test_dictionary` — The Translator 📖 - -This table solves the **"WBC vs Leukocytes"** problem. It maps machine-specific codes to our standard codes. - -| Column | Type | Description | -|--------|------|-------------| -| `machine_id` | INTEGER | FK to machines (NULL = universal) | -| `raw_code` | TEXT | What machine sends: `W.B.C`, `Leukocytes` | -| `standard_code` | TEXT | Our standard: `WBC_TOTAL` | -| `unit_conversion_factor` | REAL | Math conversion (e.g., 10 for g/dL → g/L) | - -```sql -CREATE TABLE test_dictionary ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - machine_id INTEGER, - raw_code TEXT NOT NULL, - standard_code TEXT NOT NULL, - standard_name TEXT NOT NULL, - unit_conversion_factor REAL DEFAULT 1.0, - raw_unit TEXT, - standard_unit TEXT, - reference_low REAL, - reference_high REAL, - is_active BOOLEAN DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (machine_id) REFERENCES machines(id), - UNIQUE(machine_id, raw_code) -); -``` - -**Translation Example:** - -| Machine | Raw Code | Standard Code | Conversion | -|---------|----------|---------------|------------| -| Sysmex | `WBC` | `WBC_TOTAL` | × 1.0 | -| Mindray | `Leukocytes` | `WBC_TOTAL` | × 1.0 | -| Sysmex | `HGB` (g/dL) | `HGB` (g/L) | × 10 | -| Universal | `W.B.C` | `WBC_TOTAL` | × 1.0 | - ---- - -### 8. `sync_log` — Audit Trail 📜 - -Track all sync activities for debugging and recovery. - -```sql -CREATE TABLE sync_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - direction TEXT CHECK(direction IN ('push', 'pull')), - event_type TEXT NOT NULL, - entity_type TEXT, - entity_id INTEGER, - server_response_code INTEGER, - success BOOLEAN, - duration_ms INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); -``` - ---- - -### 9. `config` — Local Settings ⚙️ - -Key-value store for workstation-specific settings. - -```sql -CREATE TABLE config ( - key TEXT PRIMARY KEY, - value TEXT, - description TEXT, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); -``` - -**Default values:** - -| Key | Value | Description | -|-----|-------|-------------| -| `workstation_id` | `LAB-WS-001` | Unique identifier | -| `server_url` | `https://api.clqms.com` | Core Server endpoint | -| `cache_days` | `7` | Days to keep cached orders | -| `auto_validate` | `false` | Auto-validate normal results | - ---- - -## 🔄 How the Sync Works - -### Outbox Pattern (Push) - -``` -┌─────────────────┐ -│ Lab Result │ -│ Generated │ -└────────┬────────┘ - ▼ -┌─────────────────┐ -│ Save to SQLite │ -│ + Outbox │ -└────────┬────────┘ - ▼ -┌─────────────────┐ ┌─────────────────┐ -│ Send to Server │────>│ Core Server │ -└────────┬────────┘ └────────┬────────┘ - │ │ - │ ◄──── ACK ─────────┘ - ▼ -┌─────────────────┐ -│ Mark as 'acked' │ -│ in Outbox │ -└─────────────────┘ -``` - -### Self-Healing Recovery - -If the workstation was offline and missed Redis notifications: - -```javascript -// On startup, ask: "Did I miss anything?" -async function recoverMissedMessages() { - const lastSync = await db.get("SELECT value FROM config WHERE key = 'last_sync'"); - const missed = await api.get(`/outbox/pending?since=${lastSync}`); - - for (const message of missed) { - await inbox.insert(message); - } -} -``` - ---- - -## 📋 Sample Data - -### Sample Machine Registration - -```sql -INSERT INTO machines (name, manufacturer, driver_file, connection_type, connection_config) -VALUES ('Sysmex XN-1000', 'Sysmex', 'driver-sysmex-xn1000.js', 'RS232', - '{"port": "COM3", "baudRate": 9600}'); -``` - -### Sample Dictionary Entry - -```sql --- Mindray calls WBC "Leukocytes" — we translate it! -INSERT INTO test_dictionary (machine_id, raw_code, standard_code, standard_name, raw_unit, standard_unit) -VALUES (2, 'Leukocytes', 'WBC_TOTAL', 'White Blood Cell Count', 'x10^9/L', '10^3/uL'); -``` - -### Sample Result with Translation - -```sql --- Machine sent: { code: "Leukocytes", value: 8.5, unit: "x10^9/L" } --- After translation: -INSERT INTO results (test_code, value, unit, flag, raw_test_code, raw_value, raw_unit) -VALUES ('WBC_TOTAL', 8.5, '10^3/uL', 'N', 'Leukocytes', '8.5', 'x10^9/L'); -``` - ---- - -## 🏆 Key Benefits - -| Feature | Benefit | -|---------|---------| -| **Offline-First** | Lab never stops, even without internet | -| **Outbox Queue** | Zero data loss guarantee | -| **Test Dictionary** | Clean, standardized data from any machine | -| **Inbox Queue** | Never miss orders, even if offline | -| **Sync Log** | Full audit trail for debugging | - ---- - -## 📁 Full SQL Migration - -The complete SQL migration file is available at: -📄 [`docs/examples/edge_workstation.sql`](/docs/examples/edge_workstation.sql) diff --git a/docs/decisions/001-database-design-constraints.md b/docs/decisions/001-database-design-constraints.md deleted file mode 100644 index 436c24f..0000000 --- a/docs/decisions/001-database-design-constraints.md +++ /dev/null @@ -1,32 +0,0 @@ -# 001. Database Design Constraints - -Date: 2025-12-18 -Status: Accepted - -## Context -The database schema and relationship design for the CLQMS system were established by management and external stakeholders. The backend engineering team was brought in after the core data structure was finalized. - -The development team has identified potential challenges regarding: -- Normalization levels in specific tables. -- Naming conventions differ from standard framework defaults. -- Specific relationship structures that may impact query performance or data integrity. - -## Decision -The backend team will implement the application logic based on the provided database schema. Significant structural changes to the database (refactoring tables, altering core relationships) are out of scope for the current development phase unless explicitly approved by management. - -The team will: -1. Map application entities to the existing table structures. -2. Handle necessary data integrity and consistency checks within the Application Layer (Models/Services) where the database constraints are insufficient. -3. Document any workarounds required to bridge the gap between the schema and the application framework (CodeIgniter 4). - -## Consequences - -### Positive -- Development can proceed immediately without spending time on database redesign discussions. -- Alignment with the manager's initial vision and requirements. - -### Negative -- **Technical Debt**: Potential accumulation of "glue code" to make modern framework features work with the non-standard schema. -- **Maintainability**: Future developers may find the data model unintuitive if it deviates significantly from standard practices. -- **Performance**: Sub-optimal schema designs may require complex queries or application-side processing that impacts performance at scale. -- **Responsibility**: The backend team explicitly notes that issues arising directly from the inherent database structure (e.g., anomalies, scaling bottlenecks related to schema) are consequences of this design constraint. diff --git a/docs/examples/edge_workstation.sql b/docs/examples/edge_workstation.sql deleted file mode 100644 index 0732cb5..0000000 --- a/docs/examples/edge_workstation.sql +++ /dev/null @@ -1,254 +0,0 @@ --- ============================================================ --- 🖥️ CLQMS Edge Workstation - SQLite Database Schema --- Project Pandaria: Offline-First LIS Architecture --- ============================================================ --- This is the LOCAL database for each Smart Workstation. --- Stack: Node.js (Electron) + SQLite --- Role: "The Cortex" - Immediate Processing --- ============================================================ - --- 🔧 Enable foreign keys (SQLite needs this explicitly) -PRAGMA foreign_keys = ON; - --- ============================================================ --- 1. 📋 CACHED ORDERS (Hot Cache - Last 7 Days) --- ============================================================ --- Orders downloaded from the Core Server for local processing. --- Workstation can work 100% offline with this data. - -CREATE TABLE IF NOT EXISTS orders ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_order_id TEXT UNIQUE NOT NULL, -- Original ID from Core Server - patient_id TEXT NOT NULL, - patient_name TEXT NOT NULL, - patient_dob DATE, - patient_gender TEXT CHECK(patient_gender IN ('M', 'F', 'O')), - order_date DATETIME NOT NULL, - priority TEXT DEFAULT 'routine' CHECK(priority IN ('stat', 'routine', 'urgent')), - status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'in_progress', 'completed', 'cancelled')), - barcode TEXT, - notes TEXT, - synced_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_orders_barcode ON orders(barcode); -CREATE INDEX idx_orders_status ON orders(status); -CREATE INDEX idx_orders_patient ON orders(patient_id); - --- ============================================================ --- 2. 🔬 ORDER TESTS (What tests are requested?) --- ============================================================ --- Each order can have multiple tests (CBC, Urinalysis, etc.) - -CREATE TABLE IF NOT EXISTS order_tests ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_id INTEGER NOT NULL, - test_code TEXT NOT NULL, -- Standardized code (e.g., 'WBC_TOTAL') - test_name TEXT NOT NULL, -- Display name (e.g., 'White Blood Cell Count') - status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'completed', 'failed')), - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (order_id) REFERENCES orders(id) ON DELETE CASCADE -); - -CREATE INDEX idx_order_tests_order ON order_tests(order_id); -CREATE INDEX idx_order_tests_code ON order_tests(test_code); - --- ============================================================ --- 3. 📊 RESULTS (Machine Output - Normalized) --- ============================================================ --- Results from lab machines, already translated to standard format. - -CREATE TABLE IF NOT EXISTS results ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - order_test_id INTEGER, - machine_id INTEGER, - test_code TEXT NOT NULL, -- Standardized test code - value REAL NOT NULL, -- Numeric result - unit TEXT NOT NULL, -- Standardized unit - reference_low REAL, - reference_high REAL, - flag TEXT CHECK(flag IN ('L', 'N', 'H', 'LL', 'HH', 'A')), -- Low, Normal, High, Critical Low/High, Abnormal - raw_value TEXT, -- Original value from machine - raw_unit TEXT, -- Original unit from machine - raw_test_code TEXT, -- Original code from machine (before translation) - validated BOOLEAN DEFAULT 0, - validated_by TEXT, - validated_at DATETIME, - machine_timestamp DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (order_test_id) REFERENCES order_tests(id) ON DELETE SET NULL, - FOREIGN KEY (machine_id) REFERENCES machines(id) ON DELETE SET NULL -); - -CREATE INDEX idx_results_order_test ON results(order_test_id); -CREATE INDEX idx_results_test_code ON results(test_code); -CREATE INDEX idx_results_validated ON results(validated); - --- ============================================================ --- 4. 📮 OUTBOX QUEUE (Registered Mail Pattern) --- ============================================================ --- Data waits here until the Core Server confirms receipt (ACK). --- Zero data loss, even if network blinks! - -CREATE TABLE IF NOT EXISTS outbox_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - event_type TEXT NOT NULL, -- 'result_created', 'result_validated', 'order_updated' - payload TEXT NOT NULL, -- JSON data to sync - target_entity TEXT, -- 'results', 'orders', etc. - target_id INTEGER, -- ID of the record - priority INTEGER DEFAULT 5, -- 1 = highest, 10 = lowest - retry_count INTEGER DEFAULT 0, - max_retries INTEGER DEFAULT 5, - last_error TEXT, - status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'sent', 'acked', 'failed')), - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - sent_at DATETIME, - acked_at DATETIME -); - -CREATE INDEX idx_outbox_status ON outbox_queue(status); -CREATE INDEX idx_outbox_priority ON outbox_queue(priority, created_at); - --- ============================================================ --- 5. 📥 INBOX QUEUE (Messages from Server) --- ============================================================ --- Incoming messages/orders from Core Server waiting to be processed. - -CREATE TABLE IF NOT EXISTS inbox_queue ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - server_message_id TEXT UNIQUE NOT NULL, -- ID from server for deduplication - event_type TEXT NOT NULL, -- 'new_order', 'order_cancelled', 'config_update' - payload TEXT NOT NULL, -- JSON data - status TEXT DEFAULT 'pending' CHECK(status IN ('pending', 'processing', 'completed', 'failed')), - error_message TEXT, - received_at DATETIME DEFAULT CURRENT_TIMESTAMP, - processed_at DATETIME -); - -CREATE INDEX idx_inbox_status ON inbox_queue(status); - --- ============================================================ --- 6. 🔌 MACHINES (Connected Lab Equipment) --- ============================================================ --- Registry of connected machines/analyzers. - -CREATE TABLE IF NOT EXISTS machines ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - name TEXT NOT NULL, -- 'Sysmex XN-1000', 'Mindray BC-6800' - manufacturer TEXT, - model TEXT, - serial_number TEXT, - driver_file TEXT NOT NULL, -- 'driver-sysmex-xn1000.js' - connection_type TEXT CHECK(connection_type IN ('RS232', 'TCP', 'USB', 'FILE')), - connection_config TEXT, -- JSON: {"port": "COM3", "baudRate": 9600} - is_active BOOLEAN DEFAULT 1, - last_communication DATETIME, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - --- ============================================================ --- 7. 📖 TEST DICTIONARY (The Translator) --- ============================================================ --- Maps machine-specific codes to standard codes. --- Solves the "WBC vs Leukocytes" problem! - -CREATE TABLE IF NOT EXISTS test_dictionary ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - machine_id INTEGER, -- NULL = universal mapping - raw_code TEXT NOT NULL, -- What the machine sends: 'W.B.C', 'Leukocytes' - standard_code TEXT NOT NULL, -- Our standard: 'WBC_TOTAL' - standard_name TEXT NOT NULL, -- 'White Blood Cell Count' - unit_conversion_factor REAL DEFAULT 1.0, -- Multiply raw value by this (e.g., 10 for g/dL to g/L) - raw_unit TEXT, -- Unit machine sends - standard_unit TEXT, -- Our standard unit - reference_low REAL, - reference_high REAL, - is_active BOOLEAN DEFAULT 1, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (machine_id) REFERENCES machines(id) ON DELETE CASCADE, - UNIQUE(machine_id, raw_code) -); - -CREATE INDEX idx_dictionary_lookup ON test_dictionary(machine_id, raw_code); - --- ============================================================ --- 8. 📜 SYNC LOG (Audit Trail) --- ============================================================ --- Track all sync activities for debugging & recovery. - -CREATE TABLE IF NOT EXISTS sync_log ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - direction TEXT CHECK(direction IN ('push', 'pull')), - event_type TEXT NOT NULL, - entity_type TEXT, - entity_id INTEGER, - server_response_code INTEGER, - server_message TEXT, - success BOOLEAN, - duration_ms INTEGER, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - -CREATE INDEX idx_sync_log_created ON sync_log(created_at DESC); - --- ============================================================ --- 9. ⚙️ LOCAL CONFIG (Workstation Settings) --- ============================================================ --- Key-value store for workstation-specific settings. - -CREATE TABLE IF NOT EXISTS config ( - key TEXT PRIMARY KEY, - value TEXT, - description TEXT, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP -); - --- ============================================================ --- 📦 SAMPLE DATA: Machines & Dictionary --- ============================================================ - --- Sample Machines -INSERT INTO machines (name, manufacturer, model, driver_file, connection_type, connection_config) VALUES -('Sysmex XN-1000', 'Sysmex', 'XN-1000', 'driver-sysmex-xn1000.js', 'RS232', '{"port": "COM3", "baudRate": 9600}'), -('Mindray BC-6800', 'Mindray', 'BC-6800', 'driver-mindray-bc6800.js', 'TCP', '{"host": "192.168.1.50", "port": 5000}'); - --- Sample Test Dictionary (The Translator) -INSERT INTO test_dictionary (machine_id, raw_code, standard_code, standard_name, raw_unit, standard_unit, unit_conversion_factor, reference_low, reference_high) VALUES --- Sysmex mappings (machine_id = 1) -(1, 'WBC', 'WBC_TOTAL', 'White Blood Cell Count', '10^3/uL', '10^3/uL', 1.0, 4.0, 11.0), -(1, 'RBC', 'RBC_TOTAL', 'Red Blood Cell Count', '10^6/uL', '10^6/uL', 1.0, 4.5, 5.5), -(1, 'HGB', 'HGB', 'Hemoglobin', 'g/dL', 'g/L', 10.0, 120, 170), -(1, 'PLT', 'PLT_TOTAL', 'Platelet Count', '10^3/uL', '10^3/uL', 1.0, 150, 400), --- Mindray mappings (machine_id = 2) - Different naming! -(2, 'Leukocytes', 'WBC_TOTAL', 'White Blood Cell Count', 'x10^9/L', '10^3/uL', 1.0, 4.0, 11.0), -(2, 'Erythrocytes', 'RBC_TOTAL', 'Red Blood Cell Count', 'x10^12/L', '10^6/uL', 1.0, 4.5, 5.5), -(2, 'Hb', 'HGB', 'Hemoglobin', 'g/L', 'g/L', 1.0, 120, 170), -(2, 'Thrombocytes', 'PLT_TOTAL', 'Platelet Count', 'x10^9/L', '10^3/uL', 1.0, 150, 400), --- Universal mappings (machine_id = NULL) -(NULL, 'W.B.C', 'WBC_TOTAL', 'White Blood Cell Count', NULL, '10^3/uL', 1.0, 4.0, 11.0), -(NULL, 'White_Cells', 'WBC_TOTAL', 'White Blood Cell Count', NULL, '10^3/uL', 1.0, 4.0, 11.0); - --- Sample Config -INSERT INTO config (key, value, description) VALUES -('workstation_id', 'LAB-WS-001', 'Unique identifier for this workstation'), -('workstation_name', 'Hematology Station 1', 'Human-readable name'), -('server_url', 'https://clqms-core.example.com/api', 'Core Server API endpoint'), -('cache_days', '7', 'Number of days to keep cached orders'), -('auto_validate', 'false', 'Auto-validate results within normal range'), -('last_sync', NULL, 'Timestamp of last successful sync'); - --- Sample Order (for testing) -INSERT INTO orders (server_order_id, patient_id, patient_name, patient_dob, patient_gender, order_date, priority, barcode) VALUES -('ORD-2025-001234', 'PAT-00001', 'John Smith', '1980-01-15', 'M', '2025-12-19 08:00:00', 'routine', 'LAB2025001234'); - -INSERT INTO order_tests (order_id, test_code, test_name) VALUES -(1, 'WBC_TOTAL', 'White Blood Cell Count'), -(1, 'RBC_TOTAL', 'Red Blood Cell Count'), -(1, 'HGB', 'Hemoglobin'), -(1, 'PLT_TOTAL', 'Platelet Count'); - --- ============================================================ --- ✅ DONE! Your Edge Workstation database is ready. --- ============================================================ diff --git a/public/assets/css/app.css b/public/assets/css/app.css deleted file mode 100644 index 9c1afb3..0000000 --- a/public/assets/css/app.css +++ /dev/null @@ -1,524 +0,0 @@ -/* ======================================== - CLQMS Frontend - Fun & Light Theme - ======================================== */ - -/* ---------- CSS Variables ---------- */ -:root { - /* Fun Color Palette */ - --primary: #6366f1; /* Indigo */ - --primary-light: #818cf8; - --primary-dark: #4f46e5; - - --secondary: #f472b6; /* Pink */ - --secondary-light: #f9a8d4; - - --accent: #34d399; /* Emerald */ - --accent-light: #6ee7b7; - - --warning: #fbbf24; /* Amber */ - --danger: #f87171; /* Red */ - --info: #38bdf8; /* Sky */ - - /* Neutrals */ - --bg-primary: #fefefe; - --bg-secondary: #f8fafc; - --bg-card: #ffffff; - - --text-primary: #1e293b; - --text-secondary: #64748b; - --text-muted: #94a3b8; - - --border-color: #e2e8f0; - --border-radius: 16px; - --border-radius-sm: 10px; - --border-radius-lg: 24px; - - /* Shadows */ - --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); - --shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); - --shadow-md: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); - --shadow-lg: 0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1); - --shadow-glow: 0 0 40px -10px var(--primary); - - /* Transitions */ - --transition-fast: 150ms ease; - --transition-normal: 250ms ease; - --transition-slow: 350ms ease; -} - -/* ---------- Reset & Base ---------- */ -*, *::before, *::after { - box-sizing: border-box; - margin: 0; - padding: 0; -} - -html { - font-size: 16px; - scroll-behavior: smooth; -} - -body { - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: var(--bg-secondary); - color: var(--text-primary); - line-height: 1.6; - min-height: 100vh; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -/* ---------- Typography ---------- */ -h1, h2, h3, h4, h5, h6 { - font-weight: 700; - line-height: 1.2; - color: var(--text-primary); -} - -h1 { font-size: 2.5rem; } -h2 { font-size: 2rem; } -h3 { font-size: 1.5rem; } -h4 { font-size: 1.25rem; } - -p { - color: var(--text-secondary); -} - -a { - color: var(--primary); - text-decoration: none; - transition: color var(--transition-fast); -} - -a:hover { - color: var(--primary-dark); -} - -/* ---------- Fun Background Patterns ---------- */ -.bg-pattern { - background-color: var(--bg-secondary); - background-image: - radial-gradient(circle at 20% 80%, rgba(99, 102, 241, 0.08) 0%, transparent 50%), - radial-gradient(circle at 80% 20%, rgba(244, 114, 182, 0.08) 0%, transparent 50%), - radial-gradient(circle at 40% 40%, rgba(52, 211, 153, 0.06) 0%, transparent 40%); -} - -.bg-gradient-fun { - background: linear-gradient(135deg, - rgba(99, 102, 241, 0.1) 0%, - rgba(244, 114, 182, 0.1) 50%, - rgba(52, 211, 153, 0.1) 100%); -} - -/* ---------- Card Component ---------- */ -.card { - background: var(--bg-card); - border-radius: var(--border-radius); - box-shadow: var(--shadow-md); - padding: 2rem; - transition: transform var(--transition-normal), box-shadow var(--transition-normal); -} - -.card:hover { - transform: translateY(-2px); - box-shadow: var(--shadow-lg); -} - -.card-glass { - background: rgba(255, 255, 255, 0.8); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border: 1px solid rgba(255, 255, 255, 0.5); -} - -/* ---------- Form Elements ---------- */ -.form-group { - margin-bottom: 1.5rem; -} - -.form-label { - display: block; - font-size: 0.875rem; - font-weight: 600; - color: var(--text-primary); - margin-bottom: 0.5rem; -} - -.form-input { - width: 100%; - padding: 0.875rem 1rem; - font-size: 1rem; - font-family: inherit; - color: var(--text-primary); - background: var(--bg-secondary); - border: 2px solid var(--border-color); - border-radius: var(--border-radius-sm); - transition: all var(--transition-fast); - outline: none; -} - -.form-input:focus { - border-color: var(--primary); - background: var(--bg-card); - box-shadow: 0 0 0 4px rgba(99, 102, 241, 0.15); -} - -.form-input:hover:not(:focus) { - border-color: var(--text-muted); -} - -.form-input::placeholder { - color: var(--text-muted); -} - -.form-input-icon { - position: relative; -} - -.form-input-icon .icon { - position: absolute; - left: 1rem; - top: 50%; - transform: translateY(-50%); - color: var(--text-muted); - pointer-events: none; - transition: color var(--transition-fast); -} - -.form-input-icon .form-input { - padding-left: 2.75rem; -} - -.form-input-icon:focus-within .icon { - color: var(--primary); -} - -/* Password toggle */ -.password-toggle { - position: absolute; - right: 1rem; - top: 50%; - transform: translateY(-50%); - background: none; - border: none; - color: var(--text-muted); - cursor: pointer; - padding: 0.25rem; - transition: color var(--transition-fast); -} - -.password-toggle:hover { - color: var(--primary); -} - -/* ---------- Buttons ---------- */ -.btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - padding: 0.875rem 1.5rem; - font-size: 1rem; - font-weight: 600; - font-family: inherit; - border: none; - border-radius: var(--border-radius-sm); - cursor: pointer; - transition: all var(--transition-fast); - outline: none; - text-decoration: none; -} - -.btn:disabled { - opacity: 0.6; - cursor: not-allowed; -} - -.btn-primary { - background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%); - color: white; - box-shadow: 0 4px 14px -3px rgba(99, 102, 241, 0.5); -} - -.btn-primary:hover:not(:disabled) { - transform: translateY(-2px); - box-shadow: 0 6px 20px -3px rgba(99, 102, 241, 0.6); -} - -.btn-primary:active:not(:disabled) { - transform: translateY(0); -} - -.btn-secondary { - background: var(--bg-secondary); - color: var(--text-primary); - border: 2px solid var(--border-color); -} - -.btn-secondary:hover:not(:disabled) { - background: var(--bg-card); - border-color: var(--primary); - color: var(--primary); -} - -.btn-ghost { - background: transparent; - color: var(--text-secondary); -} - -.btn-ghost:hover { - background: var(--bg-secondary); - color: var(--primary); -} - -.btn-block { - width: 100%; -} - -.btn-lg { - padding: 1rem 2rem; - font-size: 1.125rem; - border-radius: var(--border-radius); -} - -/* ---------- Checkbox ---------- */ -.checkbox-wrapper { - display: flex; - align-items: center; - gap: 0.75rem; - cursor: pointer; -} - -.checkbox-input { - width: 1.25rem; - height: 1.25rem; - accent-color: var(--primary); - cursor: pointer; -} - -.checkbox-label { - font-size: 0.875rem; - color: var(--text-secondary); - user-select: none; -} - -/* ---------- Alerts ---------- */ -.alert { - padding: 1rem 1.25rem; - border-radius: var(--border-radius-sm); - font-size: 0.875rem; - font-weight: 500; - display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 1.5rem; -} - -.alert-error { - background: rgba(248, 113, 113, 0.15); - color: #dc2626; - border: 1px solid rgba(248, 113, 113, 0.3); -} - -.alert-success { - background: rgba(52, 211, 153, 0.15); - color: #059669; - border: 1px solid rgba(52, 211, 153, 0.3); -} - -.alert-info { - background: rgba(56, 189, 248, 0.15); - color: #0284c7; - border: 1px solid rgba(56, 189, 248, 0.3); -} - -/* ---------- Spinner ---------- */ -.spinner { - width: 1.25rem; - height: 1.25rem; - border: 2px solid rgba(255, 255, 255, 0.3); - border-top-color: white; - border-radius: 50%; - animation: spin 0.8s linear infinite; -} - -@keyframes spin { - to { transform: rotate(360deg); } -} - -/* ---------- Login Page Specific ---------- */ -.login-container { - min-height: 100vh; - display: flex; - align-items: center; - justify-content: center; - padding: 2rem; -} - -.login-card { - width: 100%; - max-width: 420px; -} - -.login-header { - text-align: center; - margin-bottom: 2rem; -} - -.login-logo { - width: 80px; - height: 80px; - margin: 0 auto 1.5rem; - background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); - border-radius: 20px; - display: flex; - align-items: center; - justify-content: center; - font-size: 2rem; - color: white; - box-shadow: 0 10px 30px -10px rgba(99, 102, 241, 0.5); - animation: float 3s ease-in-out infinite; -} - -@keyframes float { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-10px); } -} - -.login-title { - font-size: 1.75rem; - margin-bottom: 0.5rem; - background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -.login-subtitle { - color: var(--text-muted); - font-size: 0.95rem; -} - -.login-footer { - text-align: center; - margin-top: 2rem; - padding-top: 1.5rem; - border-top: 1px solid var(--border-color); -} - -.login-footer p { - font-size: 0.875rem; -} - -/* ---------- Decorative Elements ---------- */ -.floating-shapes { - position: fixed; - inset: 0; - pointer-events: none; - overflow: hidden; - z-index: -1; -} - -.shape { - position: absolute; - border-radius: 50%; - opacity: 0.5; - animation: floatShape 20s ease-in-out infinite; -} - -.shape-1 { - width: 300px; - height: 300px; - background: linear-gradient(135deg, rgba(99, 102, 241, 0.15) 0%, transparent 70%); - top: -100px; - right: -100px; - animation-delay: 0s; -} - -.shape-2 { - width: 400px; - height: 400px; - background: linear-gradient(135deg, rgba(244, 114, 182, 0.12) 0%, transparent 70%); - bottom: -150px; - left: -150px; - animation-delay: -7s; -} - -.shape-3 { - width: 200px; - height: 200px; - background: linear-gradient(135deg, rgba(52, 211, 153, 0.12) 0%, transparent 70%); - top: 40%; - left: 10%; - animation-delay: -14s; -} - -@keyframes floatShape { - 0%, 100% { transform: translate(0, 0) rotate(0deg); } - 25% { transform: translate(20px, -30px) rotate(5deg); } - 50% { transform: translate(-10px, 20px) rotate(-5deg); } - 75% { transform: translate(30px, 10px) rotate(3deg); } -} - -/* ---------- Utilities ---------- */ -.text-center { text-align: center; } -.text-muted { color: var(--text-muted); } -.text-primary { color: var(--primary); } -.text-sm { font-size: 0.875rem; } -.mt-1 { margin-top: 0.25rem; } -.mt-2 { margin-top: 0.5rem; } -.mt-4 { margin-top: 1rem; } -.mb-2 { margin-bottom: 0.5rem; } -.mb-4 { margin-bottom: 1rem; } - -.flex { display: flex; } -.items-center { align-items: center; } -.justify-between { justify-content: space-between; } -.gap-2 { gap: 0.5rem; } - -/* ---------- Animations ---------- */ -.fade-in { - animation: fadeIn 0.5s ease-out; -} - -@keyframes fadeIn { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } -} - -.shake { - animation: shake 0.5s ease-in-out; -} - -@keyframes shake { - 0%, 100% { transform: translateX(0); } - 20%, 60% { transform: translateX(-5px); } - 40%, 80% { transform: translateX(5px); } -} - -/* ---------- Responsive ---------- */ -@media (max-width: 480px) { - .login-container { - padding: 1rem; - } - - .card { - padding: 1.5rem; - } - - .login-logo { - width: 64px; - height: 64px; - font-size: 1.5rem; - } - - .login-title { - font-size: 1.5rem; - } -} diff --git a/public/assets/js/app.js b/public/assets/js/app.js deleted file mode 100644 index 56cc2b5..0000000 --- a/public/assets/js/app.js +++ /dev/null @@ -1,186 +0,0 @@ -/** - * CLQMS Frontend - Global Alpine.js Components & Utilities - */ - -// Wait for Alpine to be ready -document.addEventListener('alpine:init', () => { - - /** - * Global Auth Store - * Manages authentication state across the app - */ - Alpine.store('auth', { - user: null, - isAuthenticated: false, - - setUser(userData) { - this.user = userData; - this.isAuthenticated = !!userData; - }, - - clearUser() { - this.user = null; - this.isAuthenticated = false; - } - }); - - /** - * Toast Notification Store - */ - Alpine.store('toast', { - messages: [], - - show(message, type = 'info', duration = 4000) { - const id = Date.now(); - this.messages.push({ id, message, type }); - - setTimeout(() => { - this.dismiss(id); - }, duration); - }, - - dismiss(id) { - this.messages = this.messages.filter(m => m.id !== id); - }, - - success(message) { this.show(message, 'success'); }, - error(message) { this.show(message, 'error', 6000); }, - info(message) { this.show(message, 'info'); } - }); - - /** - * Login Component - */ - Alpine.data('loginForm', () => ({ - username: '', - password: '', - rememberMe: false, - showPassword: false, - isLoading: false, - error: null, - - async submitLogin() { - // Reset error - this.error = null; - - // Validation - if (!this.username.trim()) { - this.error = 'Please enter your username'; - this.shakeForm(); - return; - } - - if (!this.password) { - this.error = 'Please enter your password'; - this.shakeForm(); - return; - } - - // Start loading - this.isLoading = true; - - try { - const response = await fetch('/api/auth/login', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - credentials: 'include', // Important for cookies - body: JSON.stringify({ - username: this.username.trim(), - password: this.password - }) - }); - - const data = await response.json(); - - if (response.ok && data.status === 'success') { - // Store user data - Alpine.store('auth').setUser(data.data); - - // Show success feedback - Alpine.store('toast').success('Login successful! Redirecting...'); - - // Redirect to dashboard - setTimeout(() => { - window.location.href = '/dashboard'; - }, 500); - } else { - // Handle error - this.error = data.message || 'Invalid username or password'; - this.shakeForm(); - } - } catch (err) { - console.error('Login error:', err); - this.error = 'Connection error. Please try again.'; - this.shakeForm(); - } finally { - this.isLoading = false; - } - }, - - shakeForm() { - const form = this.$refs.loginCard; - if (form) { - form.classList.add('shake'); - setTimeout(() => form.classList.remove('shake'), 500); - } - }, - - togglePassword() { - this.showPassword = !this.showPassword; - } - })); - -}); - -/** - * Utility Functions - */ -const Utils = { - // Format date to locale string - formatDate(dateString) { - return new Date(dateString).toLocaleDateString('id-ID', { - year: 'numeric', - month: 'short', - day: 'numeric' - }); - }, - - // Debounce function - debounce(func, wait) { - let timeout; - return function executedFunction(...args) { - const later = () => { - clearTimeout(timeout); - func(...args); - }; - clearTimeout(timeout); - timeout = setTimeout(later, wait); - }; - }, - - // API helper with credentials - async api(endpoint, options = {}) { - const defaultOptions = { - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json' - }, - credentials: 'include' - }; - - const response = await fetch(endpoint, { ...defaultOptions, ...options }); - const data = await response.json(); - - if (!response.ok) { - throw new Error(data.message || 'API request failed'); - } - - return data; - } -}; - -// Expose Utils globally -window.Utils = Utils; diff --git a/public/clqms01.sql.gz b/public/clqms01.sql.gz deleted file mode 100644 index 8e65911..0000000 Binary files a/public/clqms01.sql.gz and /dev/null differ diff --git a/public/css/v2/styles.css b/public/css/v2/styles.css new file mode 100644 index 0000000..427dac0 --- /dev/null +++ b/public/css/v2/styles.css @@ -0,0 +1,932 @@ +/** + * CLQMS V2 - Custom Tailwind Design System + * Premium glassmorphism & modern aesthetics + */ + +/* ============================================ + CSS VARIABLES - DESIGN TOKENS + ============================================ */ +:root { + /* Primary Colors */ + --color-primary: 30 64 175; + /* Blue 800 */ + --color-primary-hover: 30 58 138; + /* Blue 900 */ + --color-primary-light: 59 130 246; + /* Blue 500 */ + + /* Secondary Colors */ + --color-secondary: 29 78 216; + /* Blue 700 */ + --color-secondary-hover: 30 64 175; + /* Blue 800 */ + + /* Semantic Colors */ + --color-success: 16 185 129; + /* Emerald 500 */ + --color-warning: 245 158 11; + /* Amber 500 */ + --color-error: 239 68 68; + /* Red 500 */ + --color-info: 14 165 233; + /* Sky 500 */ + + /* Neutral Colors - Light Theme */ + --color-text: 15 23 42; + /* Slate 900 */ + --color-text-muted: 100 116 139; + /* Slate 500 */ + --color-bg: 248 250 252; + /* Slate 50 */ + --color-surface: 255 255 255; + /* White */ + --color-border: 226 232 240; + /* Slate 200 */ + + /* Shadows */ + --shadow-sm: 0 1px 2px 0 rgb(0 0 0 / 0.05); + --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1); + --shadow-lg: 0 10px 15px -3px rgb(0 0 0 / 0.1); + --shadow-xl: 0 20px 25px -5px rgb(0 0 0 / 0.1); + --shadow-2xl: 0 25px 50px -12px rgb(0 0 0 / 0.25); + + /* Border Radius - Less rounded for modern aesthetic */ + --radius-sm: 0.375rem; + --radius-md: 0.625rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; + + /* Transitions */ + --transition-fast: 150ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-base: 200ms cubic-bezier(0.4, 0, 0.2, 1); + --transition-slow: 300ms cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Dark Theme Variables */ +[data-theme="dark"] { + --color-text: 248 250 252; + /* Slate 50 */ + --color-text-muted: 148 163 184; + /* Slate 400 */ + --color-bg: 15 23 42; + /* Slate 900 */ + --color-surface: 30 41 59; + /* Slate 800 */ + --color-border: 51 65 85; + /* Slate 700 */ +} + +/* ============================================ + BASE STYLES + ============================================ */ +* { + box-sizing: border-box; +} + +body { + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background-color: rgb(var(--color-bg)); + color: rgb(var(--color-text)); + transition: background-color var(--transition-base), color var(--transition-base); +} + +/* Smooth transitions for theme switching */ +* { + transition-property: background-color, border-color, color, fill, stroke; + transition-duration: var(--transition-base); + transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); +} + +/* Remove transitions for transforms and opacity (performance) */ +*:where(:not(:has(> *))) { + transition-property: background-color, border-color, color, fill, stroke, opacity, transform; +} + +/* Custom Scrollbar */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: rgb(var(--color-bg)); +} + +::-webkit-scrollbar-thumb { + background: rgb(var(--color-border)); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: rgb(var(--color-text-muted)); +} + +/* ============================================ + UTILITY CLASSES + ============================================ */ + +/* Alpine.js cloak */ +[x-cloak] { + display: none !important; +} + +/* Glass Effect */ +.glass { + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.2); +} + +[data-theme="dark"] .glass { + background: rgba(30, 41, 59, 0.8); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +/* ============================================ + BUTTONS + ============================================ */ + +/* Base Button */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.625rem 1.25rem; + font-size: 0.875rem; + font-weight: 600; + line-height: 1.25rem; + border-radius: var(--radius-md); + cursor: pointer; + transition: all var(--transition-base); + border: none; + outline: none; + white-space: nowrap; + user-select: none; +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; +} + +/* Primary Button */ +.btn-primary { + background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary))); + color: white; + box-shadow: 0 4px 14px rgba(var(--color-primary), 0.4); +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(var(--color-primary), 0.5); +} + +.btn-primary:active:not(:disabled) { + transform: translateY(0); +} + +/* Secondary Button */ +.btn-secondary { + background: rgb(var(--color-secondary)); + color: white; + box-shadow: 0 4px 14px rgba(var(--color-secondary), 0.4); +} + +.btn-secondary:hover:not(:disabled) { + background: rgb(var(--color-secondary-hover)); + transform: translateY(-2px); +} + +/* Outline Buttons */ +.btn-outline { + background: transparent; + border: 2px solid rgb(var(--color-primary)); + color: rgb(var(--color-primary)); +} + +.btn-outline:hover:not(:disabled) { + background: rgb(var(--color-primary)); + color: white; +} + +.btn-outline-secondary { + border-color: rgb(var(--color-secondary)); + color: rgb(var(--color-secondary)); +} + +.btn-outline-accent { + border-color: rgb(var(--color-info)); + color: rgb(var(--color-info)); +} + +.btn-outline-info { + border-color: rgb(var(--color-info)); + color: rgb(var(--color-info)); +} + +/* Ghost Button */ +.btn-ghost { + background: transparent; + color: rgb(var(--color-text)); +} + +.btn-ghost:hover:not(:disabled) { + background: rgba(var(--color-text), 0.05); +} + +[data-theme="dark"] .btn-ghost:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); +} + +/* Button Sizes */ +.btn-sm { + padding: 0.375rem 0.875rem; + font-size: 0.8125rem; +} + +.btn-xs { + padding: 0.25rem 0.625rem; + font-size: 0.75rem; +} + +.btn-lg { + padding: 0.875rem 1.75rem; + font-size: 1rem; +} + +/* Button Shapes */ +.btn-square { + padding: 0.625rem; + aspect-ratio: 1; +} + +.btn-circle { + padding: 0.625rem; + aspect-ratio: 1; + border-radius: 9999px; +} + +/* ============================================ + CARDS + ============================================ */ + +.card { + background: rgb(var(--color-surface)); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-md); + border: 1px solid rgb(var(--color-border) / 0.5); + overflow: hidden; + transition: all var(--transition-base); +} + +.card:hover { + box-shadow: var(--shadow-lg); +} + +/* Glass Card */ +.card-glass { + background: rgba(255, 255, 255, 0.8); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border: 1px solid rgba(255, 255, 255, 0.2); + box-shadow: var(--shadow-xl); + border-radius: var(--radius-lg); +} + +[data-theme="dark"] .card-glass { + background: rgba(30, 41, 59, 0.8); + border: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Card with gradient border */ +.card-gradient { + position: relative; + background: rgb(var(--color-surface)); + border: none; +} + +.card-gradient::before { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--radius-lg); + padding: 1px; + background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary))); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + mask-composite: exclude; +} + +/* Input with icon wrapper */ +.input-icon-wrapper { + position: relative; +} + +.input-icon-wrapper .input-icon { + position: absolute; + left: 0.75rem; + top: 50%; + transform: translateY(-50%); + pointer-events: none; + opacity: 0.5; +} + +.input-icon-wrapper .input { + padding-left: 2.5rem; +} + +/* Input with left icon */ +.input-with-icon-left { + padding-left: 2.5rem; +} + +/* Input with right icon */ +.input-with-icon-right { + padding-right: 2.5rem; +} + +/* ============================================ + INPUTS & FORMS + ============================================ */ + +.input, +.select, +.textarea { + width: 100%; + padding: 0.75rem 1rem; + font-size: 0.875rem; + line-height: 1.5; + color: rgb(var(--color-text)); + background-color: rgb(var(--color-surface)); + border: 1px solid rgb(var(--color-border)); + border-radius: var(--radius-md); + transition: all var(--transition-base); + outline: none; + height: auto; + min-height: 42px; +} + +/* Input with left icon - increased padding for icon */ +.input.input-with-icon, +.input-with-icon.input { + padding-left: 2.75rem; +} + +.input:focus, +.select:focus, +.textarea:focus { + border-color: rgb(var(--color-primary)); + box-shadow: 0 0 0 3px rgba(var(--color-primary), 0.15); + background-color: rgb(var(--color-surface)); +} + +.input:disabled, +.select:disabled, +.textarea:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* Input with error */ +.input-error { + border-color: rgb(var(--color-error)); +} + +.input-error:focus { + box-shadow: 0 0 0 3px rgba(var(--color-error), 0.15); +} + +/* Input Sizes */ +.input-sm { + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; + min-height: 34px; +} + +.input-xs { + padding: 0.25rem 0.5rem; + font-size: 0.75rem; + min-height: 26px; +} + +/* Checkbox */ +.checkbox { + width: 1.25rem; + height: 1.25rem; + border: 2px solid rgb(var(--color-border)); + border-radius: var(--radius-sm); + cursor: pointer; + transition: all var(--transition-base); + appearance: none; + background-color: rgb(var(--color-surface)); +} + +.checkbox:checked { + background-color: rgb(var(--color-primary)); + border-color: rgb(var(--color-primary)); + background-image: url("data:image/svg+xml,%3csvg viewBox='0 0 16 16' fill='white' xmlns='http://www.w3.org/2000/svg'%3e%3cpath d='M12.207 4.793a1 1 0 010 1.414l-5 5a1 1 0 01-1.414 0l-2-2a1 1 0 011.414-1.414L6.5 9.086l4.293-4.293a1 1 0 011.414 0z'/%3e%3c/svg%3e"); +} + +.checkbox-sm { + width: 1rem; + height: 1rem; +} + +/* Label */ +.label { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.5rem 0; +} + +.label-text { + font-size: 0.875rem; + color: rgb(var(--color-text)); +} + +.label-text-alt { + font-size: 0.75rem; + color: rgb(var(--color-text-muted)); +} + +/* ============================================ + TABLES + ============================================ */ + +.table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 0.875rem; +} + +.table thead { + background: rgb(var(--color-bg)); + border-bottom: 1px solid rgb(var(--color-border)); +} + +.table th { + padding: 0.5rem 0.75rem; + text-align: left; + font-weight: 600; + color: rgb(var(--color-text-muted)); + text-transform: uppercase; + font-size: 0.75rem; + letter-spacing: 0.05em; +} + +.table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid rgb(var(--color-border) / 0.5); +} + +.table tbody tr { + transition: background-color var(--transition-fast); +} + +.table tbody tr:hover { + background: rgb(var(--color-bg) / 0.5); +} + +.table tbody tr:last-child td { + border-bottom: none; +} + +/* Compact Table Variant */ +.table.table-compact th, +.table.table-compact td { + padding: 0.25rem 0.5rem; + font-size: 0.8125rem; +} + +.table.table-compact .badge { + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; +} + +/* ============================================ + BADGES + ============================================ */ + +.badge { + display: inline-flex; + align-items: center; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.75rem; + font-weight: 600; + line-height: 1rem; +} + +.badge-primary { + background: rgba(var(--color-primary), 0.15); + color: rgb(var(--color-primary)); +} + +.badge-secondary { + background: rgba(var(--color-secondary), 0.15); + color: rgb(var(--color-secondary)); +} + +.badge-success { + background: rgba(var(--color-success), 0.15); + color: rgb(var(--color-success)); +} + +.badge-warning { + background: rgba(var(--color-warning), 0.15); + color: rgb(var(--color-warning)); +} + +.badge-error { + background: rgba(var(--color-error), 0.15); + color: rgb(var(--color-error)); +} + +.badge-info { + background: rgba(var(--color-info), 0.15); + color: rgb(var(--color-info)); +} + +.badge-ghost { + background: rgba(var(--color-text), 0.1); + color: rgb(var(--color-text)); +} + +.badge-sm { + padding: 0.125rem 0.5rem; + font-size: 0.6875rem; +} + +/* ============================================ + ALERTS + ============================================ */ + +.alert { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1rem; + border-radius: var(--radius-md); + font-size: 0.875rem; +} + +.alert-success { + background: rgba(var(--color-success), 0.1); + color: rgb(var(--color-success)); + border: 1px solid rgba(var(--color-success), 0.3); +} + +.alert-error { + background: rgba(var(--color-error), 0.1); + color: rgb(var(--color-error)); + border: 1px solid rgba(var(--color-error), 0.3); +} + +.alert-warning { + background: rgba(var(--color-warning), 0.1); + color: rgb(var(--color-warning)); + border: 1px solid rgba(var(--color-warning), 0.3); +} + +.alert-info { + background: rgba(var(--color-info), 0.1); + color: rgb(var(--color-info)); + border: 1px solid rgba(var(--color-info), 0.3); +} + +/* ============================================ + MODALS + ============================================ */ + +.modal-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + padding: 1rem; +} + +.modal-content { + background: rgb(var(--color-surface)); + border-radius: var(--radius-xl); + box-shadow: var(--shadow-2xl); + max-width: 56rem; + width: 100%; + max-height: 90vh; + overflow-y: auto; + animation: modalEnter var(--transition-slow) ease-out; +} + +@keyframes modalEnter { + from { + opacity: 0; + transform: scale(0.95) translateY(10px); + } + + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +/* ============================================ + LOADING SPINNER + ============================================ */ + +.spinner { + display: inline-block; + width: 1.25rem; + height: 1.25rem; + border: 2px solid rgba(var(--color-primary), 0.3); + border-top-color: rgb(var(--color-primary)); + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +.spinner-sm { + width: 1rem; + height: 1rem; + border-width: 2px; +} + +.spinner-lg { + width: 2rem; + height: 2rem; + border-width: 3px; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +/* ============================================ + AVATAR + ============================================ */ + +.avatar { + display: inline-flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.avatar-circle { + border-radius: 9999px; +} + +.avatar-rounded { + border-radius: var(--radius-md); +} + +/* ============================================ + DIVIDER + ============================================ */ + +.divider { + display: flex; + align-items: center; + gap: 1rem; + margin: 1.5rem 0; + color: rgb(var(--color-text-muted)); + font-size: 0.875rem; + font-weight: 500; +} + +.divider::before, +.divider::after { + content: ''; + flex: 1; + height: 1px; + background: rgb(var(--color-border)); +} + +/* ============================================ + DROPDOWN + ============================================ */ + +.dropdown { + position: relative; + display: inline-block; +} + +.dropdown-content { + position: absolute; + background: rgb(var(--color-surface)); + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + border: 1px solid rgb(var(--color-border)); + padding: 0.5rem; + min-width: 12rem; + z-index: 50; + animation: dropdownEnter var(--transition-fast) ease-out; +} + +.dropdown-end .dropdown-content { + right: 0; +} + +@keyframes dropdownEnter { + from { + opacity: 0; + transform: translateY(-4px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} + +/* ============================================ + MENU / NAVIGATION + ============================================ */ + +.menu { + display: flex; + flex-direction: column; + gap: 0.25rem; + list-style: none; + padding: 0; + margin: 0; +} + +.menu li { + display: block; +} + +.menu a, +.menu button { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + color: rgb(var(--color-text)); + text-decoration: none; + transition: all var(--transition-fast); + cursor: pointer; + border: none; + background: transparent; + width: 100%; + text-align: left; + font-size: 0.875rem; +} + +.menu a:hover, +.menu button:hover { + background: rgb(var(--color-bg)); +} + +.menu a.active { + background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary))); + color: white; + box-shadow: 0 4px 12px rgba(var(--color-primary), 0.4); +} + +.menu-sm a, +.menu-sm button { + padding: 0.5rem 0.75rem; + font-size: 0.8125rem; +} + +/* ============================================ + SIDEBAR + ============================================ */ + +.sidebar { + background: linear-gradient(180deg, rgb(30 41 59), rgb(15 23 42)); + color: rgba(255, 255, 255, 0.9); + transition: width var(--transition-slow), transform var(--transition-slow); +} + +[data-theme="dark"] .sidebar { + background: linear-gradient(180deg, rgb(15 23 42), rgb(0 0 0)); +} + +.sidebar .menu a, +.sidebar .menu button { + color: rgba(255, 255, 255, 0.7); +} + +.sidebar .menu a:hover, +.sidebar .menu button:hover { + background: rgba(255, 255, 255, 0.1); + color: white; +} + +.sidebar .menu a.active { + background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary))); + color: white; +} + +/* ============================================ + ANIMATIONS + ============================================ */ + +@keyframes fadeIn { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +@keyframes slideInRight { + from { + transform: translateX(100%); + } + + to { + transform: translateX(0); + } +} + +@keyframes slideInLeft { + from { + transform: translateX(-100%); + } + + to { + transform: translateX(0); + } +} + +@keyframes slideInUp { + from { + transform: translateY(100%); + } + + to { + transform: translateY(0); + } +} + +@keyframes pulse { + + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.5; + } +} + +.animate-fadeIn { + animation: fadeIn var(--transition-base) ease-out; +} + +.animate-slideInRight { + animation: slideInRight var(--transition-slow) ease-out; +} + +.animate-slideInLeft { + animation: slideInLeft var(--transition-slow) ease-out; +} + +.animate-slideInUp { + animation: slideInUp var(--transition-slow) ease-out; +} + +.animate-pulse { + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +/* ============================================ + UTILITY CLASSES + ============================================ */ + +.text-gradient { + background: linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary))); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.shadow-glow { + box-shadow: 0 0 20px rgba(var(--color-primary), 0.3); +} + +.border-gradient { + border: 2px solid transparent; + background-image: linear-gradient(rgb(var(--color-surface)), rgb(var(--color-surface))), + linear-gradient(135deg, rgb(var(--color-primary)), rgb(var(--color-secondary))); + background-origin: border-box; + background-clip: padding-box, border-box; +} \ No newline at end of file diff --git a/public/upload/meow.jpg b/public/upload/meow.jpg deleted file mode 100644 index f28151a..0000000 Binary files a/public/upload/meow.jpg and /dev/null differ diff --git a/public/upload/panda.jpg b/public/upload/panda.jpg deleted file mode 100644 index 000b33c..0000000 Binary files a/public/upload/panda.jpg and /dev/null differ diff --git a/tests/README.md b/tests/README.md index fc40e44..74eb717 100644 --- a/tests/README.md +++ b/tests/README.md @@ -116,3 +116,16 @@ Be sure to modify the test case (or create your own) to point to your seed and m and include any additional steps to be run before tests in the `setUp()` method. See [Testing Your Database](https://codeigniter.com/user_guide/testing/database.html) for details. + +## Uniform Show Endpoints Tests + +A specific test suite has been created to ensure all `show` endpoints return a uniform structure (a single object or `null` in the `data` field). + +To run this specific test: +```console +> vendor\bin\phpunit tests/feature/UniformShowTest.php +``` + +This test verifies: +1. **Uniformity**: All `/api/.../{id}` endpoints return a single associative array (object) in the `data` field when found. +2. **Null State**: All endpoints return `null` in the `data` field when the record is not found, rather than an empty array or missing key. diff --git a/tests/UniformShowTest_README.md b/tests/UniformShowTest_README.md new file mode 100644 index 0000000..97ed7c0 --- /dev/null +++ b/tests/UniformShowTest_README.md @@ -0,0 +1,33 @@ +# Uniform Show Endpoints Testing + +This document describes the testing strategy and execution for ensuring all API `show` endpoints follow a uniform response structure. + +## Requirement + +All `show` endpoints (e.g., `/api/location/1`, `/api/patient/1`, etc.) must return: +1. **A single Object** (associative array) in the `data` field when a record is found. +2. **Null** in the `data` field when a record is not found. + +Previously, some endpoints returned a sequential array with one item, which was inconsistent for frontend development. + +## Test Suite + +The test suite is located at: `tests/feature/UniformShowTest.php` + +### What it tests: +- **`testShowEndpointsReturnObjectWhenFound`**: Iterates through a list of known endpoints and verifies that if data is returned, it is an associative array (object) and not a sequential array. +- **`testShowEndpointsReturnNullWhenNotFound`**: Iterates through endpoints with non-existent IDs and verifies that the `data` field is strictly `null`. + +## How to Run + +Ensure your test database is configured in `phpunit.xml` or `.env`. + +Run the following command from the project root: + +```bash +vendor/bin/phpunit tests/feature/UniformShowTest.php +``` + +## Maintenance + +If you add a new resource with a `show` method, add its endpoint to the `$endpoints` array in `tests/feature/UniformShowTest.php` to ensure it remains compliant with the uniformity requirement. diff --git a/tests/_support/v2/MasterTestCase.php b/tests/_support/v2/MasterTestCase.php new file mode 100644 index 0000000..5b33d69 --- /dev/null +++ b/tests/_support/v2/MasterTestCase.php @@ -0,0 +1,325 @@ +token = $this->generateTestToken(); + } + + /** + * Cleanup after test + */ + protected function tearDown(): void + { + parent::tearDown(); + } + + /** + * Generate JWT token for testing + */ + protected function generateTestToken(): string + { + $key = getenv('JWT_SECRET') ?: 'my-secret-key'; + $payload = [ + 'iss' => 'localhost', + 'aud' => 'localhost', + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 3600, + 'uid' => 1, + 'email' => 'admin@admin.com' + ]; + return JWT::encode($payload, $key, 'HS256'); + } + + /** + * Make authenticated GET request + */ + protected function get(string $path, array $options = []) + { + $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]); + return $this->call('get', $path, $options); + } + + /** + * Make authenticated POST request + */ + protected function post(string $path, array $options = []) + { + $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]); + return $this->call('post', $path, $options); + } + + /** + * Make authenticated PUT request + */ + protected function put(string $path, array $options = []) + { + $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]); + return $this->call('put', $path, $options); + } + + /** + * Make authenticated DELETE request + */ + protected function delete(string $path, array $options = []) + { + $this->withHeaders(['Authorization' => 'Bearer ' . $this->token]); + return $this->call('delete', $path, $options); + } + + /** + * Create a TEST type test definition + */ + protected function createTestData(): array + { + return [ + 'SiteID' => 1, + 'TestSiteCode' => $this->testSiteCode, + 'TestSiteName' => 'Test Definition ' . time(), + 'TestType' => self::TEST_TYPE_TEST, + 'Description' => 'Test description', + 'SeqScr' => 10, + 'SeqRpt' => 10, + 'IndentLeft' => 0, + 'VisibleScr' => 1, + 'VisibleRpt' => 1, + 'CountStat' => 1, + 'details' => [ + 'DisciplineID' => 1, + 'DepartmentID' => 1, + 'ResultType' => 1, // Numeric + 'RefType' => 1, // NMRC + 'Unit1' => 'mg/dL', + 'Decimal' => 2, + 'Method' => 'Test Method', + 'ExpectedTAT' => 60 + ], + 'testmap' => [ + [ + 'HostType' => 'HIS', + 'HostID' => 'TEST001', + 'HostTestCode' => 'TEST001', + 'HostTestName' => 'Test (HIS)' + ] + ] + ]; + } + + /** + * Create a PARAM type test definition + */ + protected function createParamData(): array + { + return [ + 'SiteID' => 1, + 'TestSiteCode' => 'PARM' . substr(time(), -4), + 'TestSiteName' => 'Parameter Test ' . time(), + 'TestType' => self::TEST_TYPE_PARAM, + 'Description' => 'Parameter test description', + 'SeqScr' => 5, + 'SeqRpt' => 5, + 'VisibleScr' => 1, + 'VisibleRpt' => 1, + 'CountStat' => 1, + 'details' => [ + 'DisciplineID' => 1, + 'DepartmentID' => 1, + 'ResultType' => 1, + 'RefType' => 1, + 'Unit1' => 'unit', + 'Decimal' => 1, + 'Method' => 'Parameter Method' + ] + ]; + } + + /** + * Create a GROUP type test definition with members + */ + protected function createGroupData(array $memberIds = []): array + { + return [ + 'SiteID' => 1, + 'TestSiteCode' => 'GRUP' . substr(time(), -4), + 'TestSiteName' => 'Group Test ' . time(), + 'TestType' => self::TEST_TYPE_GROUP, + 'Description' => 'Group test description', + 'SeqScr' => 100, + 'SeqRpt' => 100, + 'VisibleScr' => 1, + 'VisibleRpt' => 1, + 'CountStat' => 1, + 'Members' => $memberIds ?: [1, 2], + 'testmap' => [ + [ + 'HostType' => 'LIS', + 'HostID' => 'LIS001', + 'HostTestCode' => 'PANEL', + 'HostTestName' => 'Test Panel (LIS)' + ] + ] + ]; + } + + /** + * Create a CALC type test definition + */ + protected function createCalcData(): array + { + return [ + 'SiteID' => 1, + 'TestSiteCode' => 'CALC' . substr(time(), -4), + 'TestSiteName' => 'Calculated Test ' . time(), + 'TestType' => self::TEST_TYPE_CALC, + 'Description' => 'Calculated test description', + 'SeqScr' => 50, + 'SeqRpt' => 50, + 'VisibleScr' => 1, + 'VisibleRpt' => 1, + 'CountStat' => 1, + 'details' => [ + 'DisciplineID' => 1, + 'DepartmentID' => 1, + 'FormulaInput' => '["TEST1", "TEST2"]', + 'FormulaCode' => 'TEST1 + TEST2', + 'FormulaLang' => 'SQL', + 'RefType' => 1, + 'Unit1' => 'mg/dL', + 'Decimal' => 0, + 'Method' => 'Calculation Method' + ], + 'testmap' => [ + [ + 'HostType' => 'LIS', + 'HostID' => 'LIS001', + 'HostTestCode' => 'CALCR', + 'HostTestName' => 'Calculated Result (LIS)' + ] + ] + ]; + } + + /** + * Assert API response has success status + */ + protected function assertSuccessResponse($response, string $message = 'Response should be successful'): void + { + $body = json_decode($response->response()->getBody(), true); + $this->assertArrayHasKey('status', $body, $message); + $this->assertEquals('success', $body['status'], $message); + } + + /** + * Assert API response has error status + */ + protected function assertErrorResponse($response, string $message = 'Response should be an error'): void + { + $body = json_decode($response->response()->getBody(), true); + $this->assertArrayHasKey('status', $body, $message); + $this->assertNotEquals('success', $body['status'], $message); + } + + /** + * Assert response has data key + */ + protected function assertHasData($response, string $message = 'Response should have data'): void + { + $body = json_decode($response->response()->getBody(), true); + $this->assertArrayHasKey('data', $body, $message); + } + + /** + * Get test type name from VID + */ + protected function getTestTypeName(int $vid): string + { + return match ($vid) { + self::TEST_TYPE_TEST => 'TEST', + self::TEST_TYPE_PARAM => 'PARAM', + self::TEST_TYPE_CALC => 'CALC', + self::TEST_TYPE_GROUP => 'GROUP', + self::TEST_TYPE_TITLE => 'TITLE', + default => 'UNKNOWN' + }; + } + + /** + * Skip test if database not available + */ + protected function requireDatabase(): void + { + $db = \Config\Database::connect(); + try { + $db->connect(); + } catch (\Exception $e) { + $this->markTestSkipped('Database not available: ' . $e->getMessage()); + } + } + + /** + * Skip test if required seeded data not found + */ + protected function requireSeededData(): void + { + $db = \Config\Database::connect(); + $count = $db->table('valueset') + ->where('VSetID', self::VALUESET_TEST_TYPE) + ->countAllResults(); + + if ($count === 0) { + $this->markTestSkipped('Test type valuesets not seeded'); + } + } +} diff --git a/tests/feature/SimpleTest.php b/tests/feature/SimpleTest.php new file mode 100644 index 0000000..e45c606 --- /dev/null +++ b/tests/feature/SimpleTest.php @@ -0,0 +1,16 @@ +assertTrue(true); + } +} diff --git a/tests/feature/TestDef/TestDefSiteTest.php b/tests/feature/TestDef/TestDefSiteTest.php new file mode 100644 index 0000000..98441f1 --- /dev/null +++ b/tests/feature/TestDef/TestDefSiteTest.php @@ -0,0 +1,374 @@ +withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get('api/tests'); + + $result->assertStatus(200); + $result->assertJSONExact([ + 'status' => 'success', + 'message' => 'Data fetched successfully', + 'data' => $result->getJSON(true)['data'] + ]); + } + + /** + * Test listing all tests returns array + */ + public function testIndexReturnsArray(): void + { + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get('api/tests'); + + $result->assertStatus(200); + $response = $result->getJSON(true); + $this->assertIsArray($response['data']); + } + + /** + * Test index contains test type information + */ + public function testIndexContainsTypeInformation(): void + { + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get('api/tests'); + + $result->assertStatus(200); + $response = $result->getJSON(true); + + if (!empty($response['data'])) { + $test = $response['data'][0]; + $this->assertArrayHasKey('TypeCode', $test); + $this->assertArrayHasKey('TypeName', $test); + } + } + + /** + * Test filtering by test type + */ + public function testIndexFiltersByTestType(): void + { + // Test filtering by TEST type (VID = 1) + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get('api/tests?TestType=1'); + + $result->assertStatus(200); + $response = $result->getJSON(true); + + foreach ($response['data'] as $test) { + $this->assertEquals('TEST', $test['TypeCode']); + } + } + + /** + * Test filtering by keyword + */ + public function testIndexFiltersByKeyword(): void + { + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get('api/tests?TestSiteName=HB'); + + $result->assertStatus(200); + $response = $result->getJSON(true); + + if (!empty($response['data'])) { + foreach ($response['data'] as $test) { + $this->assertStringContainsString('HB', $test['TestSiteName']); + } + } + } + + /** + * Test showing single test returns success + */ + public function testShowReturnsSuccess(): void + { + // Get a test ID from the seeder data + $model = new TestDefSiteModel(); + $test = $model->first(); + + if ($test) { + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get("api/tests/{$test['TestSiteID']}"); + + $result->assertStatus(200); + $response = $result->getJSON(true); + $this->assertArrayHasKey('data', $response); + } else { + $this->markTestSkipped('No test data available'); + } + } + + /** + * Test showing single test includes type-specific details for TEST type + */ + public function testShowIncludesTechDetailsForTestType(): void + { + $model = new TestDefSiteModel(); + $test = $model->first(); + + if ($test && $test['TypeCode'] === 'TEST') { + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get("api/tests/{$test['TestSiteID']}"); + + $result->assertStatus(200); + $response = $result->getJSON(true); + $this->assertArrayHasKey('testdeftech', $response['data']); + } else { + $this->markTestSkipped('No TEST type data available'); + } + } + + /** + * Test showing single test includes type-specific details for CALC type + */ + public function testShowIncludesCalcDetailsForCalcType(): void + { + $model = new TestDefSiteModel(); + $test = $model->first(); + + if ($test && $test['TypeCode'] === 'CALC') { + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get("api/tests/{$test['TestSiteID']}"); + + $result->assertStatus(200); + $response = $result->getJSON(true); + $this->assertArrayHasKey('testdefcal', $response['data']); + } else { + $this->markTestSkipped('No CALC type data available'); + } + } + + /** + * Test showing single test includes type-specific details for GROUP type + */ + public function testShowIncludesGrpDetailsForGroupType(): void + { + $model = new TestDefSiteModel(); + $test = $model->first(); + + if ($test && $test['TypeCode'] === 'GROUP') { + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get("api/tests/{$test['TestSiteID']}"); + + $result->assertStatus(200); + $response = $result->getJSON(true); + $this->assertArrayHasKey('testdefgrp', $response['data']); + } else { + $this->markTestSkipped('No GROUP type data available'); + } + } + + /** + * Test creating a new test + */ + public function testCreateTest(): void + { + $testData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'NEWTEST', + 'TestSiteName' => 'New Test', + 'TestType' => 1, // TEST type + 'Description' => 'Test description', + 'SeqScr' => 100, + 'SeqRpt' => 100, + 'VisibleScr' => 1, + 'VisibleRpt' => 1, + 'CountStat' => 1 + ]; + + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->post('api/tests', $testData); + + $result->assertStatus(201); + $response = $result->getJSON(true); + $this->assertArrayHasKey('data', $response); + $this->assertArrayHasKey('TestSiteId', $response['data']); + } + + /** + * Test creating test with validation error (missing required fields) + */ + public function testCreateTestValidationError(): void + { + $testData = [ + 'SiteID' => 1 + // Missing required fields + ]; + + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->post('api/tests', $testData); + + $result->assertStatus(400); + } + + /** + * Test updating a test + */ + public function testUpdateTest(): void + { + $model = new TestDefSiteModel(); + $test = $model->first(); + + if ($test) { + $updateData = [ + 'TestSiteID' => $test['TestSiteID'], + 'TestSiteName' => 'Updated Test Name', + 'Description' => 'Updated description' + ]; + + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->patch('api/tests', $updateData); + + $result->assertStatus(200); + $response = $result->getJSON(true); + $this->assertEquals('success', $response['status']); + } else { + $this->markTestSkipped('No test data available'); + } + } + + /** + * Test deleting a test (soft delete) + */ + public function testDeleteTest(): void + { + $model = new TestDefSiteModel(); + $test = $model->first(); + + if ($test) { + $deleteData = [ + 'TestSiteID' => $test['TestSiteID'] + ]; + + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->delete('api/tests', $deleteData); + + $result->assertStatus(200); + $response = $result->getJSON(true); + $this->assertEquals('success', $response['status']); + $this->assertArrayHasKey('EndDate', $response['data']); + } else { + $this->markTestSkipped('No test data available'); + } + } + + /** + * Test getting non-existent test returns empty data + */ + public function testShowNonExistentTest(): void + { + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get('api/tests/999999'); + + $result->assertStatus(200); + $response = $result->getJSON(true); + $this->assertNull($response['data']); + } + + /** + * Test test types are correctly mapped from valueset + */ + public function testTestTypesAreMapped(): void + { + $model = new TestDefSiteModel(); + $tests = $model->findAll(); + + $validTypes = ['TEST', 'PARAM', 'CALC', 'GROUP', 'TITLE']; + + foreach ($tests as $test) { + if (isset($test['TypeCode'])) { + $this->assertContains($test['TypeCode'], $validTypes); + } + } + } + + /** + * Test filtering by visible on screen + */ + public function testIndexFiltersByVisibleScr(): void + { + $result = $this->withHeaders([ + 'Content-Type' => 'application/json', + 'Accept' => 'application/json' + ])->get('api/tests?VisibleScr=1'); + + $result->assertStatus(200); + $response = $result->getJSON(true); + + foreach ($response['data'] as $test) { + $this->assertEquals(1, $test['VisibleScr']); + } + } + + /** + * Test all test types from seeder are present + */ + public function testAllTestTypesArePresent(): void + { + $model = new TestDefSiteModel(); + $tests = $model->findAll(); + + $typeCodes = array_column($tests, 'TypeCode'); + $uniqueTypes = array_unique($typeCodes); + + // Check that we have at least TEST and PARAM types from seeder + $this->assertContains('TEST', $uniqueTypes); + $this->assertContains('PARAM', $uniqueTypes); + $this->assertContains('CALC', $uniqueTypes); + $this->assertContains('GROUP', $uniqueTypes); + } +} diff --git a/tests/feature/UniformShowTest.php b/tests/feature/UniformShowTest.php new file mode 100644 index 0000000..4016199 --- /dev/null +++ b/tests/feature/UniformShowTest.php @@ -0,0 +1,111 @@ + 'localhost', + 'aud' => 'localhost', + 'iat' => time(), + 'nbf' => time(), + 'exp' => time() + 3600, + 'uid' => 1, + 'email' => 'admin@admin.com' + ]; + $this->token = \Firebase\JWT\JWT::encode($payload, $key, 'HS256'); + } + + // Override get to inject cookie header + public function get(string $path, array $options = []) { + $this->withHeaders(['Cookie' => 'token=' . $this->token]); + return $this->call('get', $path, $options); + } + + /** + * Test that show endpoints return a single object (associative array) in 'data' when found. + */ + public function testShowEndpointsReturnObjectWhenFound() + { + // representative endpoints. + $endpoints = [ + 'api/location', + 'api/organization/site', + 'api/organization/account', + 'api/patient', + 'api/tests', + 'api/specimen/containerdef', + 'api/contact', + ]; + + foreach ($endpoints as $url) { + // We first check index to get a valid ID if possible + $indexResult = $this->get($url); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + $id = 1; // logical default + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $firstItem = $indexBody['data'][0]; + // Try to guess ID key + $idKeys = ['LocationID', 'SiteID', 'AccountID', 'InternalPID', 'TestSiteID', 'ConDefID', 'ContactID', 'VID', 'id']; + foreach ($idKeys as $key) { + if (isset($firstItem[$key])) { + $id = $firstItem[$key]; + break; + } + } + } + + $showUrl = $url . '/' . $id; + $result = $this->get($showUrl); + $body = json_decode($result->response()->getBody(), true); + + if ($result->response()->getStatusCode() === 200 && isset($body['data']) && $body['data'] !== null) { + $this->assertTrue( + $this->is_assoc($body['data']), + "Endpoint $showUrl should return an object in 'data', but got a sequential array or empty array. Body: " . $result->response()->getBody() + ); + } + } + } + + public function testShowEndpointsReturnNullWhenNotFound() + { + $endpoints = [ + 'api/location/9999999', + 'api/organization/site/9999999', + 'api/patient/9999999', + ]; + + foreach ($endpoints as $url) { + $result = $this->get($url); + $result->assertStatus(200); + $body = json_decode($result->response()->getBody(), true); + + $this->assertArrayHasKey('data', $body, "Endpoint $url missing 'data' key. Body: " . $result->response()->getBody()); + $this->assertNull($body['data'], "Endpoint $url should return null in 'data' when not found. Body: " . $result->response()->getBody()); + } + } + + /** + * Helper to check if array is associative. + */ + private function is_assoc(array $arr) + { + if (array() === $arr) return false; + return array_keys($arr) !== range(0, count($arr) - 1); + } +} diff --git a/tests/feature/v2/master/TestDef/TestDefCalcTest.php b/tests/feature/v2/master/TestDef/TestDefCalcTest.php new file mode 100644 index 0000000..985b216 --- /dev/null +++ b/tests/feature/v2/master/TestDef/TestDefCalcTest.php @@ -0,0 +1,328 @@ + 1, + 'TestSiteCode' => 'CALC' . substr(time(), -4), + 'TestSiteName' => 'Calculated Test ' . time(), + 'TestType' => $this::TEST_TYPE_CALC, + 'Description' => 'Calculated test with formula', + 'SeqScr' => 50, + 'SeqRpt' => 50, + 'VisibleScr' => 1, + 'VisibleRpt' => 1, + 'CountStat' => 1, + 'details' => [ + 'DisciplineID' => 1, + 'DepartmentID' => 1, + 'FormulaInput' => '["CHOL", "HDL", "TG"]', + 'FormulaCode' => 'CHOL - HDL - (TG / 5)', + 'FormulaLang' => 'SQL', + 'RefType' => 1, // NMRC + 'Unit1' => 'mg/dL', + 'Decimal' => 0, + 'Method' => 'Friedewald Formula' + ] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($calcData)]); + + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + + if ($status === 201) { + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('created', $body['status']); + + // Verify calc details were created + $calcId = $body['data']['TestSiteId']; + $showResult = $this->get($this->endpoint . '/' . $calcId); + $showBody = json_decode($showResult->response()->getBody(), true); + + if ($showBody['data'] !== null) { + $this->assertArrayHasKey('testdefcal', $showBody['data']); + } + } + } + + /** + * Test CALC with different formula languages + */ + public function testCalcWithDifferentFormulaLanguages(): void + { + $languages = ['Phyton', 'CQL', 'FHIRP', 'SQL']; + + foreach ($languages as $lang) { + $calcData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'C' . substr(time(), -5) . strtoupper(substr($lang, 0, 1)), + 'TestSiteName' => "Calc with $lang", + 'TestType' => $this::TEST_TYPE_CALC, + 'details' => [ + 'FormulaInput' => '["TEST1"]', + 'FormulaCode' => 'TEST1 * 2', + 'FormulaLang' => $lang + ] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($calcData)]); + $status = $result->response()->getStatusCode(); + + $this->assertTrue( + in_array($status, [201, 400, 500]), + "CALC with $lang: Expected 201, 400, or 500, got $status" + ); + } + } + + /** + * Test CALC with JSON formula input + */ + public function testCalcWithJsonFormulaInput(): void + { + $calcData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'CJSN' . substr(time(), -3), + 'TestSiteName' => 'Calc with JSON Input', + 'TestType' => $this::TEST_TYPE_CALC, + 'details' => [ + 'FormulaInput' => '["parameter1", "parameter2", "parameter3"]', + 'FormulaCode' => '(param1 + param2) / param3', + 'FormulaLang' => 'FHIRP' + ] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($calcData)]); + $status = $result->response()->getStatusCode(); + + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + } + + /** + * Test CALC with complex formula + */ + public function testCalcWithComplexFormula(): void + { + $calcData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'CCMP' . substr(time(), -3), + 'TestSiteName' => 'Calc with Complex Formula', + 'TestType' => $this::TEST_TYPE_CALC, + 'details' => [ + 'FormulaInput' => '["WBC", "NEUT", "LYMPH", "MONO", "EOS", "BASO"]', + 'FormulaCode' => 'if WBC > 0 then (NEUT + LYMPH + MONO + EOS + BASO) / WBC * 100 else 0', + 'FormulaLang' => 'Phyton' + ] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($calcData)]); + $status = $result->response()->getStatusCode(); + + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + } + + /** + * Test update CALC formula + */ + public function testUpdateCalcFormula(): void + { + // Create a CALC first + $calcData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'UPCL' . substr(time(), -4), + 'TestSiteName' => 'Update Calc Test', + 'TestType' => $this::TEST_TYPE_CALC, + 'details' => [ + 'FormulaInput' => '["A", "B"]', + 'FormulaCode' => 'A + B', + 'FormulaLang' => 'SQL' + ] + ]; + + $createResult = $this->post($this->endpoint, ['body' => json_encode($calcData)]); + $createStatus = $createResult->response()->getStatusCode(); + + if ($createStatus === 201) { + $createBody = json_decode($createResult->response()->getBody(), true); + $calcId = $createBody['data']['TestSiteId'] ?? null; + + if ($calcId) { + // Update formula + $updateData = [ + 'TestSiteName' => 'Updated Calc Test Name', + 'details' => [ + 'FormulaInput' => '["A", "B", "C"]', + 'FormulaCode' => 'A + B + C' + ] + ]; + + $updateResult = $this->put($this->endpoint . '/' . $calcId, ['body' => json_encode($updateData)]); + $updateStatus = $updateResult->response()->getStatusCode(); + + $this->assertTrue( + in_array($updateStatus, [200, 400, 500]), + "Expected 200, 400, or 500, got $updateStatus" + ); + } + } + } + + /** + * Test CALC has correct TypeCode in response + */ + public function testCalcTypeCodeInResponse(): void + { + $indexResult = $this->get($this->endpoint . '?TestType=CALC'); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $calc = $indexBody['data'][0]; + + // Verify TypeCode is CALC + $this->assertEquals('CALC', $calc['TypeCode'] ?? ''); + } + } + + /** + * Test CALC details structure + */ + public function testCalcDetailsStructure(): void + { + $indexResult = $this->get($this->endpoint . '?TestType=CALC'); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $calc = $indexBody['data'][0]; + $calcId = $calc['TestSiteID'] ?? null; + + if ($calcId) { + $showResult = $this->get($this->endpoint . '/' . $calcId); + $showBody = json_decode($showResult->response()->getBody(), true); + + if ($showBody['data'] !== null && isset($showBody['data']['testdefcal'])) { + $calcDetails = $showBody['data']['testdefcal']; + + if (is_array($calcDetails) && !empty($calcDetails)) { + $firstDetail = $calcDetails[0]; + + // Check required fields in calc structure + $this->assertArrayHasKey('TestCalID', $firstDetail); + $this->assertArrayHasKey('TestSiteID', $firstDetail); + $this->assertArrayHasKey('FormulaInput', $firstDetail); + $this->assertArrayHasKey('FormulaCode', $firstDetail); + + // Check for joined discipline/department + if (isset($firstDetail['DisciplineName'])) { + $this->assertArrayHasKey('DepartmentName', $firstDetail); + } + } + } + } + } + } + + /** + * Test CALC delete cascades to details + */ + public function testCalcDeleteCascadesToDetails(): void + { + // Create a CALC + $calcData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'CDEL' . substr(time(), -4), + 'TestSiteName' => 'Calc to Delete', + 'TestType' => $this::TEST_TYPE_CALC, + 'details' => [ + 'FormulaInput' => '["TEST1"]', + 'FormulaCode' => 'TEST1 * 2' + ] + ]; + + $createResult = $this->post($this->endpoint, ['body' => json_encode($calcData)]); + $createStatus = $createResult->response()->getStatusCode(); + + if ($createStatus === 201) { + $createBody = json_decode($createResult->response()->getBody(), true); + $calcId = $createBody['data']['TestSiteId'] ?? null; + + if ($calcId) { + // Delete the CALC + $deleteResult = $this->delete($this->endpoint . '/' . $calcId); + $deleteStatus = $deleteResult->response()->getStatusCode(); + + $this->assertTrue( + in_array($deleteStatus, [200, 404, 500]), + "Expected 200, 404, or 500, got $deleteStatus" + ); + + if ($deleteStatus === 200) { + // Verify CALC details are also soft deleted + $showResult = $this->get($this->endpoint . '/' . $calcId); + $showBody = json_decode($showResult->response()->getBody(), true); + + // CALC should show EndDate set + if ($showBody['data'] !== null) { + $this->assertNotNull($showBody['data']['EndDate']); + } + } + } + } + } + + /** + * Test CALC with result unit configuration + */ + public function testCalcWithResultUnit(): void + { + $units = ['mg/dL', 'g/L', 'mmol/L', '%', 'IU/L']; + + foreach ($units as $unit) { + $calcData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'CUNT' . substr(time(), -3) . substr($unit, 0, 1), + 'TestSiteName' => "Calc with $unit", + 'TestType' => $this::TEST_TYPE_CALC, + 'details' => [ + 'Unit1' => $unit, + 'Decimal' => 2, + 'FormulaInput' => '["TEST1"]', + 'FormulaCode' => 'TEST1' + ] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($calcData)]); + $status = $result->response()->getStatusCode(); + + $this->assertTrue( + in_array($status, [201, 400, 500]), + "CALC with unit $unit: Expected 201, 400, or 500, got $status" + ); + } + } +} diff --git a/tests/feature/v2/master/TestDef/TestDefGroupTest.php b/tests/feature/v2/master/TestDef/TestDefGroupTest.php new file mode 100644 index 0000000..81d70bc --- /dev/null +++ b/tests/feature/v2/master/TestDef/TestDefGroupTest.php @@ -0,0 +1,291 @@ +getExistingTestIds(); + + $groupData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'GRUP' . substr(time(), -4), + 'TestSiteName' => 'Test Group ' . time(), + 'TestType' => $this::TEST_TYPE_GROUP, + 'Description' => 'Group test with members', + 'SeqScr' => 100, + 'SeqRpt' => 100, + 'VisibleScr' => 1, + 'VisibleRpt' => 1, + 'CountStat' => 1, + 'Members' => $memberIds + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($groupData)]); + + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + + if ($status === 201) { + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('created', $body['status']); + + // Verify members were created + $groupId = $body['data']['TestSiteId']; + $showResult = $this->get($this->endpoint . '/' . $groupId); + $showBody = json_decode($showResult->response()->getBody(), true); + + if ($showBody['data'] !== null) { + $this->assertArrayHasKey('testdefgrp', $showBody['data']); + } + } + } + + /** + * Test create GROUP without members + */ + public function testCreateGroupWithoutMembers(): void + { + $groupData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'GREM' . substr(time(), -4), + 'TestSiteName' => 'Empty Group ' . time(), + 'TestType' => $this::TEST_TYPE_GROUP, + 'Members' => [] // Empty members + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($groupData)]); + + // Should still succeed but with warning or empty members + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [201, 400]), + "Expected 201 or 400, got $status" + ); + } + + /** + * Test update GROUP members + */ + public function testUpdateGroupMembers(): void + { + // Create a group first + $memberIds = $this->getExistingTestIds(); + $groupData = $this->createGroupData($memberIds); + $groupData['TestSiteCode'] = 'UPMB' . substr(time(), -4); + + $createResult = $this->post($this->endpoint, ['body' => json_encode($groupData)]); + $createStatus = $createResult->response()->getStatusCode(); + + if ($createStatus === 201) { + $createBody = json_decode($createResult->response()->getBody(), true); + $groupId = $createBody['data']['TestSiteId'] ?? null; + + if ($groupId) { + // Update with new members + $updateData = [ + 'Members' => array_slice($memberIds, 0, 1) // Only one member + ]; + + $updateResult = $this->put($this->endpoint . '/' . $groupId, ['body' => json_encode($updateData)]); + $updateStatus = $updateResult->response()->getStatusCode(); + + $this->assertTrue( + in_array($updateStatus, [200, 400, 500]), + "Expected 200, 400, or 500, got $updateStatus" + ); + } + } + } + + /** + * Test add member to existing GROUP + */ + public function testAddMemberToGroup(): void + { + // Get existing test + $indexResult = $this->get($this->endpoint . '?TestType=GROUP'); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $group = $indexBody['data'][0]; + $groupId = $group['TestSiteID'] ?? null; + + if ($groupId) { + // Get a test ID to add + $testIds = $this->getExistingTestIds(); + $newMemberId = $testIds[0] ?? 1; + + $updateData = [ + 'Members' => [$newMemberId] + ]; + + $result = $this->put($this->endpoint . '/' . $groupId, ['body' => json_encode($updateData)]); + $status = $result->response()->getStatusCode(); + + $this->assertTrue( + in_array($status, [200, 400, 500]), + "Expected 200, 400, or 500, got $status" + ); + } + } + } + + /** + * Test GROUP with single member + */ + public function testGroupWithSingleMember(): void + { + $memberIds = $this->getExistingTestIds(); + + $groupData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'GSGL' . substr(time(), -4), + 'TestSiteName' => 'Single Member Group ' . time(), + 'TestType' => $this::TEST_TYPE_GROUP, + 'Members' => [array_slice($memberIds, 0, 1)[0] ?? 1] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($groupData)]); + + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + } + + /** + * Test GROUP members have correct structure + */ + public function testGroupMembersStructure(): void + { + $indexResult = $this->get($this->endpoint . '?TestType=GROUP'); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $group = $indexBody['data'][0]; + $groupId = $group['TestSiteID'] ?? null; + + if ($groupId) { + $showResult = $this->get($this->endpoint . '/' . $groupId); + $showBody = json_decode($showResult->response()->getBody(), true); + + if ($showBody['data'] !== null && isset($showBody['data']['testdefgrp'])) { + $members = $showBody['data']['testdefgrp']; + + if (is_array($members) && !empty($members)) { + $firstMember = $members[0]; + + // Check required fields in member structure + $this->assertArrayHasKey('TestGrpID', $firstMember); + $this->assertArrayHasKey('TestSiteID', $firstMember); + $this->assertArrayHasKey('Member', $firstMember); + + // Check for joined test details (if loaded) + if (isset($firstMember['TestSiteCode'])) { + $this->assertArrayHasKey('TestSiteName', $firstMember); + } + } + } + } + } + } + + /** + * Test GROUP delete cascades to members + */ + public function testGroupDeleteCascadesToMembers(): void + { + // Create a group + $memberIds = $this->getExistingTestIds(); + $groupData = $this->createGroupData($memberIds); + $groupData['TestSiteCode'] = 'GDEL' . substr(time(), -4); + + $createResult = $this->post($this->endpoint, ['body' => json_encode($groupData)]); + $createStatus = $createResult->response()->getStatusCode(); + + if ($createStatus === 201) { + $createBody = json_decode($createResult->response()->getBody(), true); + $groupId = $createBody['data']['TestSiteId'] ?? null; + + if ($groupId) { + // Delete the group + $deleteResult = $this->delete($this->endpoint . '/' . $groupId); + $deleteStatus = $deleteResult->response()->getStatusCode(); + + $this->assertTrue( + in_array($deleteStatus, [200, 404, 500]), + "Expected 200, 404, or 500, got $deleteStatus" + ); + + if ($deleteStatus === 200) { + // Verify group members are also soft deleted + $showResult = $this->get($this->endpoint . '/' . $groupId); + $showBody = json_decode($showResult->response()->getBody(), true); + + // Group should show EndDate set + if ($showBody['data'] !== null) { + $this->assertNotNull($showBody['data']['EndDate']); + } + } + } + } + } + + /** + * Test GROUP type has correct TypeCode in response + */ + public function testGroupTypeCodeInResponse(): void + { + $indexResult = $this->get($this->endpoint . '?TestType=GROUP'); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $group = $indexBody['data'][0]; + + // Verify TypeCode is GROUP + $this->assertEquals('GROUP', $group['TypeCode'] ?? ''); + } + } + + /** + * Helper to get existing test IDs + */ + private function getExistingTestIds(): array + { + $indexResult = $this->get($this->endpoint); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + $ids = []; + if (isset($indexBody['data']) && is_array($indexBody['data'])) { + foreach ($indexBody['data'] as $item) { + if (isset($item['TestSiteID'])) { + $ids[] = $item['TestSiteID']; + } + if (count($ids) >= 3) { + break; + } + } + } + + return $ids ?: [1, 2, 3]; + } +} diff --git a/tests/feature/v2/master/TestDef/TestDefParamTest.php b/tests/feature/v2/master/TestDef/TestDefParamTest.php new file mode 100644 index 0000000..96b5e2c --- /dev/null +++ b/tests/feature/v2/master/TestDef/TestDefParamTest.php @@ -0,0 +1,288 @@ + 1, + 'TestSiteCode' => 'PARM' . substr(time(), -4), + 'TestSiteName' => 'Parameter Test ' . time(), + 'TestType' => $this::TEST_TYPE_PARAM, + 'Description' => 'Parameter/sub-test description', + 'SeqScr' => 5, + 'SeqRpt' => 5, + 'VisibleScr' => 1, + 'VisibleRpt' => 1, + 'CountStat' => 1, + 'details' => [ + 'DisciplineID' => 1, + 'DepartmentID' => 1, + 'ResultType' => 1, // Numeric + 'RefType' => 1, // NMRC + 'Unit1' => 'unit', + 'Decimal' => 1, + 'Method' => 'Parameter Method' + ] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($paramData)]); + + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + + if ($status === 201) { + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('created', $body['status']); + + // Verify tech details were created + $paramId = $body['data']['TestSiteId']; + $showResult = $this->get($this->endpoint . '/' . $paramId); + $showBody = json_decode($showResult->response()->getBody(), true); + + if ($showBody['data'] !== null) { + $this->assertArrayHasKey('testdeftech', $showBody['data']); + } + } + } + + /** + * Test PARAM has correct TypeCode in response + */ + public function testParamTypeCodeInResponse(): void + { + $indexResult = $this->get($this->endpoint . '?TestType=PARAM'); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $param = $indexBody['data'][0]; + + // Verify TypeCode is PARAM + $this->assertEquals('PARAM', $param['TypeCode'] ?? ''); + } + } + + /** + * Test PARAM details structure + */ + public function testParamDetailsStructure(): void + { + $indexResult = $this->get($this->endpoint . '?TestType=PARAM'); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $param = $indexBody['data'][0]; + $paramId = $param['TestSiteID'] ?? null; + + if ($paramId) { + $showResult = $this->get($this->endpoint . '/' . $paramId); + $showBody = json_decode($showResult->response()->getBody(), true); + + if ($showBody['data'] !== null && isset($showBody['data']['testdeftech'])) { + $techDetails = $showBody['data']['testdeftech']; + + if (is_array($techDetails) && !empty($techDetails)) { + $firstDetail = $techDetails[0]; + + // Check required fields in tech structure + $this->assertArrayHasKey('TestTechID', $firstDetail); + $this->assertArrayHasKey('TestSiteID', $firstDetail); + $this->assertArrayHasKey('ResultType', $firstDetail); + $this->assertArrayHasKey('RefType', $firstDetail); + + // Check for joined discipline/department + if (isset($firstDetail['DisciplineName'])) { + $this->assertArrayHasKey('DepartmentName', $firstDetail); + } + } + } + } + } + } + + /** + * Test PARAM with different result types + */ + public function testParamWithDifferentResultTypes(): void + { + $resultTypes = [ + 1 => 'NMRIC', // Numeric + 2 => 'RANGE', // Range + 3 => 'TEXT', // Text + 4 => 'VSET' // Value Set + ]; + + foreach ($resultTypes as $resultTypeId => $resultTypeName) { + $paramData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'PR' . substr(time(), -4) . substr($resultTypeName, 0, 1), + 'TestSiteName' => "Param with $resultTypeName", + 'TestType' => $this::TEST_TYPE_PARAM, + 'details' => [ + 'ResultType' => $resultTypeId, + 'RefType' => 1 + ] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($paramData)]); + $status = $result->response()->getStatusCode(); + + $this->assertTrue( + in_array($status, [201, 400, 500]), + "PARAM with ResultType $resultTypeName: Expected 201, 400, or 500, got $status" + ); + } + } + + /** + * Test PARAM with different reference types + */ + public function testParamWithDifferentRefTypes(): void + { + $refTypes = [ + 1 => 'NMRC', // Numeric + 2 => 'TEXT' // Text + ]; + + foreach ($refTypes as $refTypeId => $refTypeName) { + $paramData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'PR' . substr(time(), -4) . 'R' . substr($refTypeName, 0, 1), + 'TestSiteName' => "Param with RefType $refTypeName", + 'TestType' => $this::TEST_TYPE_PARAM, + 'details' => [ + 'ResultType' => 1, + 'RefType' => $refTypeId + ] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($paramData)]); + $status = $result->response()->getStatusCode(); + + $this->assertTrue( + in_array($status, [201, 400, 500]), + "PARAM with RefType $refTypeName: Expected 201, 400, or 500, got $status" + ); + } + } + + /** + * Test PARAM delete cascades to details + */ + public function testParamDeleteCascadesToDetails(): void + { + // Create a PARAM + $paramData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'PDEL' . substr(time(), -4), + 'TestSiteName' => 'Param to Delete', + 'TestType' => $this::TEST_TYPE_PARAM, + 'details' => [ + 'ResultType' => 1, + 'RefType' => 1 + ] + ]; + + $createResult = $this->post($this->endpoint, ['body' => json_encode($paramData)]); + $createStatus = $createResult->response()->getStatusCode(); + + if ($createStatus === 201) { + $createBody = json_decode($createResult->response()->getBody(), true); + $paramId = $createBody['data']['TestSiteId'] ?? null; + + if ($paramId) { + // Delete the PARAM + $deleteResult = $this->delete($this->endpoint . '/' . $paramId); + $deleteStatus = $deleteResult->response()->getStatusCode(); + + $this->assertTrue( + in_array($deleteStatus, [200, 404, 500]), + "Expected 200, 404, or 500, got $deleteStatus" + ); + + if ($deleteStatus === 200) { + // Verify PARAM details are also soft deleted + $showResult = $this->get($this->endpoint . '/' . $paramId); + $showBody = json_decode($showResult->response()->getBody(), true); + + // PARAM should show EndDate set + if ($showBody['data'] !== null) { + $this->assertNotNull($showBody['data']['EndDate']); + } + } + } + } + } + + /** + * Test PARAM visibility settings + */ + public function testParamVisibilitySettings(): void + { + $visibilityCombinations = [ + ['VisibleScr' => 1, 'VisibleRpt' => 1], + ['VisibleScr' => 1, 'VisibleRpt' => 0], + ['VisibleScr' => 0, 'VisibleRpt' => 1], + ['VisibleScr' => 0, 'VisibleRpt' => 0] + ]; + + foreach ($visibilityCombinations as $vis) { + $paramData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'PVIS' . substr(time(), -4), + 'TestSiteName' => 'Visibility Test', + 'TestType' => $this::TEST_TYPE_PARAM, + 'VisibleScr' => $vis['VisibleScr'], + 'VisibleRpt' => $vis['VisibleRpt'] + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($paramData)]); + $status = $result->response()->getStatusCode(); + + $this->assertTrue( + in_array($status, [201, 400, 500]), + "PARAM visibility ({$vis['VisibleScr']}, {$vis['VisibleRpt']}): Expected 201, 400, or 500, got $status" + ); + } + } + + /** + * Test PARAM sequence ordering + */ + public function testParamSequenceOrdering(): void + { + $paramData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'PSEQ' . substr(time(), -4), + 'TestSiteName' => 'Sequenced Param', + 'TestType' => $this::TEST_TYPE_PARAM, + 'SeqScr' => 25, + 'SeqRpt' => 30 + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($paramData)]); + $status = $result->response()->getStatusCode(); + + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + } +} diff --git a/tests/feature/v2/master/TestDef/TestDefSiteTest.php b/tests/feature/v2/master/TestDef/TestDefSiteTest.php new file mode 100644 index 0000000..2d635f1 --- /dev/null +++ b/tests/feature/v2/master/TestDef/TestDefSiteTest.php @@ -0,0 +1,375 @@ +get($this->endpoint); + $result->assertStatus(200); + + $body = json_decode($result->response()->getBody(), true); + $this->assertArrayHasKey('status', $body); + $this->assertArrayHasKey('data', $body); + $this->assertArrayHasKey('message', $body); + } + + /** + * Test index with SiteID filter + */ + public function testIndexWithSiteFilter(): void + { + $result = $this->get($this->endpoint . '?SiteID=1'); + $result->assertStatus(200); + + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('success', $body['status']); + } + + /** + * Test index with TestType filter + */ + public function testIndexWithTestTypeFilter(): void + { + // Filter by TEST type + $result = $this->get($this->endpoint . '?TestType=TEST'); + $result->assertStatus(200); + + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('success', $body['status']); + } + + /** + * Test index with Visibility filter + */ + public function testIndexWithVisibilityFilter(): void + { + $result = $this->get($this->endpoint . '?VisibleScr=1&VisibleRpt=1'); + $result->assertStatus(200); + + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('success', $body['status']); + } + + /** + * Test index with keyword search + */ + public function testIndexWithKeywordSearch(): void + { + $result = $this->get($this->endpoint . '?TestSiteName=hemoglobin'); + $result->assertStatus(200); + + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('success', $body['status']); + } + + /** + * Test show endpoint returns single test + */ + public function testShowReturnsSingleTest(): void + { + // First get the list to find a valid ID + $indexResult = $this->get($this->endpoint); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $firstItem = $indexBody['data'][0]; + $testSiteID = $firstItem['TestSiteID'] ?? null; + + if ($testSiteID) { + $showResult = $this->get($this->endpoint . '/' . $testSiteID); + $showResult->assertStatus(200); + + $body = json_decode($showResult->response()->getBody(), true); + $this->assertArrayHasKey('data', $body); + $this->assertEquals('success', $body['status']); + + // Check that related details are loaded based on TestType + if ($body['data'] !== null) { + $typeCode = $body['data']['TypeCode'] ?? ''; + if ($typeCode === 'CALC') { + $this->assertArrayHasKey('testdefcal', $body['data']); + } elseif ($typeCode === 'GROUP') { + $this->assertArrayHasKey('testdefgrp', $body['data']); + } elseif (in_array($typeCode, ['TEST', 'PARAM'])) { + $this->assertArrayHasKey('testdeftech', $body['data']); + } + // All types should have testmap + $this->assertArrayHasKey('testmap', $body['data']); + } + } + } + } + + /** + * Test show with non-existent ID returns null data + */ + public function testShowWithInvalidIDReturnsNull(): void + { + $result = $this->get($this->endpoint . '/9999999'); + $result->assertStatus(200); + + $body = json_decode($result->response()->getBody(), true); + $this->assertArrayHasKey('data', $body); + $this->assertNull($body['data']); + } + + /** + * Test create new TEST type test definition + */ + public function testCreateTestTypeTest(): void + { + $testData = $this->createTestData(); + + $result = $this->post($this->endpoint, ['body' => json_encode($testData)]); + + $status = $result->response()->getStatusCode(); + // Expect 201 (created) or 400 (validation error) or 500 (server error) + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + + if ($status === 201) { + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('created', $body['status']); + $this->assertArrayHasKey('TestSiteId', $body['data']); + } + } + + /** + * Test create new PARAM type test definition + */ + public function testCreateParamTypeTest(): void + { + $paramData = $this->createParamData(); + + $result = $this->post($this->endpoint, ['body' => json_encode($paramData)]); + + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + } + + /** + * Test create new GROUP type test definition + */ + public function testCreateGroupTypeTest(): void + { + // First create some member tests + $memberIds = $this->getExistingTestIds(); + + $groupData = $this->createGroupData($memberIds); + + $result = $this->post($this->endpoint, ['body' => json_encode($groupData)]); + + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + } + + /** + * Test create new CALC type test definition + */ + public function testCreateCalcTypeTest(): void + { + $calcData = $this->createCalcData(); + + $result = $this->post($this->endpoint, ['body' => json_encode($calcData)]); + + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [201, 400, 500]), + "Expected 201, 400, or 500, got $status" + ); + } + + /** + * Test update existing test + */ + public function testUpdateTest(): void + { + $indexResult = $this->get($this->endpoint); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + if (isset($indexBody['data']) && is_array($indexBody['data']) && !empty($indexBody['data'])) { + $firstItem = $indexBody['data'][0]; + $testSiteID = $firstItem['TestSiteID'] ?? null; + + if ($testSiteID) { + $updateData = [ + 'TestSiteName' => 'Updated Test Name ' . time(), + 'Description' => 'Updated description' + ]; + + $result = $this->put($this->endpoint . '/' . $testSiteID, ['body' => json_encode($updateData)]); + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [200, 404, 500]), + "Expected 200, 404, or 500, got $status" + ); + + if ($status === 200) { + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('success', $body['status']); + } + } + } + } + + /** + * Test soft delete (disable) test + */ + public function testDeleteTest(): void + { + // Create a test first to delete + $testData = $this->createTestData(); + $testData['TestSiteCode'] = 'DEL' . substr(time(), -4); + + $createResult = $this->post($this->endpoint, ['body' => json_encode($testData)]); + $createStatus = $createResult->response()->getStatusCode(); + + if ($createStatus === 201) { + $createBody = json_decode($createResult->response()->getBody(), true); + $testSiteID = $createBody['data']['TestSiteId'] ?? null; + + if ($testSiteID) { + $deleteResult = $this->delete($this->endpoint . '/' . $testSiteID); + $deleteStatus = $deleteResult->response()->getStatusCode(); + + $this->assertTrue( + in_array($deleteStatus, [200, 404, 500]), + "Expected 200, 404, or 500, got $deleteStatus" + ); + + if ($deleteStatus === 200) { + $deleteBody = json_decode($deleteResult->response()->getBody(), true); + $this->assertEquals('success', $deleteBody['status']); + $this->assertArrayHasKey('EndDate', $deleteBody['data']); + } + } + } + } + + /** + * Test validation - missing required fields + */ + public function testCreateValidationRequiredFields(): void + { + $invalidData = [ + 'TestSiteName' => 'Test without required fields' + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]); + $result->assertStatus(400); + } + + /** + * Test that TestSiteCode is max 6 characters + */ + public function testTestSiteCodeLength(): void + { + $invalidData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'HB123456', // 8 characters - invalid + 'TestSiteName' => 'Test with too long code', + 'TestType' => $this::TEST_TYPE_TEST + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]); + $result->assertStatus(400); + } + + /** + * Test that TestSiteCode is at least 3 characters + */ + public function testTestSiteCodeMinLength(): void + { + $invalidData = [ + 'SiteID' => 1, + 'TestSiteCode' => 'HB', // 2 characters - invalid + 'TestSiteName' => 'Test with too short code', + 'TestType' => $this::TEST_TYPE_TEST + ]; + + $result = $this->post($this->endpoint, ['body' => json_encode($invalidData)]); + $result->assertStatus(400); + } + + /** + * Test that duplicate TestSiteCode is rejected + */ + public function testDuplicateTestSiteCode(): void + { + // First create a test + $testData = $this->createTestData(); + $testData['TestSiteCode'] = 'DUP' . substr(time(), -3); + + $this->post($this->endpoint, ['body' => json_encode($testData)]); + + // Try to create another test with the same code + $duplicateData = $testData; + $duplicateData['TestSiteName'] = 'Different Name'; + + $result = $this->post($this->endpoint, ['body' => json_encode($duplicateData)]); + + // Should fail with 400 or 500 + $status = $result->response()->getStatusCode(); + $this->assertTrue( + in_array($status, [400, 500]), + "Expected 400 or 500 for duplicate, got $status" + ); + } + + /** + * Test filtering by multiple parameters + */ + public function testIndexWithMultipleFilters(): void + { + $result = $this->get($this->endpoint . '?SiteID=1&TestType=TEST&VisibleScr=1'); + $result->assertStatus(200); + + $body = json_decode($result->response()->getBody(), true); + $this->assertEquals('success', $body['status']); + } + + /** + * Helper method to get existing test IDs for group members + */ + private function getExistingTestIds(): array + { + $indexResult = $this->get($this->endpoint); + $indexBody = json_decode($indexResult->response()->getBody(), true); + + $ids = []; + if (isset($indexBody['data']) && is_array($indexBody['data'])) { + foreach ($indexBody['data'] as $item) { + if (isset($item['TestSiteID'])) { + $ids[] = $item['TestSiteID']; + } + if (count($ids) >= 2) { + break; + } + } + } + + return $ids ?: [1, 2]; + } +} diff --git a/tests/unit/TestDef/TestDefModelsTest.php b/tests/unit/TestDef/TestDefModelsTest.php new file mode 100644 index 0000000..d070c67 --- /dev/null +++ b/tests/unit/TestDef/TestDefModelsTest.php @@ -0,0 +1,237 @@ +testDefSiteModel = new TestDefSiteModel(); + $this->testDefTechModel = new TestDefTechModel(); + $this->testDefCalModel = new TestDefCalModel(); + $this->testDefGrpModel = new TestDefGrpModel(); + $this->testMapModel = new TestMapModel(); + } + + /** + * Test TestDefSiteModel has correct table name + */ + public function testTestDefSiteModelTable() + { + $this->assertEquals('testdefsite', $this->testDefSiteModel->table); + } + + /** + * Test TestDefSiteModel has correct primary key + */ + public function testTestDefSiteModelPrimaryKey() + { + $this->assertEquals('TestSiteID', $this->testDefSiteModel->primaryKey); + } + + /** + * Test TestDefSiteModel has correct allowed fields + */ + public function testTestDefSiteModelAllowedFields() + { + $allowedFields = $this->testDefSiteModel->allowedFields; + + $this->assertContains('SiteID', $allowedFields); + $this->assertContains('TestSiteCode', $allowedFields); + $this->assertContains('TestSiteName', $allowedFields); + $this->assertContains('TestType', $allowedFields); + $this->assertContains('Description', $allowedFields); + $this->assertContains('SeqScr', $allowedFields); + $this->assertContains('SeqRpt', $allowedFields); + $this->assertContains('IndentLeft', $allowedFields); + $this->assertContains('FontStyle', $allowedFields); + $this->assertContains('VisibleScr', $allowedFields); + $this->assertContains('VisibleRpt', $allowedFields); + $this->assertContains('CountStat', $allowedFields); + $this->assertContains('CreateDate', $allowedFields); + $this->assertContains('StartDate', $allowedFields); + $this->assertContains('EndDate', $allowedFields); + } + + /** + * Test TestDefSiteModel uses soft deletes + */ + public function testTestDefSiteModelSoftDeletes() + { + $this->assertTrue($this->testDefSiteModel->useSoftDeletes); + $this->assertEquals('EndDate', $this->testDefSiteModel->deletedField); + } + + /** + * Test TestDefTechModel has correct table name + */ + public function testTestDefTechModelTable() + { + $this->assertEquals('testdeftech', $this->testDefTechModel->table); + } + + /** + * Test TestDefTechModel has correct primary key + */ + public function testTestDefTechModelPrimaryKey() + { + $this->assertEquals('TestTechID', $this->testDefTechModel->primaryKey); + } + + /** + * Test TestDefTechModel has correct allowed fields + */ + public function testTestDefTechModelAllowedFields() + { + $allowedFields = $this->testDefTechModel->allowedFields; + + $this->assertContains('TestSiteID', $allowedFields); + $this->assertContains('DisciplineID', $allowedFields); + $this->assertContains('DepartmentID', $allowedFields); + $this->assertContains('ResultType', $allowedFields); + $this->assertContains('RefType', $allowedFields); + $this->assertContains('VSet', $allowedFields); + $this->assertContains('Unit1', $allowedFields); + $this->assertContains('Factor', $allowedFields); + $this->assertContains('Unit2', $allowedFields); + $this->assertContains('Decimal', $allowedFields); + $this->assertContains('Method', $allowedFields); + $this->assertContains('ExpectedTAT', $allowedFields); + } + + /** + * Test TestDefCalModel has correct table name + */ + public function testTestDefCalModelTable() + { + $this->assertEquals('testdefcal', $this->testDefCalModel->table); + } + + /** + * Test TestDefCalModel has correct primary key + */ + public function testTestDefCalModelPrimaryKey() + { + $this->assertEquals('TestCalID', $this->testDefCalModel->primaryKey); + } + + /** + * Test TestDefCalModel has correct allowed fields + */ + public function testTestDefCalModelAllowedFields() + { + $allowedFields = $this->testDefCalModel->allowedFields; + + $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); + $this->assertContains('Factor', $allowedFields); + $this->assertContains('Unit2', $allowedFields); + $this->assertContains('Decimal', $allowedFields); + $this->assertContains('Method', $allowedFields); + } + + /** + * Test TestDefGrpModel has correct table name + */ + public function testTestDefGrpModelTable() + { + $this->assertEquals('testdefgrp', $this->testDefGrpModel->table); + } + + /** + * Test TestDefGrpModel has correct primary key + */ + public function testTestDefGrpModelPrimaryKey() + { + $this->assertEquals('TestGrpID', $this->testDefGrpModel->primaryKey); + } + + /** + * Test TestDefGrpModel has correct allowed fields + */ + public function testTestDefGrpModelAllowedFields() + { + $allowedFields = $this->testDefGrpModel->allowedFields; + + $this->assertContains('TestSiteID', $allowedFields); + $this->assertContains('Member', $allowedFields); + } + + /** + * Test TestMapModel has correct table name + */ + public function testTestMapModelTable() + { + $this->assertEquals('testmap', $this->testMapModel->table); + } + + /** + * Test TestMapModel has correct primary key + */ + public function testTestMapModelPrimaryKey() + { + $this->assertEquals('TestMapID', $this->testMapModel->primaryKey); + } + + /** + * Test TestMapModel has correct allowed fields + */ + public function testTestMapModelAllowedFields() + { + $allowedFields = $this->testMapModel->allowedFields; + + $this->assertContains('TestSiteID', $allowedFields); + $this->assertContains('HostType', $allowedFields); + $this->assertContains('HostID', $allowedFields); + $this->assertContains('HostDataSource', $allowedFields); + $this->assertContains('HostTestCode', $allowedFields); + $this->assertContains('HostTestName', $allowedFields); + $this->assertContains('ClientType', $allowedFields); + $this->assertContains('ClientID', $allowedFields); + $this->assertContains('ClientDataSource', $allowedFields); + $this->assertContains('ConDefID', $allowedFields); + $this->assertContains('ClientTestCode', $allowedFields); + $this->assertContains('ClientTestName', $allowedFields); + } + + /** + * Test all models use soft deletes + */ + public function testAllModelsUseSoftDeletes() + { + $this->assertTrue($this->testDefTechModel->useSoftDeletes); + $this->assertTrue($this->testDefCalModel->useSoftDeletes); + $this->assertTrue($this->testDefGrpModel->useSoftDeletes); + $this->assertTrue($this->testMapModel->useSoftDeletes); + } + + /** + * Test all models have EndDate as deleted field + */ + public function testAllModelsUseEndDateAsDeletedField() + { + $this->assertEquals('EndDate', $this->testDefTechModel->deletedField); + $this->assertEquals('EndDate', $this->testDefCalModel->deletedField); + $this->assertEquals('EndDate', $this->testDefGrpModel->deletedField); + $this->assertEquals('EndDate', $this->testMapModel->deletedField); + } +} diff --git a/tests/unit/v2/master/TestDef/TestDefCalModelTest.php b/tests/unit/v2/master/TestDef/TestDefCalModelTest.php new file mode 100644 index 0000000..a7bf3ed --- /dev/null +++ b/tests/unit/v2/master/TestDef/TestDefCalModelTest.php @@ -0,0 +1,145 @@ +model = new TestDefCalModel(); + } + + /** + * Test model has correct table name + */ + public function testModelHasCorrectTableName(): void + { + $this->assertEquals('testdefcal', $this->model->table); + } + + /** + * Test model has correct primary key + */ + public function testModelHasCorrectPrimaryKey(): void + { + $this->assertEquals('TestCalID', $this->model->primaryKey); + } + + /** + * Test model uses soft deletes + */ + public function testModelUsesSoftDeletes(): void + { + $this->assertTrue($this->model->useSoftDeletes); + $this->assertEquals('EndDate', $this->model->deletedField); + } + + /** + * Test model has correct allowed fields + */ + public function testModelHasCorrectAllowedFields(): void + { + $allowedFields = $this->model->allowedFields; + + // Foreign key + $this->assertContains('TestSiteID', $allowedFields); + + // Calculation fields + $this->assertContains('DisciplineID', $allowedFields); + $this->assertContains('DepartmentID', $allowedFields); + $this->assertContains('FormulaInput', $allowedFields); + $this->assertContains('FormulaCode', $allowedFields); + + // Result fields + $this->assertContains('RefType', $allowedFields); + $this->assertContains('Unit1', $allowedFields); + $this->assertContains('Factor', $allowedFields); + $this->assertContains('Unit2', $allowedFields); + $this->assertContains('Decimal', $allowedFields); + $this->assertContains('Method', $allowedFields); + + // Timestamp fields + $this->assertContains('CreateDate', $allowedFields); + $this->assertContains('EndDate', $allowedFields); + } + + /** + * Test model uses timestamps + */ + public function testModelUsesTimestamps(): void + { + $this->assertTrue($this->model->useTimestamps); + $this->assertEquals('CreateDate', $this->model->createdField); + } + + /** + * Test model return type is array + */ + public function testModelReturnTypeIsArray(): void + { + $this->assertEquals('array', $this->model->returnType); + } + + /** + * Test model has correct skip validation + */ + public function testModelSkipValidation(): void + { + $this->assertFalse($this->model->skipValidation); + } + + /** + * Test model has correct useAutoIncrement + */ + public function testModelUseAutoIncrement(): void + { + $this->assertTrue($this->model->useAutoIncrement); + } + + /** + * Test FormulaInput field is in allowed fields + */ + public function testFormulaInputFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('FormulaInput', $allowedFields); + } + + /** + * Test FormulaCode field is in allowed fields + */ + public function testFormulaCodeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('FormulaCode', $allowedFields); + } + + /** + * Test RefType field is in allowed fields + */ + public function testRefTypeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('RefType', $allowedFields); + } + + /** + * Test Method field is in allowed fields + */ + public function testMethodFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('Method', $allowedFields); + } +} diff --git a/tests/unit/v2/master/TestDef/TestDefGrpModelTest.php b/tests/unit/v2/master/TestDef/TestDefGrpModelTest.php new file mode 100644 index 0000000..5951120 --- /dev/null +++ b/tests/unit/v2/master/TestDef/TestDefGrpModelTest.php @@ -0,0 +1,132 @@ +model = new TestDefGrpModel(); + } + + /** + * Test model has correct table name + */ + public function testModelHasCorrectTableName(): void + { + $this->assertEquals('testdefgrp', $this->model->table); + } + + /** + * Test model has correct primary key + */ + public function testModelHasCorrectPrimaryKey(): void + { + $this->assertEquals('TestGrpID', $this->model->primaryKey); + } + + /** + * Test model uses soft deletes + */ + public function testModelUsesSoftDeletes(): void + { + $this->assertTrue($this->model->useSoftDeletes); + $this->assertEquals('EndDate', $this->model->deletedField); + } + + /** + * Test model has correct allowed fields + */ + public function testModelHasCorrectAllowedFields(): void + { + $allowedFields = $this->model->allowedFields; + + // Foreign keys + $this->assertContains('TestSiteID', $allowedFields); + $this->assertContains('Member', $allowedFields); + + // Timestamp fields + $this->assertContains('CreateDate', $allowedFields); + $this->assertContains('EndDate', $allowedFields); + } + + /** + * Test model uses timestamps + */ + public function testModelUsesTimestamps(): void + { + $this->assertTrue($this->model->useTimestamps); + $this->assertEquals('CreateDate', $this->model->createdField); + } + + /** + * Test model return type is array + */ + public function testModelReturnTypeIsArray(): void + { + $this->assertEquals('array', $this->model->returnType); + } + + /** + * Test model has correct skip validation + */ + public function testModelSkipValidation(): void + { + $this->assertFalse($this->model->skipValidation); + } + + /** + * Test model has correct useAutoIncrement + */ + public function testModelUseAutoIncrement(): void + { + $this->assertTrue($this->model->useAutoIncrement); + } + + /** + * Test TestSiteID field is in allowed fields + */ + public function testTestSiteIDFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('TestSiteID', $allowedFields); + } + + /** + * Test Member field is in allowed fields + */ + public function testMemberFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('Member', $allowedFields); + } + + /** + * Test CreateDate field is in allowed fields + */ + public function testCreateDateFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('CreateDate', $allowedFields); + } + + /** + * Test EndDate field is in allowed fields + */ + public function testEndDateFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('EndDate', $allowedFields); + } +} diff --git a/tests/unit/v2/master/TestDef/TestDefSiteModelMasterTest.php b/tests/unit/v2/master/TestDef/TestDefSiteModelMasterTest.php new file mode 100644 index 0000000..528feb0 --- /dev/null +++ b/tests/unit/v2/master/TestDef/TestDefSiteModelMasterTest.php @@ -0,0 +1,220 @@ +model = new TestDefSiteModel(); + } + + /** + * Test model has correct table name + */ + public function testModelHasCorrectTableName(): void + { + $this->assertEquals('testdefsite', $this->model->table); + } + + /** + * Test model has correct primary key + */ + public function testModelHasCorrectPrimaryKey(): void + { + $this->assertEquals('TestSiteID', $this->model->primaryKey); + } + + /** + * Test model uses soft deletes + */ + public function testModelUsesSoftDeletes(): void + { + $this->assertTrue($this->model->useSoftDeletes); + $this->assertEquals('EndDate', $this->model->deletedField); + } + + /** + * Test model has correct allowed fields for master data + */ + public function testModelHasCorrectAllowedFields(): void + { + $allowedFields = $this->model->allowedFields; + + // Core required fields + $this->assertContains('SiteID', $allowedFields); + $this->assertContains('TestSiteCode', $allowedFields); + $this->assertContains('TestSiteName', $allowedFields); + $this->assertContains('TestType', $allowedFields); + + // Display and ordering fields + $this->assertContains('Description', $allowedFields); + $this->assertContains('SeqScr', $allowedFields); + $this->assertContains('SeqRpt', $allowedFields); + $this->assertContains('IndentLeft', $allowedFields); + $this->assertContains('FontStyle', $allowedFields); + + // Visibility fields + $this->assertContains('VisibleScr', $allowedFields); + $this->assertContains('VisibleRpt', $allowedFields); + $this->assertContains('CountStat', $allowedFields); + + // Timestamp fields + $this->assertContains('CreateDate', $allowedFields); + $this->assertContains('StartDate', $allowedFields); + $this->assertContains('EndDate', $allowedFields); + } + + /** + * Test model uses timestamps + */ + public function testModelUsesTimestamps(): void + { + $this->assertTrue($this->model->useTimestamps); + $this->assertEquals('CreateDate', $this->model->createdField); + $this->assertEquals('StartDate', $this->model->updatedField); + } + + /** + * Test model return type is array + */ + public function testModelReturnTypeIsArray(): void + { + $this->assertEquals('array', $this->model->returnType); + } + + /** + * Test model has correct skip validation + */ + public function testModelSkipValidation(): void + { + $this->assertFalse($this->model->skipValidation); + } + + /** + * Test model has correct useAutoIncrement + */ + public function testModelUseAutoIncrement(): void + { + $this->assertTrue($this->model->useAutoIncrement); + } + + /** + * Test TestSiteCode field is in allowed fields + */ + public function testTestSiteCodeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('TestSiteCode', $allowedFields); + } + + /** + * Test TestSiteName field is in allowed fields + */ + public function testTestSiteNameFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('TestSiteName', $allowedFields); + } + + /** + * Test TestType field is in allowed fields + */ + public function testTestTypeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('TestType', $allowedFields); + } + + /** + * Test SiteID field is in allowed fields + */ + public function testSiteIDFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('SiteID', $allowedFields); + } + + /** + * Test Description field is in allowed fields + */ + public function testDescriptionFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('Description', $allowedFields); + } + + /** + * Test SeqScr field is in allowed fields + */ + public function testSeqScrFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('SeqScr', $allowedFields); + } + + /** + * Test SeqRpt field is in allowed fields + */ + public function testSeqRptFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('SeqRpt', $allowedFields); + } + + /** + * Test VisibleScr field is in allowed fields + */ + public function testVisibleScrFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('VisibleScr', $allowedFields); + } + + /** + * Test VisibleRpt field is in allowed fields + */ + public function testVisibleRptFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('VisibleRpt', $allowedFields); + } + + /** + * Test CountStat field is in allowed fields + */ + public function testCountStatFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('CountStat', $allowedFields); + } + + /** + * Test getTests method exists and is callable + */ + public function testGetTestsMethodExists(): void + { + $this->assertTrue(method_exists($this->model, 'getTests')); + $this->assertIsCallable([$this->model, 'getTests']); + } + + /** + * Test getTest method exists and is callable + */ + public function testGetTestMethodExists(): void + { + $this->assertTrue(method_exists($this->model, 'getTest')); + $this->assertIsCallable([$this->model, 'getTest']); + } +} diff --git a/tests/unit/v2/master/TestDef/TestDefSiteModelTest.php b/tests/unit/v2/master/TestDef/TestDefSiteModelTest.php new file mode 100644 index 0000000..241ccec --- /dev/null +++ b/tests/unit/v2/master/TestDef/TestDefSiteModelTest.php @@ -0,0 +1,137 @@ +model = new TestDefSiteModel(); + } + + /** + * Test model has correct table name + */ + public function testModelHasCorrectTableName(): void + { + $this->assertEquals('testdefsite', $this->model->table); + } + + /** + * Test model has correct primary key + */ + public function testModelHasCorrectPrimaryKey(): void + { + $this->assertEquals('TestSiteID', $this->model->primaryKey); + } + + /** + * Test model uses soft deletes + */ + public function testModelUsesSoftDeletes(): void + { + $this->assertTrue($this->model->useSoftDeletes); + $this->assertEquals('EndDate', $this->model->deletedField); + } + + /** + * Test model has correct allowed fields + */ + public function testModelHasCorrectAllowedFields(): void + { + $allowedFields = $this->model->allowedFields; + + // Core required fields + $this->assertContains('SiteID', $allowedFields); + $this->assertContains('TestSiteCode', $allowedFields); + $this->assertContains('TestSiteName', $allowedFields); + $this->assertContains('TestType', $allowedFields); + + // Optional fields + $this->assertContains('Description', $allowedFields); + $this->assertContains('SeqScr', $allowedFields); + $this->assertContains('SeqRpt', $allowedFields); + $this->assertContains('IndentLeft', $allowedFields); + $this->assertContains('FontStyle', $allowedFields); + $this->assertContains('VisibleScr', $allowedFields); + $this->assertContains('VisibleRpt', $allowedFields); + $this->assertContains('CountStat', $allowedFields); + + // Timestamp fields + $this->assertContains('CreateDate', $allowedFields); + $this->assertContains('StartDate', $allowedFields); + $this->assertContains('EndDate', $allowedFields); + } + + /** + * Test model uses timestamps + */ + public function testModelUsesTimestamps(): void + { + $this->assertTrue($this->model->useTimestamps); + $this->assertEquals('CreateDate', $this->model->createdField); + $this->assertEquals('StartDate', $this->model->updatedField); + } + + /** + * Test model return type is array + */ + public function testModelReturnTypeIsArray(): void + { + $this->assertEquals('array', $this->model->returnType); + } + + /** + * Test model has correct skip validation + */ + public function testModelSkipValidation(): void + { + $this->assertFalse($this->model->skipValidation); + } + + /** + * Test model has correct useAutoIncrement + */ + public function testModelUseAutoIncrement(): void + { + $this->assertTrue($this->model->useAutoIncrement); + } + + /** + * Test TestSiteCode field is in allowed fields + */ + public function testTestSiteCodeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('TestSiteCode', $allowedFields); + } + + /** + * Test TestSiteName field is in allowed fields + */ + public function testTestSiteNameFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('TestSiteName', $allowedFields); + } + + /** + * Test TestType field is in allowed fields + */ + public function testTestTypeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('TestType', $allowedFields); + } +} diff --git a/tests/unit/v2/master/TestDef/TestDefTechModelTest.php b/tests/unit/v2/master/TestDef/TestDefTechModelTest.php new file mode 100644 index 0000000..296a28b --- /dev/null +++ b/tests/unit/v2/master/TestDef/TestDefTechModelTest.php @@ -0,0 +1,160 @@ +model = new TestDefTechModel(); + } + + /** + * Test model has correct table name + */ + public function testModelHasCorrectTableName(): void + { + $this->assertEquals('testdeftech', $this->model->table); + } + + /** + * Test model has correct primary key + */ + public function testModelHasCorrectPrimaryKey(): void + { + $this->assertEquals('TestTechID', $this->model->primaryKey); + } + + /** + * Test model uses soft deletes + */ + public function testModelUsesSoftDeletes(): void + { + $this->assertTrue($this->model->useSoftDeletes); + $this->assertEquals('EndDate', $this->model->deletedField); + } + + /** + * Test model has correct allowed fields + */ + public function testModelHasCorrectAllowedFields(): void + { + $allowedFields = $this->model->allowedFields; + + // Foreign key + $this->assertContains('TestSiteID', $allowedFields); + + // Technical fields + $this->assertContains('DisciplineID', $allowedFields); + $this->assertContains('DepartmentID', $allowedFields); + $this->assertContains('ResultType', $allowedFields); + $this->assertContains('RefType', $allowedFields); + $this->assertContains('VSet', $allowedFields); + + // Quantity and units + $this->assertContains('ReqQty', $allowedFields); + $this->assertContains('ReqQtyUnit', $allowedFields); + $this->assertContains('Unit1', $allowedFields); + $this->assertContains('Factor', $allowedFields); + $this->assertContains('Unit2', $allowedFields); + $this->assertContains('Decimal', $allowedFields); + + // Collection and method + $this->assertContains('CollReq', $allowedFields); + $this->assertContains('Method', $allowedFields); + $this->assertContains('ExpectedTAT', $allowedFields); + + // Timestamp fields + $this->assertContains('CreateDate', $allowedFields); + $this->assertContains('EndDate', $allowedFields); + } + + /** + * Test model uses timestamps + */ + public function testModelUsesTimestamps(): void + { + $this->assertTrue($this->model->useTimestamps); + $this->assertEquals('CreateDate', $this->model->createdField); + } + + /** + * Test model return type is array + */ + public function testModelReturnTypeIsArray(): void + { + $this->assertEquals('array', $this->model->returnType); + } + + /** + * Test model has correct skip validation + */ + public function testModelSkipValidation(): void + { + $this->assertFalse($this->model->skipValidation); + } + + /** + * Test model has correct useAutoIncrement + */ + public function testModelUseAutoIncrement(): void + { + $this->assertTrue($this->model->useAutoIncrement); + } + + /** + * Test TestSiteID field is in allowed fields + */ + public function testTestSiteIDFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('TestSiteID', $allowedFields); + } + + /** + * Test ResultType field is in allowed fields + */ + public function testResultTypeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('ResultType', $allowedFields); + } + + /** + * Test RefType field is in allowed fields + */ + public function testRefTypeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('RefType', $allowedFields); + } + + /** + * Test Unit1 field is in allowed fields + */ + public function testUnit1FieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('Unit1', $allowedFields); + } + + /** + * Test Method field is in allowed fields + */ + public function testMethodFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('Method', $allowedFields); + } +} diff --git a/tests/unit/v2/master/TestDef/TestMapModelTest.php b/tests/unit/v2/master/TestDef/TestMapModelTest.php new file mode 100644 index 0000000..64f3d4c --- /dev/null +++ b/tests/unit/v2/master/TestDef/TestMapModelTest.php @@ -0,0 +1,155 @@ +model = new TestMapModel(); + } + + /** + * Test model has correct table name + */ + public function testModelHasCorrectTableName(): void + { + $this->assertEquals('testmap', $this->model->table); + } + + /** + * Test model has correct primary key + */ + public function testModelHasCorrectPrimaryKey(): void + { + $this->assertEquals('TestMapID', $this->model->primaryKey); + } + + /** + * Test model uses soft deletes + */ + public function testModelUsesSoftDeletes(): void + { + $this->assertTrue($this->model->useSoftDeletes); + $this->assertEquals('EndDate', $this->model->deletedField); + } + + /** + * Test model has correct allowed fields + */ + public function testModelHasCorrectAllowedFields(): void + { + $allowedFields = $this->model->allowedFields; + + // Foreign key + $this->assertContains('TestSiteID', $allowedFields); + + // Host system mapping + $this->assertContains('HostType', $allowedFields); + $this->assertContains('HostID', $allowedFields); + $this->assertContains('HostDataSource', $allowedFields); + $this->assertContains('HostTestCode', $allowedFields); + $this->assertContains('HostTestName', $allowedFields); + + // Client system mapping + $this->assertContains('ClientType', $allowedFields); + $this->assertContains('ClientID', $allowedFields); + $this->assertContains('ClientDataSource', $allowedFields); + $this->assertContains('ConDefID', $allowedFields); + $this->assertContains('ClientTestCode', $allowedFields); + $this->assertContains('ClientTestName', $allowedFields); + + // Timestamp fields + $this->assertContains('CreateDate', $allowedFields); + $this->assertContains('EndDate', $allowedFields); + } + + /** + * Test model uses timestamps + */ + public function testModelUsesTimestamps(): void + { + $this->assertTrue($this->model->useTimestamps); + $this->assertEquals('CreateDate', $this->model->createdField); + } + + /** + * Test model return type is array + */ + public function testModelReturnTypeIsArray(): void + { + $this->assertEquals('array', $this->model->returnType); + } + + /** + * Test model has correct skip validation + */ + public function testModelSkipValidation(): void + { + $this->assertFalse($this->model->skipValidation); + } + + /** + * Test model has correct useAutoIncrement + */ + public function testModelUseAutoIncrement(): void + { + $this->assertTrue($this->model->useAutoIncrement); + } + + /** + * Test HostType field is in allowed fields + */ + public function testHostTypeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('HostType', $allowedFields); + } + + /** + * Test HostID field is in allowed fields + */ + public function testHostIDFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('HostID', $allowedFields); + } + + /** + * Test HostTestCode field is in allowed fields + */ + public function testHostTestCodeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('HostTestCode', $allowedFields); + } + + /** + * Test ClientType field is in allowed fields + */ + public function testClientTypeFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('ClientType', $allowedFields); + } + + /** + * Test TestSiteID field is in allowed fields + */ + public function testTestSiteIDFieldExists(): void + { + $allowedFields = $this->model->allowedFields; + $this->assertContains('TestSiteID', $allowedFields); + } +}